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 CHANGED
@@ -111,7 +111,9 @@ The decision engages at `five_hour >= 50%` (policy.greedySessionFloor): from the
111
111
 
112
112
  **Shipped since (0.13.0):** Codex as a second pool, parallel state (`codex-accounts.json`, `codex-creds/`, own flock), same pace-pressure policy. Codex differences that shaped it: restart IS the switch (a running codex refuses another account's credential), usage is a free direct GET with epoch resets and a duration-classified window set (the weekly window is primary on current plans), the refresh token rotates with reuse punished (harvest-by-true-owner, persist every rotation), and codex's new Stop-hook system drives the auto-swap through a codex supervisor shim that respawns `codex resume <session-id>` (hooks must be trusted once via `/hooks`). Sibling sessions left on ANY non-live account are reconciled cross-session (owner decisions 2026-07-20; a non-live session cannot refresh cross-account and would wedge at token expiry, healthy or not): the deciding actor drops a reconcile marker addressed to the sibling's supervisor, and the sibling's own Stop hook promotes it into a respawn onto the live account at its next turn boundary - the only safe respawn point - while a blocked live seat never receives a signal.
113
113
 
114
- **Non-goals:** an API/MITM proxy; reimplementing OAuth beyond the single refresh-grant call in the swap.
114
+ **Shipped since (relay companion):** `tokenmaxxing relay` hosts durable tmux workers for cheap host subagents. Claude `--permission-mode` vocabulary (default `auto`), Codex sandbox mapping, per-session flock + registry under `relay/`, additive turn-done markers (never `respawn/`), and `permission-needed` stdout pings decided by main. Host finish hooks still run on every return including pings. Skill + agents live under `agent-plugin/` (`relay-session`, `tokenmaxxing-claude`, `tokenmaxxing-codex`). Docs: `docs/content/docs/commands.mdx`, `state-directory.mdx`, `agent-plugin.mdx`.
115
+
116
+ **Non-goals:** an API/MITM proxy; reimplementing OAuth beyond the single refresh-grant call in the swap; Slack serve as the relay bus; soft concurrency caps; Cloud Agents without local tmux.
115
117
 
116
118
  ---
117
119
 
@@ -0,0 +1,43 @@
1
+ ---
2
+ name: tokenmaxxing-claude
3
+ description: Cheap shell-only relay to a durable Claude Code worker via tokenmaxxing relay. Use for long-running Claude sessions with permission pings back to main.
4
+ model: inherit
5
+ readonly: false
6
+ is_background: false
7
+ ---
8
+
9
+ # Tokenmaxxing Claude relay
10
+
11
+ You are a thin shell. Do not edit files yourself. Do not spawn nested Task/Agent children. Only call `tokenmaxxing relay` (alias `xx relay`).
12
+
13
+ ## Commands
14
+
15
+ ```bash
16
+ tokenmaxxing relay turn --worker claude --permission-mode auto --cwd <dir> --session <uuid-or-omit> "<prompt>"
17
+ tokenmaxxing relay decide --session <uuid> --approve
18
+ tokenmaxxing relay decide --session <uuid> --deny
19
+ tokenmaxxing relay set-permission-mode --session <uuid> --permission-mode <mode>
20
+ tokenmaxxing relay status --session <uuid>
21
+ tokenmaxxing relay destroy --session <uuid>
22
+ ```
23
+
24
+ Default permission mode is `auto`. Modes: `default` (alias `manual`), `acceptEdits`, `plan`, `auto`, `dontAsk`, `bypassPermissions`.
25
+
26
+ ## Stdout protocol
27
+
28
+ ```
29
+ session: <id>
30
+ permission-mode: <mode>
31
+ # turn output OR:
32
+ permission-needed: <requestId>
33
+ summary: <one line>
34
+ detail: <flagged action>
35
+ session: <id>
36
+ permission-mode: auto
37
+ ```
38
+
39
+ On `permission-needed`, return that block to main and stop. Main decides (or auto-approves when Cursor Run Mode is Run Everything, or when Claude main is `bypassPermissions`). Then you run `relay decide` and wait.
40
+
41
+ ## Finish hooks
42
+
43
+ Your host finish hook (`subagentStop` / `SubagentStop`) must run on every return, including permission-needed. A permission park is not a worker Stop.
@@ -0,0 +1,40 @@
1
+ ---
2
+ name: tokenmaxxing-codex
3
+ description: Cheap shell-only relay to a durable Codex worker via tokenmaxxing relay. Claude permission-mode names map to Codex sandbox flags.
4
+ model: inherit
5
+ readonly: false
6
+ is_background: false
7
+ ---
8
+
9
+ # Tokenmaxxing Codex relay
10
+
11
+ You are a thin shell. Do not edit files yourself. Do not spawn nested Task/Agent children. Only call `tokenmaxxing relay` (alias `xx relay`).
12
+
13
+ ## Commands
14
+
15
+ ```bash
16
+ tokenmaxxing relay turn --worker codex --permission-mode auto --cwd <dir> --session <uuid-or-omit> "<prompt>"
17
+ tokenmaxxing relay decide --session <uuid> --approve
18
+ tokenmaxxing relay decide --session <uuid> --deny
19
+ tokenmaxxing relay set-permission-mode --session <uuid> --permission-mode <mode>
20
+ tokenmaxxing relay status --session <uuid>
21
+ tokenmaxxing relay destroy --session <uuid>
22
+ ```
23
+
24
+ CLI still speaks Claude permission-mode names. Codex mapping:
25
+
26
+ | Mode | Codex flags |
27
+ |---|---|
28
+ | `plan` / `default` | `--sandbox read-only` + ask-for-approval on |
29
+ | `acceptEdits` | `--sandbox workspace-write` |
30
+ | `auto` | workspace-write + ask-for-approval |
31
+ | `dontAsk` | read-only + approval never |
32
+ | `bypassPermissions` | `--sandbox danger-full-access` + approval off |
33
+
34
+ ## Stdout protocol
35
+
36
+ Same as the Claude relay agent. On `permission-needed`, return the block to main; then `relay decide`.
37
+
38
+ ## Finish hooks
39
+
40
+ Host finish hooks run on every return including permission-needed. Worker Stop still runs only on real turn finish.
@@ -0,0 +1,14 @@
1
+ {
2
+ "version": 1,
3
+ "hooks": {
4
+ "tokenmaxxingRelay": {
5
+ "description": "tokenmaxxing-owned Cursor hook group for relay finish behavior. Host finish hooks must still run on every subagent return including permission-needed.",
6
+ "subagentStop": [
7
+ {
8
+ "type": "command",
9
+ "command": "/usr/bin/true"
10
+ }
11
+ ]
12
+ }
13
+ }
14
+ }
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
3
3
  "name": "tokenmaxxing",
4
- "version": "1.7.0",
4
+ "version": "1.8.0",
5
5
  "description": "Pool Claude Code and Codex logins, switch on pace pressure, and expose safe ops tools to agent clients.",
6
6
  "author": {
7
7
  "name": "anaclumos",
@@ -10,5 +10,11 @@
10
10
  "homepage": "https://github.com/anaclumos/tokenmaxxing",
11
11
  "repository": "https://github.com/anaclumos/tokenmaxxing",
12
12
  "license": "MIT",
13
- "keywords": ["claude", "codex", "quota", "account-switching", "mcp"]
13
+ "keywords": [
14
+ "claude",
15
+ "codex",
16
+ "quota",
17
+ "account-switching",
18
+ "mcp"
19
+ ]
14
20
  }
@@ -0,0 +1,118 @@
1
+ ---
2
+ name: relay-session
3
+ description: Run durable Claude Code or Codex workers through tokenmaxxing relay (tmux). Use when a host agent needs a long-lived pooled worker, permission pings to main, or tokenmaxxing-claude / tokenmaxxing-codex agents.
4
+ ---
5
+
6
+ # Relay session
7
+
8
+ Host agents stay cheap. They shell into `tokenmaxxing relay`, which owns a durable tmux worker (Claude Code or Codex via tokenmaxxing). High-churn concurrent sessions are expected.
9
+
10
+ Install templates: `tokenmaxxing relay install --target cursor|claude|all`.
11
+
12
+ ## Claude worker permission modes
13
+
14
+ CLI: `claude --permission-mode <mode>` / `relay set-permission-mode --permission-mode <mode>`.
15
+
16
+ | Mode | What runs without asking |
17
+ |---|---|
18
+ | `default` (alias `manual`) | Reads only |
19
+ | `acceptEdits` | Reads, file edits, common filesystem cmds in cwd |
20
+ | `plan` | Reads (+ classifier-approved cmds when auto available) |
21
+ | `auto` | Everything with background safety classifier (**relay default**) |
22
+ | `dontAsk` | Only pre-approved tools; else deny |
23
+ | `bypassPermissions` | Everything (needs allow-dangerously flags) |
24
+
25
+ Claude `auto` is classifier-assisted autonomy, not "defer to main". Relay pings main only when the worker would still prompt.
26
+
27
+ ## Cursor Run Modes (main only)
28
+
29
+ | Run Mode | Role for relay |
30
+ |---|---|
31
+ | Auto-review | Main decides each ping or escalates to the user |
32
+ | Allowlist | Main decides / escalates |
33
+ | Run Everything | Main auto-`relay decide --approve` on worker pings |
34
+
35
+ Do not probe undocumented APIs for main's Run Mode. Main states or infers it.
36
+
37
+ If Claude main is itself in `bypassPermissions`, auto-approve worker pings the same way.
38
+
39
+ ## Codex mapping
40
+
41
+ | Claude mode | Codex flags |
42
+ |---|---|
43
+ | `plan` / `default` | `--sandbox read-only` + ask-for-approval on |
44
+ | `acceptEdits` | `--sandbox workspace-write` |
45
+ | `auto` | workspace-write + ask-for-approval |
46
+ | `dontAsk` | read-only + approval never |
47
+ | `bypassPermissions` | `--sandbox danger-full-access` + approval off |
48
+
49
+ ## Shared stdout protocol
50
+
51
+ ```
52
+ session: <id>
53
+ permission-mode: <mode>
54
+ # turn output OR:
55
+ permission-needed: <requestId>
56
+ summary: <one line>
57
+ detail: <flagged action>
58
+ session: <id>
59
+ permission-mode: auto
60
+ ```
61
+
62
+ ## Commands
63
+
64
+ | Command | Job |
65
+ |---|---|
66
+ | `relay turn` | Ensure session, send prompt, wait until turn-done or permission-needed |
67
+ | `relay decide` | Approve/deny pending ping; resume worker |
68
+ | `relay set-permission-mode` | Change live worker mode |
69
+ | `relay status` / `destroy` / `gc` | Inspect, tear down, reap |
70
+ | `relay install` / `relay config` | Host templates + relay.json |
71
+
72
+ Flags: `--worker claude|codex`, `--session`, `--cwd`, `--permission-mode`, prompt via argv/stdin.
73
+
74
+ ## Finish hooks
75
+
76
+ 1. Cheap relay host finish hooks (`subagentStop` / `SubagentStop`) fire on every return, including `permission-needed`.
77
+ 2. Worker Stop / Codex Stop still run on real turn finish (pool logic unchanged).
78
+ 3. Relay turn-done markers are additive under `$TOKENMAXXING_HOME/relay/turn-done/`. Permission park is not a fake worker Stop. Never write into `respawn/`.
79
+
80
+ ## Cursor IPC matrix
81
+
82
+ | Contract | Relay support |
83
+ |---|---|
84
+ | Task / custom agent prompt | Primary in |
85
+ | Task final message | Primary out (delta or permission-needed) |
86
+ | Task resume / interrupt | Continue with decide / set-permission-mode / next turn |
87
+ | Foreground / background + completion notify | Prefer foreground under modes that still prompt |
88
+ | Frontmatter model / readonly / is_background / tools | Shell-only templates; configurable cheap model |
89
+ | `/name` delegate | Same agents |
90
+ | subagentStart / subagentStop (+ followup_message) | Optional hooks; finish hooks always run |
91
+ | Task preToolUse / postToolUse | Optional |
92
+ | UpdateCurrentStep | UI phases only, not permission channel |
93
+ | Nesting | Relay must not spawn Task children |
94
+
95
+ ## Claude IPC matrix
96
+
97
+ | Contract | Relay support |
98
+ |---|---|
99
+ | Agent tool prompt / final result | Primary |
100
+ | SendMessage resume / mid-run | Primary continue path |
101
+ | Background + completion notify | Supported |
102
+ | Fork / `/subtask` | Supported if host uses it |
103
+ | Permission UI bubble | Orthogonal; do not suppress |
104
+ | PermissionRequest / Notification | Optional hooks; worker pings stay stdout protocol |
105
+ | SubagentStart / SubagentStop | Finish hooks always run on every return including pings |
106
+ | Pre/PostToolUse (incl. on Agent) | Optional |
107
+ | Agent teams mailbox / task list | Same stdout contract over team delivery |
108
+ | Cross-session SendMessage | Supported; peer messages never carry user authority |
109
+ | subagentStatusLine | UI only |
110
+
111
+ ## Hard stops
112
+
113
+ - Never pattern-kill tmux. Destroy by exact session name only.
114
+ - Never print credentials or OAuth tokens.
115
+ - Never spawn nested Task/Agent from the cheap relay agent.
116
+ - Prefer `tokenmaxxing-claude` / `tokenmaxxing-codex` agents over ad-hoc shells.
117
+
118
+ See [references/ipc.md](references/ipc.md).
@@ -0,0 +1,23 @@
1
+ # Relay IPC checklist
2
+
3
+ ## Cursor
4
+
5
+ - In: Task prompt, `/tokenmaxxing-claude`, `/tokenmaxxing-codex`
6
+ - Out: final message with stdout protocol
7
+ - Continue: Task resume after `relay decide` or `relay set-permission-mode`
8
+ - Finish: `subagentStop` always (including permission-needed returns)
9
+ - Optional: `subagentStart`, Task `preToolUse` / `postToolUse`
10
+ - Not a permission channel: `UpdateCurrentStep`, transcripts
11
+
12
+ ## Claude Code
13
+
14
+ - In: Agent tool prompt
15
+ - Out: Agent final result with stdout protocol
16
+ - Continue: SendMessage / next Agent turn after decide
17
+ - Finish: `SubagentStop` always (including permission-needed returns)
18
+ - Optional: `PermissionRequest` / `Notification` on main; worker pings still use relay stdout
19
+ - UI only: `subagentStatusLine`
20
+
21
+ ## Finish-hook rule
22
+
23
+ Permission park returns to main without faking a worker Stop. Worker Stop hooks still run when the worker actually finishes a turn and write additive `relay/turn-done/<session>` markers only.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tokenmaxxing",
3
- "version": "1.7.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",
@@ -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
+ }