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
@@ -1,54 +1,129 @@
1
- // Best-effort interlock with Claude Code's OWN credential-refresh lock so our
2
- // keychain write can't collide with a concurrent token refresh. Claude uses the
3
- // npm `proper-lockfile` (mkdir-based) at <claudeDir>/.oauth_refresh.lock - the
4
- // lock is the DIRECTORY itself. We mkdir it; on contention we wait briefly, then
5
- // proceed anyway (our own flock already serializes tokenmaxxing swaps, and claude
6
- // only refreshes near token expiry / on 401, rarely at the 98% usage trigger).
1
+ // Interlock with Claude Code's OWN credential-refresh locks so our live-store
2
+ // writes can't interleave with a concurrent token refresh. Binary-verified
3
+ // against 2.1.214's embedded refresh code: claude takes TWO proper-lockfile
4
+ // (mkdir-based) locks rooted at its credentials dir - the primary
5
+ // <credDir>/.oauth_refresh.lock plus a legacy sibling <realpath(credDir)>.lock
6
+ // (~/.claude.lock by default, kept for coordination with older versions) -
7
+ // with stale: 60s, a 5s lock-mtime heartbeat while held, and on contention it
8
+ // retries 5x with 1-2s jitter then FAILS the refresh (lock_timeout) instead of
9
+ // proceeding unlocked. We mirror all of that, including the failure: a lock
10
+ // still held past the retries means a refresh is mid-flight, which is exactly
11
+ // when a live-store write could clobber a rotation. Callers retry next tick.
12
+ //
13
+ // Theft is detected the way proper-lockfile does it: the heartbeat re-stats
14
+ // each lock and compares against the mtime WE last stored - a deviation or a
15
+ // vanished dir means someone reclaimed the lock (only possible if this process
16
+ // stalled past the 60s stale bar). fn gets a `compromised()` probe to check
17
+ // before persisting, release skips dirs we no longer own, and the wrapper
18
+ // throws terminally so no caller trusts work done on a stolen lock.
7
19
 
8
- import { mkdirSync, rmdirSync, statSync } from "node:fs";
20
+ import { mkdirSync, realpathSync, rmdirSync, statSync, utimesSync } from "node:fs";
9
21
  import { join } from "node:path";
10
- import { paths } from "./paths.ts";
22
+ import { delay } from "es-toolkit";
23
+ import { z } from "zod";
24
+ import { credDir } from "./paths.ts";
11
25
  import { log } from "./log.ts";
12
26
 
13
- const STALE_MS = 20_000; // proper-lockfile default stale is 10s; be generous
27
+ const STALE_MS = 60_000; // claude's own proper-lockfile `stale`
28
+ const HEARTBEAT_MS = 5_000; // claude's own `update` cadence
29
+ const ATTEMPTS = 5; // claude's own ELOCKED retry budget
30
+ const RETRY_MS = 1_000; // claude sleeps 1000 + rand(1000) between attempts
31
+
32
+ /** mkdir-take one lock dir, reclaiming it when older than claude's stale bar. */
33
+ function tryAcquire(lockDir: string): boolean {
34
+ try {
35
+ mkdirSync(lockDir);
36
+ return true;
37
+ } catch (e) {
38
+ const errno = z.object({ code: z.string() }).safeParse(e);
39
+ if (!errno.success || errno.data.code !== "EEXIST") throw e;
40
+ }
41
+ try {
42
+ if (Date.now() - statSync(lockDir).mtimeMs > STALE_MS) {
43
+ rmdirSync(lockDir);
44
+ mkdirSync(lockDir);
45
+ return true;
46
+ }
47
+ } catch { /* raced: another actor reclaimed it first */ }
48
+ return false;
49
+ }
14
50
 
15
51
  export async function withClaudeRefreshLock<T>(
16
- fn: () => Promise<T> | T,
17
- opts: { timeoutMs?: number } = {},
52
+ fn: (lock: { compromised: () => boolean }) => Promise<T> | T,
53
+ opts: { attempts?: number; retryMs?: number; heartbeatMs?: number } = {},
18
54
  ): Promise<T> {
19
- const lockDir = join(paths.claudeDir, ".oauth_refresh.lock");
20
- const timeoutMs = opts.timeoutMs ?? 3000;
21
- const start = Date.now();
22
- let held = false;
55
+ const attempts = opts.attempts ?? ATTEMPTS;
56
+ const retryMs = opts.retryMs ?? RETRY_MS;
57
+ const dir = credDir();
58
+ mkdirSync(dir, { recursive: true }); // claude mkdirs it before locking too
59
+ const primary = join(dir, ".oauth_refresh.lock");
60
+ let legacyRoot = dir;
61
+ try { legacyRoot = realpathSync(dir); } catch { /* unresolvable: lock the raw path, like claude */ }
62
+ const legacy = `${legacyRoot}.lock`;
23
63
 
24
- while (Date.now() - start < timeoutMs) {
25
- try {
26
- mkdirSync(lockDir); // atomic; EEXIST means someone holds it
27
- held = true;
28
- break;
29
- } catch (e) {
30
- const code = (e as { code?: string }).code;
31
- if (code === "EEXIST") {
32
- try {
33
- if (Date.now() - statSync(lockDir).mtimeMs > STALE_MS) {
34
- rmdirSync(lockDir); // reclaim a stale lock
35
- continue;
36
- }
37
- } catch { /* raced away */ }
38
- await Bun.sleep(150);
39
- continue;
64
+ const held: string[] = []; // in release order: legacy first, then primary (claude's order)
65
+ for (let attempt = 1; held.length === 0; attempt++) {
66
+ if (tryAcquire(primary)) {
67
+ try {
68
+ if (tryAcquire(legacy)) {
69
+ held.push(legacy, primary);
70
+ break;
71
+ }
72
+ } catch (e) {
73
+ // claude proceeds on a legacy acquire ERROR; only contention aborts.
74
+ log("claudelock.legacy_error", { err: e instanceof Error ? e.message : String(e) });
75
+ held.push(primary);
76
+ break;
40
77
  }
41
- // parent dir missing or other error skip the interlock entirely
42
- break;
78
+ rmdirSync(primary); // legacy contested: release and re-take both, like claude
43
79
  }
80
+ if (attempt >= attempts) {
81
+ log("claudelock.contested", { attempts: attempt });
82
+ throw new Error(
83
+ "claude's credential-refresh lock is contested (a token refresh is likely mid-flight) - not touching the live credential store; retry shortly",
84
+ );
85
+ }
86
+ await delay(retryMs + Math.random() * retryMs);
44
87
  }
45
- if (!held) log("claudelock.proceed_without", {});
88
+
89
+ // the mtime we last stored per lock; a deviation means the lock was stolen.
90
+ const ours = new Map<string, number>();
91
+ for (const d of held) ours.set(d, statSync(d).mtimeMs);
92
+ let compromised = false;
93
+ const markCompromised = () => {
94
+ if (!compromised) {
95
+ compromised = true;
96
+ log("claudelock.compromised", {});
97
+ }
98
+ };
99
+ const heartbeat = setInterval(() => {
100
+ const now = new Date();
101
+ for (const d of held) {
102
+ try {
103
+ if (statSync(d).mtimeMs !== ours.get(d)) {
104
+ markCompromised(); // someone re-took it; stop touching their lock
105
+ continue;
106
+ }
107
+ utimesSync(d, now, now);
108
+ ours.set(d, statSync(d).mtimeMs);
109
+ } catch {
110
+ markCompromised(); // dir vanished: stolen or force-cleaned
111
+ }
112
+ }
113
+ }, opts.heartbeatMs ?? HEARTBEAT_MS);
46
114
 
47
115
  try {
48
- return await fn();
116
+ const result = await fn({ compromised: () => compromised });
117
+ if (compromised) {
118
+ throw new Error("claude's credential-refresh lock was reclaimed while held (this process stalled past the 60s stale bar) - treat this critical section as failed");
119
+ }
120
+ return result;
49
121
  } finally {
50
- if (held) {
51
- try { rmdirSync(lockDir); } catch { /* already gone */ }
122
+ clearInterval(heartbeat);
123
+ for (const d of held) {
124
+ try {
125
+ if (statSync(d).mtimeMs === ours.get(d)) rmdirSync(d); // release only what is still ours
126
+ } catch { /* already gone */ }
52
127
  }
53
128
  }
54
129
  }
@@ -4,7 +4,7 @@
4
4
  // rust-v0.144.5 manager.rs), so every mutation here runs under tokenmaxxing's
5
5
  // own codex flock, held by the caller.
6
6
 
7
- import { readFileSync } from "node:fs";
7
+ import { readFileSync, rmSync } from "node:fs";
8
8
  import { join } from "node:path";
9
9
  import { z } from "zod";
10
10
  import { writeFileAtomic } from "./atomic.ts";
@@ -17,7 +17,12 @@ function isEnoent(e: unknown): boolean {
17
17
 
18
18
  /** auth.json at an explicit path (the live file, or an onboard dir's), or null
19
19
  * when absent. Throws on a present-but-unparsable file: that is drift to
20
- * surface, not to paper over. */
20
+ * surface, not to paper over. One valid-but-unpoolable state maps to null
21
+ * instead of throwing: `codex login --with-api-key` writes auth.json with
22
+ * `tokens` OMITTED (serde skip_serializing_if, verified rust-v0.144.5) - the
23
+ * real binary runs fine on it, so the shim and status must too, and with no
24
+ * ChatGPT tokens there is nothing tokenmaxxing can pool (closing-review
25
+ * catch). */
21
26
  export function readCodexAuthAt(input: { path: string }): CodexAuthJson | null {
22
27
  let raw: string;
23
28
  try {
@@ -26,7 +31,10 @@ export function readCodexAuthAt(input: { path: string }): CodexAuthJson | null {
26
31
  if (isEnoent(e)) return null;
27
32
  throw e;
28
33
  }
29
- return CodexAuthJsonSchema.parse(JSON.parse(raw));
34
+ const parsed = JSON.parse(raw);
35
+ const probe = z.looseObject({ tokens: z.unknown().optional() }).parse(parsed);
36
+ if (probe.tokens === undefined || probe.tokens === null) return null;
37
+ return CodexAuthJsonSchema.parse(parsed);
30
38
  }
31
39
 
32
40
  /** The live auth.json, or null when codex has no login here. */
@@ -57,6 +65,13 @@ export function writeParkedCodexAuth(input: { credFile: string; auth: CodexAuthJ
57
65
  writeFileAtomic(parkedPath(input), JSON.stringify(CodexAuthJsonSchema.parse(input.auth), null, 2), 0o600);
58
66
  }
59
67
 
68
+ /** `rm --codex` uses this so the path shape (.json suffix) has one owner:
69
+ * a hand-built path without the suffix silently missed the real file under
70
+ * rmSync force (PR #37 review catch). */
71
+ export function deleteParkedCodexAuth(input: { credFile: string }): void {
72
+ rmSync(parkedPath(input), { force: true });
73
+ }
74
+
60
75
  /** Identity claims inside the id_token JWT payload (verified against a live
61
76
  * 0.144.4 token: the chatgpt fields sit under the api.openai.com/auth claim). */
62
77
  const IdClaimsSchema = z.looseObject({
@@ -10,7 +10,7 @@ import { loadConfig } from "./state.ts";
10
10
  import { paths } from "./paths.ts";
11
11
 
12
12
  /** First PATH entry with a `codex` that is not us. null when PATH has none. */
13
- export function scanPathForCodex(): string | null {
13
+ function scanPathForCodex(): string | null {
14
14
  for (const d of (process.env.PATH ?? "").split(":")) {
15
15
  if (!d) continue;
16
16
  const cand = join(d, "codex");
@@ -6,8 +6,11 @@
6
6
  // depleted pre-park (a swap only lands where a window is usable NOW; a
7
7
  // depleted pool stays put and recovers when a cached reset passes).
8
8
 
9
+ import { existsSync, mkdirSync, readdirSync, rmSync } from "node:fs";
10
+ import { join } from "node:path";
9
11
  import { z } from "zod";
10
12
  import { withLock } from "./lock.ts";
13
+ import { writeFileAtomic } from "./atomic.ts";
11
14
  import { codexPaths } from "./paths.ts";
12
15
  import { loadConfig } from "./state.ts";
13
16
  import { loadCodexAccounts, loadCodexLastSwapAt, saveCodexAccounts } from "./codexstate.ts";
@@ -15,12 +18,12 @@ import { codexCurrentWins, isCodexEngaged, isCodexExhausted, pickBestCodex } fro
15
18
  import { performCodexSwap } from "./codexswap.ts";
16
19
  import { CodexInvalidGrantError, refreshCodexAuth } from "./codexoauth.ts";
17
20
  import { fetchCodexUsage } from "./codexusage.ts";
18
- import { isCodexAccessExpiring, readLiveCodexAuth, writeLiveCodexAuth, writeParkedCodexAuth } from "./codexauth.ts";
21
+ import { codexIdentityOf, isCodexAccessExpiring, readLiveCodexAuth, writeLiveCodexAuth, writeParkedCodexAuth } from "./codexauth.ts";
19
22
  import { liveCodexAccountId } from "./codexsample.ts";
20
- import { targetableCodexAccounts } from "./codexpresence.ts";
23
+ import { livingCodexPresences, presentCodexAccountIds, targetableCodexAccounts } from "./codexpresence.ts";
21
24
  import { effectiveBars } from "./picker.ts";
22
25
  import { log } from "./log.ts";
23
- import { CodexAccountSchema } from "./types.ts";
26
+ import { CodexAccountSchema, CodexReconcileMarkerSchema, type CodexAccount } from "./types.ts";
24
27
 
25
28
  const CodexSwapDecisionSchema = z.object({
26
29
  swapped: z.boolean(),
@@ -33,26 +36,48 @@ const POST_SWAP_COOLDOWN_MS = 45_000;
33
36
 
34
37
  /**
35
38
  * Sample the LIVE credential's usage and stamp it onto its TRUE owner in the
36
- * pool (the usage read echoes the token's own account id: labels drift, the
37
- * token cannot lie). Refreshes the live blob first when it is near expiry,
38
- * persisting the rotation to both the live file and the owner's parked copy.
39
- * Returns the owner, or null when the live credential is absent or unpooled.
39
+ * pool (the id_token's own identity: labels drift, the token cannot lie).
40
+ * Refreshes the live blob first when it is near expiry (and no supervised
41
+ * session is running it - see the presence guard below), persisting the
42
+ * rotation to the live file AND the owner's parked copy in the same step -
43
+ * before the usage fetch, so a failed fetch can never strand the parked copy
44
+ * on the reuse-punished superseded refresh token. A dead live grant marks the
45
+ * owner needs-reauth and keeps the cached snapshot, so the decision below can
46
+ * still swap AWAY from it. Returns the owner id, or null when the live
47
+ * credential is absent or unpooled.
40
48
  */
41
49
  async function sampleLiveOntoOwner(input: { now: number }): Promise<string | null> {
42
50
  const { now } = input;
43
51
  let live = readLiveCodexAuth();
44
52
  if (!live) return null;
53
+ const index = loadCodexAccounts();
54
+ const identity = codexIdentityOf({ auth: live });
55
+ const owner = index.accounts.find((account) => account.accountId === identity.accountId);
56
+ if (!owner) return null;
45
57
 
46
- if (isCodexAccessExpiring({ auth: live, now })) {
47
- live = await refreshCodexAuth({ auth: live, now });
58
+ // The near-expiry refresh is skipped while any supervised session RUNS the
59
+ // live account: a sibling's Stop hook (or the timer) can land mid-turn of
60
+ // that session, and two concurrent POSTs of the same rotating refresh token
61
+ // reuse-punish the loser into a dead grant family (closing-review catch).
62
+ // The unrotated token stays valid within the margin, so the fetch below
63
+ // still samples; once expired it fails loudly and the next cycle, after the
64
+ // running session's own refresh, recovers.
65
+ if (isCodexAccessExpiring({ auth: live, now }) && !presentCodexAccountIds().has(identity.accountId)) {
66
+ try {
67
+ live = await refreshCodexAuth({ auth: live, now });
68
+ } catch (e) {
69
+ if (e instanceof CodexInvalidGrantError) {
70
+ owner.needsReauth = true;
71
+ saveCodexAccounts({ index });
72
+ log("codexdecide.live_invalid_grant", { account: owner.accountId.slice(0, 8) });
73
+ return owner.accountId;
74
+ }
75
+ throw e;
76
+ }
48
77
  writeLiveCodexAuth({ auth: live });
78
+ writeParkedCodexAuth({ credFile: owner.credFile, auth: live });
49
79
  }
50
80
  const usage = await fetchCodexUsage({ auth: live });
51
-
52
- const index = loadCodexAccounts();
53
- const owner = index.accounts.find((account) => account.accountId === usage.accountId);
54
- if (!owner) return null;
55
- writeParkedCodexAuth({ credFile: owner.credFile, auth: live });
56
81
  owner.lastUsage = { aggregate: usage.aggregate, perLimit: usage.perLimit };
57
82
  owner.lastUsageAt = now;
58
83
  if (usage.email != null) owner.email = usage.email;
@@ -61,13 +86,86 @@ async function sampleLiveOntoOwner(input: { now: number }): Promise<string | nul
61
86
  return owner.accountId;
62
87
  }
63
88
 
64
- export async function evaluateAndMaybeSwapCodex(input: { now?: number }): Promise<CodexSwapDecision> {
65
- const now = input.now ?? Date.now();
66
- const lastSwapAt = loadCodexLastSwapAt();
67
- if (lastSwapAt != null && now - lastSwapAt < POST_SWAP_COOLDOWN_MS) {
68
- return { swapped: false, account: null, reason: "post-swap-cooldown" };
89
+ /**
90
+ * OWNER-APPROVED sibling reconcile (option b 2026-07-20, widened to ALL
91
+ * non-live siblings by the in-session owner ruling later that day): codex
92
+ * cannot hot-swap, so a pool swap respawns only the deciding session -
93
+ * siblings keep running whatever account they started on, cannot refresh it
94
+ * cross-account, and would wedge at token expiry. The deciding actor
95
+ * therefore SIGNALS every pooled non-live supervisor cross-session: a
96
+ * reconcile marker addressed by supervisorId (presence filenames carry it).
97
+ * The signal is a marker, never a kill: the only safe respawn point is the
98
+ * sibling's own turn boundary, so its Stop hook promotes the marker into a
99
+ * respawn marker there, adding the session id its stdin alone knows. No
100
+ * respawn-cost margin applies - the alternative to a respawn is a guaranteed
101
+ * eventual wedge - and no signal is ever sent while the LIVE seat is itself
102
+ * unusable (the no-depleted-pre-park analog: never move a session onto a
103
+ * blocked account). Orphaned markers (their supervisor exited before
104
+ * promoting) are garbage-collected here. Idempotent per supervisor: an
105
+ * existing marker is left alone.
106
+ *
107
+ * There is deliberately NO self-exclusion (bugbot/pullfrog/cubic review
108
+ * catches, PR #34): a session whose presence names a non-live account IS the
109
+ * stranded sibling even inside its own hook - its "normal decision" evaluates
110
+ * only the live seat and would never rescue it. A signal the sweep writes for
111
+ * the calling session is consumed by the promote pass its own hook runs right
112
+ * after the evaluation, same boundary. The deciding session that just swapped
113
+ * away leaves a self-addressed signal behind too; the promote staleness guard
114
+ * (presence rewritten at respawn) drops it as moot.
115
+ */
116
+ function reconcileNonLiveSiblings(input: {
117
+ index: { accounts: CodexAccount[] };
118
+ liveAccountId: string;
119
+ bars: { session: number; weekly: number };
120
+ now: number;
121
+ }): void {
122
+ const { index, liveAccountId, bars, now } = input;
123
+ const unusable = (account: CodexAccount): boolean =>
124
+ account.needsReauth === true || isCodexExhausted({ account, thresholds: bars, now });
125
+ const liveAccount = index.accounts.find((account) => account.accountId === liveAccountId);
126
+ if (!liveAccount || unusable(liveAccount)) return;
127
+ const living = livingCodexPresences();
128
+ // gc signals addressed to supervisors that no longer live (exited before
129
+ // promoting): nothing would ever consume them.
130
+ if (existsSync(codexPaths.reconcileDir)) {
131
+ const alive = new Set(living.map((presence) => presence.supervisorId));
132
+ for (const name of readdirSync(codexPaths.reconcileDir)) {
133
+ if (!alive.has(name)) rmSync(join(codexPaths.reconcileDir, name), { force: true });
134
+ }
135
+ }
136
+ for (const presence of living) {
137
+ if (presence.accountId === liveAccountId) continue; // riding the live seat: fine
138
+ const seated = index.accounts.find((account) => account.accountId === presence.accountId);
139
+ // OWNER RULING (2026-07-20, in-session, superseding the exhausted-only
140
+ // scope of option b): EVERY pooled non-live sibling is signaled, healthy
141
+ // included. Codex's guarded reload refuses a cross-account auth.json
142
+ // refresh, so a non-live session WILL wedge with "Please sign in again"
143
+ // at its access token's expiry (hours) - the safe-boundary respawn onto
144
+ // the live seat costs one visible restart, keeps the transcript
145
+ // (`codex resume <sid>`), and makes the whole pool follow the seat.
146
+ // Unpooled seats stay untouched: not ours to move.
147
+ if (!seated) continue;
148
+ const markerPath = join(codexPaths.reconcileDir, presence.supervisorId);
149
+ if (existsSync(markerPath)) continue;
150
+ mkdirSync(codexPaths.reconcileDir, { recursive: true });
151
+ writeFileAtomic(markerPath, JSON.stringify(CodexReconcileMarkerSchema.parse({ accountId: presence.accountId, ts: now })));
152
+ log("codexdecide.reconcile_signal", { supervisorId: presence.supervisorId.slice(0, 8), account: presence.accountId.slice(0, 8) });
153
+ }
154
+ }
155
+
156
+ /** The re-sweep after a PERSISTED swap: failures are logged, never thrown -
157
+ * the caller must still report swapped:true so the Stop hook writes the
158
+ * deciding session's own respawn marker. */
159
+ function postSwapResweep(input: { liveAccountId: string; bars: { session: number; weekly: number }; now: number }): void {
160
+ try {
161
+ reconcileNonLiveSiblings({ index: loadCodexAccounts(), liveAccountId: input.liveAccountId, bars: input.bars, now: input.now });
162
+ } catch (e) {
163
+ log("codexdecide.resweep_failed", { err: e instanceof Error ? e.message : String(e) });
69
164
  }
165
+ }
70
166
 
167
+ export async function evaluateAndMaybeSwapCodex(input: { now?: number }): Promise<CodexSwapDecision> {
168
+ const now = input.now ?? Date.now();
71
169
  const cfg = loadConfig();
72
170
  const bars = effectiveBars(cfg);
73
171
 
@@ -86,6 +184,19 @@ export async function evaluateAndMaybeSwapCodex(input: { now?: number }): Promis
86
184
  return { swapped: false, account: null, reason: "live-credential-not-in-pool" };
87
185
  }
88
186
 
187
+ // Cross-session sibling reconcile runs on EVERY evaluation, BEFORE both
188
+ // the cooldown return and the engagement gate: a swap strands siblings at
189
+ // exactly the moment the cooldown starts (bugbot/cubic review catch,
190
+ // PR #34), and a sibling can be dead while the live seat is healthy and
191
+ // disengaged. Cached windows are enough here - the promote pass
192
+ // revalidates the destination's usability at consumption time.
193
+ reconcileNonLiveSiblings({ index, liveAccountId: activeId, bars, now });
194
+
195
+ const lastSwapAt = loadCodexLastSwapAt();
196
+ if (lastSwapAt != null && now - lastSwapAt < POST_SWAP_COOLDOWN_MS) {
197
+ return { swapped: false, account: null, reason: "post-swap-cooldown" };
198
+ }
199
+
89
200
  // Freshness: re-sample the live credential once its owner's cached
90
201
  // snapshot ages past the poll TTL (there is no push feed in between).
91
202
  const activeEntry = index.accounts.find((account) => account.accountId === activeId);
@@ -101,7 +212,11 @@ export async function evaluateAndMaybeSwapCodex(input: { now?: number }): Promis
101
212
  const active = index.accounts.find((account) => account.accountId === activeId) ?? null;
102
213
  if (!active) return { swapped: false, account: null, reason: "no-active-account" };
103
214
 
215
+ // A dead live grant always engages: the seat is unusable regardless of
216
+ // cached usage, and codexCurrentWins/pickBestCodex already exclude
217
+ // needs-reauth accounts, so both branches route onto a healthy target.
104
218
  const engaged =
219
+ active.needsReauth === true ||
105
220
  isCodexEngaged({ account: active, floor: cfg.policy.greedySessionFloor, now }) ||
106
221
  isCodexExhausted({ account: active, thresholds: bars, now });
107
222
  if (!engaged) return { swapped: false, account: null, reason: "under-threshold-or-stale" };
@@ -128,6 +243,17 @@ export async function evaluateAndMaybeSwapCodex(input: { now?: number }): Promis
128
243
  throw e;
129
244
  }
130
245
  log("codexdecide.greedy_swap", { account: best.accountId.slice(0, 8) });
246
+ // Re-sweep with the NEW live seat: siblings on the account this swap
247
+ // just departed were invisible to the entry sweep (their account WAS
248
+ // the live seat then), and the cooldown blocks the next evaluation's
249
+ // sweep-entry for 45s (pullfrog review catch, PR #34). GUARDED: the
250
+ // swap is already persisted, so a sweep failure (e.g. a corrupt
251
+ // presence file, which livingCodexPresences throws on by design) must
252
+ // not eat the swapped:true return - the Stop hook needs it to write
253
+ // THIS session's respawn marker (bugbot/cubic P0 catch, PR #34). The
254
+ // entry sweep stays fail-loud: there nothing irreversible has
255
+ // happened yet.
256
+ postSwapResweep({ liveAccountId: best.accountId, bars, now });
131
257
  return { swapped: true, account: best, reason: "swapped" };
132
258
  }
133
259
  }
@@ -151,6 +277,10 @@ export async function evaluateAndMaybeSwapCodex(input: { now?: number }): Promis
151
277
  throw e;
152
278
  }
153
279
  log("codexdecide.hard_swap", { account: best.accountId.slice(0, 8) });
280
+ // Same guarded post-swap re-sweep as the greedy branch: the departed
281
+ // account's siblings become signalable only once it stops being the
282
+ // live seat, and a sweep failure must not eat the swapped:true return.
283
+ postSwapResweep({ liveAccountId: best.accountId, bars, now });
154
284
  return { swapped: true, account: best, reason: "swapped" };
155
285
  }
156
286
  });
@@ -11,9 +11,18 @@ import { nextWeeklyReset } from "./picker.ts";
11
11
  import { isSessionWindow, weeklyWindowOf } from "./codexusage.ts";
12
12
  import type { CodexAccount, CodexWindow, Thresholds } from "./types.ts";
13
13
 
14
- function liveUsed(input: { window: CodexWindow; now: number }): number {
15
- const { window, now } = input;
16
- return window.resetsAt != null && window.resetsAt <= now ? 0 : window.usedPercentage;
14
+ /** A window whose reset has passed reads as empty. A NULL reset self-bounds
15
+ * at sampledAt + the window's own duration (mirroring the claude picker's
16
+ * blockedUntil, closing-review catch): the wire schema allows reset_at null,
17
+ * and without the bound an over-bar null-reset window benched the account
18
+ * FOREVER on a headless box where nothing re-samples parked accounts - the
19
+ * no-permanent-bench invariant. No duration or sample time = trust the
20
+ * reading (unmeasured recovery must not look safe). */
21
+ function liveUsed(input: { window: CodexWindow; now: number; sampledAt: number | null }): number {
22
+ const { window, now, sampledAt } = input;
23
+ if (window.resetsAt != null) return window.resetsAt <= now ? 0 : window.usedPercentage;
24
+ if (sampledAt != null && window.windowSeconds != null && now >= sampledAt + window.windowSeconds * 1000) return 0;
25
+ return window.usedPercentage;
17
26
  }
18
27
 
19
28
  function allWindows(account: CodexAccount): CodexWindow[] {
@@ -29,8 +38,9 @@ function barFor(input: { window: CodexWindow; thresholds: Thresholds }): number
29
38
  /** A window at/over its bar whose reset has not passed blocks the account. */
30
39
  export function isCodexExhausted(input: { account: CodexAccount; thresholds: Thresholds; now: number }): boolean {
31
40
  const { account, thresholds, now } = input;
41
+ const sampledAt = account.lastUsageAt ?? null;
32
42
  return allWindows(account).some(
33
- (window) => liveUsed({ window, now }) >= barFor({ window, thresholds }),
43
+ (window) => liveUsed({ window, now, sampledAt }) >= barFor({ window, thresholds }),
34
44
  );
35
45
  }
36
46
 
@@ -43,7 +53,7 @@ export function codexPacePressure(input: { account: CodexAccount; now: number })
43
53
  if (!weekly) return 0;
44
54
  const reset = nextWeeklyReset(weekly.resetsAt, now);
45
55
  if (reset == null) return 0;
46
- return Math.max(0, 100 - liveUsed({ window: weekly, now })) / Math.max(1, reset - now);
56
+ return Math.max(0, 100 - liveUsed({ window: weekly, now, sampledAt: account.lastUsageAt ?? null })) / Math.max(1, reset - now);
47
57
  }
48
58
 
49
59
  function weeklyExpiryOf(input: { account: CodexAccount; now: number }): number {
@@ -102,5 +112,6 @@ export function codexCurrentWins(input: {
102
112
  * rather than the session window alone. */
103
113
  export function isCodexEngaged(input: { account: CodexAccount; floor: number; now: number }): boolean {
104
114
  const { account, floor, now } = input;
105
- return allWindows(account).some((window) => liveUsed({ window, now }) >= floor);
115
+ const sampledAt = account.lastUsageAt ?? null;
116
+ return allWindows(account).some((window) => liveUsed({ window, now, sampledAt }) >= floor);
106
117
  }
@@ -6,26 +6,39 @@
6
6
  // installing it as live would yank the running session's grant. Supervisors
7
7
  // therefore declare their session's account in a presence file at every
8
8
  // (re)spawn; the picker refuses to target present accounts and the sampler
9
- // refuses to refresh their parked blobs. Staleness is PID-based: a presence
10
- // whose supervisor died is ignored and cleaned up.
9
+ // refuses to refresh their parked blobs. Staleness is checked by process
10
+ // IDENTITY (pid + ps lstart), not bare pid-aliveness: after a supervisor
11
+ // crash a recycled pid would otherwise keep its account benched forever.
11
12
 
12
13
  import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync } from "node:fs";
13
14
  import { join } from "node:path";
14
15
  import { z } from "zod";
15
16
  import { codexPaths } from "./paths.ts";
16
17
  import { writeFileAtomic } from "./atomic.ts";
18
+ import { pidExists, pidStartTime } from "./proc.ts";
17
19
 
18
20
  const PresenceSchema = z.object({
19
21
  accountId: z.string(),
20
22
  pid: z.number(),
21
- ts: z.number(),
23
+ startedAt: z.string(),
22
24
  });
23
25
 
24
- export function writeCodexPresence(input: { supervisorId: string; accountId: string }): void {
26
+ /** `pid` should be the CODEX CHILD's pid when known (the supervisor passes
27
+ * it): the session IS the child, and pinning the supervisor's own pid let a
28
+ * SIGKILLed supervisor prune the presence while its orphaned codex kept
29
+ * running and rotating the account's token - un-benching a live account for
30
+ * samplers and the picker (closing-review catch). Defaults to process.pid
31
+ * for callers that ARE the session-owning process (tests, future uses). */
32
+ export function writeCodexPresence(input: { supervisorId: string; accountId: string; pid?: number }): void {
33
+ const pid = input.pid ?? process.pid;
34
+ const startedAt = pidStartTime(pid);
35
+ // The pid must be ps-visible; a null here means ps broke or the process
36
+ // already died - corrupt state to fail loudly on, never a masked placeholder.
37
+ if (startedAt == null) throw new Error(`could not read pid ${pid}'s start time (ps lstart) - refusing to write an unverifiable presence file`);
25
38
  mkdirSync(codexPaths.presenceDir, { recursive: true });
26
39
  writeFileAtomic(
27
40
  join(codexPaths.presenceDir, input.supervisorId),
28
- JSON.stringify(PresenceSchema.parse({ accountId: input.accountId, pid: process.pid, ts: Date.now() })),
41
+ JSON.stringify(PresenceSchema.parse({ accountId: input.accountId, pid, startedAt })),
29
42
  );
30
43
  }
31
44
 
@@ -33,35 +46,60 @@ export function clearCodexPresence(input: { supervisorId: string }): void {
33
46
  rmSync(join(codexPaths.presenceDir, input.supervisorId), { force: true });
34
47
  }
35
48
 
36
- function pidAlive(pid: number): boolean {
37
- try {
38
- process.kill(pid, 0);
39
- return true;
40
- } catch {
41
- return false;
42
- }
43
- }
49
+ const LivingPresenceSchema = z.object({ supervisorId: z.string(), accountId: z.string() });
50
+ export type LivingPresence = z.infer<typeof LivingPresenceSchema>;
44
51
 
45
- /** Account ids with a LIVING supervisor. Dead supervisors' files are removed. */
46
- export function presentCodexAccountIds(): Set<string> {
47
- const present = new Set<string>();
48
- if (!existsSync(codexPaths.presenceDir)) return present;
52
+ /** Every LIVING supervised session (pid + start-time identity match), as
53
+ * {supervisorId, accountId} pairs - the reconcile sweep needs to address a
54
+ * specific supervisor, not just know which accounts are busy. Dead or
55
+ * recycled-pid presences are removed. A file that exists but fails to parse
56
+ * THROWS (review catch, PR #31): a presence file is what keeps a RUNNING
57
+ * session's account from being swapped out from under it, so damaged state
58
+ * must fail the decision loudly, never silently drop the protection. */
59
+ export function livingCodexPresences(): LivingPresence[] {
60
+ const living: LivingPresence[] = [];
61
+ if (!existsSync(codexPaths.presenceDir)) return living;
49
62
  for (const name of readdirSync(codexPaths.presenceDir)) {
50
63
  const file = join(codexPaths.presenceDir, name);
64
+ let raw: string;
65
+ try {
66
+ raw = readFileSync(file, "utf8");
67
+ } catch (e) {
68
+ // a supervisor exiting between readdir and read clears its own file
69
+ const errno = z.object({ code: z.string() }).safeParse(e);
70
+ if (errno.success && errno.data.code === "ENOENT") continue;
71
+ throw e;
72
+ }
51
73
  const parsed = PresenceSchema.safeParse((() => {
52
74
  try {
53
- return JSON.parse(readFileSync(file, "utf8"));
75
+ return JSON.parse(raw);
54
76
  } catch {
55
77
  return null;
56
78
  }
57
79
  })());
58
- if (!parsed.success || !pidAlive(parsed.data.pid)) {
80
+ if (!parsed.success) {
81
+ throw new Error(`${file} is not a readable presence record - it may belong to a RUNNING codex session, refusing to treat it as absent; remove the file (or respawn that session) to proceed`);
82
+ }
83
+ const observed = pidStartTime(parsed.data.pid);
84
+ if (observed !== parsed.data.startedAt) {
85
+ // A null lstart is ambiguous: dead pid, or ps itself failing. Deleting
86
+ // on a ps failure would silently unbench a RUNNING session's account
87
+ // (review catch, PR #31), so only a confirmed-dead pid - or a live pid
88
+ // with a DIFFERENT start time, a recycle - may clear the file.
89
+ if (observed == null && pidExists(parsed.data.pid)) {
90
+ throw new Error(`ps could not read the start time of live pid ${parsed.data.pid} (${file}) - refusing to clear a presence file that may guard a RUNNING codex session`);
91
+ }
59
92
  rmSync(file, { force: true });
60
93
  continue;
61
94
  }
62
- present.add(parsed.data.accountId);
95
+ living.push(LivingPresenceSchema.parse({ supervisorId: name, accountId: parsed.data.accountId }));
63
96
  }
64
- return present;
97
+ return living;
98
+ }
99
+
100
+ /** Account ids with a LIVING supervisor - the picker/sampler exclusion view. */
101
+ export function presentCodexAccountIds(): Set<string> {
102
+ return new Set(livingCodexPresences().map((presence) => presence.accountId));
65
103
  }
66
104
 
67
105
  /** The accounts a swap may target: running accounts are off limits, except the