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.
@@ -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
+ }
@@ -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;
@@ -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
- export function ensurePathInRc(rc: string): "added" | "present" {
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
- writeFileAtomic(target, `${body}${sep0}${addition}`, statSync(target).mode & 0o777);
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
- appendFileSync(target, `${sep}export PATH="${dir}:$PATH" ${PATH_LINE_MARK}\n`);
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
- writeFileAtomic(target, kept.join("\n"), statSync(target).mode & 0o777);
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 };