tokenmaxxing 1.7.0 → 1.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/DESIGN.md +3 -1
- package/agent-plugin/agents/tokenmaxxing-claude.md +43 -0
- package/agent-plugin/agents/tokenmaxxing-codex.md +40 -0
- package/agent-plugin/hooks/cursor-relay.json +14 -0
- package/agent-plugin/plugin.json +8 -2
- package/agent-plugin/skills/relay-session/SKILL.md +118 -0
- package/agent-plugin/skills/relay-session/references/ipc.md +23 -0
- package/package.json +1 -1
- package/src/cli/codexinit.ts +11 -2
- package/src/cli/init.ts +9 -3
- package/src/cli/relay.ts +323 -0
- package/src/entries/codexstophook.ts +10 -0
- package/src/entries/relaypermission.ts +105 -0
- package/src/entries/stophook.ts +11 -0
- package/src/lib/install.ts +61 -7
- package/src/lib/paths.ts +3 -0
- package/src/lib/relay/config.ts +84 -0
- package/src/lib/relay/decide.ts +75 -0
- package/src/lib/relay/gc.ts +80 -0
- package/src/lib/relay/install.ts +143 -0
- package/src/lib/relay/markers.ts +148 -0
- package/src/lib/relay/modes.ts +82 -0
- package/src/lib/relay/protocol.ts +61 -0
- package/src/lib/relay/registry.ts +175 -0
- package/src/lib/relay/tmux.ts +109 -0
- package/src/lib/relay/turn.ts +137 -0
- package/src/lib/relay/worker.ts +141 -0
- package/src/main.ts +5 -0
|
@@ -25,6 +25,9 @@ import { loadConfig } from "../lib/state.ts";
|
|
|
25
25
|
import { effectiveBars } from "../lib/picker.ts";
|
|
26
26
|
import { CODEX_SUPERVISOR_ID_ENV } from "./codexsupervisor.ts";
|
|
27
27
|
import { CodexReconcileMarkerSchema, CodexRespawnMarkerSchema, CodexStopStdinSchema, type CodexAccount } from "../lib/types.ts";
|
|
28
|
+
import { writeTurnDoneMarker } from "../lib/relay/markers.ts";
|
|
29
|
+
import { registryHas } from "../lib/relay/registry.ts";
|
|
30
|
+
import { RELAY_SESSION_ENV } from "../lib/relay/worker.ts";
|
|
28
31
|
import { log } from "../lib/log.ts";
|
|
29
32
|
|
|
30
33
|
const SupervisorIdSchema = z.string().min(1).optional().catch(undefined);
|
|
@@ -138,6 +141,13 @@ export async function handleCodexStop(input: { rawStdin: string }): Promise<void
|
|
|
138
141
|
const sessionId = parsed.success ? (parsed.data.session_id ?? null) : null;
|
|
139
142
|
|
|
140
143
|
try {
|
|
144
|
+
// Additive relay turn-done marker (never writes into respawn/).
|
|
145
|
+
const relaySid = process.env[RELAY_SESSION_ENV];
|
|
146
|
+
if (relaySid != null && registryHas({ sessionId: relaySid })) {
|
|
147
|
+
writeTurnDoneMarker({ sessionId: relaySid, source: "codex-stop" });
|
|
148
|
+
log("codexstop.relay_turn_done", { session: relaySid.slice(0, 8) });
|
|
149
|
+
}
|
|
150
|
+
|
|
141
151
|
// No supervisor = no decision AT ALL, checked before evaluate can swap:
|
|
142
152
|
// hooks.json is global, so this hook also fires in sessions launched
|
|
143
153
|
// around the PATH shim (IDE extension, absolute path), and a swap with
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
// PermissionRequest hook for relay workers. When TOKENMAXXING_RELAY_SESSION is
|
|
2
|
+
// set, park on a pending marker until `relay decide` writes a decision, then
|
|
3
|
+
// return allow/deny. Under bypassPermissions (or when pings are disabled),
|
|
4
|
+
// auto-allow without surfacing. Never blocks non-relay sessions. Always exits 0
|
|
5
|
+
// with a JSON decision body Claude understands.
|
|
6
|
+
|
|
7
|
+
import { delay } from "es-toolkit";
|
|
8
|
+
import { z } from "zod";
|
|
9
|
+
import { loadRelayConfig } from "../lib/relay/config.ts";
|
|
10
|
+
import {
|
|
11
|
+
clearDecision,
|
|
12
|
+
readDecision,
|
|
13
|
+
writePendingRequest,
|
|
14
|
+
} from "../lib/relay/markers.ts";
|
|
15
|
+
import { permissionPingsEnabled } from "../lib/relay/modes.ts";
|
|
16
|
+
import { readEntry, registryHas } from "../lib/relay/registry.ts";
|
|
17
|
+
import { RELAY_SESSION_ENV } from "../lib/relay/worker.ts";
|
|
18
|
+
import { log } from "../lib/log.ts";
|
|
19
|
+
|
|
20
|
+
const StdinSchema = z.looseObject({
|
|
21
|
+
tool_name: z.string().optional(),
|
|
22
|
+
tool_input: z.unknown().optional(),
|
|
23
|
+
request_id: z.string().optional(),
|
|
24
|
+
permission_suggestions: z.unknown().optional(),
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
async function readStdin(): Promise<string> {
|
|
28
|
+
const chunks: Uint8Array[] = [];
|
|
29
|
+
for await (const c of Bun.stdin.stream()) chunks.push(c);
|
|
30
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function allow(): string {
|
|
34
|
+
return JSON.stringify({ hookSpecificOutput: { hookEventName: "PermissionRequest", decision: { behavior: "allow" } } });
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function deny(): string {
|
|
38
|
+
return JSON.stringify({ hookSpecificOutput: { hookEventName: "PermissionRequest", decision: { behavior: "deny" } } });
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function fallbackAllow(): string {
|
|
42
|
+
// Unknown Claude envelope shapes: fail open for non-relay; for relay we still
|
|
43
|
+
// prefer an explicit decision. Default allow keeps the worker unblocked if
|
|
44
|
+
// the host never answers within timeout only after we already tried.
|
|
45
|
+
return allow();
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export async function handleRelayPermission(input: { rawStdin: string }): Promise<string> {
|
|
49
|
+
if (process.env.TOKENMAXXING_PROBE) return allow();
|
|
50
|
+
|
|
51
|
+
const sessionId = process.env[RELAY_SESSION_ENV];
|
|
52
|
+
if (sessionId == null || sessionId === "") return allow();
|
|
53
|
+
if (!registryHas({ sessionId })) return allow();
|
|
54
|
+
|
|
55
|
+
const entry = readEntry({ sessionId });
|
|
56
|
+
if (entry != null && !permissionPingsEnabled({ mode: entry.permissionMode })) {
|
|
57
|
+
return allow();
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const parsed = StdinSchema.safeParse((() => {
|
|
61
|
+
try {
|
|
62
|
+
return JSON.parse(input.rawStdin);
|
|
63
|
+
} catch {
|
|
64
|
+
return {};
|
|
65
|
+
}
|
|
66
|
+
})());
|
|
67
|
+
const requestId = parsed.success && parsed.data.request_id
|
|
68
|
+
? parsed.data.request_id
|
|
69
|
+
: crypto.randomUUID();
|
|
70
|
+
const toolName = parsed.success ? (parsed.data.tool_name ?? "tool") : "tool";
|
|
71
|
+
const detail = parsed.success
|
|
72
|
+
? JSON.stringify(parsed.data.tool_input ?? parsed.data).slice(0, 500)
|
|
73
|
+
: input.rawStdin.slice(0, 500);
|
|
74
|
+
|
|
75
|
+
writePendingRequest({
|
|
76
|
+
sessionId,
|
|
77
|
+
requestId,
|
|
78
|
+
summary: `Permission needed: ${toolName}`,
|
|
79
|
+
detail,
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
const cfg = loadRelayConfig();
|
|
83
|
+
const deadline = Date.now() + cfg.decideTimeoutMs;
|
|
84
|
+
while (Date.now() < deadline) {
|
|
85
|
+
const decision = readDecision({ sessionId, requestId });
|
|
86
|
+
if (decision != null) {
|
|
87
|
+
clearDecision({ sessionId, requestId });
|
|
88
|
+
return decision.approve ? allow() : deny();
|
|
89
|
+
}
|
|
90
|
+
await delay(50);
|
|
91
|
+
}
|
|
92
|
+
log("relay.permission_timeout", { session: sessionId.slice(0, 8), requestId: requestId.slice(0, 8) });
|
|
93
|
+
return deny();
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export async function runRelayPermissionHook(): Promise<number> {
|
|
97
|
+
try {
|
|
98
|
+
const out = await handleRelayPermission({ rawStdin: await readStdin() });
|
|
99
|
+
process.stdout.write(out);
|
|
100
|
+
} catch (e) {
|
|
101
|
+
log("relay.permission_error", { err: e instanceof Error ? e.message : String(e) });
|
|
102
|
+
process.stdout.write(fallbackAllow());
|
|
103
|
+
}
|
|
104
|
+
return 0;
|
|
105
|
+
}
|
package/src/entries/stophook.ts
CHANGED
|
@@ -11,6 +11,9 @@ import { z } from "zod";
|
|
|
11
11
|
import { paths } from "../lib/paths.ts";
|
|
12
12
|
import { writeFileAtomic } from "../lib/atomic.ts";
|
|
13
13
|
import { evaluateAndMaybeSwap } from "../lib/decide.ts";
|
|
14
|
+
import { writeTurnDoneMarker } from "../lib/relay/markers.ts";
|
|
15
|
+
import { registryHas } from "../lib/relay/registry.ts";
|
|
16
|
+
import { RELAY_SESSION_ENV } from "../lib/relay/worker.ts";
|
|
14
17
|
import { RespawnMarkerSchema } from "../lib/types.ts";
|
|
15
18
|
import { log } from "../lib/log.ts";
|
|
16
19
|
|
|
@@ -41,6 +44,14 @@ export async function runStopHook(): Promise<number> {
|
|
|
41
44
|
const pinnedSid = process.env.TOKENMAXXING_SESSION_ID;
|
|
42
45
|
|
|
43
46
|
try {
|
|
47
|
+
// Additive relay turn-done marker (never writes into respawn/). Only when
|
|
48
|
+
// a registry entry exists for this relay session.
|
|
49
|
+
const relaySid = process.env[RELAY_SESSION_ENV];
|
|
50
|
+
if (relaySid != null && registryHas({ sessionId: relaySid })) {
|
|
51
|
+
writeTurnDoneMarker({ sessionId: relaySid, source: "claude-stop" });
|
|
52
|
+
log("stop.relay_turn_done", { session: relaySid.slice(0, 8) });
|
|
53
|
+
}
|
|
54
|
+
|
|
44
55
|
// Anticipatory depleted swaps are only sane when the respawn marker below
|
|
45
56
|
// will actually pause the session until the reset.
|
|
46
57
|
const canPause = process.env.TOKENMAXXING_SUPERVISED === "1" && pinnedSid != null;
|
package/src/lib/install.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// The wrapper is a 2-line `exec ... __supervise "$@"` shim so dispatch never
|
|
3
3
|
// depends on argv0 semantics.
|
|
4
4
|
|
|
5
|
-
import { appendFileSync, existsSync, mkdirSync, readFileSync, realpathSync, rmSync, statSync } from "node:fs";
|
|
5
|
+
import { accessSync, appendFileSync, constants, existsSync, mkdirSync, readFileSync, realpathSync, rmSync, statSync } from "node:fs";
|
|
6
6
|
import { basename, dirname, join } from "node:path";
|
|
7
7
|
import { escape } from "es-toolkit";
|
|
8
8
|
import { z } from "zod";
|
|
@@ -54,6 +54,38 @@ export function skipImperativeTimer(): boolean {
|
|
|
54
54
|
return EnvFlagSchema.parse(process.env.TOKENMAXXING_SKIP_TIMER) != null;
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
+
function isNixStorePath(path: string): boolean {
|
|
58
|
+
return path === "/nix/store" || path.startsWith("/nix/store/");
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function isEacces(e: unknown): boolean {
|
|
62
|
+
return typeof e === "object" && e != null && "code" in e && e.code === "EACCES";
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Home Manager (and similar) point ~/.zshrc at a nix-store file. Writing
|
|
66
|
+
* through that symlink throws EACCES; soft-skip instead. Still write through
|
|
67
|
+
* ordinary writable symlink targets (PR #36). */
|
|
68
|
+
function cannotWriteRcTarget(target: string): boolean {
|
|
69
|
+
if (EnvFlagSchema.parse(process.env.TOKENMAXXING_SKIP_SHELL_RC) != null) return true;
|
|
70
|
+
if (isNixStorePath(target)) return true;
|
|
71
|
+
if (!existsSync(target)) return false;
|
|
72
|
+
try {
|
|
73
|
+
accessSync(target, constants.W_OK);
|
|
74
|
+
return false;
|
|
75
|
+
} catch {
|
|
76
|
+
return true;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** User-facing lines when ensurePathInRc soft-skips a managed shell rc. */
|
|
81
|
+
export function managedShellRcSkipLines(): { headline: string; detail: string; exportLine: string } {
|
|
82
|
+
return {
|
|
83
|
+
headline: "shell rc is managed (Home Manager / nix-store) - PATH was not auto-edited",
|
|
84
|
+
detail: `put ${paths.binDir} on PATH via home.sessionPath (programs.tokenmaxxing Home Manager module sets this), e.g.`,
|
|
85
|
+
exportLine: `home.sessionPath = [ "${paths.binDir}" ];`,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
57
89
|
/** Nix supervisor shim: prefer a PATH-stable `tokenmaxxing` (profile /
|
|
58
90
|
* current-system, excluding this binDir) so upgrades/GC of an old store
|
|
59
91
|
* generation stay reachable; fall back to bun+entry for the rare
|
|
@@ -394,8 +426,11 @@ export function shellRcPath(): string | null {
|
|
|
394
426
|
const PATH_LINE_MARK = "# tokenmaxxing PATH";
|
|
395
427
|
|
|
396
428
|
/** Idempotently append the supervisor-bin PATH line to `rc` (created if absent).
|
|
397
|
-
* A pre-existing hand-added line for the bin dir also counts as present.
|
|
398
|
-
|
|
429
|
+
* A pre-existing hand-added line for the bin dir also counts as present.
|
|
430
|
+
* Returns `"skipped"` when the resolved target is immutable (nix-store /
|
|
431
|
+
* non-writable / TOKENMAXXING_SKIP_SHELL_RC) so callers can print guidance
|
|
432
|
+
* instead of surfacing EACCES. */
|
|
433
|
+
export function ensurePathInRc(rc: string): "added" | "present" | "skipped" {
|
|
399
434
|
const dir = paths.binDir.startsWith(`${HOME}/`) ? `$HOME${paths.binDir.slice(HOME.length)}` : paths.binDir;
|
|
400
435
|
// Write through a dotfile-managed symlink, never over it: writeFileAtomic
|
|
401
436
|
// renames a sibling temp over its target, which would replace the link with
|
|
@@ -413,17 +448,29 @@ export function ensurePathInRc(rc: string): "added" | "present" {
|
|
|
413
448
|
// relocation.
|
|
414
449
|
const kept = lines.filter((line) => isCurrentExport(line) || !line.includes(PATH_LINE_MARK));
|
|
415
450
|
if (kept.length !== lines.length) {
|
|
451
|
+
if (cannotWriteRcTarget(target)) return "skipped";
|
|
416
452
|
const body = kept.join("\n");
|
|
417
453
|
const sep0 = body === "" || body.endsWith("\n") ? "" : "\n";
|
|
418
454
|
const addition = kept.some(isCurrentExport) ? "" : `export PATH="${dir}:$PATH" ${PATH_LINE_MARK}\n`;
|
|
419
455
|
// preserve the rc's own mode: writeFileAtomic defaults to 0600, which
|
|
420
456
|
// would silently tighten a normally 0644 shell rc (PR #36 review catch)
|
|
421
|
-
|
|
457
|
+
try {
|
|
458
|
+
writeFileAtomic(target, `${body}${sep0}${addition}`, statSync(target).mode & 0o777);
|
|
459
|
+
} catch (e) {
|
|
460
|
+
if (isEacces(e)) return "skipped";
|
|
461
|
+
throw e;
|
|
462
|
+
}
|
|
422
463
|
return "added";
|
|
423
464
|
}
|
|
424
465
|
if (lines.some(isCurrentExport)) return "present";
|
|
466
|
+
if (cannotWriteRcTarget(target)) return "skipped";
|
|
425
467
|
const sep = current === "" || current.endsWith("\n") ? "" : "\n";
|
|
426
|
-
|
|
468
|
+
try {
|
|
469
|
+
appendFileSync(target, `${sep}export PATH="${dir}:$PATH" ${PATH_LINE_MARK}\n`);
|
|
470
|
+
} catch (e) {
|
|
471
|
+
if (isEacces(e)) return "skipped";
|
|
472
|
+
throw e;
|
|
473
|
+
}
|
|
427
474
|
return "added";
|
|
428
475
|
}
|
|
429
476
|
|
|
@@ -468,7 +515,8 @@ export function findClaudeShadowers(rcText: string): ShellShadower[] {
|
|
|
468
515
|
* PATH` line pointing at an emptied binDir is exactly how the supervisor
|
|
469
516
|
* recursion incident started (.memory/supervisor-recursion-guards.md), so
|
|
470
517
|
* uninstall must not leave one behind (closing-review catch).
|
|
471
|
-
* Returns true when a line was removed.
|
|
518
|
+
* Returns true when a line was removed. Soft-skips (returns false, no throw)
|
|
519
|
+
* when the resolved target is immutable. */
|
|
472
520
|
export function removePathFromRc(rc: string): boolean {
|
|
473
521
|
if (!existsSync(rc)) return false;
|
|
474
522
|
// same symlink + mode treatment as ensurePathInRc: write through a
|
|
@@ -477,7 +525,13 @@ export function removePathFromRc(rc: string): boolean {
|
|
|
477
525
|
const lines = readFileSync(target, "utf8").split("\n");
|
|
478
526
|
const kept = lines.filter((line) => !line.includes(PATH_LINE_MARK));
|
|
479
527
|
if (kept.length === lines.length) return false;
|
|
480
|
-
|
|
528
|
+
if (cannotWriteRcTarget(target)) return false;
|
|
529
|
+
try {
|
|
530
|
+
writeFileAtomic(target, kept.join("\n"), statSync(target).mode & 0o777);
|
|
531
|
+
} catch (e) {
|
|
532
|
+
if (isEacces(e)) return false;
|
|
533
|
+
throw e;
|
|
534
|
+
}
|
|
481
535
|
return true;
|
|
482
536
|
}
|
|
483
537
|
|
package/src/lib/paths.ts
CHANGED
|
@@ -36,6 +36,9 @@ export const paths = {
|
|
|
36
36
|
sampleDir: join(TM_HOME, "sample"),
|
|
37
37
|
/** linux only: parked credential .json files (0700 dir, 0600 files). */
|
|
38
38
|
credsDir: join(TM_HOME, "creds"),
|
|
39
|
+
/** Durable tmux relay companion: config + per-session state (not respawn/). */
|
|
40
|
+
relayJson: join(TM_HOME, "relay.json"),
|
|
41
|
+
relayDir: join(TM_HOME, "relay"),
|
|
39
42
|
|
|
40
43
|
/** ~/.claude.json - holds the active `oauthAccount` identity object. */
|
|
41
44
|
claudeJson: env("TOKENMAXXING_CLAUDE_JSON", join(HOME, ".claude.json")),
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
// $TOKENMAXXING_HOME/relay.json - sparse overrides merged with defaults.
|
|
2
|
+
|
|
3
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
import { paths } from "../paths.ts";
|
|
6
|
+
import { writeFileAtomic } from "../atomic.ts";
|
|
7
|
+
import { ClaudePermissionModeSchema } from "./modes.ts";
|
|
8
|
+
|
|
9
|
+
const WorkerSchema = z.enum(["claude", "codex"]);
|
|
10
|
+
export type RelayWorker = z.infer<typeof WorkerSchema>;
|
|
11
|
+
|
|
12
|
+
const HostsSchema = z.object({
|
|
13
|
+
cursor: z.object({ model: z.string().min(1).optional() }).default({}),
|
|
14
|
+
claude: z.object({ model: z.string().min(1).optional() }).default({}),
|
|
15
|
+
}).default({ cursor: {}, claude: {} });
|
|
16
|
+
|
|
17
|
+
export const RelayConfigFileSchema = z.object({
|
|
18
|
+
defaultWorker: WorkerSchema.optional(),
|
|
19
|
+
defaultPermissionMode: ClaudePermissionModeSchema.optional(),
|
|
20
|
+
turnTimeoutMs: z.number().int().positive().optional(),
|
|
21
|
+
decideTimeoutMs: z.number().int().positive().optional(),
|
|
22
|
+
idleTtlMs: z.number().int().positive().optional(),
|
|
23
|
+
sameSessionBusyMs: z.number().int().positive().optional(),
|
|
24
|
+
sessionPrefix: z.string().min(1).optional(),
|
|
25
|
+
hosts: HostsSchema.optional(),
|
|
26
|
+
});
|
|
27
|
+
export type RelayConfigFile = z.infer<typeof RelayConfigFileSchema>;
|
|
28
|
+
|
|
29
|
+
export const RelayConfigSchema = z.object({
|
|
30
|
+
defaultWorker: WorkerSchema,
|
|
31
|
+
defaultPermissionMode: ClaudePermissionModeSchema,
|
|
32
|
+
turnTimeoutMs: z.number().int().positive(),
|
|
33
|
+
decideTimeoutMs: z.number().int().positive(),
|
|
34
|
+
idleTtlMs: z.number().int().positive(),
|
|
35
|
+
sameSessionBusyMs: z.number().int().positive(),
|
|
36
|
+
sessionPrefix: z.string().min(1),
|
|
37
|
+
hosts: z.object({
|
|
38
|
+
cursor: z.object({ model: z.string().min(1).optional() }),
|
|
39
|
+
claude: z.object({ model: z.string().min(1).optional() }),
|
|
40
|
+
}),
|
|
41
|
+
});
|
|
42
|
+
export type RelayConfig = z.infer<typeof RelayConfigSchema>;
|
|
43
|
+
|
|
44
|
+
export const DEFAULT_RELAY_CONFIG: RelayConfig = {
|
|
45
|
+
defaultWorker: "claude",
|
|
46
|
+
defaultPermissionMode: "auto",
|
|
47
|
+
turnTimeoutMs: 30 * 60 * 1000,
|
|
48
|
+
decideTimeoutMs: 30 * 60 * 1000,
|
|
49
|
+
idleTtlMs: 60 * 60 * 1000,
|
|
50
|
+
sameSessionBusyMs: 50,
|
|
51
|
+
sessionPrefix: "xx-relay-",
|
|
52
|
+
hosts: { cursor: {}, claude: {} },
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
export function loadRelayConfig(): RelayConfig {
|
|
56
|
+
if (!existsSync(paths.relayJson)) return DEFAULT_RELAY_CONFIG;
|
|
57
|
+
const raw = RelayConfigFileSchema.parse(JSON.parse(readFileSync(paths.relayJson, "utf8")));
|
|
58
|
+
return RelayConfigSchema.parse({
|
|
59
|
+
...DEFAULT_RELAY_CONFIG,
|
|
60
|
+
...raw,
|
|
61
|
+
hosts: {
|
|
62
|
+
cursor: { ...DEFAULT_RELAY_CONFIG.hosts.cursor, ...raw.hosts?.cursor },
|
|
63
|
+
claude: { ...DEFAULT_RELAY_CONFIG.hosts.claude, ...raw.hosts?.claude },
|
|
64
|
+
},
|
|
65
|
+
defaultPermissionMode: raw.defaultPermissionMode ?? DEFAULT_RELAY_CONFIG.defaultPermissionMode,
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function writeRelayConfig(input: { file: RelayConfigFile }): void {
|
|
70
|
+
const validated = RelayConfigFileSchema.parse(input.file);
|
|
71
|
+
writeFileAtomic(paths.relayJson, JSON.stringify(validated, null, 2) + "\n");
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function mergeRelayConfigFile(input: { patch: RelayConfigFile }): RelayConfig {
|
|
75
|
+
const existing = existsSync(paths.relayJson)
|
|
76
|
+
? RelayConfigFileSchema.parse(JSON.parse(readFileSync(paths.relayJson, "utf8")))
|
|
77
|
+
: {};
|
|
78
|
+
const next = RelayConfigFileSchema.parse({ ...existing, ...input.patch, hosts: {
|
|
79
|
+
cursor: { ...existing.hosts?.cursor, ...input.patch.hosts?.cursor },
|
|
80
|
+
claude: { ...existing.hosts?.claude, ...input.patch.hosts?.claude },
|
|
81
|
+
} });
|
|
82
|
+
writeRelayConfig({ file: next });
|
|
83
|
+
return loadRelayConfig();
|
|
84
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
// relay decide: approve/deny a pending permission ping and resume the wait.
|
|
2
|
+
|
|
3
|
+
import { delay } from "es-toolkit";
|
|
4
|
+
import { loadRelayConfig } from "./config.ts";
|
|
5
|
+
import {
|
|
6
|
+
clearPendingRequest,
|
|
7
|
+
listPendingRequests,
|
|
8
|
+
readPendingRequest,
|
|
9
|
+
writeDecision,
|
|
10
|
+
} from "./markers.ts";
|
|
11
|
+
import { runTurn, type TurnResult } from "./turn.ts";
|
|
12
|
+
import { readEntry, touchEntry, withSessionLock } from "./registry.ts";
|
|
13
|
+
|
|
14
|
+
export type DecideParams = {
|
|
15
|
+
sessionId: string;
|
|
16
|
+
requestId?: string;
|
|
17
|
+
approve: boolean;
|
|
18
|
+
/** After writing the decision, wait for turn-done or the next ping. */
|
|
19
|
+
wait?: boolean;
|
|
20
|
+
cwd?: string;
|
|
21
|
+
timeoutMs?: number;
|
|
22
|
+
now?: () => number;
|
|
23
|
+
sleep?: (ms: number) => Promise<void>;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export async function runDecide(input: DecideParams): Promise<{
|
|
27
|
+
decisionWritten: boolean;
|
|
28
|
+
requestId: string;
|
|
29
|
+
turn?: TurnResult;
|
|
30
|
+
}> {
|
|
31
|
+
const entry = readEntry({ sessionId: input.sessionId });
|
|
32
|
+
if (entry == null) throw new Error(`relay session not found: ${input.sessionId}`);
|
|
33
|
+
|
|
34
|
+
let requestId = input.requestId ?? entry.pendingRequestId;
|
|
35
|
+
if (requestId == null) {
|
|
36
|
+
const pending = listPendingRequests({ sessionId: input.sessionId });
|
|
37
|
+
requestId = pending[0]?.requestId;
|
|
38
|
+
}
|
|
39
|
+
if (requestId == null) throw new Error(`no pending permission request for session ${input.sessionId}`);
|
|
40
|
+
|
|
41
|
+
const pending = readPendingRequest({ sessionId: input.sessionId, requestId });
|
|
42
|
+
if (pending == null) throw new Error(`pending request not found: ${requestId}`);
|
|
43
|
+
|
|
44
|
+
writeDecision({
|
|
45
|
+
sessionId: input.sessionId,
|
|
46
|
+
requestId,
|
|
47
|
+
approve: input.approve,
|
|
48
|
+
now: (input.now ?? Date.now)(),
|
|
49
|
+
});
|
|
50
|
+
clearPendingRequest({ sessionId: input.sessionId, requestId });
|
|
51
|
+
await withSessionLock({
|
|
52
|
+
sessionId: input.sessionId,
|
|
53
|
+
fn: () => touchEntry({
|
|
54
|
+
sessionId: input.sessionId,
|
|
55
|
+
state: "running",
|
|
56
|
+
pendingRequestId: null,
|
|
57
|
+
now: (input.now ?? Date.now)(),
|
|
58
|
+
}),
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
if (input.wait === false) {
|
|
62
|
+
return { decisionWritten: true, requestId };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const cfg = loadRelayConfig();
|
|
66
|
+
const turn = await runTurn({
|
|
67
|
+
sessionId: input.sessionId,
|
|
68
|
+
cwd: input.cwd ?? entry.cwd,
|
|
69
|
+
waitOnly: true,
|
|
70
|
+
timeoutMs: input.timeoutMs ?? cfg.decideTimeoutMs,
|
|
71
|
+
now: input.now,
|
|
72
|
+
sleep: input.sleep ?? delay,
|
|
73
|
+
});
|
|
74
|
+
return { decisionWritten: true, requestId, turn };
|
|
75
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
// relay destroy / gc / status. Exact tmux session names only; never pattern-kill.
|
|
2
|
+
|
|
3
|
+
import { rmSync } from "node:fs";
|
|
4
|
+
import { loadRelayConfig } from "./config.ts";
|
|
5
|
+
import { clearSessionMarkers } from "./markers.ts";
|
|
6
|
+
import {
|
|
7
|
+
deleteEntry,
|
|
8
|
+
listEntries,
|
|
9
|
+
readEntry,
|
|
10
|
+
sessionLockPath,
|
|
11
|
+
withSessionLock,
|
|
12
|
+
type RelayRegistryEntry,
|
|
13
|
+
} from "./registry.ts";
|
|
14
|
+
import { getTmux } from "./tmux.ts";
|
|
15
|
+
|
|
16
|
+
export async function destroySession(input: { sessionId: string }): Promise<boolean> {
|
|
17
|
+
const entry = readEntry({ sessionId: input.sessionId });
|
|
18
|
+
if (entry == null) {
|
|
19
|
+
// Still try exact tmux name from config prefix in case registry was lost.
|
|
20
|
+
return false;
|
|
21
|
+
}
|
|
22
|
+
await withSessionLock({
|
|
23
|
+
sessionId: input.sessionId,
|
|
24
|
+
fn: () => {
|
|
25
|
+
getTmux().killSession({ name: entry.tmuxName });
|
|
26
|
+
clearSessionMarkers({ sessionId: input.sessionId });
|
|
27
|
+
deleteEntry({ sessionId: input.sessionId });
|
|
28
|
+
rmSync(sessionLockPath({ sessionId: input.sessionId }), { force: true });
|
|
29
|
+
},
|
|
30
|
+
});
|
|
31
|
+
return true;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export type GcResult = {
|
|
35
|
+
reaped: string[];
|
|
36
|
+
kept: string[];
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
/** Reap dead tmux sessions and idle sessions past idleTtlMs. */
|
|
40
|
+
export async function gcSessions(input: { now?: number; idleTtlMs?: number } = {}): Promise<GcResult> {
|
|
41
|
+
const cfg = loadRelayConfig();
|
|
42
|
+
const now = input.now ?? Date.now();
|
|
43
|
+
const idleTtlMs = input.idleTtlMs ?? cfg.idleTtlMs;
|
|
44
|
+
const reaped: string[] = [];
|
|
45
|
+
const kept: string[] = [];
|
|
46
|
+
const tmux = getTmux();
|
|
47
|
+
|
|
48
|
+
for (const entry of listEntries()) {
|
|
49
|
+
const alive = tmux.hasSession({ name: entry.tmuxName });
|
|
50
|
+
const idleTooLong = now - entry.lastActiveAt > idleTtlMs;
|
|
51
|
+
if (!alive || idleTooLong || entry.state === "destroyed") {
|
|
52
|
+
await destroySession({ sessionId: entry.sessionId });
|
|
53
|
+
reaped.push(entry.sessionId);
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
kept.push(entry.sessionId);
|
|
57
|
+
}
|
|
58
|
+
return { reaped, kept };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function statusSessions(): RelayRegistryEntry[] {
|
|
62
|
+
const tmux = getTmux();
|
|
63
|
+
return listEntries().map((entry) => ({
|
|
64
|
+
...entry,
|
|
65
|
+
// annotate liveness in a non-schema field via spread for printers; keep schema pure
|
|
66
|
+
})).map((entry) => {
|
|
67
|
+
void tmux.hasSession({ name: entry.tmuxName });
|
|
68
|
+
return entry;
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export type StatusRow = RelayRegistryEntry & { tmuxAlive: boolean };
|
|
73
|
+
|
|
74
|
+
export function statusRows(): StatusRow[] {
|
|
75
|
+
const tmux = getTmux();
|
|
76
|
+
return listEntries().map((entry) => ({
|
|
77
|
+
...entry,
|
|
78
|
+
tmuxAlive: tmux.hasSession({ name: entry.tmuxName }),
|
|
79
|
+
}));
|
|
80
|
+
}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
// relay install: write thin host agent templates + optional hook snippets.
|
|
2
|
+
// Merge only tokenmaxxing-owned keys.
|
|
3
|
+
|
|
4
|
+
import { existsSync, mkdirSync, readFileSync, cpSync } from "node:fs";
|
|
5
|
+
import { dirname, join } from "node:path";
|
|
6
|
+
import { z } from "zod";
|
|
7
|
+
import { writeFileAtomic } from "../atomic.ts";
|
|
8
|
+
import { paths } from "../paths.ts";
|
|
9
|
+
import { isOurHookCommand } from "../settings.ts";
|
|
10
|
+
|
|
11
|
+
const pluginRoot = () => join(import.meta.dir, "../../../agent-plugin");
|
|
12
|
+
|
|
13
|
+
function repoAgentsDir(): string {
|
|
14
|
+
return join(pluginRoot(), "agents");
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function repoSkillDir(): string {
|
|
18
|
+
return join(pluginRoot(), "skills", "relay-session");
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function repoHooksDir(): string {
|
|
22
|
+
return join(pluginRoot(), "hooks");
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export type InstallTarget = "cursor" | "claude" | "all";
|
|
26
|
+
|
|
27
|
+
export type InstallResult = {
|
|
28
|
+
agentsWritten: string[];
|
|
29
|
+
skillWritten: boolean;
|
|
30
|
+
hooksMerged: string[];
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
function copyAgent(input: { name: string; destDir: string }): string {
|
|
34
|
+
mkdirSync(input.destDir, { recursive: true });
|
|
35
|
+
const src = join(repoAgentsDir(), input.name);
|
|
36
|
+
const dest = join(input.destDir, input.name);
|
|
37
|
+
if (!existsSync(src)) throw new Error(`missing agent template: ${src}`);
|
|
38
|
+
cpSync(src, dest);
|
|
39
|
+
return dest;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function copySkill(input: { destDir: string }): boolean {
|
|
43
|
+
const src = repoSkillDir();
|
|
44
|
+
if (!existsSync(src)) return false;
|
|
45
|
+
mkdirSync(input.destDir, { recursive: true });
|
|
46
|
+
cpSync(src, input.destDir, { recursive: true });
|
|
47
|
+
return true;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const CursorHooksSchema = z.looseObject({
|
|
51
|
+
version: z.number().optional(),
|
|
52
|
+
hooks: z.record(z.string(), z.unknown()).optional(),
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
const TOKENMAXXING_HOOK_KEY = "tokenmaxxingRelay";
|
|
56
|
+
|
|
57
|
+
/** Merge Cursor hooks.json: only the tokenmaxxingRelay key is ours. */
|
|
58
|
+
export function mergeCursorHooks(input: { hooksPath: string }): boolean {
|
|
59
|
+
const snippetPath = join(repoHooksDir(), "cursor-relay.json");
|
|
60
|
+
if (!existsSync(snippetPath)) return false;
|
|
61
|
+
const snippet = CursorHooksSchema.parse(JSON.parse(readFileSync(snippetPath, "utf8")));
|
|
62
|
+
let existing: z.infer<typeof CursorHooksSchema> = { version: 1, hooks: {} };
|
|
63
|
+
if (existsSync(input.hooksPath)) {
|
|
64
|
+
try {
|
|
65
|
+
existing = CursorHooksSchema.parse(JSON.parse(readFileSync(input.hooksPath, "utf8")));
|
|
66
|
+
} catch {
|
|
67
|
+
existing = { version: 1, hooks: {} };
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
existing.hooks ??= {};
|
|
71
|
+
const ours = snippet.hooks?.[TOKENMAXXING_HOOK_KEY];
|
|
72
|
+
if (ours === undefined) return false;
|
|
73
|
+
existing.hooks[TOKENMAXXING_HOOK_KEY] = ours;
|
|
74
|
+
mkdirSync(dirname(input.hooksPath), { recursive: true });
|
|
75
|
+
writeFileAtomic(input.hooksPath, JSON.stringify(existing, null, 2) + "\n");
|
|
76
|
+
return true;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const ClaudeSettingsLoose = z.looseObject({
|
|
80
|
+
hooks: z.record(z.string(), z.array(z.looseObject({
|
|
81
|
+
matcher: z.string().optional(),
|
|
82
|
+
hooks: z.array(z.looseObject({ type: z.string(), command: z.string() })).default([]),
|
|
83
|
+
}))).optional(),
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
const RELAY_PERM_SUB = "__relay-permission-hook";
|
|
87
|
+
|
|
88
|
+
/** Append PermissionRequest hook for relay; never remove foreign hooks. */
|
|
89
|
+
export function mergeClaudeRelayPermissionHook(input: { settingsPath?: string } = {}): boolean {
|
|
90
|
+
const settingsPath = input.settingsPath ?? paths.claudeSettings;
|
|
91
|
+
const bin = join(paths.binDir, "tokenmaxxing");
|
|
92
|
+
const command = `${JSON.stringify(bin)} ${RELAY_PERM_SUB}`;
|
|
93
|
+
let settings: z.infer<typeof ClaudeSettingsLoose> = {};
|
|
94
|
+
if (existsSync(settingsPath)) {
|
|
95
|
+
settings = ClaudeSettingsLoose.parse(JSON.parse(readFileSync(settingsPath, "utf8")));
|
|
96
|
+
}
|
|
97
|
+
settings.hooks ??= {};
|
|
98
|
+
settings.hooks.PermissionRequest ??= [];
|
|
99
|
+
const arr = settings.hooks.PermissionRequest;
|
|
100
|
+
const present = arr.some((g) => g.hooks.some((h) => h.command === command || isOurHookCommand(h.command, RELAY_PERM_SUB)));
|
|
101
|
+
if (!present) {
|
|
102
|
+
arr.push({ hooks: [{ type: "command", command }] });
|
|
103
|
+
}
|
|
104
|
+
mkdirSync(dirname(settingsPath), { recursive: true });
|
|
105
|
+
const mode = existsSync(settingsPath) ? 0o600 : 0o600;
|
|
106
|
+
writeFileAtomic(settingsPath, JSON.stringify(settings, null, 2) + "\n", mode);
|
|
107
|
+
return true;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function installRelayHosts(input: {
|
|
111
|
+
target: InstallTarget;
|
|
112
|
+
cursorAgentsDir?: string;
|
|
113
|
+
claudeAgentsDir?: string;
|
|
114
|
+
cursorHooksPath?: string;
|
|
115
|
+
mergeHooks?: boolean;
|
|
116
|
+
}): InstallResult {
|
|
117
|
+
const home = process.env.HOME ?? "";
|
|
118
|
+
const cursorAgents = input.cursorAgentsDir ?? join(home, ".cursor", "agents");
|
|
119
|
+
const claudeAgents = input.claudeAgentsDir ?? join(home, ".claude", "agents");
|
|
120
|
+
const agentsWritten: string[] = [];
|
|
121
|
+
const hooksMerged: string[] = [];
|
|
122
|
+
let skillWritten = false;
|
|
123
|
+
|
|
124
|
+
if (input.target === "cursor" || input.target === "all") {
|
|
125
|
+
agentsWritten.push(copyAgent({ name: "tokenmaxxing-claude.md", destDir: cursorAgents }));
|
|
126
|
+
agentsWritten.push(copyAgent({ name: "tokenmaxxing-codex.md", destDir: cursorAgents }));
|
|
127
|
+
skillWritten = copySkill({ destDir: join(home, ".cursor", "skills", "relay-session") }) || skillWritten;
|
|
128
|
+
if (input.mergeHooks !== false) {
|
|
129
|
+
const hooksPath = input.cursorHooksPath ?? join(home, ".cursor", "hooks.json");
|
|
130
|
+
if (mergeCursorHooks({ hooksPath })) hooksMerged.push(hooksPath);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
if (input.target === "claude" || input.target === "all") {
|
|
134
|
+
agentsWritten.push(copyAgent({ name: "tokenmaxxing-claude.md", destDir: claudeAgents }));
|
|
135
|
+
agentsWritten.push(copyAgent({ name: "tokenmaxxing-codex.md", destDir: claudeAgents }));
|
|
136
|
+
if (input.mergeHooks !== false) {
|
|
137
|
+
if (mergeClaudeRelayPermissionHook()) hooksMerged.push(paths.claudeSettings);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
return { agentsWritten, skillWritten, hooksMerged };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export { RELAY_PERM_SUB };
|