tokenmaxxing 1.8.0 → 1.9.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 (85) hide show
  1. package/DESIGN.md +2 -4
  2. package/README.md +1 -1
  3. package/agent-plugin/plugin.json +1 -1
  4. package/package.json +1 -1
  5. package/src/cli/add.ts +1 -8
  6. package/src/cli/auth.ts +0 -23
  7. package/src/cli/check.ts +19 -13
  8. package/src/cli/codexadd.ts +0 -17
  9. package/src/cli/codexinit.ts +0 -40
  10. package/src/cli/codexrm.ts +0 -13
  11. package/src/cli/codexswitch.ts +0 -15
  12. package/src/cli/config.ts +0 -30
  13. package/src/cli/doctor.ts +1 -14
  14. package/src/cli/init.ts +1 -33
  15. package/src/cli/ls.ts +0 -2
  16. package/src/cli/onboard.ts +0 -37
  17. package/src/cli/rename.ts +0 -19
  18. package/src/cli/render.ts +0 -23
  19. package/src/cli/rm.ts +0 -19
  20. package/src/cli/status.ts +0 -80
  21. package/src/cli/switch.ts +1 -49
  22. package/src/cli/watch.ts +0 -17
  23. package/src/entries/codexstophook.ts +2 -73
  24. package/src/entries/codexsupervisor.ts +1 -67
  25. package/src/entries/mcp.ts +0 -11
  26. package/src/entries/sessionstart.ts +1 -8
  27. package/src/entries/statusline.ts +0 -66
  28. package/src/entries/stopfailurehook.ts +93 -0
  29. package/src/entries/stophook.ts +3 -34
  30. package/src/entries/subagentstatusline.ts +0 -19
  31. package/src/entries/supervisor.ts +32 -132
  32. package/src/lib/atomic.ts +0 -16
  33. package/src/lib/claudebin.ts +4 -55
  34. package/src/lib/claudejson.ts +0 -10
  35. package/src/lib/claudelock.ts +13 -35
  36. package/src/lib/codexauth.ts +0 -29
  37. package/src/lib/codexbin.ts +0 -10
  38. package/src/lib/codexdecide.ts +1 -112
  39. package/src/lib/codexoauth.ts +0 -16
  40. package/src/lib/codexpick.ts +0 -31
  41. package/src/lib/codexpresence.ts +0 -35
  42. package/src/lib/codexsample.ts +0 -23
  43. package/src/lib/codexstate.ts +0 -7
  44. package/src/lib/codexswap.ts +0 -32
  45. package/src/lib/codexusage.ts +0 -28
  46. package/src/lib/credstore.ts +0 -24
  47. package/src/lib/decide.ts +127 -180
  48. package/src/lib/http.ts +0 -9
  49. package/src/lib/install.ts +6 -127
  50. package/src/lib/keychain.ts +1 -39
  51. package/src/lib/lock.ts +0 -24
  52. package/src/lib/log.ts +0 -14
  53. package/src/lib/oauth.ts +1 -31
  54. package/src/lib/paths.ts +1 -48
  55. package/src/lib/picker.ts +1 -84
  56. package/src/lib/proc.ts +0 -17
  57. package/src/lib/sample.ts +0 -68
  58. package/src/lib/sessions.ts +0 -13
  59. package/src/lib/settings.ts +15 -42
  60. package/src/lib/state.ts +23 -77
  61. package/src/lib/swap.ts +3 -87
  62. package/src/lib/tty.ts +0 -4
  63. package/src/lib/types.ts +13 -140
  64. package/src/lib/usage.ts +108 -196
  65. package/src/lib/worktree.ts +0 -8
  66. package/src/main.ts +5 -40
  67. package/src/sdk.ts +0 -59
  68. package/agent-plugin/agents/tokenmaxxing-claude.md +0 -43
  69. package/agent-plugin/agents/tokenmaxxing-codex.md +0 -40
  70. package/agent-plugin/hooks/cursor-relay.json +0 -14
  71. package/agent-plugin/skills/relay-session/SKILL.md +0 -118
  72. package/agent-plugin/skills/relay-session/references/ipc.md +0 -23
  73. package/src/cli/relay.ts +0 -323
  74. package/src/entries/relaypermission.ts +0 -105
  75. package/src/lib/relay/config.ts +0 -84
  76. package/src/lib/relay/decide.ts +0 -75
  77. package/src/lib/relay/gc.ts +0 -80
  78. package/src/lib/relay/install.ts +0 -143
  79. package/src/lib/relay/markers.ts +0 -148
  80. package/src/lib/relay/modes.ts +0 -82
  81. package/src/lib/relay/protocol.ts +0 -61
  82. package/src/lib/relay/registry.ts +0 -175
  83. package/src/lib/relay/tmux.ts +0 -109
  84. package/src/lib/relay/turn.ts +0 -137
  85. package/src/lib/relay/worker.ts +0 -141
@@ -1,26 +1,11 @@
1
- // Stop hook. Fires when claude finishes a turn (transcript already committed).
2
- // A plain swap needs no respawn: the running session adopts the swapped
3
- // credential on its own (<=30s on macOS, next request on Linux). Only a
4
- // depleted-pool wait - when running under the supervisor - drops a respawn
5
- // marker keyed by this session id: the supervisor SIGTERMs its child at this
6
- // clean boundary, counts down to the reset, then relaunches `--resume`. We
7
- // never kill claude ourselves.
8
-
9
1
  import { join } from "node:path";
10
2
  import { z } from "zod";
11
3
  import { paths } from "../lib/paths.ts";
12
4
  import { writeFileAtomic } from "../lib/atomic.ts";
13
5
  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";
17
6
  import { RespawnMarkerSchema } from "../lib/types.ts";
18
7
  import { log } from "../lib/log.ts";
19
8
 
20
- // session_id must be a real transcript UUID: a malformed value would ride the
21
- // respawn marker into `--resume <garbage>`, which claude treats as a picker
22
- // search term (PR #36 review catch); non-UUID input drops to undefined and the
23
- // marker falls back to the pinned sid.
24
9
  const StopStdin = z.looseObject({ session_id: z.uuid().optional().catch(undefined) });
25
10
 
26
11
  async function readStdin(): Promise<string> {
@@ -30,43 +15,27 @@ async function readStdin(): Promise<string> {
30
15
  }
31
16
 
32
17
  export async function runStopHook(): Promise<number> {
33
- // recursion guard - our own `-p '/usage'` probe re-enters hooks.
34
18
  if (process.env.TOKENMAXXING_PROBE) return 0;
35
19
 
36
20
  const raw = await readStdin();
37
21
  const parsed = StopStdin.safeParse((() => { try { return JSON.parse(raw); } catch { return {}; } })());
38
- // TWO session ids with different jobs (closing-review HIGH catch): the
39
- // PINNED id (env, set once by the supervisor) names the marker file the
40
- // supervisor actually watches and survives /clear; the STDIN id names the
41
- // CURRENT transcript to resume and drifts to a new value after /clear.
42
- // Keying the file by the stdin id orphaned every post-/clear marker.
43
22
  const stdinSid = parsed.success ? parsed.data.session_id : undefined;
44
23
  const pinnedSid = process.env.TOKENMAXXING_SESSION_ID;
45
24
 
46
25
  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
-
55
- // Anticipatory depleted swaps are only sane when the respawn marker below
56
- // will actually pause the session until the reset.
57
26
  const canPause = process.env.TOKENMAXXING_SUPERVISED === "1" && pinnedSid != null;
58
27
  const decision = await evaluateAndMaybeSwap(Date.now(), canPause);
59
28
  if (decision.account && (decision.swapped || decision.waitUntil !== undefined)) {
60
29
  log(decision.swapped ? "stop.swapped" : "stop.wait", { account: decision.account.accountUuid.slice(0, 8), waitUntil: decision.waitUntil });
61
- // Respawn only for a depleted-pool wait: pausing until the reset requires
62
- // killing the child. A plain swap leaves the session running to adopt.
63
30
  if (decision.waitUntil !== undefined && canPause && pinnedSid) {
64
31
  const marker = join(paths.respawnDir, pinnedSid);
32
+ const launchedAt = z.coerce.number().finite().optional().catch(undefined).parse(process.env.TOKENMAXXING_LAUNCHED_AT);
65
33
  const payload = RespawnMarkerSchema.parse({
66
34
  account: decision.account.label,
67
35
  ts: Date.now(),
68
36
  waitUntil: decision.waitUntil,
69
37
  sessionId: stdinSid ?? pinnedSid,
38
+ ...(launchedAt != null ? { launchedAt } : {}),
70
39
  });
71
40
  writeFileAtomic(marker, JSON.stringify(payload));
72
41
  log("stop.marker", { session: (stdinSid ?? pinnedSid).slice(0, 8) });
@@ -75,5 +44,5 @@ export async function runStopHook(): Promise<number> {
75
44
  } catch (e) {
76
45
  log("stop.error", { err: e instanceof Error ? e.message : String(e) });
77
46
  }
78
- return 0; // never block the stop
47
+ return 0;
79
48
  }
@@ -1,16 +1,3 @@
1
- // Renders claude's subagentStatusLine: one {id, content} JSON line per active
2
- // subagent task, so the agents panel shows each subagent's model, effort, and
3
- // ctx fill the way the main statusline shows the session's (user ask
4
- // 2026-07-18). This per-row panel surface is the only place subagent info can
5
- // appear: the main statusLine command never learns which subagent the UI is
6
- // viewing (verified against the 2.1.214 bundle). Row shape mirrors the main
7
- // info block, info first so claude's end-truncation eats the label instead:
8
- // "fable (high) <task label>" - the family name painted by the task's
9
- // context fill (colorless mode keeps a numeric ctx token).
10
- // A task we emit nothing for keeps claude's default row (emitting empty
11
- // content would HIDE it). Must NEVER break the panel: render what parses,
12
- // skip what doesn't.
13
-
14
1
  import { z } from "zod";
15
2
  import { makeColors, makeUsagePaint } from "../cli/render.ts";
16
3
  import { readStdin } from "./statusline.ts";
@@ -19,14 +6,11 @@ import { SubagentStatusLineStdinSchema } from "../lib/types.ts";
19
6
  const RowCtxSchema = z.object({ color: z.boolean(), truecolor: z.boolean() });
20
7
  export type RowCtx = z.infer<typeof RowCtxSchema>;
21
8
 
22
- /** "claude-fable-5" -> "fable" (lowercase family, matching the chart-label
23
- * convention); an id without the claude- prefix passes through whole. */
24
9
  function modelFamily(id: string): string {
25
10
  const [head, family] = id.split("-");
26
11
  return head === "claude" && family ? family : id;
27
12
  }
28
13
 
29
- /** Pure renderer: subagentStatusLine stdin -> {id, content} JSON lines. */
30
14
  export function renderSubagentRows(stdinObj: unknown, ctx: RowCtx): string[] {
31
15
  const parsed = SubagentStatusLineStdinSchema.safeParse(stdinObj);
32
16
  if (!parsed.success) return [];
@@ -41,8 +25,6 @@ export function renderSubagentRows(stdinObj: unknown, ctx: RowCtx): string[] {
41
25
  ? Math.round((t.tokenCount / t.contextWindowSize) * 100)
42
26
  : null;
43
27
  const info: string[] = [];
44
- // The model name carries the task's context-fill color (same rule as the
45
- // main line); colorless mode keeps the numeric ctx token instead.
46
28
  if (t.model != null) {
47
29
  const family = modelFamily(t.model);
48
30
  const body = pct != null ? col.bold(paint(pct)(family)) : col.bold(family);
@@ -66,7 +48,6 @@ export async function runSubagentStatusline(): Promise<number> {
66
48
  try {
67
49
  obj = JSON.parse(raw);
68
50
  } catch {
69
- // malformed stdin - emit nothing, claude keeps its default rows
70
51
  }
71
52
  const colorterm = z.string().optional().parse(process.env.COLORTERM);
72
53
  const rows = renderSubagentRows(obj, {
@@ -1,12 +1,3 @@
1
- // The `claude` supervisor. Invoked in place of claude (via ~/.config/tokenmaxxing/
2
- // bin/claude on PATH). Runs the REAL claude with inherited stdio (claude owns the
3
- // real terminal exactly as if run directly), pins a session id, and watches for a
4
- // respawn marker dropped by the Stop hook on a depleted-pool wait (plain swaps
5
- // adopt in place and never respawn). When the marker appears it SIGTERMs its own
6
- // child at the (already-committed) turn boundary, counts down to the reset, and
7
- // relaunches `claude --resume <id>`. Process/terminal manager only - it never
8
- // reads or proxies tokens.
9
-
10
1
  import { existsSync, mkdirSync, readFileSync, rmSync, readdirSync, statSync } from "node:fs";
11
2
  import { join } from "node:path";
12
3
  import { maxBy } from "es-toolkit";
@@ -23,13 +14,6 @@ const NONINTERACTIVE_SUBCMDS = new Set([
23
14
  "setup-token", "plugin", "agents", "completion", "help",
24
15
  ]);
25
16
 
26
- // Root flags whose VALUE tokens must never be read as the subcommand (e.g.
27
- // `--settings config` is an interactive session, not `claude config`): the
28
- // same hardening class shouldManageCodex got. Split by arity, mirroring
29
- // claude's own commander declarations (--help-verified 2.1.215; claude changes
30
- // monthly - a newly added value-taking flag regresses only that flag's
31
- // collision case). `--session-id` / `-r` / `--resume` consume their values in
32
- // dedicated branches below.
33
17
  const VALUE_TAKING_ROOT_FLAGS = new Set([
34
18
  "--agent", "--agents", "--append-system-prompt", "--append-system-prompt-file",
35
19
  "--debug-file", "--effort", "--fallback-model", "--input-format",
@@ -38,12 +22,10 @@ const VALUE_TAKING_ROOT_FLAGS = new Set([
38
22
  "--remote-control-session-name-prefix", "--setting-sources", "--settings",
39
23
  "--system-prompt",
40
24
  ]);
41
- // Variadic (`<x...>`): commander consumes EVERY following non-dash token.
42
25
  const VARIADIC_ROOT_FLAGS = new Set([
43
26
  "--add-dir", "--allowedTools", "--allowed-tools", "--betas",
44
27
  "--disallowedTools", "--disallowed-tools", "--file", "--mcp-config", "--tools",
45
28
  ]);
46
- // Optional value (`[x]`): commander consumes the next token unless it is a flag.
47
29
  const OPTIONAL_VALUE_ROOT_FLAGS = new Set([
48
30
  "-d", "--debug", "--from-pr", "--prompt-suggestions", "--remote-control", "-w", "--worktree",
49
31
  ]);
@@ -73,33 +55,17 @@ export function analyzeArgs(argv: string[]): Analysis {
73
55
  if (a === "-p" || a === "--print") printMode = true;
74
56
  else if (a === "--version" || a === "-v" || a === "--help" || a === "-h") printMode = true;
75
57
  else if (a === "--session-id") {
76
- // A non-UUID here must never become supervisor state: the sid names the
77
- // respawn-marker and session-flag paths (an unvalidated value could
78
- // traverse out of them), and real claude rejects a malformed id anyway -
79
- // pass it through unmanaged and let claude do the rejecting.
80
58
  const next = argv[++i] ?? null;
81
59
  if (next && isUuid(next)) sessionId = next;
82
60
  else invalidSessionArg = true;
83
61
  }
84
62
  else if (a === "-c" || a === "--continue") continueLatest = true;
85
63
  else if (a === "-r" || a === "--resume") {
86
- // A UUID resume is managed (the sid is known, marker paths can be
87
- // pinned). Bare `-r`, or `-r <term>` (binary-verified 2.1.214: a non-id
88
- // value is an interactive-picker SEARCH TERM), choose the sid INSIDE
89
- // claude - the supervisor cannot pin marker paths for an unknown sid, so
90
- // those pass through unmanaged and claude behaves exactly as without the
91
- // wrapper (same accepted state as claude's bg-daemon sessions: swaps
92
- // still adopt in place, hooks still fire; only the depleted-pool
93
- // countdown is absent).
94
64
  const next = argv[i + 1];
95
65
  if (next && !next.startsWith("-") && isUuid(next)) { resumeId = next; i++; }
96
66
  else pickerResume = true;
97
67
  }
98
68
  else if (a === "--fork-session") forkSession = true;
99
- // commander's `--flag=value` forms (closing-review catch: unrecognized,
100
- // they were skipped as unknown dash-args, so the supervisor pinned a
101
- // fresh random sid while claude ran the flag-selected session - markers
102
- // and session flags landed under an id nothing was running).
103
69
  else if (a.startsWith("--session-id=")) {
104
70
  const value = a.slice("--session-id=".length);
105
71
  if (isUuid(value)) sessionId = value;
@@ -123,18 +89,11 @@ export function analyzeArgs(argv: string[]): Analysis {
123
89
  }
124
90
 
125
91
  const isSubcmd = firstPositional !== null && NONINTERACTIVE_SUBCMDS.has(firstPositional);
126
- // A forked resume gets a NEW session id chosen inside claude, so the
127
- // supervisor cannot pin marker paths - pass through unmanaged like
128
- // picker-mode resume (closing-review catch: managing it paired the marker
129
- // to the stale pre-fork sid, and a respawn would fork yet another session).
130
92
  const forkResume = forkSession && (resumeId !== null || continueLatest);
131
93
  const manage = !printMode && !isSubcmd && !invalidSessionArg && !pickerResume && !forkResume && !process.env.TOKENMAXXING_PROBE;
132
94
  return { manage, sessionId, resumeId, continueLatest };
133
95
  }
134
96
 
135
- /** Remove session-selecting flags so we can inject our own on respawn. Managed
136
- * argv can only carry a UUID-valued resume (picker-mode passes through
137
- * unmanaged), so the value is always consumed with its flag. */
138
97
  export function stripSessionFlags(argv: string[]): string[] {
139
98
  const out: string[] = [];
140
99
  for (let i = 0; i < argv.length; i++) {
@@ -142,43 +101,17 @@ export function stripSessionFlags(argv: string[]): string[] {
142
101
  if (a === "--session-id") { i++; continue; }
143
102
  if (a === "-c" || a === "--continue") continue;
144
103
  if (a === "-r" || a === "--resume") { i++; continue; }
145
- // the commander `=` forms carry their value in the same token
146
104
  if (a.startsWith("--session-id=") || a.startsWith("--resume=")) continue;
147
- // --fork-session must not survive into respawn args: bare `--fork-session`
148
- // is inert and stays managed, but a depleted-pool respawn injects
149
- // `--resume <sid>` - with the flag still present claude would FORK to a
150
- // NEW session id, permanently unpairing the supervisor's marker path from
151
- // the running session (closing-review catch).
152
105
  if (a === "--fork-session") continue;
153
106
  out.push(a);
154
107
  }
155
108
  return out;
156
109
  }
157
110
 
158
- /** Remove positional tokens (the one-shot initial prompt) while keeping every
159
- * flag and its consumed value(s). A positional is a submit-once user turn:
160
- * persisting or replaying it on a respawn / later `--resume` re-injects the
161
- * original instruction into an already-progressed session (adversarial-review
162
- * HIGH catch) - only real flags like --model belong in sessions/ files and
163
- * respawn args.
164
- *
165
- * TRADEOFF (WONTFIX, flagged PR #37): when claude adds a value-taking root
166
- * flag before the sets above are updated, that value reads as a positional
167
- * and is dropped, so the respawn launches without it and claude errors on the
168
- * missing argument - loud, and the user relaunches. The alternative default
169
- * (treat a bare token after an UNRECOGNIZED flag as that flag's value) turns
170
- * the same staleness silent: a newly added BOOLEAN flag sitting before the
171
- * prompt would make the prompt look like a value and replay a submit-once
172
- * turn, which is the exact harm this function exists to prevent. The
173
- * ambiguity is irreducible without claude's own option table, and a loud
174
- * broken launch beats a silent re-submit. */
175
111
  export function stripPositionals(argv: string[]): string[] {
176
112
  const out: string[] = [];
177
113
  for (let i = 0; i < argv.length; i++) {
178
114
  const a = argv[i]!;
179
- // `--` ends option parsing: everything after it is positional (a prompt
180
- // deliberately starting with "-"), never a flag to persist (PR #37
181
- // review catch). The delimiter itself is dropped with them.
182
115
  if (a === "--") break;
183
116
  if (!a.startsWith("-")) continue;
184
117
  out.push(a);
@@ -193,12 +126,6 @@ export function stripPositionals(argv: string[]): string[] {
193
126
  return out;
194
127
  }
195
128
 
196
- /** Newest transcript session id for the current cwd (for `-c`). claude's
197
- * project-dir slug maps EVERY non-alphanumeric char to "-": the regex below
198
- * mirrors claude's own, byte for byte (binary-verified 2.1.215, the external-
199
- * contract regex exception). The old [/.]-only mapping missed underscores
200
- * etc., so `-c` in such a cwd silently opened a brand-new session instead of
201
- * continuing (closing-review catch). */
202
129
  function latestSessionForCwd(): string | null {
203
130
  const slug = process.cwd().replace(/[^a-zA-Z0-9]/g, "-");
204
131
  const projDir = join(paths.claudeDir, "projects", slug);
@@ -214,24 +141,35 @@ function latestSessionForCwd(): string | null {
214
141
  }
215
142
  }
216
143
 
217
- /** Read + validate a respawn marker. An unparseable one (a version-skew hook,
218
- * corruption) is dropped loudly and reported as absent: the watcher checks
219
- * validity BEFORE the SIGTERM, so garbage can never kill the session, and the
220
- * post-exit consume can never throw after the child is already dead (PR #36
221
- * review catch). */
222
- function consumableMarker(marker: string): z.infer<typeof RespawnMarkerSchema> | null {
144
+ const MarkerGateSchema = z.object({
145
+ launchedAt: z.number(),
146
+ overriddenUntil: z.number(),
147
+ });
148
+ type MarkerGate = z.infer<typeof MarkerGateSchema>;
149
+
150
+ function consumableMarker(marker: string, gate: MarkerGate): z.infer<typeof RespawnMarkerSchema> | null {
151
+ let m: z.infer<typeof RespawnMarkerSchema>;
223
152
  try {
224
- return RespawnMarkerSchema.parse(JSON.parse(readFileSync(marker, "utf8")));
153
+ m = RespawnMarkerSchema.parse(JSON.parse(readFileSync(marker, "utf8")));
225
154
  } catch (e) {
226
155
  rmSync(marker, { force: true });
227
156
  log("supervisor.marker_invalid", { err: e instanceof Error ? e.message : String(e) });
228
157
  return null;
229
158
  }
159
+ if (m.launchedAt !== undefined && m.launchedAt !== gate.launchedAt) {
160
+ rmSync(marker, { force: true });
161
+ log("supervisor.marker_stale", { markerLaunch: m.launchedAt, childLaunch: gate.launchedAt });
162
+ return null;
163
+ }
164
+ if (m.waitUntil > Date.now() && m.waitUntil <= gate.overriddenUntil) {
165
+ rmSync(marker, { force: true });
166
+ log("supervisor.marker_overridden", { waitUntil: m.waitUntil });
167
+ return null;
168
+ }
169
+ return m;
230
170
  }
231
171
 
232
- /** Interruptible countdown until `until`, shown in the terminal (claude is dead,
233
- * so the statusLine can't render it). Ctrl-C resumes immediately. */
234
- async function countdownWait(acct: string, until: number): Promise<void> {
172
+ async function countdownWait(acct: string, until: number): Promise<boolean> {
235
173
  let aborted = false;
236
174
  const onInt = () => { aborted = true; };
237
175
  process.on("SIGINT", onInt);
@@ -245,13 +183,10 @@ async function countdownWait(acct: string, until: number): Promise<void> {
245
183
  }
246
184
  process.removeListener("SIGINT", onInt);
247
185
  process.stdout.write(`\n\x1b[36m↻ resuming on ${acct}...\x1b[0m\n`);
186
+ return aborted;
248
187
  }
249
188
 
250
- /** Entry point: `claude ...args`. */
251
189
  export async function runSupervisor(argv: string[]): Promise<number> {
252
- // Depth cap: every spawn below tags its child, so a claudeBin that leads back
253
- // here (pinned shim re-execing `claude` from PATH) dies at a handful of
254
- // processes instead of fork-bombing the machine (2026-07-12 incident).
255
190
  const depth = wrapDepth();
256
191
  if (depth >= MAX_WRAP_DEPTH) {
257
192
  console.error(
@@ -260,8 +195,6 @@ export async function runSupervisor(argv: string[]): Promise<number> {
260
195
  log("supervisor.loop_abort", { depth });
261
196
  return 1;
262
197
  }
263
- // Rate backstop: an env-sanitizing shim in the loop strips the sentinel, but
264
- // it cannot erase the on-disk entry counter.
265
198
  if (wrapperEntryRateTripped(Date.now())) {
266
199
  console.error(
267
200
  `tokenmaxxing: ${LOOP_DIAGNOSIS} (over ${WRAP_RATE_MAX} wrapper entries in ${WRAP_RATE_WINDOW_MS / 1000}s) - claudeBin in ${paths.configJson} does not launch the real Claude binary. Fix claudeBin, then run \`tokenmaxxing doctor\`.`,
@@ -273,21 +206,7 @@ export async function runSupervisor(argv: string[]): Promise<number> {
273
206
  const info = analyzeArgs(argv);
274
207
  const childEnv = { ...process.env, [WRAP_DEPTH_ENV]: String(depth + 1) };
275
208
 
276
- // Pass-through: no session management, no respawn - exact stock behavior.
277
- // The unmanaged-zone sentinel (pooledSpawnEnv) forces it regardless of argv:
278
- // everything below an SDK-driven session runs the real claude unsupervised,
279
- // so a repo script invoking `claude` from inside a serve turn works instead
280
- // of dying at the depth cap. The sentinel rides childEnv, so the whole
281
- // subtree stays unmanaged, and depth still counts wrapper entries toward
282
- // the cap above, keeping a poisoned pin inside the zone bounded.
283
209
  if (!info.manage || process.env[UNMANAGED_ENV]) {
284
- // STRIP the supervision pairing env (mirrors the codex shim's passthrough
285
- // arm, closing-review catch): a nested unmanaged claude inside a
286
- // supervised session (e.g. the agent running `claude -p ...`) would
287
- // otherwise inherit TOKENMAXXING_SUPERVISED/TOKENMAXXING_SESSION_ID, and
288
- // its Stop hooks - which DO fire in print mode - would compute
289
- // canPause=true and could anticipatorily pre-park the pool against a
290
- // marker path the OUTER supervisor owns.
291
210
  const passthroughEnv: Record<string, string | undefined> = { ...childEnv };
292
211
  delete passthroughEnv.TOKENMAXXING_SUPERVISED;
293
212
  delete passthroughEnv.TOKENMAXXING_SESSION_ID;
@@ -296,7 +215,6 @@ export async function runSupervisor(argv: string[]): Promise<number> {
296
215
  return p.exitCode ?? (p.signalCode ? 1 : 0);
297
216
  }
298
217
 
299
- // Decide the managed session id + whether we're resuming an existing one.
300
218
  let base = stripSessionFlags(argv);
301
219
  let sid: string;
302
220
  let resuming = false;
@@ -312,19 +230,10 @@ export async function runSupervisor(argv: string[]): Promise<number> {
312
230
  sid = crypto.randomUUID();
313
231
  }
314
232
 
315
- // Restore the original launch flags when resuming a session with none given
316
- // this time (a bare `claude --resume <id>`, or the depleted-pool recovery).
317
233
  if (resuming && base.length === 0) {
318
234
  const persisted = loadSessionFlags(sid);
319
- // enforce the flags-only contract at the trust boundary, not just at
320
- // write: a sessions/ file written before stripPositionals existed can
321
- // still carry the original prompt, and restoring it verbatim would
322
- // re-submit that prompt on a bare `claude --resume` (PR #37 review
323
- // catch). Idempotent on well-formed files.
324
235
  if (persisted) base = stripPositionals(persisted);
325
236
  }
326
- // The FIRST launch keeps a positional prompt (the user just typed it);
327
- // everything persisted or respawned carries flags only.
328
237
  const persistable = stripPositionals(base);
329
238
  saveSessionFlags(sid, persistable, process.cwd());
330
239
  pruneStaleSessions(Date.now());
@@ -335,28 +244,27 @@ export async function runSupervisor(argv: string[]): Promise<number> {
335
244
  const marker = join(paths.respawnDir, sid);
336
245
  const savedTermios = saveTermios();
337
246
 
338
- // Supervisor survives the SIGINT/SIGHUP that flow to the foreground group;
339
- // claude (the child, same pgrp) receives and handles them itself.
340
247
  process.on("SIGINT", () => {});
341
248
  process.on("SIGHUP", () => {});
342
249
 
343
250
  let respawns = 0;
251
+ let overriddenUntil = 0;
344
252
  while (true) {
345
253
  if (existsSync(marker)) rmSync(marker, { force: true });
346
254
  log("supervisor.launch", { sid, respawns, args: launchArgs.join(" ") });
347
255
 
256
+ const gate: MarkerGate = { launchedAt: Date.now(), overriddenUntil };
348
257
  const child = Bun.spawn([real, ...launchArgs], {
349
258
  stdin: "inherit",
350
259
  stdout: "inherit",
351
260
  stderr: "inherit",
352
- env: { ...childEnv, TOKENMAXXING_SUPERVISED: "1", TOKENMAXXING_SESSION_ID: sid },
261
+ env: { ...childEnv, TOKENMAXXING_SUPERVISED: "1", TOKENMAXXING_SESSION_ID: sid, TOKENMAXXING_LAUNCHED_AT: String(gate.launchedAt) },
353
262
  });
354
263
 
355
- // Race the child's own exit against the appearance of a respawn marker.
356
264
  let done = false;
357
265
  const markerWatch = (async () => {
358
266
  while (!done) {
359
- if (existsSync(marker) && consumableMarker(marker) != null) return true;
267
+ if (existsSync(marker) && consumableMarker(marker, gate) != null) return true;
360
268
  await Bun.sleep(150);
361
269
  }
362
270
  return false;
@@ -365,32 +273,24 @@ export async function runSupervisor(argv: string[]): Promise<number> {
365
273
  const winner = await Promise.race([exited, markerWatch.then((m) => (m ? "marker" : "exit"))]);
366
274
 
367
275
  if (winner === "marker") {
368
- child.kill(); // SIGTERM at the committed turn boundary
276
+ child.kill();
369
277
  }
370
278
  await child.exited;
371
279
  done = true;
372
280
  await markerWatch.catch(() => {});
373
281
  restoreTermios(savedTermios);
374
282
 
375
- const m = existsSync(marker) ? consumableMarker(marker) : null;
283
+ const m = existsSync(marker) ? consumableMarker(marker, gate) : null;
376
284
  if (m) {
377
285
  rmSync(marker, { force: true });
378
286
  respawns++;
379
- if (m.waitUntil > Date.now()) await countdownWait(m.account, m.waitUntil);
380
- else process.stdout.write(`\n\x1b[36m↻ tokenmaxxing: switched to ${m.account} - resuming...\x1b[0m\n`);
381
- // resume the marker's CURRENT transcript, not the pinned id: after
382
- // /clear they differ, and resuming the pinned id would revive the
383
- // pre-/clear conversation (closing-review HIGH catch). Persist the
384
- // flags under that transcript id too, so a later bare
385
- // `claude --resume <id>` restores them (PR #36 review catch). FLAGS
386
- // only: replaying a positional prompt would re-submit it as a fresh
387
- // turn on the progressed session (adversarial-review HIGH catch).
287
+ if (m.waitUntil > Date.now()) {
288
+ if (await countdownWait(m.account, m.waitUntil)) overriddenUntil = m.waitUntil;
289
+ } else process.stdout.write(`\n\x1b[36m↻ tokenmaxxing: switched to ${m.account} - resuming...\x1b[0m\n`);
388
290
  saveSessionFlags(m.sessionId, persistable, process.cwd());
389
- launchArgs = ["--resume", m.sessionId, ...persistable];
291
+ launchArgs = ["--resume", m.sessionId, ...persistable, ...(m.prompt ? ["--", m.prompt] : [])];
390
292
  continue;
391
293
  }
392
- // No marker: claude exited on its own (quit, crash, resume refused). Log it -
393
- // "the process just exited" is undiagnosable without the code/signal.
394
294
  log("supervisor.exit", { sid, respawns, code: child.exitCode, signal: child.signalCode });
395
295
  return child.exitCode ?? (child.signalCode ? 1 : 0);
396
296
  }
package/src/lib/atomic.ts CHANGED
@@ -1,13 +1,6 @@
1
- // Atomic file writes (temp + rename on the same filesystem) and small fs utils.
2
-
3
1
  import { closeSync, mkdirSync, openSync, renameSync, rmSync, writeSync, fsyncSync } from "node:fs";
4
2
  import { dirname } from "node:path";
5
3
 
6
- /**
7
- * Write `data` to `file` atomically: write a sibling temp file, fsync it, then
8
- * rename over the target. A concurrent reader sees either the old or new file,
9
- * never a partial one.
10
- */
11
4
  export function writeFileAtomic(file: string, data: string | Uint8Array, mode = 0o600): void {
12
5
  const dir = dirname(file);
13
6
  mkdirSync(dir, { recursive: true });
@@ -16,11 +9,6 @@ export function writeFileAtomic(file: string, data: string | Uint8Array, mode =
16
9
  const fd = openSync(tmp, "wx", mode);
17
10
  try {
18
11
  try {
19
- // write(2) may write FEWER bytes than asked without throwing (ENOSPC mid-
20
- // write, signal interruption): loop until done and fail loudly on a stuck
21
- // fd, or a truncated temp file gets fsynced and renamed over the target
22
- // as a successful-looking corrupt file (closing-review catch - for a
23
- // parked credential that silently loses a just-rotated refresh token).
24
12
  let offset = 0;
25
13
  while (offset < bytes.length) {
26
14
  const written = writeSync(fd, bytes, offset);
@@ -33,13 +21,9 @@ export function writeFileAtomic(file: string, data: string | Uint8Array, mode =
33
21
  }
34
22
  renameSync(tmp, file);
35
23
  } catch (e) {
36
- // a failed write must not strand the partial temp file - it can hold a
37
- // truncated credential (PR #36 review catch) - and a failed CLEANUP must
38
- // not mask the write error the caller needs (second-round catch)
39
24
  try {
40
25
  rmSync(tmp, { force: true });
41
26
  } catch {
42
- // the original write error below is the one that matters
43
27
  }
44
28
  throw e;
45
29
  }
@@ -1,5 +1,3 @@
1
- // Resolve the REAL claude binary (never our shim on PATH).
2
-
3
1
  import { existsSync, mkdirSync, readFileSync, realpathSync, statSync } from "node:fs";
4
2
  import { dirname, join } from "node:path";
5
3
  import { uniq } from "es-toolkit";
@@ -8,23 +6,9 @@ import { paths } from "./paths.ts";
8
6
  import { loadConfig } from "./state.ts";
9
7
  import { writeFileAtomic } from "./atomic.ts";
10
8
 
11
- /** How many tokenmaxxing wrappers sit above this process. Every supervisor
12
- * spawn increments it in the child env; the wrapper refuses to run at the cap,
13
- * so ANY claudeBin indirection that leads back to the wrapper (a pinned shim
14
- * that re-execs `claude` from PATH) dies in a handful of processes instead of
15
- * fork-bombing the machine (2026-07-12: ~1800 runaway bun processes). */
16
9
  export const WRAP_DEPTH_ENV = "TOKENMAXXING_WRAP_DEPTH";
17
10
  export const MAX_WRAP_DEPTH = 5;
18
- /** "No session management anywhere below this env": the wrapper passes a
19
- * `claude`/`codex` invocation straight through to the real binary, unmanaged.
20
- * Set by pooledSpawnEnv so an SDK-driven session's descendants (an agent
21
- * running `bun run validate` whose script invokes `claude -p`) still work
22
- * instead of dying at the depth cap, which is a LOOP diagnostic, not an
23
- * unmanaged-zone marker. Depth keeps incrementing per wrapper entry under
24
- * this sentinel, so a poisoned pin inside the zone still aborts at the cap. */
25
11
  export const UNMANAGED_ENV = "TOKENMAXXING_UNMANAGED";
26
- /** Stable fragment of the loop-abort diagnostic; verifyRealClaude greps a
27
- * child's stderr for it to name the failure precisely. */
28
12
  export const LOOP_DIAGNOSIS = "wrapper re-entered without reaching the real claude";
29
13
 
30
14
  export function wrapDepth(env: Record<string, string | undefined> = process.env): number {
@@ -32,12 +16,6 @@ export function wrapDepth(env: Record<string, string | undefined> = process.env)
32
16
  return Number.isFinite(n) && n > 0 ? Math.floor(n) : 0;
33
17
  }
34
18
 
35
- /** Non-env backstop for the depth sentinel: an env-sanitizing shim in the loop
36
- * (env -i / corporate launchers) strips the sentinel every cycle, so the
37
- * wrapper ALSO counts its own entries in an on-disk sliding window that no
38
- * child environment can erase. A self-spawn loop sustains several entries per
39
- * second indefinitely; legitimate bursts (a tmux session restore launching
40
- * dozens of panes) land once and go quiet, staying far under the cap. */
41
19
  export const WRAP_RATE_MAX = 60;
42
20
  export const WRAP_RATE_WINDOW_MS = 30_000;
43
21
  const SpawnRateSchema = z.object({ entries: z.array(z.number()) });
@@ -47,13 +25,13 @@ export function wrapperEntryRateTripped(now: number): boolean {
47
25
  let entries: number[] = [];
48
26
  try {
49
27
  entries = SpawnRateSchema.parse(JSON.parse(readFileSync(file, "utf8"))).entries;
50
- } catch { /* absent or corrupt - start a fresh window */ }
28
+ } catch { }
51
29
  entries = entries.filter((t) => now - t < WRAP_RATE_WINDOW_MS);
52
30
  entries.push(now);
53
31
  try {
54
32
  mkdirSync(paths.home, { recursive: true });
55
33
  writeFileAtomic(file, JSON.stringify({ entries }));
56
- } catch { /* an unwritable home must never block launching claude */ }
34
+ } catch { }
57
35
  return entries.length > WRAP_RATE_MAX;
58
36
  }
59
37
 
@@ -65,21 +43,12 @@ function realpathOrNull(p: string): string | null {
65
43
  }
66
44
  }
67
45
 
68
- /** True when `bin` IS tokenmaxxing (the `claude` wrapper, the `xx` alias, or the
69
- * installed binary, via any symlink): spawning it as claude recurses through
70
- * the supervisor. Realpath-based - a trailing slash, a symlinked dir, or a
71
- * symlink to the wrapper must not defeat it the way the old exact-string
72
- * binDir compare could. */
73
46
  export function pointsBackAtUs(bin: string): boolean {
74
47
  const resolved = realpathOrNull(bin);
75
48
  const binDir = realpathOrNull(paths.binDir);
76
49
  return resolved != null && binDir != null && dirname(resolved) === binDir;
77
50
  }
78
51
 
79
- /** Every PATH `claude` that is not us, in PATH order, deduped by realpath.
80
- * All of them, not just the first: a user-made wrapper script named claude can
81
- * sit ahead of the real binary, and verified resolution must be able to walk
82
- * past it. */
83
52
  function scanPathForClaudeCandidates(): string[] {
84
53
  const seen = new Set<string>();
85
54
  const out: string[] = [];
@@ -94,12 +63,11 @@ function scanPathForClaudeCandidates(): string[] {
94
63
  out.push(cand);
95
64
  }
96
65
  }
97
- } catch { /* ignore */ }
66
+ } catch { }
98
67
  }
99
68
  return out;
100
69
  }
101
70
 
102
- /** First PATH entry with a `claude` that is not us. null when PATH has none. */
103
71
  export function scanPathForClaude(): string | null {
104
72
  return scanPathForClaudeCandidates()[0] ?? null;
105
73
  }
@@ -107,14 +75,9 @@ export function scanPathForClaude(): string | null {
107
75
  export function resolveRealClaude(): string {
108
76
  const cfg = loadConfig();
109
77
  if (cfg.claudeBin) {
110
- // A configured-but-vanished binary must not silently degrade to the PATH
111
- // scan: under a relocated TOKENMAXXING_HOME the scan's binDir guard misses
112
- // the installed wrapper, which then recurses through the supervisor
113
- // (observed 2026-07-12 as a forever-hung `/usage` probe).
114
78
  if (!existsSync(cfg.claudeBin)) {
115
79
  throw new Error(`configured claudeBin does not exist: ${cfg.claudeBin} - fix config.json`);
116
80
  }
117
- // A pin that leads back to us is the recursion incident, not a claude.
118
81
  if (pointsBackAtUs(cfg.claudeBin)) {
119
82
  throw new Error(
120
83
  `configured claudeBin (${cfg.claudeBin}) is tokenmaxxing's own wrapper - spawning it recurses. Point claudeBin at the real claude binary in ${paths.configJson}`,
@@ -127,18 +90,10 @@ export function resolveRealClaude(): string {
127
90
  throw new Error("could not locate the real `claude` binary (set claudeBin in config.json)");
128
91
  }
129
92
 
130
- /** Behavioral check used by init/doctor before trusting a pin: the binary must
131
- * answer `--version` without re-entering this wrapper. Depth is preset to the
132
- * cap so an indirection back into us aborts on its FIRST wrapper entry instead
133
- * of recursing. Returns null when the binary passes, else the failure detail. */
134
93
  export function verifyRealClaude(bin: string): string | null {
135
94
  const env = { ...process.env, [WRAP_DEPTH_ENV]: String(MAX_WRAP_DEPTH), TOKENMAXXING_PROBE: "1" };
136
95
  let p: ReturnType<typeof Bun.spawnSync>;
137
96
  try {
138
- // spawnSync throws on an unrunnable path (ENOENT/EACCES) - that is a
139
- // verification failure to report, not a crash. SIGKILL: a TERM-trapping
140
- // candidate must not hang the very repair commands (init/doctor) that the
141
- // loop-abort diagnostic points the user at.
142
97
  p = Bun.spawnSync([bin, "--version"], { env, stdout: "pipe", stderr: "pipe", timeout: 15_000, killSignal: "SIGKILL" });
143
98
  } catch (e) {
144
99
  return e instanceof Error ? e.message : String(e);
@@ -146,8 +101,6 @@ export function verifyRealClaude(bin: string): string | null {
146
101
  const outText = (p.stdout?.toString() ?? "").trim();
147
102
  const err = (p.stderr?.toString() ?? "").trim();
148
103
  if (p.exitCode === 0) {
149
- // exit 0 only proves something ran; the output must identify as claude
150
- // ("2.1.207 (Claude Code)" on 2.1.207) or the pin is some other program.
151
104
  if (/claude/i.test(outText)) return null;
152
105
  return `--version output does not identify claude: "${outText.slice(0, 80)}"`;
153
106
  }
@@ -155,15 +108,11 @@ export function verifyRealClaude(bin: string): string | null {
155
108
  return `--version exited ${p.exitCode ?? "on signal/timeout"}: ${(err || outText).slice(0, 160)}`;
156
109
  }
157
110
 
158
- /** Resolution for `init`'s pin: resolve, then behaviorally verify. A configured
159
- * bin that fails verification (e.g. a shim pinned by an old version) falls back
160
- * to the PATH scan, and the scan walks past failing candidates (a user-made
161
- * claude wrapper ahead of the real binary) instead of giving up on the first. */
162
111
  export function resolveVerifiedClaude(): string {
163
112
  const candidates: string[] = [];
164
113
  try {
165
114
  candidates.push(resolveRealClaude());
166
- } catch { /* broken config - the scan below is init's repair path */ }
115
+ } catch { }
167
116
  candidates.push(...scanPathForClaudeCandidates());
168
117
 
169
118
  const failures: string[] = [];