tokenmaxxing 1.7.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 (67) hide show
  1. package/DESIGN.md +2 -2
  2. package/README.md +1 -1
  3. package/agent-plugin/plugin.json +8 -2
  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 +11 -42
  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 +8 -34
  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 -63
  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 -23
  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 +57 -124
  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 -45
  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 -35
  67. package/src/sdk.ts +0 -59
@@ -1,16 +1,9 @@
1
- // Idempotent merge of tokenmaxxing's entries into the user-owned
2
- // ~/.claude/settings.json: our statusLine + subagentStatusLine, a Stop hook,
3
- // a SessionStart hook. Hooks APPEND to existing arrays; the statusLine slots
4
- // are ours outright - tokenmaxxing renders them natively, so any other
5
- // statusLine command is replaced.
6
-
7
1
  import { existsSync, readFileSync, statSync } from "node:fs";
8
2
  import { join } from "node:path";
9
3
  import { z } from "zod";
10
4
  import { paths } from "./paths.ts";
11
5
  import { writeFileAtomic } from "./atomic.ts";
12
6
 
13
- /** Absolute path to the installed tokenmaxxing binary the settings entries call. */
14
7
  export function installedBin(): string {
15
8
  return join(paths.binDir, "tokenmaxxing");
16
9
  }
@@ -30,62 +23,50 @@ const SUBCMD = {
30
23
  statusline: "__statusline",
31
24
  subagentStatusline: "__subagent-statusline",
32
25
  stop: "__stop-hook",
26
+ stopFailure: "__stop-failure-hook",
33
27
  sessionStart: "__session-start",
34
28
  } as const;
35
29
 
30
+ const STOP_FAILURE_MATCHER = "rate_limit";
31
+
36
32
  function readSettings(): Settings {
37
33
  if (!existsSync(paths.claudeSettings)) return {};
38
34
  return SettingsSchema.parse(JSON.parse(readFileSync(paths.claudeSettings, "utf8")));
39
35
  }
40
36
 
41
37
  function writeSettings(s: Settings): void {
42
- // Preserve the user's mode: settings.json can carry an env block with
43
- // credentials, and the atomic rename would otherwise widen a 0600 file to
44
- // world-readable. A brand-new file starts at the conservative 0600.
45
38
  const mode = existsSync(paths.claudeSettings) ? statSync(paths.claudeSettings).mode & 0o777 : 0o600;
46
39
  writeFileAtomic(paths.claudeSettings, JSON.stringify(s, null, 2) + "\n", mode);
47
40
  }
48
41
 
49
- /** True if a hook/statusline command string is one tokenmaxxing installed. */
50
42
  function isOurCommand(cmd: string | undefined): boolean {
51
43
  if (!cmd) return false;
52
44
  return (
53
45
  cmd.includes(SUBCMD.statusline) ||
54
46
  cmd.includes(SUBCMD.subagentStatusline) ||
55
47
  cmd.includes(SUBCMD.stop) ||
48
+ cmd.includes(SUBCMD.stopFailure) ||
56
49
  cmd.includes(SUBCMD.sessionStart) ||
57
- // also match the installed bin path even if the subcommand text changes
58
50
  cmd.includes(installedBin())
59
51
  );
60
52
  }
61
53
 
62
- /** The exact command string installSettings writes for a subcommand; doctor
63
- * compares against it verbatim, so a green check proves the canonical entry. */
64
54
  function ourCommand(sub: string): string {
65
55
  return `${JSON.stringify(installedBin())} ${sub}`;
66
56
  }
67
57
 
68
- function ourHookGroup(sub: string): HookGroup {
69
- return { hooks: [{ type: "command", command: ourCommand(sub) }] };
58
+ function ourHookGroup(sub: string, matcher?: string): HookGroup {
59
+ return { ...(matcher ? { matcher } : {}), hooks: [{ type: "command", command: ourCommand(sub) }] };
70
60
  }
71
61
 
72
- function appendHook(s: Settings, event: string, sub: string): void {
62
+ function appendHook(s: Settings, event: string, sub: string, matcher?: string): void {
73
63
  s.hooks ??= {};
74
64
  s.hooks[event] ??= [];
75
65
  const arr = s.hooks[event]!;
76
66
  const present = arr.some((g) => g.hooks.some((h) => h.command === ourCommand(sub)));
77
- if (!present) arr.push(ourHookGroup(sub));
67
+ if (!present) arr.push(ourHookGroup(sub, matcher));
78
68
  }
79
69
 
80
- /** True only for a command tokenmaxxing itself wrote - the exact historical
81
- * shape `"<...>/tokenmaxxing" <sub>` at ANY install path (so stale
82
- * pre-relocation entries match too). A foreign command that merely mentions
83
- * the subcommand or the path as text is NOT ours and must survive removal. */
84
- /** Structural ownership: exactly `"<path>/tokenmaxxing" <sub>`. Exported for
85
- * the codex hooks.json installer, whose old includes()-based match deleted
86
- * foreign hooks sharing a group and misclassified commands merely mentioning
87
- * the subcommand (closing-review catch; the same class settings.ts's own
88
- * removeHook was fixed for in PR #31). */
89
70
  export function isOurHookCommand(cmd: string, sub: string): boolean {
90
71
  if (!cmd.endsWith(` ${sub}`)) return false;
91
72
  const quotedPath = cmd.slice(0, cmd.length - (sub.length + 1));
@@ -103,34 +84,28 @@ export function isOurHookCommand(cmd: string, sub: string): boolean {
103
84
  function removeHook(s: Settings, event: string, sub: string): void {
104
85
  const arr = s.hooks?.[event];
105
86
  if (!arr) return;
106
- // Strip only VERIFIED tokenmaxxing-owned entries from WITHIN each group:
107
- // foreign hooks sharing a group - or merely mentioning our strings - survive
108
- // (review catches, PR #31), and a group is dropped only once it is empty.
109
87
  for (const g of arr) g.hooks = g.hooks.filter((h) => !isOurHookCommand(h.command, sub));
110
88
  s.hooks![event] = arr.filter((g) => g.hooks.length > 0);
111
89
  if (s.hooks![event]!.length === 0) delete s.hooks![event];
112
90
  }
113
91
 
114
- /** Install the entries: take both statusLine slots, append our hooks. Stale
115
- * same-subcommand hooks from an OLD install path are dropped first, so a
116
- * TOKENMAXXING_HOME relocation rewrites the entries instead of leaving dead
117
- * paths that read as installed (relocation residue is how the supervisor
118
- * recursion incident started). Foreign hooks are untouched. */
119
92
  export function installSettings(): void {
120
93
  const s = readSettings();
121
94
  s.statusLine = { type: "command", command: ourCommand(SUBCMD.statusline) };
122
95
  s.subagentStatusLine = { type: "command", command: ourCommand(SUBCMD.subagentStatusline) };
123
96
  removeHook(s, "Stop", SUBCMD.stop);
97
+ removeHook(s, "StopFailure", SUBCMD.stopFailure);
124
98
  removeHook(s, "SessionStart", SUBCMD.sessionStart);
125
99
  appendHook(s, "Stop", SUBCMD.stop);
100
+ appendHook(s, "StopFailure", SUBCMD.stopFailure, STOP_FAILURE_MATCHER);
126
101
  appendHook(s, "SessionStart", SUBCMD.sessionStart);
127
102
  writeSettings(s);
128
103
  }
129
104
 
130
- /** Remove our entries. The statusLine slots are deleted only if they are ours. */
131
105
  export function uninstallSettings(): void {
132
106
  const s = readSettings();
133
107
  removeHook(s, "Stop", SUBCMD.stop);
108
+ removeHook(s, "StopFailure", SUBCMD.stopFailure);
134
109
  removeHook(s, "SessionStart", SUBCMD.sessionStart);
135
110
  if (s.statusLine && isOurCommand(s.statusLine.command)) delete s.statusLine;
136
111
  if (s.subagentStatusLine && isOurCommand(s.subagentStatusLine.command)) delete s.subagentStatusLine;
@@ -141,22 +116,20 @@ const SettingsCheckSchema = z.object({
141
116
  statusLineOk: z.boolean(),
142
117
  subagentStatusLineOk: z.boolean(),
143
118
  stopOk: z.boolean(),
119
+ stopFailureOk: z.boolean(),
144
120
  sessionStartOk: z.boolean(),
145
121
  });
146
122
  export type SettingsCheck = z.infer<typeof SettingsCheckSchema>;
147
123
 
148
124
  export function checkSettings(): SettingsCheck {
149
125
  const s = readSettings();
150
- // Exact-command identity (review catch, PR #31): only the verbatim string
151
- // installSettings writes counts as installed. A hook pointing at an OLD
152
- // install path reads as broken, and a foreign command that merely mentions
153
- // the path or subcommand as text never green-lights.
154
- const has = (event: string, sub: string) =>
155
- !!s.hooks?.[event]?.some((g) => g.hooks.some((h) => h.command === ourCommand(sub)));
126
+ const has = (event: string, sub: string, matcher?: string) =>
127
+ !!s.hooks?.[event]?.some((g) => (matcher === undefined || g.matcher === matcher) && g.hooks.some((h) => h.command === ourCommand(sub)));
156
128
  return {
157
129
  statusLineOk: s.statusLine?.command === ourCommand(SUBCMD.statusline),
158
130
  subagentStatusLineOk: s.subagentStatusLine?.command === ourCommand(SUBCMD.subagentStatusline),
159
131
  stopOk: has("Stop", SUBCMD.stop),
132
+ stopFailureOk: has("StopFailure", SUBCMD.stopFailure, STOP_FAILURE_MATCHER),
160
133
  sessionStartOk: has("SessionStart", SUBCMD.sessionStart),
161
134
  };
162
135
  }
package/src/lib/state.ts CHANGED
@@ -1,5 +1,3 @@
1
- // Config + accounts index + usage snapshot persistence. All writes atomic.
2
-
3
1
  import { existsSync, readFileSync, rmSync, statSync, utimesSync } from "node:fs";
4
2
  import { isEqual } from "es-toolkit";
5
3
  import { z } from "zod";
@@ -10,6 +8,7 @@ import {
10
8
  ConfigSchema,
11
9
  LastSwapSchema,
12
10
  ModelUsageStateSchema,
11
+ NextCheckSchema,
13
12
  UsageStateSchema,
14
13
  type AccountsIndex,
15
14
  type Config,
@@ -17,33 +16,16 @@ import {
17
16
  type UsageState,
18
17
  } from "./types.ts";
19
18
 
20
- // ---- config.json (minimal, fixed schema) ---------------------------------
21
-
22
19
  const DEFAULT_CONFIG: Config = {
23
- // LAYER 1 - screening bars, split per window (user 2026-07-16): a session
24
- // reset is cheap to sit out, weekly quota is use-it-or-lose-it so it drains
25
- // to 98. These drive normal account-to-account switching with headroom.
26
20
  thresholds: { session: 95, weekly: 98 },
27
- // LAYER 2 - the wall bars (default the server's own 100% limit). Reached only
28
- // once every account is over its Layer 1 bar: from there a session pumps the
29
- // last drops up to the wall instead of parking with quota unspent.
30
21
  hardThresholds: { session: 100, weekly: 100 },
31
22
  claudeBin: "",
32
23
  codexBin: "",
33
- // per-model weekly caps exist only for Sonnet and Fable (no Opus-only quota,
34
- // per the user 2026-07-12), and only Fable's is worth switching on.
35
- // greedySessionFloor 50: half a session window buys the swap (user 2026-07-16).
36
24
  policy: { projectionMargin: 0, greedySessionFloor: 50, switchModels: ["fable"], usagePollTtlMs: 90_000, maxWaitMs: 3_600_000 },
37
25
  };
38
26
 
39
- /** Percent-of-window values: out-of-range bars make every account read as
40
- * exhausted, so the schema rejects them at the config gate. The cross-field
41
- * case (a projectionMargin at or above a threshold zeroes the effective bar)
42
- * is caught by ConfigSchema's refine on the merged result. */
43
27
  const PercentSchema = z.number().min(0).max(100);
44
28
 
45
- /** On-disk shape (all optional); validated via Zod, merged over defaults.
46
- * Exported for `xx config`, which edits and housekeeps the sparse file. */
47
29
  export const ConfigFileSchema = z
48
30
  .object({
49
31
  thresholds: z.object({ session: PercentSchema, weekly: PercentSchema }).partial(),
@@ -68,12 +50,6 @@ const MergeOutcomeSchema = z.union([
68
50
  ]);
69
51
  export type MergeOutcome = z.infer<typeof MergeOutcomeSchema>;
70
52
 
71
- /** Merge a validated sparse file over the defaults, apply the env binary
72
- * overrides, and validate the merged WHOLE (the projectionMargin-vs-
73
- * thresholds refine). Shared by loadConfig and `xx config set`: set must
74
- * reject a value whose merged result would make every later loadConfig
75
- * throw, silently disabling status/switch/hooks/statusline until the file is
76
- * hand-repaired (closing-review catch). */
77
53
  export function mergeConfigFile(p: z.infer<typeof ConfigFileSchema>): MergeOutcome {
78
54
  const cfg: Config = {
79
55
  ...DEFAULT_CONFIG,
@@ -94,15 +70,12 @@ export function mergeConfigFile(p: z.infer<typeof ConfigFileSchema>): MergeOutco
94
70
  if (p.policy?.switchModels) {
95
71
  cfg.policy.switchModels = p.policy.switchModels.map((s) => s.toLowerCase());
96
72
  }
97
- // env overrides win for the real binaries (tests / relocation)
98
73
  const envBin = realClaudeBinFromEnv();
99
74
  if (envBin) cfg.claudeBin = envBin;
100
75
  const envCodexBin = realCodexBinFromEnv();
101
76
  if (envCodexBin) cfg.codexBin = envCodexBin;
102
77
  const merged = ConfigSchema.safeParse(cfg);
103
78
  if (!merged.success) {
104
- // per-field values passed but the merged whole is unusable (the
105
- // projectionMargin-vs-thresholds refine); name the reason, not a zod dump.
106
79
  return { ok: false, detail: merged.error.issues.map((issue) => issue.message).join("; ") };
107
80
  }
108
81
  return { ok: true, config: merged.data };
@@ -115,15 +88,10 @@ export function loadConfig(): Config {
115
88
  try {
116
89
  raw = JSON.parse(readFileSync(paths.configJson, "utf8"));
117
90
  } catch {
118
- // Silent defaults here once meant a corrupt file could quietly unpin
119
- // claudeBin; a damaged config is the user's to repair, loudly.
120
91
  throw new Error(`${paths.configJson} is corrupt (unparsable JSON) - fix or remove it`);
121
92
  }
122
93
  const parsed = ConfigFileSchema.safeParse(raw);
123
94
  if (!parsed.success) {
124
- // Valid JSON with wrong-typed KNOWN keys must not silently drop pins
125
- // like claudeBin; unknown keys are stripped by the schema and stay
126
- // tolerated (that is `config tidy`'s territory, not an error).
127
95
  const fields = parsed.error.issues.map((issue) => issue.path.join(".")).join(", ");
128
96
  throw new Error(`${paths.configJson} has wrong-typed values (${fields}) - fix or remove them`);
129
97
  }
@@ -134,12 +102,6 @@ export function loadConfig(): Config {
134
102
  return outcome.config;
135
103
  }
136
104
 
137
- /** Pin one binary path into the SPARSE config file, preserving every other
138
- * override verbatim. Never write the merged config here: baking defaults (or
139
- * an ambient TOKENMAXXING_*_BIN env override) into the file freezes future
140
- * default changes as stale explicit values and misreports every `xx config`
141
- * source as "file" (closing-review catch; the sparse-overrides contract is
142
- * config.ts's header). Throws on a corrupt file, like loadConfig. */
143
105
  export function pinBinOverride(input: { key: "claudeBin" | "codexBin"; bin: string }): void {
144
106
  let raw: Record<string, unknown> = {};
145
107
  if (existsSync(paths.configJson)) {
@@ -149,15 +111,9 @@ export function pinBinOverride(input: { key: "claudeBin" | "codexBin"; bin: stri
149
111
  writeFileAtomic(paths.configJson, JSON.stringify(raw, null, 2) + "\n");
150
112
  }
151
113
 
152
- // ---- accounts.json -------------------------------------------------------
153
-
154
114
  const emptyIndex = (): AccountsIndex => ({ version: 1, activeAccountUuid: null, accounts: [] });
155
115
 
156
116
  export function loadAccounts(): AccountsIndex {
157
- // Absent = genuinely empty. Present-but-unreadable THROWS (mirrors the codex
158
- // state loaders): a truncated index once read as an empty pool would send
159
- // `init` down first-time onboarding and overwrite it, orphaning every parked
160
- // credential. Damaged state is the user's to repair, loudly.
161
117
  if (!existsSync(paths.accountsJson)) return emptyIndex();
162
118
  let json: unknown;
163
119
  try {
@@ -176,8 +132,6 @@ export function saveAccounts(idx: AccountsIndex): void {
176
132
  writeFileAtomic(paths.accountsJson, JSON.stringify(AccountsIndexSchema.parse(idx), null, 2) + "\n");
177
133
  }
178
134
 
179
- // ---- usage.json ----------------------------------------------------------
180
-
181
135
  export function loadUsage(): UsageState | null {
182
136
  if (!existsSync(paths.usageJson)) return null;
183
137
  try {
@@ -188,17 +142,11 @@ export function loadUsage(): UsageState | null {
188
142
  }
189
143
  }
190
144
 
191
- /** Drop the statusLine-fed snapshots after a swap: their windows belong to the
192
- * pre-swap account and would otherwise be read under the new active org. */
193
145
  export function clearUsageSnapshots(): void {
194
146
  rmSync(paths.usageJson, { force: true });
195
147
  rmSync(paths.modelUsageJson, { force: true });
196
148
  }
197
149
 
198
- // ---- lastswap.json (epoch ms of the last swap; absent = never swapped;
199
- // present-but-corrupt THROWS - silently reading a damaged swap clock as
200
- // never-swapped would bypass the post-swap cooldown) ----
201
-
202
150
  export function loadLastSwapAt(): number | null {
203
151
  if (!existsSync(paths.lastSwapJson)) return null;
204
152
  let json: unknown;
@@ -214,16 +162,6 @@ export function saveLastSwapAt(ts: number): void {
214
162
  writeFileAtomic(paths.lastSwapJson, JSON.stringify(LastSwapSchema.parse({ ts })));
215
163
  }
216
164
 
217
- // ---- depleted.json (the last depleted-wait decision; absent = none) ----
218
- // Written on every depleted-wait so hooks that hit an early exit (post-swap
219
- // cooldown, raced re-check, cleared snapshots) can REPLAY the wait to their
220
- // own supervisor: without the replay only the first session's Stop hook ever
221
- // saw a marker-writable decision and sibling sessions never paused (DESIGN.md
222
- // 3.5's fan-out). The record dies three ways: waitUntil passing, the live
223
- // seat (claude's oauthAccount) moving off the recorded account, and ANY
224
- // completed swap clearing it outright (performSwap - the depleted path
225
- // re-records its own wait right after its pre-park swap returns).
226
-
227
165
  const DepletedWaitSchema = z.object({ waitUntil: z.number(), accountUuid: z.string(), ts: z.number() });
228
166
  export type DepletedWait = z.infer<typeof DepletedWaitSchema>;
229
167
 
@@ -246,23 +184,36 @@ export function clearDepletedWait(): void {
246
184
  rmSync(paths.depletedJson, { force: true });
247
185
  }
248
186
 
249
- /** An alive feed re-proving unchanged figures still refreshes `ts` this often,
250
- * so cache-age displays stay honest without a write+fsync per tick. */
187
+ export const MAX_CHECK_DELAY_MS = 300_000;
188
+
189
+ export function loadNextCheckDueAt(now: number): number | null {
190
+ if (!existsSync(paths.nextCheckJson)) return null;
191
+ let parsed;
192
+ try {
193
+ parsed = NextCheckSchema.safeParse(JSON.parse(readFileSync(paths.nextCheckJson, "utf8")));
194
+ } catch {
195
+ return null;
196
+ }
197
+ if (!parsed.success) return null;
198
+ return parsed.data.dueAt - now > MAX_CHECK_DELAY_MS ? null : parsed.data.dueAt;
199
+ }
200
+
201
+ export function saveNextCheckDueAt(input: { dueAt: number; ts: number }): void {
202
+ writeFileAtomic(paths.nextCheckJson, JSON.stringify(NextCheckSchema.parse(input)));
203
+ }
204
+
205
+ export function clearNextCheck(): void {
206
+ rmSync(paths.nextCheckJson, { force: true });
207
+ }
208
+
251
209
  const USAGE_TS_REFRESH_MS = 10 * 60_000;
252
210
 
253
- /** Write-on-change: skip the write (and its fsync) when only `ts` would differ,
254
- * unless the stored `ts` has aged past the refresh window. A suppressed write
255
- * still bumps the file's mtime (metadata only, no fsync): mtime is the feed's
256
- * liveness heartbeat, and without the bump an alive tee re-proving unchanged
257
- * figures reads as a dead feed and the decision path goes model-blind. */
258
211
  export function writeUsage(next: UsageState): boolean {
259
212
  const prev = loadUsage();
260
213
  if (prev && isEqual({ ...prev, ts: 0 }, { ...next, ts: 0 }) && next.ts - prev.ts < USAGE_TS_REFRESH_MS) {
261
214
  try {
262
215
  utimesSync(paths.usageJson, new Date(next.ts), new Date(next.ts));
263
216
  } catch (e) {
264
- // The file vanished mid-race: a concurrent swap just invalidated these
265
- // figures. Suppressing stays correct; a write would resurrect them.
266
217
  const errno = z.object({ code: z.string() }).safeParse(e);
267
218
  if (!errno.success || errno.data.code !== "ENOENT") throw e;
268
219
  }
@@ -272,9 +223,6 @@ export function writeUsage(next: UsageState): boolean {
272
223
  return true;
273
224
  }
274
225
 
275
- /** When the usage feed last proved itself alive (usage.json mtime), null if the
276
- * snapshot is absent. Fresher than the embedded `ts`, which write-on-change
277
- * deliberately lets age while figures hold still. */
278
226
  export function usageTeeAt(): number | null {
279
227
  try {
280
228
  return statSync(paths.usageJson).mtimeMs;
@@ -283,8 +231,6 @@ export function usageTeeAt(): number | null {
283
231
  }
284
232
  }
285
233
 
286
- // ---- model-usage.json (per-model caps from `/usage`, TTL-cached) ----------
287
-
288
234
  export function loadModelUsage(): ModelUsageState | null {
289
235
  if (!existsSync(paths.modelUsageJson)) return null;
290
236
  try {
package/src/lib/swap.ts CHANGED
@@ -1,24 +1,4 @@
1
- // The account-switch sequence. Runs under the tokenmaxxing flock (held by the
2
- // caller - the Stop hook or a CLI command). The keychain/json writes additionally
3
- // run under claude's own refresh lock so they can't interleave with a token refresh.
4
- //
5
- // resolve the live credential's TRUE owner (network, no lock; an expiring
6
- // live credential refreshes under claude's refresh lock first)
7
- // refresh B's parked credential (network, no lock), persisting the rotation
8
- // at once - UNLESS B IS the live owner (label drift): then the live blob is
9
- // already the newest rotation and the parked copy must not be refreshed
10
- // ── under claude refresh lock ──
11
- // verify the live item still holds the token the owner was resolved from
12
- // (a concurrent /login or refresh in the unlocked gap aborts the swap)
13
- // harvest live → its OWNER's backup (mandatory: refresh token rotates in
14
- // place; when the owner IS the target this repairs its stale backup)
15
- // install B into the live item
16
- // rewrite oauthAccount in ~/.claude.json
17
- // mark B active (kept adjacent to the identity write: the files cannot be
18
- // crash-atomic together, but the next swap's true-owner resolution catches
19
- // and logs any crash drift - swap.harvest_drift)
20
-
21
- import { clearDepletedWait, clearUsageSnapshots, loadAccounts, saveAccounts, saveLastSwapAt } from "./state.ts";
1
+ import { clearDepletedWait, clearNextCheck, clearUsageSnapshots, loadAccounts, saveAccounts, saveLastSwapAt } from "./state.ts";
22
2
  import { readItem, writeItem, liveTarget, parkedTarget, claudeAiOauthOnly, mergeIntoLive } from "./credstore.ts";
23
3
  import { refreshCredential, isAccessTokenExpiring, fetchTokenOrg, InvalidGrantError } from "./oauth.ts";
24
4
  import { swapOAuthAccount } from "./claudejson.ts";
@@ -31,27 +11,12 @@ function parseBlob(raw: string) {
31
11
  return CredentialBlobSchema.parse(JSON.parse(raw));
32
12
  }
33
13
 
34
- /**
35
- * Mechanically switch the LIVE credential to `target`. Assumes the caller holds
36
- * the tokenmaxxing flock. Throws InvalidGrantError (after marking needs_reauth)
37
- * when target's refresh token is dead.
38
- */
39
14
  export async function performSwap(target: Account): Promise<void> {
40
15
  const idx = loadAccounts();
41
16
 
42
- // 1. resolve the live credential's TRUE owner - the harvest destination -
43
- // BEFORE any rotation. activeAccountUuid is a label, and labels drift from
44
- // the blob they describe (crash mid-swap, manual /login, historical
45
- // re-init); harvesting by label is how a backup once got destroyed, and
46
- // refreshing the target's parked copy while the target is secretly the
47
- // LIVE account would rotate a superseded grant and flag a healthy account
48
- // needs-reauth (review catch, iteration 3). The token itself cannot lie.
49
17
  const preLive = await readItem(liveTarget());
50
18
  let liveOwner: Account | null = null;
51
19
  let liveCreds: OAuthCreds | null = null;
52
- // What the live item's accessToken must still be when the critical section
53
- // below re-reads it: the owner resolution here happens UNLOCKED, so a change
54
- // in between means the resolved owner may describe a different credential.
55
20
  let expectedLiveToken: string | null = null;
56
21
  if (preLive) {
57
22
  liveCreds = parseBlob(preLive).claudeAiOauth;
@@ -59,7 +24,6 @@ export async function performSwap(target: Account): Promise<void> {
59
24
  if (isAccessTokenExpiring(liveCreds, 60_000)) {
60
25
  try {
61
26
  await withClaudeRefreshLock(async (lock) => {
62
- // re-read inside the lock: claude may have rotated it while we waited.
63
27
  const raw2 = await readItem(liveTarget());
64
28
  if (raw2 == null) throw new Error("live credential vanished while waiting for the refresh lock");
65
29
  const current = parseBlob(raw2).claudeAiOauth;
@@ -72,9 +36,6 @@ export async function performSwap(target: Account): Promise<void> {
72
36
  });
73
37
  } catch (e) {
74
38
  if (!(e instanceof InvalidGrantError)) throw e;
75
- // dead credential family: nothing worth preserving, skip the harvest.
76
- // expectedLiveToken keeps the on-disk token - the failed refresh wrote
77
- // nothing, so the blob is unchanged until someone else changes it.
78
39
  liveCreds = null;
79
40
  log("swap.harvest_skipped_dead_live", {});
80
41
  }
@@ -96,14 +57,6 @@ export async function performSwap(target: Account): Promise<void> {
96
57
  }
97
58
  }
98
59
 
99
- // 2. the credential to install. When the target IS the live owner (label
100
- // drift made us "swap onto" the account already live), the live item holds
101
- // the newest rotation and NOTHING must be installed over it - refreshing
102
- // the parked copy would rotate a superseded grant, and installing any
103
- // pre-lock snapshot could clobber a rotation claude makes meanwhile; the
104
- // harvest below repairs the stale backup and the label commit repairs the
105
- // drift. Otherwise refresh the parked credential and persist the rotation
106
- // before ANY later step can fail (mirrors codexswap).
107
60
  const selfSwap = liveOwner != null && liveOwner.accountUuid === target.accountUuid;
108
61
  let fresh: OAuthCreds | null = null;
109
62
  if (!selfSwap) {
@@ -122,74 +75,37 @@ export async function performSwap(target: Account): Promise<void> {
122
75
  await writeItem(parkedTarget(target.keychainItem), JSON.stringify({ claudeAiOauth: fresh }));
123
76
  }
124
77
 
125
- // 3. the fast, local, atomic-vs-claude-refresh critical section.
126
78
  await withClaudeRefreshLock(async (lock) => {
127
79
  const currentLive = await readItem(liveTarget());
128
80
  if (lock.compromised()) throw new Error("refresh lock compromised - aborting the swap before any write");
129
81
 
130
- // The owner above was resolved UNLOCKED (network calls must not sit inside
131
- // claude's refresh lock), so a manual /login or a claude refresh can have
132
- // replaced the live item since. Harvesting the replaced blob under the
133
- // stale owner would corrupt that owner's only backup - the exact incident
134
- // class the owner-first order exists to prevent (review catch, PR #31).
135
- // Any change aborts before a single write; the next check re-resolves the
136
- // true owner and retries.
137
82
  const currentToken = currentLive == null ? null : parseBlob(currentLive).claudeAiOauth.accessToken;
138
83
  if (currentToken !== expectedLiveToken) {
139
84
  throw new Error("live credential changed while unlocked (concurrent /login or refresh) - aborting this swap; the next check re-resolves the owner and retries");
140
85
  }
141
86
 
142
- // harvest the live claudeAiOauth into its OWNER's (small) backup item.
143
- // When the owner IS the target (label drift), the live blob is the newest
144
- // rotation - the parked refresh was skipped above - so this same write is
145
- // exactly the repair of a stale or dead parked backup.
146
87
  if (liveOwner && currentLive) {
147
88
  await writeItem(parkedTarget(liveOwner.keychainItem), claudeAiOauthOnly(currentLive));
148
89
  log("swap.harvest", { account: liveOwner.accountUuid.slice(0, 8) });
149
90
  }
150
91
 
151
- // install B: merge B's fresh claudeAiOauth into the CURRENT live blob so all
152
- // sibling state (MCP OAuth tokens, etc.) is preserved across the swap.
153
- // (B's rotation was already persisted to its backup right after the refresh.)
154
- // A self-swap installs NOTHING: the live item already holds the newest
155
- // rotation, re-read under this lock; only the label below needs repair.
156
92
  if (fresh != null) {
157
93
  await writeItem(liveTarget(), mergeIntoLive(currentLive, fresh));
158
94
  }
159
95
  swapOAuthAccount(target.oauthAccount);
160
- // record B as active immediately after the identity write. These separate
161
- // files cannot be crash-atomic together, so a crash may leave intermediate
162
- // state; the next swap resolves the live owner from the token itself and
163
- // logs any drift (swap.harvest_drift).
164
96
  idx.activeAccountUuid = target.accountUuid;
165
97
  const t2 = idx.accounts.find((a) => a.accountUuid === target.accountUuid);
166
98
  if (t2) { t2.needsReauth = false; }
167
99
  saveAccounts(idx);
168
- // the snapshots on disk still describe the pre-swap account; under the new
169
- // org label they'd trigger a bogus switch off the account just installed.
170
100
  clearUsageSnapshots();
171
- // any completed swap supersedes a recorded depleted-wait: an unexpired
172
- // stale record could otherwise replay through this swap's own cooldown
173
- // and pause the fresh seat for a long-gone decision (review catch,
174
- // PR #31). The depleted path re-records its own wait right after its
175
- // pre-park swap returns.
176
101
  clearDepletedWait();
102
+ clearNextCheck();
177
103
  saveLastSwapAt(Date.now());
178
104
  });
179
105
  log("swap.done", { account: target.accountUuid.slice(0, 8), email: target.email });
180
106
  }
181
107
 
182
- /**
183
- * Pick the best candidate and swap to it, retrying past dead refresh tokens.
184
- * Assumes the caller holds the flock (does NOT lock - avoids same-process
185
- * flock self-deadlock). Returns the account landed on, or null if none usable.
186
- */
187
108
  export async function chooseAndSwap(ctx: PickCtx): Promise<Account | null> {
188
- // ctx.currentAccountUuid is the caller-resolved SEAT (decide.ts resolves it
189
- // by the live org, label fallback): resolving here off activeAccountUuid
190
- // re-imported the label drift the caller just resolved away, and under
191
- // drift the LIVE account could be picked as its own swap target (bugbot
192
- // review catch, PR #33).
193
109
  const tried = new Set<string>();
194
110
  while (true) {
195
111
  const idx = loadAccounts();
@@ -201,7 +117,7 @@ export async function chooseAndSwap(ctx: PickCtx): Promise<Account | null> {
201
117
  await performSwap(best);
202
118
  return best;
203
119
  } catch (e) {
204
- if (e instanceof InvalidGrantError) continue; // dead token - next candidate
120
+ if (e instanceof InvalidGrantError) continue;
205
121
  throw e;
206
122
  }
207
123
  }
package/src/lib/tty.ts CHANGED
@@ -1,7 +1,3 @@
1
- // Save/restore the controlling terminal's line settings around a child that owns
2
- // the tty via inherited stdio. If we SIGTERM such a child (supervisor respawn, or
3
- // `add` auto-exit), the terminal can be left in raw mode; restoring stty fixes it.
4
-
5
1
  export function saveTermios(): string | null {
6
2
  const p = Bun.spawnSync(["/bin/sh", "-c", "stty -g </dev/tty"]);
7
3
  const s = p.stdout?.toString().trim();