tokenmaxxing 0.19.0 → 0.21.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 (59) hide show
  1. package/DESIGN.md +34 -23
  2. package/README.md +4 -4
  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/codexswitch.ts +15 -1
  10. package/src/cli/config.ts +10 -1
  11. package/src/cli/doctor.ts +3 -3
  12. package/src/cli/init.ts +54 -32
  13. package/src/cli/onboard.ts +62 -45
  14. package/src/cli/render.ts +0 -16
  15. package/src/cli/rm.ts +40 -2
  16. package/src/cli/serve.ts +650 -78
  17. package/src/cli/status.ts +69 -23
  18. package/src/cli/switch.ts +54 -19
  19. package/src/entries/codexstophook.ts +123 -4
  20. package/src/entries/codexsupervisor.ts +87 -13
  21. package/src/entries/sessionstart.ts +1 -1
  22. package/src/entries/statusline.ts +56 -20
  23. package/src/entries/stophook.ts +23 -9
  24. package/src/entries/supervisor.ts +134 -18
  25. package/src/lib/atomic.ts +28 -6
  26. package/src/lib/claudebin.ts +2 -2
  27. package/src/lib/claudejson.ts +5 -5
  28. package/src/lib/claudelock.ts +112 -37
  29. package/src/lib/codexauth.ts +10 -2
  30. package/src/lib/codexbin.ts +1 -1
  31. package/src/lib/codexdecide.ts +149 -19
  32. package/src/lib/codexpick.ts +17 -6
  33. package/src/lib/codexpresence.ts +59 -21
  34. package/src/lib/codexsample.ts +17 -8
  35. package/src/lib/codexswap.ts +10 -1
  36. package/src/lib/credstore.ts +6 -2
  37. package/src/lib/decide.ts +114 -42
  38. package/src/lib/install.ts +125 -17
  39. package/src/lib/keychain.ts +41 -15
  40. package/src/lib/lock.ts +57 -35
  41. package/src/lib/log.ts +36 -7
  42. package/src/lib/oauth.ts +18 -11
  43. package/src/lib/paths.ts +17 -11
  44. package/src/lib/picker.ts +11 -3
  45. package/src/lib/proc.ts +37 -0
  46. package/src/lib/sample.ts +91 -31
  47. package/src/lib/sessions.ts +23 -1
  48. package/src/lib/settings.ts +59 -18
  49. package/src/lib/slackbridge.ts +583 -76
  50. package/src/lib/slackstate.ts +159 -12
  51. package/src/lib/slackstream.ts +127 -21
  52. package/src/lib/state.ts +131 -35
  53. package/src/lib/swap.ts +109 -47
  54. package/src/lib/types.ts +79 -37
  55. package/src/lib/usage.ts +114 -16
  56. package/src/main.ts +61 -7
  57. package/src/serve-plugin/.claude-plugin/plugin.json +4 -0
  58. package/src/serve-plugin/skills/ask-the-user/SKILL.md +36 -0
  59. 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,106 @@ 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
+ /** Newest transcript session id for the current cwd (for `-c`). claude's
159
+ * project-dir slug maps EVERY non-alphanumeric char to "-": the regex below
160
+ * mirrors claude's own, byte for byte (binary-verified 2.1.215, the external-
161
+ * contract regex exception). The old [/.]-only mapping missed underscores
162
+ * etc., so `-c` in such a cwd silently opened a brand-new session instead of
163
+ * continuing (closing-review catch). */
80
164
  function latestSessionForCwd(): string | null {
81
- const slug = process.cwd().replace(/[/.]/g, "-");
165
+ const slug = process.cwd().replace(/[^a-zA-Z0-9]/g, "-");
82
166
  const projDir = join(paths.claudeDir, "projects", slug);
83
167
  if (!existsSync(projDir)) return null;
84
168
  try {
@@ -92,6 +176,21 @@ function latestSessionForCwd(): string | null {
92
176
  }
93
177
  }
94
178
 
179
+ /** Read + validate a respawn marker. An unparseable one (a version-skew hook,
180
+ * corruption) is dropped loudly and reported as absent: the watcher checks
181
+ * validity BEFORE the SIGTERM, so garbage can never kill the session, and the
182
+ * post-exit consume can never throw after the child is already dead (PR #36
183
+ * review catch). */
184
+ function consumableMarker(marker: string): z.infer<typeof RespawnMarkerSchema> | null {
185
+ try {
186
+ return RespawnMarkerSchema.parse(JSON.parse(readFileSync(marker, "utf8")));
187
+ } catch (e) {
188
+ rmSync(marker, { force: true });
189
+ log("supervisor.marker_invalid", { err: e instanceof Error ? e.message : String(e) });
190
+ return null;
191
+ }
192
+ }
193
+
95
194
  /** Interruptible countdown until `until`, shown in the terminal (claude is dead,
96
195
  * so the statusLine can't render it). Ctrl-C resumes immediately. */
97
196
  async function countdownWait(acct: string, until: number): Promise<void> {
@@ -138,7 +237,17 @@ export async function runSupervisor(argv: string[]): Promise<number> {
138
237
 
139
238
  // Pass-through: no session management, no respawn - exact stock behavior.
140
239
  if (!info.manage) {
141
- const p = Bun.spawn([real, ...argv], { stdin: "inherit", stdout: "inherit", stderr: "inherit", env: childEnv });
240
+ // STRIP the supervision pairing env (mirrors the codex shim's passthrough
241
+ // arm, closing-review catch): a nested unmanaged claude inside a
242
+ // supervised session (e.g. the agent running `claude -p ...`) would
243
+ // otherwise inherit TOKENMAXXING_SUPERVISED/TOKENMAXXING_SESSION_ID, and
244
+ // its Stop hooks - which DO fire in print mode - would compute
245
+ // canPause=true and could anticipatorily pre-park the pool against a
246
+ // marker path the OUTER supervisor owns.
247
+ const passthroughEnv: Record<string, string | undefined> = { ...childEnv };
248
+ delete passthroughEnv.TOKENMAXXING_SUPERVISED;
249
+ delete passthroughEnv.TOKENMAXXING_SESSION_ID;
250
+ const p = Bun.spawn([real, ...argv], { stdin: "inherit", stdout: "inherit", stderr: "inherit", env: passthroughEnv });
142
251
  await p.exited;
143
252
  return p.exitCode ?? (p.signalCode ? 1 : 0);
144
253
  }
@@ -166,6 +275,7 @@ export async function runSupervisor(argv: string[]): Promise<number> {
166
275
  if (persisted) base = persisted;
167
276
  }
168
277
  saveSessionFlags(sid, base, process.cwd());
278
+ pruneStaleSessions(Date.now());
169
279
 
170
280
  let launchArgs = resuming ? ["--resume", sid, ...base] : ["--session-id", sid, ...base];
171
281
 
@@ -194,7 +304,7 @@ export async function runSupervisor(argv: string[]): Promise<number> {
194
304
  let done = false;
195
305
  const markerWatch = (async () => {
196
306
  while (!done) {
197
- if (await Bun.file(marker).exists()) return true;
307
+ if (existsSync(marker) && consumableMarker(marker) != null) return true;
198
308
  await Bun.sleep(150);
199
309
  }
200
310
  return false;
@@ -210,13 +320,19 @@ export async function runSupervisor(argv: string[]): Promise<number> {
210
320
  await markerWatch.catch(() => {});
211
321
  restoreTermios(savedTermios);
212
322
 
213
- if (existsSync(marker)) {
214
- const m = RespawnMarkerSchema.parse(await Bun.file(marker).json());
323
+ const m = existsSync(marker) ? consumableMarker(marker) : null;
324
+ if (m) {
215
325
  rmSync(marker, { force: true });
216
326
  respawns++;
217
327
  if (m.waitUntil > Date.now()) await countdownWait(m.account, m.waitUntil);
218
328
  else process.stdout.write(`\n\x1b[36m↻ tokenmaxxing: switched to ${m.account} - resuming...\x1b[0m\n`);
219
- launchArgs = ["--resume", sid, ...base];
329
+ // resume the marker's CURRENT transcript, not the pinned id: after
330
+ // /clear they differ, and resuming the pinned id would revive the
331
+ // pre-/clear conversation (closing-review HIGH catch). Persist the
332
+ // flags under that transcript id too, so a later bare
333
+ // `claude --resume <id>` restores them (PR #36 review catch).
334
+ saveSessionFlags(m.sessionId, base, process.cwd());
335
+ launchArgs = ["--resume", m.sessionId, ...base];
220
336
  continue;
221
337
  }
222
338
  // 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
  }