tokenmaxxing 1.6.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.
Files changed (58) hide show
  1. package/DESIGN.md +5 -31
  2. package/LICENSE +21 -0
  3. package/README.md +1 -2
  4. package/agent-plugin/agents/tokenmaxxing-claude.md +43 -0
  5. package/agent-plugin/agents/tokenmaxxing-codex.md +40 -0
  6. package/agent-plugin/bin/tokenmaxxing-mcp +7 -0
  7. package/agent-plugin/hooks/cursor-relay.json +14 -0
  8. package/agent-plugin/mcp.json +10 -0
  9. package/agent-plugin/plugin.json +20 -0
  10. package/agent-plugin/skills/codex-pool/SKILL.md +23 -0
  11. package/agent-plugin/skills/codex-pool/references/codex.md +5 -0
  12. package/agent-plugin/skills/credentials-hygiene/SKILL.md +26 -0
  13. package/agent-plugin/skills/credentials-hygiene/references/credentials.md +6 -0
  14. package/agent-plugin/skills/doctor-diagnostics/SKILL.md +26 -0
  15. package/agent-plugin/skills/doctor-diagnostics/references/troubleshooting.md +5 -0
  16. package/agent-plugin/skills/pool-status/SKILL.md +27 -0
  17. package/agent-plugin/skills/pool-status/references/commands.md +8 -0
  18. package/agent-plugin/skills/relay-session/SKILL.md +118 -0
  19. package/agent-plugin/skills/relay-session/references/ipc.md +23 -0
  20. package/agent-plugin/skills/safe-contribution/SKILL.md +27 -0
  21. package/agent-plugin/skills/safe-contribution/references/ship.md +5 -0
  22. package/agent-plugin/skills/sdk-pairing/SKILL.md +33 -0
  23. package/agent-plugin/skills/sdk-pairing/references/sdk.md +6 -0
  24. package/agent-plugin/skills/switching-policy/SKILL.md +29 -0
  25. package/agent-plugin/skills/switching-policy/references/policy.md +7 -0
  26. package/package.json +3 -5
  27. package/src/cli/codexinit.ts +11 -2
  28. package/src/cli/init.ts +9 -3
  29. package/src/cli/relay.ts +323 -0
  30. package/src/entries/codexstophook.ts +10 -0
  31. package/src/entries/mcp.ts +288 -0
  32. package/src/entries/relaypermission.ts +105 -0
  33. package/src/entries/stophook.ts +11 -0
  34. package/src/lib/decide.ts +2 -4
  35. package/src/lib/install.ts +61 -7
  36. package/src/lib/lock.ts +3 -7
  37. package/src/lib/log.ts +8 -11
  38. package/src/lib/paths.ts +3 -9
  39. package/src/lib/relay/config.ts +84 -0
  40. package/src/lib/relay/decide.ts +75 -0
  41. package/src/lib/relay/gc.ts +80 -0
  42. package/src/lib/relay/install.ts +143 -0
  43. package/src/lib/relay/markers.ts +148 -0
  44. package/src/lib/relay/modes.ts +82 -0
  45. package/src/lib/relay/protocol.ts +61 -0
  46. package/src/lib/relay/registry.ts +175 -0
  47. package/src/lib/relay/tmux.ts +109 -0
  48. package/src/lib/relay/turn.ts +137 -0
  49. package/src/lib/relay/worker.ts +141 -0
  50. package/src/lib/usage.ts +6 -5
  51. package/src/main.ts +6 -6
  52. package/src/cli/serve.ts +0 -1790
  53. package/src/lib/slackbridge.ts +0 -1363
  54. package/src/lib/slackstate.ts +0 -352
  55. package/src/lib/slackstream.ts +0 -300
  56. package/src/serve-plugin/.claude-plugin/plugin.json +0 -4
  57. package/src/serve-plugin/skills/ask-the-user/SKILL.md +0 -41
  58. package/src/serve-plugin/skills/serve-session/SKILL.md +0 -50
@@ -0,0 +1,33 @@
1
+ ---
2
+ name: sdk-pairing
3
+ description: Pair tokenmaxxing with the Claude Agent SDK (ensureBestAccount, pooledOptions, stopHookCheck). Use when building or debugging Bun agents that spawn Claude through the pooled credential store.
4
+ ---
5
+
6
+ # SDK pairing
7
+
8
+ Import from the package root (`tokenmaxxing` → `src/sdk.ts`). Bun only.
9
+
10
+ ```ts
11
+ import { query } from "@anthropic-ai/claude-agent-sdk";
12
+ import { ensureBestAccount, pooledOptions, stopHookCheck } from "tokenmaxxing";
13
+
14
+ await ensureBestAccount();
15
+ for await (const message of query({
16
+ prompt: "...",
17
+ options: {
18
+ ...pooledOptions(),
19
+ hooks: { Stop: [{ hooks: [stopHookCheck] }] },
20
+ },
21
+ })) {
22
+ // capture session id from init for resume across swaps
23
+ }
24
+ ```
25
+
26
+ ## Rules
27
+
28
+ - Call `ensureBestAccount()` before each `query()` spawn (no mid-query hot-swap).
29
+ - `pooledOptions()` pins the real claude binary and a full replacement env with credential overrides scrubbed. It throws if `CLAUDE_CONFIG_DIR` or `CLAUDE_SECURESTORAGE_CONFIG_DIR` is set.
30
+ - `stopHookCheck` re-decides at turn boundaries; errors are swallowed so a broken check does not abort the turn.
31
+ - Own-accounts only (see terms docs). Offering pooled subscription logins to third parties is a ToS problem.
32
+
33
+ See [references/sdk.md](references/sdk.md).
@@ -0,0 +1,6 @@
1
+ # SDK references
2
+
3
+ - `src/sdk.ts` (self-documenting header)
4
+ - `docs/content/docs/sdk.mdx`
5
+ - `docs/content/docs/terms.mdx`
6
+ - `.memory/agent-sdk-auth-surface.md`
@@ -0,0 +1,29 @@
1
+ ---
2
+ name: switching-policy
3
+ description: Explain and apply tokenmaxxing switch policy (greedy vs hard path, pace pressure, Layer 2 wall squeeze Claude-only, model-family matching). Use before pool_switch, pool_check, or when changing thresholds.
4
+ ---
5
+
6
+ # Switching policy
7
+
8
+ ## Vocabulary
9
+
10
+ - **Engaged**: session used >= `policy.greedySessionFloor` (default 50) or any hard/screening bar crossed.
11
+ - **GREEDY path**: engaged but under every bar. Rank all accounts by pace pressure; keep seat on best-or-tie (`currentWins`); else swap to strictly better. Never depleted-waits or pre-parks.
12
+ - **HARD path**: a screening bar crossed. Swap to best usable target; if none, Layer 2 wall logic (Claude only).
13
+ - **Pace pressure**: remaining weekly percent / time to weekly reset (highest first). Not most-remaining.
14
+ - **Effective bars**: `effectiveBars(cfg)` = thresholds minus `policy.projectionMargin`. Trigger and screening must share these bars or swaps ping-pong.
15
+
16
+ ## Layer 2 (Claude only)
17
+
18
+ When the hard path finds no usable target, judge against the wall (`hardThresholds` minus margin). Under-wall seat HOLDS; walled seat swaps to best under-wall sibling. Codex has no Layer 2 last-drop swap (cannot hot-adopt).
19
+
20
+ ## Model matching
21
+
22
+ Match model families by exact token after splitting id/display on spaces, dots, and hyphens (`familyTokens` / `matchedFamily` in `src/lib/usage.ts`). Never exact full display strings (names drift: "Fable" / "Fable 5"). Unmeasured usage is unknown and ranks last, never 0 / first. Per-model weekly caps: Sonnet and Fable exist; only Fable gates a switch by default (`policy.switchModels`).
23
+
24
+ ## Agent actions
25
+
26
+ - Explain with this skill; mutate only via MCP `pool_switch` / `pool_check` with user approval, `confirm=true`, and `TOKENMAXXING_AGENT_MUTATIONS=1`.
27
+ - Do not reintroduce Stop-hook text-sniffing limit failsafes.
28
+
29
+ See [references/policy.md](references/policy.md).
@@ -0,0 +1,7 @@
1
+ # Policy sources
2
+
3
+ - `docs/content/docs/switching.mdx`
4
+ - `src/lib/decide.ts`, `src/lib/picker.ts` (inline rationale)
5
+ - `.memory/switch-policy-pace-pressure.md`
6
+
7
+ Default screening bars: session 95, weekly 98. Wall defaults 100. Cooldown 45s on the automatic path after a swap.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tokenmaxxing",
3
- "version": "1.6.0",
3
+ "version": "1.8.0",
4
4
  "description": "Automatic Claude Code account switching: pool multiple accounts and hot-swap when quota fills, resuming your session on the fresh account.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -19,6 +19,7 @@
19
19
  },
20
20
  "files": [
21
21
  "src",
22
+ "agent-plugin",
22
23
  "README.md",
23
24
  "DESIGN.md"
24
25
  ],
@@ -41,10 +42,7 @@
41
42
  "typescript": "^5.6.0"
42
43
  },
43
44
  "dependencies": {
44
- "@anthropic-ai/claude-agent-sdk": "^0.3.214",
45
- "@chat-adapter/slack": "^4.34.0",
46
- "@chat-adapter/state-memory": "^4.34.0",
47
- "chat": "^4.34.0",
45
+ "@modelcontextprotocol/sdk": "1.29.0",
48
46
  "es-toolkit": "^1.49.0",
49
47
  "ky": "^2.0.2",
50
48
  "zod": "^4.4.3"
@@ -11,7 +11,7 @@ import { codexIdentityOf, readLiveCodexAuth, writeParkedCodexAuth } from "../lib
11
11
  import { CodexUsageReadError, fetchCodexUsage } from "../lib/codexusage.ts";
12
12
  import { loadCodexAccounts, saveCodexAccounts } from "../lib/codexstate.ts";
13
13
  import { loadConfig, pinBinOverride } from "../lib/state.ts";
14
- import { installCodexSupervisor, codexSupervisorLink, ensurePathInRc, shellRcPath } from "../lib/install.ts";
14
+ import { installCodexSupervisor, codexSupervisorLink, ensurePathInRc, managedShellRcSkipLines, shellRcPath } from "../lib/install.ts";
15
15
  import { withLock } from "../lib/lock.ts";
16
16
  import { presentCodexAccountIds } from "../lib/codexpresence.ts";
17
17
  import { codexCredItemFor, codexPaths } from "../lib/paths.ts";
@@ -137,7 +137,16 @@ export async function cmdCodexInit(): Promise<number> {
137
137
 
138
138
  installCodexSupervisor();
139
139
  const rc = shellRcPath();
140
- if (rc) ensurePathInRc(rc);
140
+ if (rc) {
141
+ const pathOutcome = ensurePathInRc(rc);
142
+ if (pathOutcome === "skipped") {
143
+ const hint = managedShellRcSkipLines();
144
+ console.log();
145
+ console.log(c.yellow(`⚠ ${hint.headline}`));
146
+ console.log(c.yellow(` ${hint.detail}`));
147
+ console.log(c.yellow(` ${hint.exportLine}`));
148
+ }
149
+ }
141
150
 
142
151
  console.log();
143
152
  console.log(`${c.green("✓")} imported codex account ${c.bold(account.label)} (${account.planType ?? "?"})`);
package/src/cli/init.ts CHANGED
@@ -8,14 +8,15 @@ import { refreshCredential, isAccessTokenExpiring, fetchTokenOrg } from "../lib/
8
8
  import { withClaudeRefreshLock } from "../lib/claudelock.ts";
9
9
  import { loadAccounts, saveAccounts, loadConfig, pinBinOverride } from "../lib/state.ts";
10
10
  import { withLock } from "../lib/lock.ts";
11
- import { installSupervisor, shellRcPath, ensurePathInRc, timerActivationHint, type InstallOutcome } from "../lib/install.ts";
11
+ import { installSupervisor, shellRcPath, ensurePathInRc, managedShellRcSkipLines, timerActivationHint, type InstallOutcome } from "../lib/install.ts";
12
12
  import { resolveVerifiedClaude } from "../lib/claudebin.ts";
13
13
  import { credItemFor, paths } from "../lib/paths.ts";
14
14
  import { CredentialBlobSchema, type Account } from "../lib/types.ts";
15
15
  import { c, claudeTierLabel } from "./render.ts";
16
16
 
17
17
  /** Put the supervisor bin dir on PATH via the user's shell rc (idempotent).
18
- * Falls back to the manual instruction when the shell is unknown. */
18
+ * Falls back to the manual instruction when the shell is unknown or the rc
19
+ * target is immutable (Home Manager / nix-store). */
19
20
  function ensurePathAhead(): void {
20
21
  const rc = shellRcPath();
21
22
  if (!rc) {
@@ -24,7 +25,12 @@ function ensurePathAhead(): void {
24
25
  }
25
26
  const outcome = ensurePathInRc(rc);
26
27
  if (outcome === "added") console.log(`${c.green("✓")} added ${paths.binDir} to PATH in ${rc} - restart your shell (or \`source ${rc}\`)`);
27
- else console.log(c.yellow(`⚠ PATH line already in ${rc} - restart your shell to pick it up`));
28
+ else if (outcome === "skipped") {
29
+ const hint = managedShellRcSkipLines();
30
+ console.log(c.yellow(`⚠ ${hint.headline}`));
31
+ console.log(c.yellow(` ${hint.detail}`));
32
+ console.log(c.yellow(` ${hint.exportLine}`));
33
+ } else console.log(c.yellow(`⚠ PATH line already in ${rc} - restart your shell to pick it up`));
28
34
  }
29
35
 
30
36
  function reportTimer(out: InstallOutcome): void {
@@ -0,0 +1,323 @@
1
+ // `tokenmaxxing relay` - host-agnostic durable tmux relay companion.
2
+
3
+ import { cwd } from "node:process";
4
+ import { z } from "zod";
5
+ import { c } from "./render.ts";
6
+ import {
7
+ DEFAULT_RELAY_CONFIG,
8
+ loadRelayConfig,
9
+ mergeRelayConfigFile,
10
+ writeRelayConfig,
11
+ type RelayConfigFile,
12
+ } from "../lib/relay/config.ts";
13
+ import { runDecide } from "../lib/relay/decide.ts";
14
+ import { destroySession, gcSessions, statusRows } from "../lib/relay/gc.ts";
15
+ import { installRelayHosts, type InstallTarget } from "../lib/relay/install.ts";
16
+ import { parsePermissionMode, tryParsePermissionMode } from "../lib/relay/modes.ts";
17
+ import { runTurn } from "../lib/relay/turn.ts";
18
+ import { setLivePermissionMode } from "../lib/relay/worker.ts";
19
+ import { existsSync, readFileSync } from "node:fs";
20
+ import { paths } from "../lib/paths.ts";
21
+
22
+ function printHelp(): void {
23
+ console.log(`${c.bold("tokenmaxxing relay")} - durable tmux workers for host agents
24
+
25
+ ${c.cyan("relay turn")} [--worker claude|codex] [--session <uuid>] [--cwd <dir>]
26
+ [--permission-mode <mode>] [prompt...]
27
+ Ensure session, send prompt (argv or stdin), wait for turn-done or permission-needed
28
+ ${c.cyan("relay decide")} --session <uuid> [--request <id>] --approve|--deny [--no-wait]
29
+ Approve or deny a pending permission ping; by default wait for the next marker
30
+ ${c.cyan("relay set-permission-mode")} --session <uuid> --permission-mode <mode>
31
+ Change the live worker permission mode
32
+ ${c.cyan("relay status")} [--session <uuid>]
33
+ Inspect relay sessions
34
+ ${c.cyan("relay destroy")} --session <uuid>
35
+ Tear down one session (exact tmux name)
36
+ ${c.cyan("relay gc")}
37
+ Reap dead or idle sessions (idleTtlMs)
38
+ ${c.cyan("relay install")} [--target cursor|claude|all]
39
+ Write agent templates and merge tokenmaxxing-owned hook keys
40
+ ${c.cyan("relay config")} [get|set|show]
41
+ Inspect or edit $TOKENMAXXING_HOME/relay.json
42
+
43
+ Permission modes (Claude worker): default|acceptEdits|plan|auto|dontAsk|bypassPermissions
44
+ Alias: manual → default. Default in relay.json: auto.
45
+ Codex maps those names onto --sandbox / --ask-for-approval.`);
46
+ }
47
+
48
+ type FlagMap = {
49
+ worker?: "claude" | "codex";
50
+ session?: string;
51
+ cwd?: string;
52
+ permissionMode?: string;
53
+ request?: string;
54
+ approve?: boolean;
55
+ deny?: boolean;
56
+ noWait?: boolean;
57
+ target?: string;
58
+ positionals: string[];
59
+ };
60
+
61
+ function parseFlags(args: string[]): FlagMap {
62
+ const out: FlagMap = { positionals: [] };
63
+ for (let i = 0; i < args.length; i++) {
64
+ const a = args[i]!;
65
+ if (a === "--worker") {
66
+ const v = z.enum(["claude", "codex"]).parse(args[++i]);
67
+ out.worker = v;
68
+ } else if (a.startsWith("--worker=")) {
69
+ out.worker = z.enum(["claude", "codex"]).parse(a.slice("--worker=".length));
70
+ } else if (a === "--session") {
71
+ out.session = z.string().min(1).parse(args[++i]);
72
+ } else if (a.startsWith("--session=")) {
73
+ out.session = a.slice("--session=".length);
74
+ } else if (a === "--cwd") {
75
+ out.cwd = z.string().min(1).parse(args[++i]);
76
+ } else if (a.startsWith("--cwd=")) {
77
+ out.cwd = a.slice("--cwd=".length);
78
+ } else if (a === "--permission-mode") {
79
+ out.permissionMode = z.string().min(1).parse(args[++i]);
80
+ } else if (a.startsWith("--permission-mode=")) {
81
+ out.permissionMode = a.slice("--permission-mode=".length);
82
+ } else if (a === "--request") {
83
+ out.request = z.string().min(1).parse(args[++i]);
84
+ } else if (a.startsWith("--request=")) {
85
+ out.request = a.slice("--request=".length);
86
+ } else if (a === "--approve") {
87
+ out.approve = true;
88
+ } else if (a === "--deny") {
89
+ out.deny = true;
90
+ } else if (a === "--no-wait") {
91
+ out.noWait = true;
92
+ } else if (a === "--target") {
93
+ out.target = z.string().min(1).parse(args[++i]);
94
+ } else if (a.startsWith("--target=")) {
95
+ out.target = a.slice("--target=".length);
96
+ } else if (a === "--help" || a === "-h") {
97
+ out.positionals.push(a);
98
+ } else if (a.startsWith("-")) {
99
+ throw new Error(`unknown flag: ${a}`);
100
+ } else {
101
+ out.positionals.push(a);
102
+ }
103
+ }
104
+ return out;
105
+ }
106
+
107
+ async function readPrompt(flags: FlagMap): Promise<string> {
108
+ if (flags.positionals.length > 0) return flags.positionals.join(" ");
109
+ if (process.stdin.isTTY) return "";
110
+ const chunks: Uint8Array[] = [];
111
+ for await (const chunk of Bun.stdin.stream()) chunks.push(chunk);
112
+ return Buffer.concat(chunks).toString("utf8");
113
+ }
114
+
115
+ async function cmdTurn(args: string[]): Promise<number> {
116
+ const flags = parseFlags(args);
117
+ if (flags.positionals.includes("--help") || flags.positionals.includes("-h")) {
118
+ printHelp();
119
+ return 0;
120
+ }
121
+ const mode = flags.permissionMode != null
122
+ ? parsePermissionMode({ raw: flags.permissionMode })
123
+ : undefined;
124
+ const prompt = await readPrompt(flags);
125
+ const result = await runTurn({
126
+ sessionId: flags.session,
127
+ worker: flags.worker,
128
+ permissionMode: mode,
129
+ cwd: flags.cwd ?? cwd(),
130
+ prompt,
131
+ });
132
+ process.stdout.write(result.stdout);
133
+ return result.exitCode;
134
+ }
135
+
136
+ async function cmdDecide(args: string[]): Promise<number> {
137
+ const flags = parseFlags(args);
138
+ if (flags.session == null) {
139
+ console.error(c.red("relay decide requires --session <uuid>"));
140
+ return 1;
141
+ }
142
+ if (flags.approve === true && flags.deny === true) {
143
+ console.error(c.red("pass only one of --approve or --deny"));
144
+ return 1;
145
+ }
146
+ if (flags.approve !== true && flags.deny !== true) {
147
+ console.error(c.red("relay decide requires --approve or --deny"));
148
+ return 1;
149
+ }
150
+ const result = await runDecide({
151
+ sessionId: flags.session,
152
+ requestId: flags.request,
153
+ approve: flags.approve === true,
154
+ wait: flags.noWait !== true,
155
+ cwd: flags.cwd ?? cwd(),
156
+ });
157
+ if (result.turn != null) {
158
+ process.stdout.write(result.turn.stdout);
159
+ return result.turn.exitCode;
160
+ }
161
+ console.log(`decision written: ${result.requestId} (${flags.approve ? "approve" : "deny"})`);
162
+ return 0;
163
+ }
164
+
165
+ async function cmdSetPermissionMode(args: string[]): Promise<number> {
166
+ const flags = parseFlags(args);
167
+ if (flags.session == null || flags.permissionMode == null) {
168
+ console.error(c.red("relay set-permission-mode requires --session and --permission-mode"));
169
+ return 1;
170
+ }
171
+ const mode = parsePermissionMode({ raw: flags.permissionMode });
172
+ const entry = await setLivePermissionMode({ sessionId: flags.session, permissionMode: mode });
173
+ console.log(`session: ${entry.sessionId}`);
174
+ console.log(`permission-mode: ${entry.permissionMode}`);
175
+ return 0;
176
+ }
177
+
178
+ function cmdStatus(args: string[]): number {
179
+ const flags = parseFlags(args);
180
+ const rows = statusRows();
181
+ const filtered = flags.session != null
182
+ ? rows.filter((r) => r.sessionId === flags.session)
183
+ : rows;
184
+ if (filtered.length === 0) {
185
+ console.log(c.dim("(no relay sessions)"));
186
+ return 0;
187
+ }
188
+ for (const row of filtered) {
189
+ const alive = row.tmuxAlive ? c.green("up") : c.red("down");
190
+ console.log(
191
+ `${row.sessionId} ${row.worker} ${row.permissionMode} ${row.state} tmux=${alive} ${row.tmuxName}`,
192
+ );
193
+ }
194
+ return 0;
195
+ }
196
+
197
+ async function cmdDestroy(args: string[]): Promise<number> {
198
+ const flags = parseFlags(args);
199
+ if (flags.session == null) {
200
+ console.error(c.red("relay destroy requires --session <uuid>"));
201
+ return 1;
202
+ }
203
+ const ok = await destroySession({ sessionId: flags.session });
204
+ if (!ok) {
205
+ console.error(c.red(`relay session not found: ${flags.session}`));
206
+ return 1;
207
+ }
208
+ console.log(`destroyed ${flags.session}`);
209
+ return 0;
210
+ }
211
+
212
+ async function cmdGc(): Promise<number> {
213
+ const result = await gcSessions();
214
+ console.log(`reaped ${result.reaped.length}; kept ${result.kept.length}`);
215
+ for (const id of result.reaped) console.log(` reaped ${id}`);
216
+ return 0;
217
+ }
218
+
219
+ function cmdInstall(args: string[]): number {
220
+ const flags = parseFlags(args);
221
+ const target = z.enum(["cursor", "claude", "all"]).catch("all").parse(flags.target ?? "all") as InstallTarget;
222
+ const result = installRelayHosts({ target });
223
+ console.log(`agents: ${result.agentsWritten.length}`);
224
+ for (const p of result.agentsWritten) console.log(` ${p}`);
225
+ console.log(`skill: ${result.skillWritten ? "written" : "skipped"}`);
226
+ console.log(`hooks: ${result.hooksMerged.length}`);
227
+ for (const p of result.hooksMerged) console.log(` ${p}`);
228
+ return 0;
229
+ }
230
+
231
+ function cmdConfig(args: string[]): number {
232
+ const sub = args[0] ?? "show";
233
+ if (sub === "show" || sub === "get" && args[1] == null) {
234
+ const cfg = loadRelayConfig();
235
+ console.log(c.dim(`relay.json: ${paths.relayJson}`));
236
+ console.log(JSON.stringify(cfg, null, 2));
237
+ return 0;
238
+ }
239
+ if (sub === "get") {
240
+ const key = args[1];
241
+ if (key == null) {
242
+ console.error(c.red("relay config get <key>"));
243
+ return 1;
244
+ }
245
+ const cfg = loadRelayConfig() as Record<string, unknown>;
246
+ if (!(key in cfg)) {
247
+ console.error(c.red(`unknown key: ${key}`));
248
+ return 1;
249
+ }
250
+ console.log(JSON.stringify(cfg[key]));
251
+ return 0;
252
+ }
253
+ if (sub === "set") {
254
+ const key = args[1];
255
+ const valueText = args[2];
256
+ if (key == null || valueText == null) {
257
+ console.error(c.red("relay config set <key> <value>"));
258
+ return 1;
259
+ }
260
+ let value: unknown;
261
+ try {
262
+ value = JSON.parse(valueText);
263
+ } catch {
264
+ value = valueText;
265
+ }
266
+ if (key === "defaultPermissionMode") {
267
+ const mode = tryParsePermissionMode({ raw: String(value) });
268
+ if (mode == null) {
269
+ console.error(c.red(`invalid permission mode: ${value}`));
270
+ return 1;
271
+ }
272
+ value = mode;
273
+ }
274
+ const patch = { [key]: value } as RelayConfigFile;
275
+ const next = mergeRelayConfigFile({ patch });
276
+ console.log(`${key}: ${JSON.stringify((next as Record<string, unknown>)[key])}`);
277
+ return 0;
278
+ }
279
+ if (sub === "init") {
280
+ if (!existsSync(paths.relayJson)) {
281
+ writeRelayConfig({ file: {} });
282
+ console.log(`wrote defaults-capable ${paths.relayJson}`);
283
+ } else {
284
+ console.log(`already exists: ${paths.relayJson}`);
285
+ console.log(readFileSync(paths.relayJson, "utf8"));
286
+ }
287
+ console.log(c.dim(`effective defaultPermissionMode=${DEFAULT_RELAY_CONFIG.defaultPermissionMode}`));
288
+ return 0;
289
+ }
290
+ console.error(c.red(`unknown relay config subcommand: ${sub}`));
291
+ return 1;
292
+ }
293
+
294
+ export async function cmdRelay(args: string[]): Promise<number> {
295
+ const sub = args[0];
296
+ if (sub == null || sub === "--help" || sub === "-h" || sub === "help") {
297
+ printHelp();
298
+ return 0;
299
+ }
300
+ const rest = args.slice(1);
301
+ switch (sub) {
302
+ case "turn":
303
+ return cmdTurn(rest);
304
+ case "decide":
305
+ return cmdDecide(rest);
306
+ case "set-permission-mode":
307
+ return cmdSetPermissionMode(rest);
308
+ case "status":
309
+ return cmdStatus(rest);
310
+ case "destroy":
311
+ return cmdDestroy(rest);
312
+ case "gc":
313
+ return cmdGc();
314
+ case "install":
315
+ return cmdInstall(rest);
316
+ case "config":
317
+ return cmdConfig(rest);
318
+ default:
319
+ console.error(c.red(`unknown relay command: ${sub}`));
320
+ printHelp();
321
+ return 2;
322
+ }
323
+ }
@@ -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