tokenmaxxing 0.19.0 → 0.21.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/DESIGN.md +34 -23
- package/README.md +4 -4
- package/package.json +1 -1
- package/src/cli/add.ts +1 -0
- package/src/cli/auth.ts +25 -14
- package/src/cli/check.ts +3 -2
- package/src/cli/codexadd.ts +44 -40
- package/src/cli/codexinit.ts +59 -12
- package/src/cli/codexswitch.ts +15 -1
- package/src/cli/config.ts +10 -1
- package/src/cli/doctor.ts +3 -3
- package/src/cli/init.ts +54 -32
- package/src/cli/onboard.ts +62 -45
- package/src/cli/render.ts +0 -16
- package/src/cli/rm.ts +40 -2
- package/src/cli/serve.ts +650 -78
- package/src/cli/status.ts +69 -23
- package/src/cli/switch.ts +54 -19
- package/src/entries/codexstophook.ts +123 -4
- package/src/entries/codexsupervisor.ts +87 -13
- package/src/entries/sessionstart.ts +1 -1
- package/src/entries/statusline.ts +56 -20
- package/src/entries/stophook.ts +23 -9
- package/src/entries/supervisor.ts +134 -18
- package/src/lib/atomic.ts +28 -6
- package/src/lib/claudebin.ts +2 -2
- package/src/lib/claudejson.ts +5 -5
- package/src/lib/claudelock.ts +112 -37
- package/src/lib/codexauth.ts +10 -2
- package/src/lib/codexbin.ts +1 -1
- package/src/lib/codexdecide.ts +149 -19
- package/src/lib/codexpick.ts +17 -6
- package/src/lib/codexpresence.ts +59 -21
- package/src/lib/codexsample.ts +17 -8
- package/src/lib/codexswap.ts +10 -1
- package/src/lib/credstore.ts +6 -2
- package/src/lib/decide.ts +114 -42
- package/src/lib/install.ts +125 -17
- package/src/lib/keychain.ts +41 -15
- package/src/lib/lock.ts +57 -35
- package/src/lib/log.ts +36 -7
- package/src/lib/oauth.ts +18 -11
- package/src/lib/paths.ts +17 -11
- package/src/lib/picker.ts +11 -3
- package/src/lib/proc.ts +37 -0
- package/src/lib/sample.ts +91 -31
- package/src/lib/sessions.ts +23 -1
- package/src/lib/settings.ts +59 -18
- package/src/lib/slackbridge.ts +583 -76
- package/src/lib/slackstate.ts +159 -12
- package/src/lib/slackstream.ts +127 -21
- package/src/lib/state.ts +131 -35
- package/src/lib/swap.ts +109 -47
- package/src/lib/types.ts +79 -37
- package/src/lib/usage.ts +114 -16
- package/src/main.ts +61 -7
- package/src/serve-plugin/.claude-plugin/plugin.json +4 -0
- package/src/serve-plugin/skills/ask-the-user/SKILL.md +36 -0
- package/src/serve-plugin/skills/serve-session/SKILL.md +50 -0
package/src/lib/claudelock.ts
CHANGED
|
@@ -1,54 +1,129 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
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 {
|
|
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 =
|
|
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: {
|
|
52
|
+
fn: (lock: { compromised: () => boolean }) => Promise<T> | T,
|
|
53
|
+
opts: { attempts?: number; retryMs?: number; heartbeatMs?: number } = {},
|
|
18
54
|
): Promise<T> {
|
|
19
|
-
const
|
|
20
|
-
const
|
|
21
|
-
const
|
|
22
|
-
|
|
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
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
51
|
-
|
|
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
|
}
|
package/src/lib/codexauth.ts
CHANGED
|
@@ -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
|
-
|
|
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. */
|
package/src/lib/codexbin.ts
CHANGED
|
@@ -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
|
-
|
|
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");
|
package/src/lib/codexdecide.ts
CHANGED
|
@@ -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
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
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
|
-
|
|
47
|
-
|
|
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
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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
|
});
|
package/src/lib/codexpick.ts
CHANGED
|
@@ -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
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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
|
-
|
|
115
|
+
const sampledAt = account.lastUsageAt ?? null;
|
|
116
|
+
return allWindows(account).some((window) => liveUsed({ window, now, sampledAt }) >= floor);
|
|
106
117
|
}
|
package/src/lib/codexpresence.ts
CHANGED
|
@@ -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
|
|
10
|
-
//
|
|
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
|
-
|
|
23
|
+
startedAt: z.string(),
|
|
22
24
|
});
|
|
23
25
|
|
|
24
|
-
|
|
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
|
|
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
|
-
|
|
37
|
-
|
|
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
|
-
/**
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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(
|
|
75
|
+
return JSON.parse(raw);
|
|
54
76
|
} catch {
|
|
55
77
|
return null;
|
|
56
78
|
}
|
|
57
79
|
})());
|
|
58
|
-
if (!parsed.success
|
|
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
|
-
|
|
95
|
+
living.push(LivingPresenceSchema.parse({ supervisorId: name, accountId: parsed.data.accountId }));
|
|
63
96
|
}
|
|
64
|
-
return
|
|
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
|