tokenmaxxing 0.19.1 → 1.0.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 (61) hide show
  1. package/DESIGN.md +34 -25
  2. package/README.md +6 -5
  3. package/package.json +1 -1
  4. package/src/cli/add.ts +1 -0
  5. package/src/cli/auth.ts +25 -14
  6. package/src/cli/check.ts +3 -2
  7. package/src/cli/codexadd.ts +44 -40
  8. package/src/cli/codexinit.ts +59 -12
  9. package/src/cli/codexrm.ts +49 -0
  10. package/src/cli/codexswitch.ts +20 -2
  11. package/src/cli/config.ts +25 -1
  12. package/src/cli/doctor.ts +3 -3
  13. package/src/cli/init.ts +54 -32
  14. package/src/cli/onboard.ts +62 -45
  15. package/src/cli/rename.ts +20 -0
  16. package/src/cli/render.ts +0 -16
  17. package/src/cli/rm.ts +40 -2
  18. package/src/cli/serve.ts +638 -115
  19. package/src/cli/status.ts +69 -23
  20. package/src/cli/switch.ts +54 -19
  21. package/src/entries/codexstophook.ts +123 -4
  22. package/src/entries/codexsupervisor.ts +87 -13
  23. package/src/entries/sessionstart.ts +1 -1
  24. package/src/entries/statusline.ts +56 -20
  25. package/src/entries/stophook.ts +23 -9
  26. package/src/entries/supervisor.ts +184 -20
  27. package/src/lib/atomic.ts +28 -6
  28. package/src/lib/claudebin.ts +2 -2
  29. package/src/lib/claudejson.ts +5 -5
  30. package/src/lib/claudelock.ts +112 -37
  31. package/src/lib/codexauth.ts +18 -3
  32. package/src/lib/codexbin.ts +1 -1
  33. package/src/lib/codexdecide.ts +149 -19
  34. package/src/lib/codexpick.ts +17 -6
  35. package/src/lib/codexpresence.ts +59 -21
  36. package/src/lib/codexsample.ts +17 -8
  37. package/src/lib/codexswap.ts +10 -1
  38. package/src/lib/credstore.ts +6 -2
  39. package/src/lib/decide.ts +136 -49
  40. package/src/lib/install.ts +125 -17
  41. package/src/lib/keychain.ts +41 -15
  42. package/src/lib/lock.ts +57 -35
  43. package/src/lib/log.ts +36 -7
  44. package/src/lib/oauth.ts +18 -11
  45. package/src/lib/paths.ts +17 -11
  46. package/src/lib/picker.ts +11 -3
  47. package/src/lib/proc.ts +37 -0
  48. package/src/lib/sample.ts +91 -31
  49. package/src/lib/sessions.ts +23 -1
  50. package/src/lib/settings.ts +59 -18
  51. package/src/lib/slackbridge.ts +581 -81
  52. package/src/lib/slackstate.ts +159 -12
  53. package/src/lib/slackstream.ts +123 -20
  54. package/src/lib/state.ts +131 -35
  55. package/src/lib/swap.ts +109 -47
  56. package/src/lib/types.ts +92 -38
  57. package/src/lib/usage.ts +114 -16
  58. package/src/main.ts +70 -9
  59. package/src/serve-plugin/.claude-plugin/plugin.json +4 -0
  60. package/src/serve-plugin/skills/ask-the-user/SKILL.md +36 -0
  61. package/src/serve-plugin/skills/serve-session/SKILL.md +50 -0
@@ -24,9 +24,11 @@ import { loadAccounts, loadConfig, loadLastSwapAt, loadModelUsage, writeUsage }
24
24
  import { familyTokens, matchedFamily, parseStatusLineStdin, parseStatusLineModel } from "../lib/usage.ts";
25
25
  import { earliestReset, weeklyExpiry } from "../lib/picker.ts";
26
26
  import { worktreeName } from "../lib/worktree.ts";
27
- import { fmtResetShort, makeColors, makeUsagePaint } from "../cli/render.ts";
27
+ import { makeColors, makeUsagePaint } from "../cli/render.ts";
28
+ import { fmtResetShort } from "../lib/usage.ts";
28
29
  import {
29
30
  AccountsIndexSchema,
31
+ RateLimitsStdinSchema,
30
32
  StatusLineStdinSchema,
31
33
  UsageWindowSchema,
32
34
  type Account,
@@ -47,6 +49,11 @@ const RenderCtxSchema = z.object({
47
49
  switchModels: z.array(z.string()),
48
50
  /** linked-worktree basename, null in a main checkout. */
49
51
  worktree: z.string().nullable(),
52
+ /** the LIVE login's org from claude.json, the seat's identity - the
53
+ * activeAccountUuid label drifts after a manual /login (the same rule as
54
+ * decide.ts's seatOf; closing-review catch: the label-keyed split rendered
55
+ * the live account twice and hid the stale-labeled one). */
56
+ liveOrg: z.string().nullable(),
50
57
  now: z.number(),
51
58
  color: z.boolean(),
52
59
  /** terminal advertises 24-bit color (COLORTERM); false steps the ramp to the 256-color cube. */
@@ -115,16 +122,20 @@ export function renderStatusline(stdinObj: unknown, ctx: RenderCtx): string {
115
122
  windows.push(seg("", wins.fiveHour, wins.fiveHour.resetsAt));
116
123
  windows.push(seg("", wins.sevenDay, wins.sevenDay.resetsAt));
117
124
  }
125
+ // The seat: live-org first, stored label fallback (unknown live identity).
126
+ const seatUuid =
127
+ (ctx.liveOrg != null ? ctx.accounts.accounts.find((a) => a.organizationUuid === ctx.liveOrg)?.accountUuid : undefined) ??
128
+ ctx.accounts.activeAccountUuid;
118
129
  const active =
119
130
  windows.length > 0
120
131
  ? `${col.green("◆")} ${windows.join(" ")}`
121
- : ctx.accounts.activeAccountUuid != null
132
+ : seatUuid != null
122
133
  ? `${col.green("◆")} ?`
123
134
  : "";
124
135
 
125
136
  // ---- parked accounts, earliest upcoming reset first (needs-reauth last)
126
137
  const parked = sortBy(
127
- ctx.accounts.accounts.filter((a) => a.accountUuid !== ctx.accounts.activeAccountUuid),
138
+ ctx.accounts.accounts.filter((a) => a.accountUuid !== seatUuid),
128
139
  [(a) => (a.needsReauth ? 1 : 0), (a) => earliestReset(a, ctx.now)],
129
140
  );
130
141
  // Every parked account renders its own marker (user rule 2026-07-18: the
@@ -159,34 +170,59 @@ export async function runStatusline(): Promise<number> {
159
170
  }
160
171
  const now = Date.now();
161
172
 
173
+ // The stdin payload's own org label: those rate_limits and that
174
+ // organizationUuid ride the SAME API response, so the label can never lie
175
+ // about whose windows these are. claude.json's org is only the fallback -
176
+ // it flips at the swap while a session that has made no post-swap request
177
+ // keeps rendering the OLD account's windows indefinitely, and the 45s
178
+ // ADOPTION_GRACE_MS bounds nothing for such a session (closing-review
179
+ // catch: a stale-window tee labeled with the new org could hard-swap a
180
+ // healthy account and stamp foreign usage into it). The render below uses
181
+ // the same preference so the active ◆ seat matches the windows painted
182
+ // beside it (PR #36 review catch). Trusted ONLY when the payload's windows
183
+ // parsed too: an org label without windows would re-label state the payload
184
+ // did not carry (second-round catch).
185
+ let stdinOrg: string | null = null;
186
+
162
187
  // tee usage for the Stop hook / status - best effort, never blocks rendering.
163
188
  let org: string | null = null;
164
189
  try {
165
190
  org = readOAuthAccount()?.organizationUuid ?? null;
166
191
  const windows = obj == null ? null : parseStatusLineStdin(obj);
167
192
  const lastSwapAt = loadLastSwapAt();
168
- if (windows && (lastSwapAt == null || now - lastSwapAt >= ADOPTION_GRACE_MS)) {
169
- const state: UsageState = { ...windows, org, ts: now, model: parseStatusLineModel(obj) };
193
+ stdinOrg = windows != null ? (RateLimitsStdinSchema.safeParse(obj).data?.organizationUuid ?? null) : null;
194
+ const teeOrg = stdinOrg ?? org;
195
+ if (windows && (stdinOrg != null || lastSwapAt == null || now - lastSwapAt >= ADOPTION_GRACE_MS)) {
196
+ const state: UsageState = { ...windows, org: teeOrg, ts: now, model: parseStatusLineModel(obj) };
170
197
  writeUsage(state);
171
198
  }
172
199
  } catch {
173
200
  // skip the tee, still render below
174
201
  }
175
202
 
176
- const cfg = loadConfig();
177
- const modelUsage = loadModelUsage();
178
- const stdin = StatusLineStdinSchema.safeParse(obj);
179
- const dir = stdin.success ? (stdin.data.workspace?.current_dir ?? stdin.data.workspace?.project_dir ?? null) : null;
180
- const colorterm = z.string().optional().parse(process.env.COLORTERM);
181
- const ctx: RenderCtx = {
182
- accounts: loadAccounts(),
183
- perModel: modelUsage && modelUsage.org === org ? modelUsage.perModel : {},
184
- switchModels: cfg.policy.switchModels,
185
- worktree: dir == null ? null : worktreeName(dir),
186
- now,
187
- color: !process.env.NO_COLOR,
188
- truecolor: colorterm != null && (colorterm.includes("truecolor") || colorterm.includes("24bit")),
189
- };
190
- process.stdout.write(renderStatusline(obj, ctx) + "\n");
203
+ let line: string;
204
+ try {
205
+ const cfg = loadConfig();
206
+ const modelUsage = loadModelUsage();
207
+ const stdin = StatusLineStdinSchema.safeParse(obj);
208
+ const dir = stdin.success ? (stdin.data.workspace?.current_dir ?? stdin.data.workspace?.project_dir ?? null) : null;
209
+ const colorterm = z.string().optional().parse(process.env.COLORTERM);
210
+ const ctx: RenderCtx = {
211
+ accounts: loadAccounts(),
212
+ perModel: modelUsage && modelUsage.org === (stdinOrg ?? org) ? modelUsage.perModel : {},
213
+ switchModels: cfg.policy.switchModels,
214
+ worktree: dir == null ? null : worktreeName(dir),
215
+ liveOrg: stdinOrg ?? org,
216
+ now,
217
+ color: !process.env.NO_COLOR,
218
+ truecolor: colorterm != null && (colorterm.includes("truecolor") || colorterm.includes("24bit")),
219
+ };
220
+ line = renderStatusline(obj, ctx);
221
+ } catch (e) {
222
+ // Corrupt local state (the loaders throw on it) must stay VISIBLE: render
223
+ // the failure as the statusline itself, never abort into a blank line.
224
+ line = `tokenmaxxing: ${e instanceof Error ? e.message : String(e)}`;
225
+ }
226
+ process.stdout.write(line + "\n");
191
227
  return 0;
192
228
  }
@@ -14,7 +14,11 @@ import { evaluateAndMaybeSwap } from "../lib/decide.ts";
14
14
  import { RespawnMarkerSchema } from "../lib/types.ts";
15
15
  import { log } from "../lib/log.ts";
16
16
 
17
- const StopStdin = z.looseObject({ session_id: z.string().optional() });
17
+ // session_id must be a real transcript UUID: a malformed value would ride the
18
+ // respawn marker into `--resume <garbage>`, which claude treats as a picker
19
+ // search term (PR #36 review catch); non-UUID input drops to undefined and the
20
+ // marker falls back to the pinned sid.
21
+ const StopStdin = z.looseObject({ session_id: z.uuid().optional().catch(undefined) });
18
22
 
19
23
  async function readStdin(): Promise<string> {
20
24
  const chunks: Uint8Array[] = [];
@@ -28,27 +32,37 @@ export async function runStopHook(): Promise<number> {
28
32
 
29
33
  const raw = await readStdin();
30
34
  const parsed = StopStdin.safeParse((() => { try { return JSON.parse(raw); } catch { return {}; } })());
31
- const sessionId =
32
- (parsed.success ? parsed.data.session_id : undefined) ?? process.env.TOKENMAXXING_SESSION_ID;
35
+ // TWO session ids with different jobs (closing-review HIGH catch): the
36
+ // PINNED id (env, set once by the supervisor) names the marker file the
37
+ // supervisor actually watches and survives /clear; the STDIN id names the
38
+ // CURRENT transcript to resume and drifts to a new value after /clear.
39
+ // Keying the file by the stdin id orphaned every post-/clear marker.
40
+ const stdinSid = parsed.success ? parsed.data.session_id : undefined;
41
+ const pinnedSid = process.env.TOKENMAXXING_SESSION_ID;
33
42
 
34
43
  try {
35
44
  // Anticipatory depleted swaps are only sane when the respawn marker below
36
45
  // will actually pause the session until the reset.
37
- const canPause = process.env.TOKENMAXXING_SUPERVISED === "1" && sessionId != null;
46
+ const canPause = process.env.TOKENMAXXING_SUPERVISED === "1" && pinnedSid != null;
38
47
  const decision = await evaluateAndMaybeSwap(Date.now(), canPause);
39
48
  if (decision.account && (decision.swapped || decision.waitUntil !== undefined)) {
40
49
  log(decision.swapped ? "stop.swapped" : "stop.wait", { account: decision.account.accountUuid.slice(0, 8), waitUntil: decision.waitUntil });
41
50
  // Respawn only for a depleted-pool wait: pausing until the reset requires
42
51
  // killing the child. A plain swap leaves the session running to adopt.
43
- if (decision.waitUntil !== undefined && process.env.TOKENMAXXING_SUPERVISED === "1" && sessionId) {
44
- const marker = join(paths.respawnDir, sessionId);
45
- const payload = RespawnMarkerSchema.parse({ account: decision.account.label, ts: Date.now(), waitUntil: decision.waitUntil });
52
+ if (decision.waitUntil !== undefined && canPause && pinnedSid) {
53
+ const marker = join(paths.respawnDir, pinnedSid);
54
+ const payload = RespawnMarkerSchema.parse({
55
+ account: decision.account.label,
56
+ ts: Date.now(),
57
+ waitUntil: decision.waitUntil,
58
+ sessionId: stdinSid ?? pinnedSid,
59
+ });
46
60
  writeFileAtomic(marker, JSON.stringify(payload));
47
- log("stop.marker", { session: sessionId.slice(0, 8) });
61
+ log("stop.marker", { session: (stdinSid ?? pinnedSid).slice(0, 8) });
48
62
  }
49
63
  }
50
64
  } catch (e) {
51
- log("stop.error", { err: String((e as Error).message ?? e) });
65
+ log("stop.error", { err: e instanceof Error ? e.message : String(e) });
52
66
  }
53
67
  return 0; // never block the stop
54
68
  }
@@ -7,14 +7,14 @@
7
7
  // relaunches `claude --resume <id>`. Process/terminal manager only - it never
8
8
  // reads or proxies tokens.
9
9
 
10
- import { existsSync, mkdirSync, rmSync, readdirSync, statSync } from "node:fs";
10
+ import { existsSync, mkdirSync, readFileSync, rmSync, readdirSync, statSync } from "node:fs";
11
11
  import { join } from "node:path";
12
12
  import { maxBy } from "es-toolkit";
13
13
  import { z } from "zod";
14
14
  import { paths } from "../lib/paths.ts";
15
15
  import { LOOP_DIAGNOSIS, MAX_WRAP_DEPTH, WRAP_DEPTH_ENV, WRAP_RATE_MAX, WRAP_RATE_WINDOW_MS, resolveRealClaude, wrapDepth, wrapperEntryRateTripped } from "../lib/claudebin.ts";
16
16
  import { saveTermios, restoreTermios } from "../lib/tty.ts";
17
- import { loadSessionFlags, saveSessionFlags } from "../lib/sessions.ts";
17
+ import { loadSessionFlags, pruneStaleSessions, saveSessionFlags } from "../lib/sessions.ts";
18
18
  import { RespawnMarkerSchema } from "../lib/types.ts";
19
19
  import { log } from "../lib/log.ts";
20
20
 
@@ -23,6 +23,31 @@ const NONINTERACTIVE_SUBCMDS = new Set([
23
23
  "setup-token", "plugin", "agents", "completion", "help",
24
24
  ]);
25
25
 
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
+ const VALUE_TAKING_ROOT_FLAGS = new Set([
34
+ "--agent", "--agents", "--append-system-prompt", "--append-system-prompt-file",
35
+ "--debug-file", "--effort", "--fallback-model", "--input-format",
36
+ "--json-schema", "--max-budget-usd", "--model", "-n", "--name",
37
+ "--output-format", "--permission-mode", "--plugin-dir", "--plugin-url",
38
+ "--remote-control-session-name-prefix", "--setting-sources", "--settings",
39
+ "--system-prompt",
40
+ ]);
41
+ // Variadic (`<x...>`): commander consumes EVERY following non-dash token.
42
+ const VARIADIC_ROOT_FLAGS = new Set([
43
+ "--add-dir", "--allowedTools", "--allowed-tools", "--betas",
44
+ "--disallowedTools", "--disallowed-tools", "--file", "--mcp-config", "--tools",
45
+ ]);
46
+ // Optional value (`[x]`): commander consumes the next token unless it is a flag.
47
+ const OPTIONAL_VALUE_ROOT_FLAGS = new Set([
48
+ "-d", "--debug", "--from-pr", "--prompt-suggestions", "--remote-control", "-w", "--worktree",
49
+ ]);
50
+
26
51
  const isUuid = (s: string) => z.uuid().safeParse(s).success;
27
52
 
28
53
  const AnalysisSchema = z.object({
@@ -38,47 +63,144 @@ export function analyzeArgs(argv: string[]): Analysis {
38
63
  let resumeId: string | null = null;
39
64
  let continueLatest = false;
40
65
  let printMode = false;
66
+ let invalidSessionArg = false;
67
+ let pickerResume = false;
68
+ let forkSession = false;
41
69
  let firstPositional: string | null = null;
42
70
 
43
71
  for (let i = 0; i < argv.length; i++) {
44
72
  const a = argv[i]!;
45
73
  if (a === "-p" || a === "--print") printMode = true;
46
74
  else if (a === "--version" || a === "-v" || a === "--help" || a === "-h") printMode = true;
47
- else if (a === "--session-id") sessionId = argv[++i] ?? null;
75
+ 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
+ const next = argv[++i] ?? null;
81
+ if (next && isUuid(next)) sessionId = next;
82
+ else invalidSessionArg = true;
83
+ }
48
84
  else if (a === "-c" || a === "--continue") continueLatest = true;
49
85
  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).
50
94
  const next = argv[i + 1];
51
95
  if (next && !next.startsWith("-") && isUuid(next)) { resumeId = next; i++; }
52
- } else if (!a.startsWith("-") && firstPositional === null) {
96
+ else pickerResume = true;
97
+ }
98
+ 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
+ else if (a.startsWith("--session-id=")) {
104
+ const value = a.slice("--session-id=".length);
105
+ if (isUuid(value)) sessionId = value;
106
+ else invalidSessionArg = true;
107
+ }
108
+ else if (a.startsWith("--resume=")) {
109
+ const value = a.slice("--resume=".length);
110
+ if (isUuid(value)) resumeId = value;
111
+ else pickerResume = true;
112
+ }
113
+ else if (VALUE_TAKING_ROOT_FLAGS.has(a)) i++;
114
+ else if (VARIADIC_ROOT_FLAGS.has(a)) {
115
+ while (i + 1 < argv.length && !argv[i + 1]!.startsWith("-")) i++;
116
+ }
117
+ else if (OPTIONAL_VALUE_ROOT_FLAGS.has(a)) {
118
+ if (argv[i + 1] !== undefined && !argv[i + 1]!.startsWith("-")) i++;
119
+ }
120
+ else if (!a.startsWith("-") && firstPositional === null) {
53
121
  firstPositional = a;
54
122
  }
55
123
  }
56
124
 
57
125
  const isSubcmd = firstPositional !== null && NONINTERACTIVE_SUBCMDS.has(firstPositional);
58
- const manage = !printMode && !isSubcmd && !process.env.TOKENMAXXING_PROBE;
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
+ const forkResume = forkSession && (resumeId !== null || continueLatest);
131
+ const manage = !printMode && !isSubcmd && !invalidSessionArg && !pickerResume && !forkResume && !process.env.TOKENMAXXING_PROBE;
59
132
  return { manage, sessionId, resumeId, continueLatest };
60
133
  }
61
134
 
62
- /** Remove session-selecting flags so we can inject our own on respawn. */
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. */
63
138
  export function stripSessionFlags(argv: string[]): string[] {
64
139
  const out: string[] = [];
65
140
  for (let i = 0; i < argv.length; i++) {
66
141
  const a = argv[i]!;
67
142
  if (a === "--session-id") { i++; continue; }
68
143
  if (a === "-c" || a === "--continue") continue;
69
- if (a === "-r" || a === "--resume") {
70
- const next = argv[i + 1];
71
- if (next && !next.startsWith("-") && isUuid(next)) i++;
72
- continue;
73
- }
144
+ if (a === "-r" || a === "--resume") { i++; continue; }
145
+ // the commander `=` forms carry their value in the same token
146
+ 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
+ if (a === "--fork-session") continue;
74
153
  out.push(a);
75
154
  }
76
155
  return out;
77
156
  }
78
157
 
79
- /** Newest transcript session id for the current cwd (for `-c`/`-r`-without-id). */
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
+ export function stripPositionals(argv: string[]): string[] {
176
+ const out: string[] = [];
177
+ for (let i = 0; i < argv.length; i++) {
178
+ 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
+ if (a === "--") break;
183
+ if (!a.startsWith("-")) continue;
184
+ out.push(a);
185
+ if (VALUE_TAKING_ROOT_FLAGS.has(a)) {
186
+ if (i + 1 < argv.length) out.push(argv[++i]!);
187
+ } else if (VARIADIC_ROOT_FLAGS.has(a)) {
188
+ while (i + 1 < argv.length && !argv[i + 1]!.startsWith("-")) out.push(argv[++i]!);
189
+ } else if (OPTIONAL_VALUE_ROOT_FLAGS.has(a)) {
190
+ if (i + 1 < argv.length && !argv[i + 1]!.startsWith("-")) out.push(argv[++i]!);
191
+ }
192
+ }
193
+ return out;
194
+ }
195
+
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). */
80
202
  function latestSessionForCwd(): string | null {
81
- const slug = process.cwd().replace(/[/.]/g, "-");
203
+ const slug = process.cwd().replace(/[^a-zA-Z0-9]/g, "-");
82
204
  const projDir = join(paths.claudeDir, "projects", slug);
83
205
  if (!existsSync(projDir)) return null;
84
206
  try {
@@ -92,6 +214,21 @@ function latestSessionForCwd(): string | null {
92
214
  }
93
215
  }
94
216
 
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 {
223
+ try {
224
+ return RespawnMarkerSchema.parse(JSON.parse(readFileSync(marker, "utf8")));
225
+ } catch (e) {
226
+ rmSync(marker, { force: true });
227
+ log("supervisor.marker_invalid", { err: e instanceof Error ? e.message : String(e) });
228
+ return null;
229
+ }
230
+ }
231
+
95
232
  /** Interruptible countdown until `until`, shown in the terminal (claude is dead,
96
233
  * so the statusLine can't render it). Ctrl-C resumes immediately. */
97
234
  async function countdownWait(acct: string, until: number): Promise<void> {
@@ -138,7 +275,17 @@ export async function runSupervisor(argv: string[]): Promise<number> {
138
275
 
139
276
  // Pass-through: no session management, no respawn - exact stock behavior.
140
277
  if (!info.manage) {
141
- const p = Bun.spawn([real, ...argv], { stdin: "inherit", stdout: "inherit", stderr: "inherit", env: childEnv });
278
+ // STRIP the supervision pairing env (mirrors the codex shim's passthrough
279
+ // arm, closing-review catch): a nested unmanaged claude inside a
280
+ // supervised session (e.g. the agent running `claude -p ...`) would
281
+ // otherwise inherit TOKENMAXXING_SUPERVISED/TOKENMAXXING_SESSION_ID, and
282
+ // its Stop hooks - which DO fire in print mode - would compute
283
+ // canPause=true and could anticipatorily pre-park the pool against a
284
+ // marker path the OUTER supervisor owns.
285
+ const passthroughEnv: Record<string, string | undefined> = { ...childEnv };
286
+ delete passthroughEnv.TOKENMAXXING_SUPERVISED;
287
+ delete passthroughEnv.TOKENMAXXING_SESSION_ID;
288
+ const p = Bun.spawn([real, ...argv], { stdin: "inherit", stdout: "inherit", stderr: "inherit", env: passthroughEnv });
142
289
  await p.exited;
143
290
  return p.exitCode ?? (p.signalCode ? 1 : 0);
144
291
  }
@@ -163,9 +310,18 @@ export async function runSupervisor(argv: string[]): Promise<number> {
163
310
  // this time (a bare `claude --resume <id>`, or the depleted-pool recovery).
164
311
  if (resuming && base.length === 0) {
165
312
  const persisted = loadSessionFlags(sid);
166
- if (persisted) base = persisted;
313
+ // enforce the flags-only contract at the trust boundary, not just at
314
+ // write: a sessions/ file written before stripPositionals existed can
315
+ // still carry the original prompt, and restoring it verbatim would
316
+ // re-submit that prompt on a bare `claude --resume` (PR #37 review
317
+ // catch). Idempotent on well-formed files.
318
+ if (persisted) base = stripPositionals(persisted);
167
319
  }
168
- saveSessionFlags(sid, base, process.cwd());
320
+ // The FIRST launch keeps a positional prompt (the user just typed it);
321
+ // everything persisted or respawned carries flags only.
322
+ const persistable = stripPositionals(base);
323
+ saveSessionFlags(sid, persistable, process.cwd());
324
+ pruneStaleSessions(Date.now());
169
325
 
170
326
  let launchArgs = resuming ? ["--resume", sid, ...base] : ["--session-id", sid, ...base];
171
327
 
@@ -194,7 +350,7 @@ export async function runSupervisor(argv: string[]): Promise<number> {
194
350
  let done = false;
195
351
  const markerWatch = (async () => {
196
352
  while (!done) {
197
- if (await Bun.file(marker).exists()) return true;
353
+ if (existsSync(marker) && consumableMarker(marker) != null) return true;
198
354
  await Bun.sleep(150);
199
355
  }
200
356
  return false;
@@ -210,13 +366,21 @@ export async function runSupervisor(argv: string[]): Promise<number> {
210
366
  await markerWatch.catch(() => {});
211
367
  restoreTermios(savedTermios);
212
368
 
213
- if (existsSync(marker)) {
214
- const m = RespawnMarkerSchema.parse(await Bun.file(marker).json());
369
+ const m = existsSync(marker) ? consumableMarker(marker) : null;
370
+ if (m) {
215
371
  rmSync(marker, { force: true });
216
372
  respawns++;
217
373
  if (m.waitUntil > Date.now()) await countdownWait(m.account, m.waitUntil);
218
374
  else process.stdout.write(`\n\x1b[36m↻ tokenmaxxing: switched to ${m.account} - resuming...\x1b[0m\n`);
219
- launchArgs = ["--resume", sid, ...base];
375
+ // resume the marker's CURRENT transcript, not the pinned id: after
376
+ // /clear they differ, and resuming the pinned id would revive the
377
+ // pre-/clear conversation (closing-review HIGH catch). Persist the
378
+ // flags under that transcript id too, so a later bare
379
+ // `claude --resume <id>` restores them (PR #36 review catch). FLAGS
380
+ // only: replaying a positional prompt would re-submit it as a fresh
381
+ // turn on the progressed session (adversarial-review HIGH catch).
382
+ saveSessionFlags(m.sessionId, persistable, process.cwd());
383
+ launchArgs = ["--resume", m.sessionId, ...persistable];
220
384
  continue;
221
385
  }
222
386
  // No marker: claude exited on its own (quit, crash, resume refused). Log it -
package/src/lib/atomic.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  // Atomic file writes (temp + rename on the same filesystem) and small fs utils.
2
2
 
3
- import { closeSync, mkdirSync, openSync, renameSync, writeSync, fsyncSync } from "node:fs";
3
+ import { closeSync, mkdirSync, openSync, renameSync, rmSync, writeSync, fsyncSync } from "node:fs";
4
4
  import { dirname } from "node:path";
5
5
 
6
6
  /**
@@ -15,10 +15,32 @@ export function writeFileAtomic(file: string, data: string | Uint8Array, mode =
15
15
  const bytes = data instanceof Uint8Array ? data : new TextEncoder().encode(data);
16
16
  const fd = openSync(tmp, "wx", mode);
17
17
  try {
18
- writeSync(fd, bytes);
19
- fsyncSync(fd);
20
- } finally {
21
- closeSync(fd);
18
+ 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
+ let offset = 0;
25
+ while (offset < bytes.length) {
26
+ const written = writeSync(fd, bytes, offset);
27
+ if (written <= 0) throw new Error(`short write on ${tmp}: ${offset}/${bytes.length} bytes (disk full?)`);
28
+ offset += written;
29
+ }
30
+ fsyncSync(fd);
31
+ } finally {
32
+ closeSync(fd);
33
+ }
34
+ renameSync(tmp, file);
35
+ } 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
+ try {
40
+ rmSync(tmp, { force: true });
41
+ } catch {
42
+ // the original write error below is the one that matters
43
+ }
44
+ throw e;
22
45
  }
23
- renameSync(tmp, file);
24
46
  }
@@ -72,7 +72,7 @@ export function pointsBackAtUs(bin: string): boolean {
72
72
  * All of them, not just the first: a user-made wrapper script named claude can
73
73
  * sit ahead of the real binary, and verified resolution must be able to walk
74
74
  * past it. */
75
- export function scanPathForClaudeCandidates(): string[] {
75
+ function scanPathForClaudeCandidates(): string[] {
76
76
  const seen = new Set<string>();
77
77
  const out: string[] = [];
78
78
  for (const d of (process.env.PATH ?? "").split(":")) {
@@ -133,7 +133,7 @@ export function verifyRealClaude(bin: string): string | null {
133
133
  // loop-abort diagnostic points the user at.
134
134
  p = Bun.spawnSync([bin, "--version"], { env, stdout: "pipe", stderr: "pipe", timeout: 15_000, killSignal: "SIGKILL" });
135
135
  } catch (e) {
136
- return String((e as Error).message ?? e);
136
+ return e instanceof Error ? e.message : String(e);
137
137
  }
138
138
  const outText = (p.stdout?.toString() ?? "").trim();
139
139
  const err = (p.stderr?.toString() ?? "").trim();
@@ -7,9 +7,11 @@ import { paths } from "./paths.ts";
7
7
  import { writeFileAtomic } from "./atomic.ts";
8
8
  import { OAuthAccountSchema, type OAuthAccount } from "./types.ts";
9
9
 
10
- export function readClaudeJson(): Record<string, unknown> {
10
+ const ClaudeJsonSchema = z.record(z.string(), z.unknown());
11
+
12
+ function readClaudeJson(): Record<string, unknown> {
11
13
  if (!existsSync(paths.claudeJson)) return {};
12
- return JSON.parse(readFileSync(paths.claudeJson, "utf8")) as Record<string, unknown>;
14
+ return ClaudeJsonSchema.parse(JSON.parse(readFileSync(paths.claudeJson, "utf8")));
13
15
  }
14
16
 
15
17
  export function readOAuthAccount(): OAuthAccount | null {
@@ -32,9 +34,7 @@ export function isApiKeyMode(): boolean {
32
34
  * to unrelated keys with a stale in-memory copy.
33
35
  */
34
36
  export function swapOAuthAccount(next: OAuthAccount): void {
35
- const j = existsSync(paths.claudeJson)
36
- ? (JSON.parse(readFileSync(paths.claudeJson, "utf8")) as Record<string, unknown>)
37
- : {};
37
+ const j = readClaudeJson();
38
38
  j["oauthAccount"] = next;
39
39
  writeFileAtomic(paths.claudeJson, JSON.stringify(j, null, 2) + "\n", 0o600);
40
40
  }