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
@@ -35,16 +35,25 @@ export async function sampleCodexAccount(input: { account: CodexAccount; liveAcc
35
35
  let auth = isLive ? readLiveCodexAuth() : readParkedCodexAuth({ credFile: account.credFile });
36
36
  if (!auth) return { ok: false, reason: isLive ? "live auth.json vanished" : "no parked credential", deadGrant: false };
37
37
  if (isCodexAccessExpiring({ auth, now })) {
38
- // A parked blob whose account is RUNNING in another supervised session
39
- // is superseded by that session's live rotations: refreshing it would
40
- // trip the server's reuse punishment and kill the running session's
41
- // grant family. Skip the refresh and report a miss instead.
42
- if (!isLive && presentCodexAccountIds().has(account.accountId)) {
38
+ // NEVER refresh a PRESENT account's token, live or parked. A parked
39
+ // blob whose account is RUNNING in another supervised session is
40
+ // superseded by that session's live rotations, and the LIVE blob's
41
+ // running session can be mid-turn refreshing the same rotating token
42
+ // concurrently (both actors share the 300s margin): either way the
43
+ // loser of the race is reuse-punished into a dead grant family
44
+ // (closing-review catch; the idle-turn-boundary safety argument covers
45
+ // only the invoking session's own account). Parked reports a miss; live
46
+ // fetches on the unrotated token, still valid within the margin, and
47
+ // degrades to an honest miss once it expires.
48
+ const running = presentCodexAccountIds().has(account.accountId);
49
+ if (running && !isLive) {
43
50
  return { ok: false, reason: "running in a live codex session (parked token refresh unsafe)", deadGrant: false };
44
51
  }
45
- auth = await refreshCodexAuth({ auth, now });
46
- if (isLive) writeLiveCodexAuth({ auth });
47
- writeParkedCodexAuth({ credFile: account.credFile, auth });
52
+ if (!running) {
53
+ auth = await refreshCodexAuth({ auth, now });
54
+ if (isLive) writeLiveCodexAuth({ auth });
55
+ writeParkedCodexAuth({ credFile: account.credFile, auth });
56
+ }
48
57
  }
49
58
  const usage = await fetchCodexUsage({ auth });
50
59
  return { ok: true, usage };
@@ -78,7 +78,16 @@ export async function performCodexSwap(input: { target: CodexAccount }): Promise
78
78
  writeParkedCodexAuth({ credFile: target.credFile, auth: fresh });
79
79
 
80
80
  if (live && liveOwner) {
81
- writeParkedCodexAuth({ credFile: liveOwner.credFile, auth: live });
81
+ // Last-moment re-read: a running codex (the Apps surface) can rotate the
82
+ // live token outside our flock between the identity resolution above and
83
+ // this harvest, and parking the earlier snapshot would strand the newest
84
+ // rotation of a reuse-punished grant family. A mid-swap identity change
85
+ // refuses rather than harvesting under a stale owner.
86
+ const liveNow = readLiveCodexAuth();
87
+ if (!liveNow || codexIdentityOf({ auth: liveNow }).accountId !== liveOwner.accountId) {
88
+ throw new Error("live codex credential changed mid-swap - refusing to harvest under a stale identity; retry");
89
+ }
90
+ writeParkedCodexAuth({ credFile: liveOwner.credFile, auth: liveNow });
82
91
  log("codexswap.harvest", { account: liveOwner.accountId.slice(0, 8) });
83
92
  }
84
93
 
@@ -23,8 +23,10 @@ export type CredTarget = z.infer<typeof CredTargetSchema>;
23
23
 
24
24
  const darwin = process.platform === "darwin";
25
25
 
26
+ const BlobRecordSchema = z.record(z.string(), z.unknown());
27
+
26
28
  function isEnoent(e: unknown): boolean {
27
- return (e as { code?: string }).code === "ENOENT";
29
+ return e instanceof Error && "code" in e && e.code === "ENOENT";
28
30
  }
29
31
 
30
32
  /** Read a target's credential blob. Returns null if it does not exist. */
@@ -91,7 +93,9 @@ export function claudeAiOauthOnly(fullBlobRaw: string): string {
91
93
  /** Merge a fresh `claudeAiOauth` into the CURRENT live blob, preserving every
92
94
  * sibling key (MCP OAuth state, etc.). Returns the full blob string to install. */
93
95
  export function mergeIntoLive(currentLiveRaw: string | null, freshClaudeAiOauth: unknown): string {
94
- const base = currentLiveRaw ? (JSON.parse(currentLiveRaw) as Record<string, unknown>) : {};
96
+ // null-check, not falsiness: an EXISTING-but-empty blob is corruption and
97
+ // must surface (JSON.parse throws), never be silently replaced as "missing".
98
+ const base = currentLiveRaw == null ? {} : BlobRecordSchema.parse(JSON.parse(currentLiveRaw));
95
99
  base["claudeAiOauth"] = freshClaudeAiOauth;
96
100
  return JSON.stringify(base);
97
101
  }
package/src/lib/decide.ts CHANGED
@@ -27,7 +27,7 @@ import { maxBy } from "es-toolkit";
27
27
  import { z } from "zod";
28
28
  import { withLock } from "./lock.ts";
29
29
  import { paths } from "./paths.ts";
30
- import { loadAccounts, loadConfig, loadLastSwapAt, loadUsage, loadModelUsage, saveAccounts, saveModelUsage, usageTeeAt, writeUsage } from "./state.ts";
30
+ import { loadAccounts, loadConfig, loadDepletedWait, loadLastSwapAt, loadUsage, loadModelUsage, saveAccounts, saveDepletedWait, saveModelUsage, usageTeeAt, writeUsage } from "./state.ts";
31
31
  import { readOAuthAccount } from "./claudejson.ts";
32
32
  import { chooseAndSwap, performSwap } from "./swap.ts";
33
33
  import { currentWins, effectiveBars, pickBest, pickEarliestReset, usableAt } from "./picker.ts";
@@ -40,15 +40,30 @@ const SwapDecisionSchema = z.object({
40
40
  swapped: z.boolean(),
41
41
  account: AccountSchema.nullable(),
42
42
  reason: z.string(),
43
- /** set when every account is depleted: epoch ms the chosen account recovers. */
43
+ /** set when every account is depleted and the soonest recovery is known:
44
+ * epoch ms that account recovers. The wait target on depleted-wait;
45
+ * informational on a bare all-depleted (callers like `xx serve` park on
46
+ * it - nothing here waits). */
44
47
  waitUntil: z.number().optional(),
45
48
  });
46
49
  export type SwapDecision = z.infer<typeof SwapDecisionSchema>;
47
50
 
51
+ const WEEK_MS = 7 * 24 * 60 * 60 * 1000;
52
+ const FIVE_HOURS_MS = 5 * 60 * 60 * 1000;
53
+
48
54
  /** A window's usable-against percentage NOW: one whose cached reset has passed
49
- * is empty again, never a switch reason. */
50
- function liveUsed(w: UsageWindow, now: number): number {
51
- return w.resetsAt != null && w.resetsAt <= now ? 0 : w.usedPercentage;
55
+ * is empty again, never a switch reason. A NULL-reset window (reset clock
56
+ * failed to parse) self-bounds at sampledAt + the window's own duration,
57
+ * mirroring the picker's blockedUntil and the codex liveUsed: without the
58
+ * bound the trigger side kept reading a long-stale over-bar row as live while
59
+ * the screening side had already released it - the two halves of one decision
60
+ * disagreed, forcing hard-path swaps (or waitUntil=now respawn churn) off a
61
+ * healthy account (adversarial-review catch). */
62
+ function liveUsed(input: { window: UsageWindow; windowMs: number; sampledAt: number; now: number }): number {
63
+ const { window: w, windowMs, sampledAt, now } = input;
64
+ if (w.resetsAt != null) return w.resetsAt <= now ? 0 : w.usedPercentage;
65
+ if (now >= sampledAt + windowMs) return 0;
66
+ return w.usedPercentage;
52
67
  }
53
68
 
54
69
  /** The family's weekly cap among the `/usage` rows; when several rows match the
@@ -58,7 +73,7 @@ function capForFamily(mu: ModelUsageState, family: string, now: number): UsageWi
58
73
  const rows = Object.entries(mu.perModel)
59
74
  .filter(([k]) => familyTokens(k).includes(family))
60
75
  .map(([, w]) => w);
61
- return maxBy(rows, (w) => liveUsed(w, now));
76
+ return maxBy(rows, (w) => liveUsed({ window: w, windowMs: WEEK_MS, sampledAt: mu.sampledAt ?? mu.ts, now }));
62
77
  }
63
78
 
64
79
  /** True if the active account is over its floor on ANY screening bar: the 5h
@@ -68,11 +83,14 @@ function capForFamily(mu: ModelUsageState, family: string, now: number): UsageWi
68
83
  function isOver(u: UsageState | null, mu: ModelUsageState | null, org: string | null, cfg: Config, now: number): boolean {
69
84
  if (!u || !org || u.org !== org) return false;
70
85
  const bars = effectiveBars(cfg);
71
- if (liveUsed(u.fiveHour, now) >= bars.session || liveUsed(u.sevenDay, now) >= bars.weekly) return true;
86
+ if (
87
+ liveUsed({ window: u.fiveHour, windowMs: FIVE_HOURS_MS, sampledAt: u.ts, now }) >= bars.session ||
88
+ liveUsed({ window: u.sevenDay, windowMs: WEEK_MS, sampledAt: u.ts, now }) >= bars.weekly
89
+ ) return true;
72
90
  if (mu && mu.org === org) {
73
91
  for (const family of gatedFamilies(u.model, cfg.policy.switchModels)) {
74
92
  const cap = capForFamily(mu, family, now);
75
- if (cap && liveUsed(cap, now) >= bars.weekly) return true;
93
+ if (cap && liveUsed({ window: cap, windowMs: WEEK_MS, sampledAt: mu.sampledAt ?? mu.ts, now }) >= bars.weekly) return true;
76
94
  }
77
95
  }
78
96
  return false;
@@ -88,7 +106,7 @@ function needsPerModel(u: UsageState | null, cfg: Config): boolean {
88
106
  * a fresh session rides its account - no churn. */
89
107
  function isEngaged(u: UsageState | null, mu: ModelUsageState | null, org: string | null, cfg: Config, now: number): boolean {
90
108
  if (!u || !org || u.org !== org) return false;
91
- return liveUsed(u.fiveHour, now) >= cfg.policy.greedySessionFloor || isOver(u, mu, org, cfg, now);
109
+ return liveUsed({ window: u.fiveHour, windowMs: FIVE_HOURS_MS, sampledAt: u.ts, now }) >= cfg.policy.greedySessionFloor || isOver(u, mu, org, cfg, now);
92
110
  }
93
111
 
94
112
  const SnapshotsSchema = z.object({
@@ -140,10 +158,14 @@ async function loadFreshSnapshots(cfg: Config, org: string | null, now: number):
140
158
  u = { fiveHour: full.session, sevenDay: full.weekAll, org, ts, model: null };
141
159
  writeUsage(u);
142
160
  }
143
- mu = { perModel: full.perModel, org, ts };
161
+ mu = { perModel: full.perModel, org, ts, sampledAt: ts };
144
162
  saveModelUsage(mu);
145
163
  } else {
146
- mu = { perModel: mu?.org === org ? (mu?.perModel ?? {}) : {}, org, ts };
164
+ // the anti-storm stamp: ts=now suppresses re-probing, but the carried
165
+ // rows keep their ORIGINAL sample time - dating them by ts rolled the
166
+ // null-reset self-bound forward on every failed probe (closing-review
167
+ // catch).
168
+ mu = { perModel: mu?.org === org ? (mu?.perModel ?? {}) : {}, org, ts, sampledAt: mu?.org === org ? (mu?.sampledAt ?? mu?.ts) : undefined };
147
169
  saveModelUsage(mu);
148
170
  }
149
171
  }
@@ -156,7 +178,7 @@ async function loadFreshSnapshots(cfg: Config, org: string | null, now: number):
156
178
  * sooner runs model-blind on data the swap itself invalidated - that is how a
157
179
  * model-aware swap got immediately undone into an A<->B respawn loop. Manual
158
180
  * `switch` is unaffected. */
159
- const POST_SWAP_COOLDOWN_MS = 45_000;
181
+ export const POST_SWAP_COOLDOWN_MS = 45_000;
160
182
 
161
183
  /**
162
184
  * `anticipatory` allows the depleted path to swap onto an account that is still
@@ -170,7 +192,7 @@ const POST_SWAP_COOLDOWN_MS = 45_000;
170
192
  export async function evaluateAndMaybeSwap(now = Date.now(), anticipatory = false): Promise<SwapDecision> {
171
193
  const lastSwapAt = loadLastSwapAt();
172
194
  if (lastSwapAt != null && now - lastSwapAt < POST_SWAP_COOLDOWN_MS) {
173
- return { swapped: false, account: null, reason: "post-swap-cooldown" };
195
+ return depletedReplay(now) ?? { swapped: false, account: null, reason: "post-swap-cooldown" };
174
196
  }
175
197
 
176
198
  const cfg = loadConfig();
@@ -178,8 +200,16 @@ export async function evaluateAndMaybeSwap(now = Date.now(), anticipatory = fals
178
200
 
179
201
  const { u: usage, mu } = await loadFreshSnapshots(cfg, activeOrg, now);
180
202
 
181
- // cheap pre-check off the lock - the common case exits here.
203
+ // cheap pre-check off the lock - the common case exits here. When there is
204
+ // NO measurement for the live org (a pre-park just cleared the snapshots),
205
+ // a recorded depleted-wait still replays; a fresh measurement that reads
206
+ // under-threshold never does - measured-healthy must win over a stale wait.
182
207
  if (!isEngaged(usage, mu, activeOrg, cfg, now)) {
208
+ const measured = usage != null && activeOrg != null && usage.org === activeOrg;
209
+ if (!measured) {
210
+ const replay = depletedReplay(now);
211
+ if (replay) return replay;
212
+ }
183
213
  return { swapped: false, account: null, reason: "under-threshold-or-stale" };
184
214
  }
185
215
 
@@ -189,24 +219,57 @@ export async function evaluateAndMaybeSwap(now = Date.now(), anticipatory = fals
189
219
  const u2 = loadUsage() ?? usage;
190
220
  const mu2 = needsPerModel(u2, cfg) ? loadModelUsage() ?? mu : null;
191
221
 
222
+ // A live login whose org is KNOWN but outside the pool: do nothing - the
223
+ // codex org-guard analog. performSwap would refuse any swap over it (an
224
+ // unpooled credential's only copy must never be overwritten), and the
225
+ // seat fallback below must not stand in a stale pooled label for it: the
226
+ // depleted path could then park a supervised session against the LABELED
227
+ // account's reset while the running login is someone else entirely
228
+ // (pullfrog review catch, PR #33).
229
+ if (org2 != null && !idx.accounts.some((a) => a.organizationUuid === org2)) {
230
+ return { swapped: false, account: null, reason: "live-credential-not-in-pool" };
231
+ }
232
+
192
233
  // record the active account's aggregate usage so the picker + `status` see it.
234
+ // Resolved by the LIVE org the guard just verified, never the
235
+ // activeAccountUuid label: after a manual /login the label drifts (the
236
+ // surviving drift source, see cli/switch.ts), and a label-keyed write
237
+ // would stamp the live account's windows onto whichever account the label
238
+ // still names (closing-review catch, mirrors the codex live-identity rule).
193
239
  if (u2 && org2 && u2.org === org2) {
194
- const active = idx.accounts.find((a) => a.accountUuid === idx.activeAccountUuid);
240
+ const active = idx.accounts.find((a) => a.organizationUuid === org2);
195
241
  if (active) {
196
242
  active.lastUsage = { fiveHour: u2.fiveHour, sevenDay: u2.sevenDay };
197
243
  active.lastUsageAt = u2.ts;
198
244
  // Snapshot per-model caps too, so they still show after we switch away.
199
245
  // An empty map is a failed probe's anti-storm stamp, not a measurement -
200
246
  // it must not erase the burnt-cap snapshot the picker screens on.
201
- if (mu2 && mu2.org === org2 && Object.keys(mu2.perModel).length > 0) active.lastPerModel = mu2.perModel;
247
+ if (mu2 && mu2.org === org2 && Object.keys(mu2.perModel).length > 0) {
248
+ active.lastPerModel = mu2.perModel;
249
+ // the rows' TRUE sample time, not the write time: an anti-storm
250
+ // stamp re-writes ts while carrying old rows (closing-review catch).
251
+ active.lastPerModelAt = mu2.sampledAt ?? mu2.ts;
252
+ }
202
253
  saveAccounts(idx);
203
254
  }
204
255
  }
205
256
 
206
257
  if (!isEngaged(u2, mu2, org2, cfg, now)) {
207
- return { swapped: false, account: null, reason: "raced-already-swapped" };
258
+ return depletedReplay(now) ?? { swapped: false, account: null, reason: "raced-already-swapped" };
208
259
  }
209
260
 
261
+ // The SEAT every path below evaluates and excludes: the live org's pooled
262
+ // account when resolvable, the stored label only as fallback - the same
263
+ // identity rule as the usage stamp above and depletedReplay. Trusting the
264
+ // label here let the greedy convergence judge a stale account as "the
265
+ // seat" after a manual /login, ranking against the wrong cached windows
266
+ // and even offering the LIVE account as a swap target (bugbot review
267
+ // catch, PR #33).
268
+ const seatOf = (idx2: { activeAccountUuid: string | null; accounts: Account[] }): Account | null =>
269
+ idx2.accounts.find((a) => a.organizationUuid === org2) ??
270
+ idx2.accounts.find((a) => a.accountUuid === idx2.activeAccountUuid) ??
271
+ null;
272
+
210
273
  // Candidates are screened by the same families that drove this decision, so
211
274
  // the pool cannot ping-pong onto an account the gate would immediately flag.
212
275
  const switchFamilies = gatedFamilies(u2?.model ?? null, cfg.policy.switchModels);
@@ -224,11 +287,11 @@ export async function evaluateAndMaybeSwap(now = Date.now(), anticipatory = fals
224
287
  const ctxAll = { now, thresholds: effectiveBars(cfg), currentAccountUuid: null, switchFamilies };
225
288
  while (true) {
226
289
  const cur = loadAccounts();
227
- const active = cur.accounts.find((a) => a.accountUuid === cur.activeAccountUuid) ?? null;
290
+ const active = seatOf(cur);
228
291
  if (currentWins(active, cur.accounts, ctxAll)) {
229
292
  return { swapped: false, account: null, reason: "current-best" };
230
293
  }
231
- const best = pickBest(cur.accounts, { ...ctxAll, currentAccountUuid: cur.activeAccountUuid });
294
+ const best = pickBest(cur.accounts, { ...ctxAll, currentAccountUuid: active?.accountUuid ?? null });
232
295
  if (!best) return { swapped: false, account: null, reason: "no-usable-target" };
233
296
  try {
234
297
  await performSwap(best);
@@ -241,42 +304,66 @@ export async function evaluateAndMaybeSwap(now = Date.now(), anticipatory = fals
241
304
  }
242
305
  }
243
306
 
244
- const landed = await chooseAndSwap({ now, thresholds: effectiveBars(cfg), switchFamilies });
307
+ const landed = await chooseAndSwap({ now, thresholds: effectiveBars(cfg), switchFamilies, currentAccountUuid: seatOf(loadAccounts())?.accountUuid ?? null });
245
308
  if (landed) return { swapped: true, account: landed, reason: "swapped" };
246
309
 
247
- // Every account is depleted. Wait for whichever recovers soonest (including the
248
- // current one), if that reset is within the auto-wait window.
249
- const fresh = loadAccounts();
250
- const ctx = { now, thresholds: effectiveBars(cfg), currentAccountUuid: fresh.activeAccountUuid, switchFamilies };
251
- const current = fresh.accounts.find((a) => a.accountUuid === fresh.activeAccountUuid);
252
- const currentAt = current ? usableAt(current, ctx) : Number.POSITIVE_INFINITY;
253
- const other = pickEarliestReset(fresh.accounts, ctx);
310
+ // Every account is depleted. Wait for whichever recovers soonest (including
311
+ // the current one), if that reset is within the auto-wait window. A dead
312
+ // grant on the chosen pre-park target must not abort the wait: performSwap
313
+ // persists needs-reauth before throwing, so each retry re-ranks without the
314
+ // dead account and the loop terminates (mirrors the greedy loop above).
315
+ while (true) {
316
+ const fresh = loadAccounts();
317
+ const current = seatOf(fresh);
318
+ const ctx = { now, thresholds: effectiveBars(cfg), currentAccountUuid: current?.accountUuid ?? null, switchFamilies };
319
+ const currentAt = current ? usableAt(current, ctx) : Number.POSITIVE_INFINITY;
320
+ const other = pickEarliestReset(fresh.accounts, ctx);
254
321
 
255
- let target: Account | null = null;
256
- let waitUntil = Number.POSITIVE_INFINITY;
257
- if (other && other.availableAt < currentAt) { target = other.account; waitUntil = other.availableAt; }
258
- else if (current) { target = current; waitUntil = currentAt; }
259
- else if (other) { target = other.account; waitUntil = other.availableAt; }
322
+ let target: Account | null = null;
323
+ let waitUntil = Number.POSITIVE_INFINITY;
324
+ if (other && other.availableAt < currentAt) { target = other.account; waitUntil = other.availableAt; }
325
+ else if (current) { target = current; waitUntil = currentAt; }
326
+ else if (other) { target = other.account; waitUntil = other.availableAt; }
260
327
 
261
- if (!target || waitUntil - now > cfg.policy.maxWaitMs) {
262
- log("decide.depleted", { waitUntil: Number.isFinite(waitUntil) ? waitUntil : 0 });
263
- return { swapped: false, account: null, reason: "all-depleted" };
264
- }
328
+ if (!target || waitUntil - now > cfg.policy.maxWaitMs) {
329
+ log("decide.depleted", { waitUntil: Number.isFinite(waitUntil) ? waitUntil : 0 });
330
+ return { swapped: false, account: null, reason: "all-depleted", ...(Number.isFinite(waitUntil) ? { waitUntil } : {}) };
331
+ }
265
332
 
266
- const isCurrent = target.accountUuid === fresh.activeAccountUuid;
267
- if (!isCurrent && !anticipatory) {
268
- log("decide.depleted_no_park", { account: target.accountUuid.slice(0, 8), waitUntil });
269
- return { swapped: false, account: null, reason: "all-depleted" };
270
- }
271
- if (!isCurrent) {
272
- try {
273
- await performSwap(target);
274
- } catch (e) {
275
- if (e instanceof InvalidGrantError) return { swapped: false, account: null, reason: "all-depleted" };
276
- throw e;
333
+ const isCurrent = target.accountUuid === (current?.accountUuid ?? null);
334
+ if (!isCurrent && !anticipatory) {
335
+ log("decide.depleted_no_park", { account: target.accountUuid.slice(0, 8), waitUntil });
336
+ return { swapped: false, account: null, reason: "all-depleted", waitUntil };
337
+ }
338
+ if (!isCurrent) {
339
+ try {
340
+ await performSwap(target);
341
+ } catch (e) {
342
+ if (e instanceof InvalidGrantError) continue; // dead grant - re-rank without it
343
+ throw e;
344
+ }
277
345
  }
346
+ // Persist the wait so sibling hooks arriving through the cooldown / raced
347
+ // / cleared-snapshot exits replay it and write their OWN respawn markers.
348
+ saveDepletedWait({ waitUntil, accountUuid: target.accountUuid, ts: now });
349
+ log("decide.depleted_wait", { account: target.accountUuid.slice(0, 8), waitUntil });
350
+ return { swapped: !isCurrent, account: target, reason: "depleted-wait", waitUntil };
278
351
  }
279
- log("decide.depleted_wait", { account: target.accountUuid.slice(0, 8), waitUntil });
280
- return { swapped: !isCurrent, account: target, reason: "depleted-wait", waitUntil };
281
352
  });
282
353
  }
354
+
355
+ /** The recorded depleted-wait, iff still standing: unexpired and still naming
356
+ * the LIVE seat. The check reads claude's own oauthAccount, not the
357
+ * accounts.json label: a tokenmaxxing swap rewrites oauthAccount inside its
358
+ * critical section and a manual /login rewrites it too, while the label lags
359
+ * a manual /login and would replay a wait for an account no longer live
360
+ * (review catch, PR #31). A real swap elsewhere, a manual /login, or the
361
+ * reset passing all kill the record. */
362
+ function depletedReplay(now: number): SwapDecision | null {
363
+ const rec = loadDepletedWait();
364
+ if (!rec || rec.waitUntil <= now) return null;
365
+ const account = loadAccounts().accounts.find((a) => a.accountUuid === rec.accountUuid) ?? null;
366
+ if (!account) return null;
367
+ if (account.organizationUuid !== (readOAuthAccount()?.organizationUuid ?? null)) return null;
368
+ return { swapped: false, account, reason: "depleted-wait", waitUntil: rec.waitUntil };
369
+ }
@@ -2,13 +2,13 @@
2
2
  // The wrapper is a 2-line `exec ... __supervise "$@"` shim so dispatch never
3
3
  // depends on argv0 semantics.
4
4
 
5
- import { appendFileSync, existsSync, mkdirSync, readFileSync, realpathSync, rmSync } from "node:fs";
5
+ import { appendFileSync, existsSync, mkdirSync, readFileSync, realpathSync, rmSync, statSync } from "node:fs";
6
6
  import { basename, dirname, join } from "node:path";
7
7
  import { escape } from "es-toolkit";
8
8
  import { z } from "zod";
9
9
  import { codexPaths, HOME, paths } from "./paths.ts";
10
10
  import { writeFileAtomic } from "./atomic.ts";
11
- import { installedBin, installSettings, uninstallSettings } from "./settings.ts";
11
+ import { installedBin, installSettings, isOurHookCommand, uninstallSettings } from "./settings.ts";
12
12
  import { resolveRealClaude } from "./claudebin.ts";
13
13
 
14
14
  const InstallOutcomeSchema = z.object({
@@ -84,19 +84,27 @@ function codexStopHookCommand(): string {
84
84
  * preserving every other declaration. Codex skips new hooks until the user
85
85
  * trusts them via /hooks (trust is recorded against the hook's hash), so the
86
86
  * caller must surface that step. */
87
+ /** Surgical WITHIN groups, ownership verified structurally (closing-review
88
+ * catch, mirroring settings.ts's removeHook fix): the old whole-group filter
89
+ * deleted a foreign hook the user had appended into our group - the natural
90
+ * edit, since install writes exactly one group - and its includes() match
91
+ * claimed any command merely mentioning the subcommand. */
92
+ function withoutOurCodexStopHooks(groups: { hooks: { type?: string; command?: string }[] }[]): typeof groups {
93
+ return groups
94
+ .map((group) => ({ ...group, hooks: group.hooks.filter((hook) => !isOurHookCommand(hook.command ?? "", CODEX_STOP_HOOK_SUBCOMMAND)) }))
95
+ .filter((group) => group.hooks.length > 0);
96
+ }
97
+
87
98
  export function installCodexStopHook(): void {
88
99
  const current = existsSync(codexPaths.hooksJson)
89
100
  ? CodexHooksFileSchema.parse(JSON.parse(readFileSync(codexPaths.hooksJson, "utf8")))
90
101
  : CodexHooksFileSchema.parse({});
91
- const foreign = current.hooks.Stop.filter(
92
- (group) => !group.hooks.some((hook) => hook.command?.includes(CODEX_STOP_HOOK_SUBCOMMAND)),
93
- );
94
102
  const next = {
95
103
  ...current,
96
104
  hooks: {
97
105
  ...current.hooks,
98
106
  Stop: [
99
- ...foreign,
107
+ ...withoutOurCodexStopHooks(current.hooks.Stop),
100
108
  { hooks: [{ type: "command", command: codexStopHookCommand(), timeout: 120, statusMessage: "tokenmaxxing switch check" }] },
101
109
  ],
102
110
  },
@@ -112,7 +120,7 @@ export function uninstallCodexStopHook(): void {
112
120
  ...current,
113
121
  hooks: {
114
122
  ...current.hooks,
115
- Stop: current.hooks.Stop.filter((group) => !group.hooks.some((hook) => hook.command?.includes(CODEX_STOP_HOOK_SUBCOMMAND))),
123
+ Stop: withoutOurCodexStopHooks(current.hooks.Stop),
116
124
  },
117
125
  };
118
126
  writeFileAtomic(codexPaths.hooksJson, JSON.stringify(next, null, 2) + "\n");
@@ -165,7 +173,7 @@ function run(cmd: string[]): boolean {
165
173
  /** Install + activate the periodic check job. False means the unit files are in
166
174
  * place but activation failed (e.g. systemd user session absent over ssh) -
167
175
  * the caller prints the manual activation step. */
168
- export function installCheckTimer(): boolean {
176
+ function installCheckTimer(): boolean {
169
177
  if (process.platform === "darwin") {
170
178
  const plist = launchdPlist();
171
179
  writeFileAtomic(
@@ -245,17 +253,71 @@ export function checkTimerHealthy(): boolean {
245
253
  );
246
254
  }
247
255
 
248
- export function uninstallCheckTimer(): void {
256
+ /** The manual deactivation command for a still-loaded timer, per platform. */
257
+ export function timerDeactivationHint(): string {
258
+ if (process.platform === "darwin") {
259
+ return `launchctl bootout gui/$(id -u)/${LAUNCHD_LABEL}`;
260
+ }
261
+ return "systemctl --user disable --now tokenmaxxing-check.timer";
262
+ }
263
+
264
+ /** Is the launchd check job loaded? Exit contract verified on this machine
265
+ * (macOS 26, 2026-07-20): `launchctl print` exits 0 for a loaded job and 113
266
+ * for a missing one. Anything else - including a spawn failure or timeout -
267
+ * is "unavailable": an unanswerable probe must never read as "not loaded". */
268
+ function launchdJobLoaded(): "loaded" | "not-loaded" | "unavailable" {
269
+ const domain = launchdDomain();
270
+ if (domain == null) return "unavailable";
271
+ try {
272
+ const { exitCode } = Bun.spawnSync(["launchctl", "print", `${domain}/${LAUNCHD_LABEL}`], { stdout: "ignore", stderr: "ignore", timeout: 10_000 });
273
+ if (exitCode === 0) return "loaded";
274
+ return exitCode === 113 ? "not-loaded" : "unavailable";
275
+ } catch {
276
+ return "unavailable";
277
+ }
278
+ }
279
+
280
+ /** Is the systemd check timer active? Classification goes by the state
281
+ * string, never the exit code: systemctl(1) documents only "0 if at least
282
+ * one is active, non-zero otherwise" for is-active, but guarantees "unless
283
+ * --quiet is specified, this will also print the current unit state to
284
+ * standard output" (verified against the systemd manpage 2026-07-20). The
285
+ * failure mode is container-verified (ubuntu:24.04 systemd, no user bus,
286
+ * 2026-07-20): a dead session bus prints NOTHING to stdout ("Failed to
287
+ * connect to bus" goes to stderr, exit 1), so an empty or unrecognized
288
+ * stdout is "unavailable" - "cannot ask" never reads as "inactive". */
289
+ function systemdTimerActive(): "active" | "not-active" | "unavailable" {
290
+ try {
291
+ const proc = Bun.spawnSync(["systemctl", "--user", "is-active", "tokenmaxxing-check.timer"], { stdout: "pipe", stderr: "ignore", timeout: 10_000 });
292
+ const state = proc.stdout.toString().trim();
293
+ if (state === "active" || state === "activating" || state === "reloading") return "active";
294
+ if (state === "inactive" || state === "failed" || state === "deactivating" || state === "unknown" || state === "maintenance") return "not-active";
295
+ return "unavailable";
296
+ } catch {
297
+ return "unavailable";
298
+ }
299
+ }
300
+
301
+ /** True when the job is verifiably no longer loaded. A swallowed bootout
302
+ * failure once meant a half-uninstalled state kept firing `tokenmaxxing
303
+ * check` every 180s against a package that may be gone, silently
304
+ * (closing-review catch): deactivation is checked, not assumed - a job seen
305
+ * loaded must deactivate successfully, and an unanswerable probe (service
306
+ * manager unusable) reports false rather than pretending it is gone. */
307
+ function uninstallCheckTimer(): boolean {
249
308
  if (process.platform === "darwin") {
250
309
  const domain = launchdDomain();
251
- if (domain != null) run(["launchctl", "bootout", `${domain}/${LAUNCHD_LABEL}`]);
310
+ const loaded = launchdJobLoaded();
311
+ const deactivated = loaded === "loaded" && domain != null ? run(["launchctl", "bootout", `${domain}/${LAUNCHD_LABEL}`]) : loaded === "not-loaded";
252
312
  rmSync(launchdPlist(), { force: true });
253
- return;
313
+ return deactivated;
254
314
  }
255
- run(["systemctl", "--user", "disable", "--now", "tokenmaxxing-check.timer"]);
315
+ const active = systemdTimerActive();
316
+ const deactivated = active === "active" ? run(["systemctl", "--user", "disable", "--now", "tokenmaxxing-check.timer"]) : active === "not-active";
256
317
  rmSync(join(paths.systemdUserDir, "tokenmaxxing-check.timer"), { force: true });
257
318
  rmSync(join(paths.systemdUserDir, "tokenmaxxing-check.service"), { force: true });
258
319
  run(["systemctl", "--user", "daemon-reload"]);
320
+ return deactivated;
259
321
  }
260
322
 
261
323
  /** The rc file of the user's login shell, or null when the shell is unknown.
@@ -275,10 +337,33 @@ const PATH_LINE_MARK = "# tokenmaxxing PATH";
275
337
  * A pre-existing hand-added line for the bin dir also counts as present. */
276
338
  export function ensurePathInRc(rc: string): "added" | "present" {
277
339
  const dir = paths.binDir.startsWith(`${HOME}/`) ? `$HOME${paths.binDir.slice(HOME.length)}` : paths.binDir;
278
- const current = existsSync(rc) ? readFileSync(rc, "utf8") : "";
279
- if (current.includes(PATH_LINE_MARK) || current.includes(`${paths.binDir}:`) || current.includes(`${dir}:`)) return "present";
340
+ // Write through a dotfile-managed symlink, never over it: writeFileAtomic
341
+ // renames a sibling temp over its target, which would replace the link with
342
+ // a plain file while the dotfiles target keeps the stale line (PR #36
343
+ // second-round catch).
344
+ const target = existsSync(rc) ? realpathSync(rc) : rc;
345
+ const current = existsSync(target) ? readFileSync(target, "utf8") : "";
346
+ const isCurrentExport = (line: string) => line.includes(`${paths.binDir}:`) || line.includes(`${dir}:`);
347
+ const lines = current === "" ? [] : current.split("\n");
348
+ // A marked line for a DIFFERENT dir is removed even when the current dir is
349
+ // also exported: PATH prepends stack, so a stale marked line BELOW the
350
+ // current one would still win resolution - the recursion incident's exact
351
+ // vector (closing-review catch + PR #36 second-round catch). A bare marker
352
+ // check alone once kept such a line alive after a TOKENMAXXING_HOME
353
+ // relocation.
354
+ const kept = lines.filter((line) => isCurrentExport(line) || !line.includes(PATH_LINE_MARK));
355
+ if (kept.length !== lines.length) {
356
+ const body = kept.join("\n");
357
+ const sep0 = body === "" || body.endsWith("\n") ? "" : "\n";
358
+ const addition = kept.some(isCurrentExport) ? "" : `export PATH="${dir}:$PATH" ${PATH_LINE_MARK}\n`;
359
+ // preserve the rc's own mode: writeFileAtomic defaults to 0600, which
360
+ // would silently tighten a normally 0644 shell rc (PR #36 review catch)
361
+ writeFileAtomic(target, `${body}${sep0}${addition}`, statSync(target).mode & 0o777);
362
+ return "added";
363
+ }
364
+ if (lines.some(isCurrentExport)) return "present";
280
365
  const sep = current === "" || current.endsWith("\n") ? "" : "\n";
281
- appendFileSync(rc, `${sep}export PATH="${dir}:$PATH" ${PATH_LINE_MARK}\n`);
366
+ appendFileSync(target, `${sep}export PATH="${dir}:$PATH" ${PATH_LINE_MARK}\n`);
282
367
  return "added";
283
368
  }
284
369
 
@@ -318,11 +403,34 @@ export function findClaudeShadowers(rcText: string): ShellShadower[] {
318
403
  return out;
319
404
  }
320
405
 
321
- export function uninstallSupervisor(): void {
406
+ /** Remove ONLY the marker-tagged PATH line this tool added; a hand-added
407
+ * PATH entry without the marker is the user's own. A stale `# tokenmaxxing
408
+ * PATH` line pointing at an emptied binDir is exactly how the supervisor
409
+ * recursion incident started (see AGENTS.md), so uninstall must not leave
410
+ * one behind (closing-review catch). Returns true when a line was removed. */
411
+ export function removePathFromRc(rc: string): boolean {
412
+ if (!existsSync(rc)) return false;
413
+ // same symlink + mode treatment as ensurePathInRc: write through a
414
+ // dotfile-managed link, keep the rc's own permissions (PR #36 catches)
415
+ const target = realpathSync(rc);
416
+ const lines = readFileSync(target, "utf8").split("\n");
417
+ const kept = lines.filter((line) => !line.includes(PATH_LINE_MARK));
418
+ if (kept.length === lines.length) return false;
419
+ writeFileAtomic(target, kept.join("\n"), statSync(target).mode & 0o777);
420
+ return true;
421
+ }
422
+
423
+ const UninstallOutcomeSchema = z.object({ timerDeactivated: z.boolean(), pathLineRemoved: z.boolean() });
424
+ export type UninstallOutcome = z.infer<typeof UninstallOutcomeSchema>;
425
+
426
+ export function uninstallSupervisor(): UninstallOutcome {
322
427
  uninstallSettings();
323
- uninstallCheckTimer();
428
+ const timerDeactivated = uninstallCheckTimer();
324
429
  uninstallCodexSupervisor();
325
430
  for (const f of [paths.supervisorLink, join(paths.binDir, "xx"), installedBin()]) {
326
431
  if (existsSync(f)) rmSync(f, { force: true });
327
432
  }
433
+ const rc = shellRcPath();
434
+ const pathLineRemoved = rc != null && removePathFromRc(rc);
435
+ return { timerDeactivated, pathLineRemoved };
328
436
  }