tokenmaxxing 1.8.0 → 1.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/DESIGN.md +2 -4
- package/README.md +1 -1
- package/agent-plugin/plugin.json +1 -1
- package/package.json +1 -1
- package/src/cli/add.ts +1 -8
- package/src/cli/auth.ts +0 -23
- package/src/cli/check.ts +19 -13
- package/src/cli/codexadd.ts +0 -17
- package/src/cli/codexinit.ts +0 -40
- package/src/cli/codexrm.ts +0 -13
- package/src/cli/codexswitch.ts +0 -15
- package/src/cli/config.ts +0 -30
- package/src/cli/doctor.ts +1 -14
- package/src/cli/init.ts +1 -33
- package/src/cli/ls.ts +0 -2
- package/src/cli/onboard.ts +0 -37
- package/src/cli/rename.ts +0 -19
- package/src/cli/render.ts +0 -23
- package/src/cli/rm.ts +0 -19
- package/src/cli/status.ts +0 -80
- package/src/cli/switch.ts +1 -49
- package/src/cli/watch.ts +0 -17
- package/src/entries/codexstophook.ts +2 -73
- package/src/entries/codexsupervisor.ts +1 -67
- package/src/entries/mcp.ts +0 -11
- package/src/entries/sessionstart.ts +1 -8
- package/src/entries/statusline.ts +0 -66
- package/src/entries/stopfailurehook.ts +93 -0
- package/src/entries/stophook.ts +3 -34
- package/src/entries/subagentstatusline.ts +0 -19
- package/src/entries/supervisor.ts +32 -132
- package/src/lib/atomic.ts +0 -16
- package/src/lib/claudebin.ts +4 -55
- package/src/lib/claudejson.ts +0 -10
- package/src/lib/claudelock.ts +13 -35
- package/src/lib/codexauth.ts +0 -29
- package/src/lib/codexbin.ts +0 -10
- package/src/lib/codexdecide.ts +1 -112
- package/src/lib/codexoauth.ts +0 -16
- package/src/lib/codexpick.ts +0 -31
- package/src/lib/codexpresence.ts +0 -35
- package/src/lib/codexsample.ts +0 -23
- package/src/lib/codexstate.ts +0 -7
- package/src/lib/codexswap.ts +0 -32
- package/src/lib/codexusage.ts +0 -28
- package/src/lib/credstore.ts +0 -24
- package/src/lib/decide.ts +127 -180
- package/src/lib/http.ts +0 -9
- package/src/lib/install.ts +6 -127
- package/src/lib/keychain.ts +1 -39
- package/src/lib/lock.ts +0 -24
- package/src/lib/log.ts +0 -14
- package/src/lib/oauth.ts +1 -31
- package/src/lib/paths.ts +1 -48
- package/src/lib/picker.ts +1 -84
- package/src/lib/proc.ts +0 -17
- package/src/lib/sample.ts +0 -68
- package/src/lib/sessions.ts +0 -13
- package/src/lib/settings.ts +15 -42
- package/src/lib/state.ts +23 -77
- package/src/lib/swap.ts +3 -87
- package/src/lib/tty.ts +0 -4
- package/src/lib/types.ts +13 -140
- package/src/lib/usage.ts +108 -196
- package/src/lib/worktree.ts +0 -8
- package/src/main.ts +5 -40
- package/src/sdk.ts +0 -59
- package/agent-plugin/agents/tokenmaxxing-claude.md +0 -43
- package/agent-plugin/agents/tokenmaxxing-codex.md +0 -40
- package/agent-plugin/hooks/cursor-relay.json +0 -14
- package/agent-plugin/skills/relay-session/SKILL.md +0 -118
- package/agent-plugin/skills/relay-session/references/ipc.md +0 -23
- package/src/cli/relay.ts +0 -323
- package/src/entries/relaypermission.ts +0 -105
- package/src/lib/relay/config.ts +0 -84
- package/src/lib/relay/decide.ts +0 -75
- package/src/lib/relay/gc.ts +0 -80
- package/src/lib/relay/install.ts +0 -143
- package/src/lib/relay/markers.ts +0 -148
- package/src/lib/relay/modes.ts +0 -82
- package/src/lib/relay/protocol.ts +0 -61
- package/src/lib/relay/registry.ts +0 -175
- package/src/lib/relay/tmux.ts +0 -109
- package/src/lib/relay/turn.ts +0 -137
- package/src/lib/relay/worker.ts +0 -141
package/src/lib/claudejson.ts
CHANGED
|
@@ -1,6 +1,3 @@
|
|
|
1
|
-
// Read/replace the `oauthAccount` identity object in ~/.claude.json.
|
|
2
|
-
// We touch ONLY oauthAccount and preserve every other key verbatim.
|
|
3
|
-
|
|
4
1
|
import { existsSync, readFileSync } from "node:fs";
|
|
5
2
|
import { z } from "zod";
|
|
6
3
|
import { paths } from "./paths.ts";
|
|
@@ -19,20 +16,13 @@ export function readOAuthAccount(): OAuthAccount | null {
|
|
|
19
16
|
return parsed.success ? parsed.data : null;
|
|
20
17
|
}
|
|
21
18
|
|
|
22
|
-
/** Detect API-key / helper auth mode (no poolable subscription credential). */
|
|
23
19
|
export function isApiKeyMode(): boolean {
|
|
24
20
|
if (process.env.ANTHROPIC_API_KEY || process.env.ANTHROPIC_AUTH_TOKEN) return true;
|
|
25
21
|
const j = readClaudeJson();
|
|
26
|
-
// apiKeyHelper is configured in settings, but a persisted flag may appear here too.
|
|
27
22
|
if (z.string().min(1).safeParse(j["apiKeyHelper"]).success) return true;
|
|
28
23
|
return false;
|
|
29
24
|
}
|
|
30
25
|
|
|
31
|
-
/**
|
|
32
|
-
* Atomically replace ONLY oauthAccount, preserving all other keys and their
|
|
33
|
-
* insertion order. Reads fresh from disk so we never clobber concurrent edits
|
|
34
|
-
* to unrelated keys with a stale in-memory copy.
|
|
35
|
-
*/
|
|
36
26
|
export function swapOAuthAccount(next: OAuthAccount): void {
|
|
37
27
|
const j = readClaudeJson();
|
|
38
28
|
j["oauthAccount"] = next;
|
package/src/lib/claudelock.ts
CHANGED
|
@@ -1,22 +1,3 @@
|
|
|
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.
|
|
19
|
-
|
|
20
1
|
import { mkdirSync, realpathSync, rmdirSync, statSync, utimesSync } from "node:fs";
|
|
21
2
|
import { join } from "node:path";
|
|
22
3
|
import { delay } from "es-toolkit";
|
|
@@ -24,12 +5,11 @@ import { z } from "zod";
|
|
|
24
5
|
import { credDir } from "./paths.ts";
|
|
25
6
|
import { log } from "./log.ts";
|
|
26
7
|
|
|
27
|
-
const STALE_MS = 60_000;
|
|
28
|
-
const HEARTBEAT_MS = 5_000;
|
|
29
|
-
const ATTEMPTS = 5;
|
|
30
|
-
const RETRY_MS = 1_000;
|
|
8
|
+
const STALE_MS = 60_000;
|
|
9
|
+
const HEARTBEAT_MS = 5_000;
|
|
10
|
+
const ATTEMPTS = 5;
|
|
11
|
+
const RETRY_MS = 1_000;
|
|
31
12
|
|
|
32
|
-
/** mkdir-take one lock dir, reclaiming it when older than claude's stale bar. */
|
|
33
13
|
function tryAcquire(lockDir: string): boolean {
|
|
34
14
|
try {
|
|
35
15
|
mkdirSync(lockDir);
|
|
@@ -44,7 +24,7 @@ function tryAcquire(lockDir: string): boolean {
|
|
|
44
24
|
mkdirSync(lockDir);
|
|
45
25
|
return true;
|
|
46
26
|
}
|
|
47
|
-
} catch {
|
|
27
|
+
} catch { }
|
|
48
28
|
return false;
|
|
49
29
|
}
|
|
50
30
|
|
|
@@ -55,13 +35,13 @@ export async function withClaudeRefreshLock<T>(
|
|
|
55
35
|
const attempts = opts.attempts ?? ATTEMPTS;
|
|
56
36
|
const retryMs = opts.retryMs ?? RETRY_MS;
|
|
57
37
|
const dir = credDir();
|
|
58
|
-
mkdirSync(dir, { recursive: true });
|
|
38
|
+
mkdirSync(dir, { recursive: true });
|
|
59
39
|
const primary = join(dir, ".oauth_refresh.lock");
|
|
60
40
|
let legacyRoot = dir;
|
|
61
|
-
try { legacyRoot = realpathSync(dir); } catch {
|
|
41
|
+
try { legacyRoot = realpathSync(dir); } catch { }
|
|
62
42
|
const legacy = `${legacyRoot}.lock`;
|
|
63
43
|
|
|
64
|
-
const held: string[] = [];
|
|
44
|
+
const held: string[] = [];
|
|
65
45
|
for (let attempt = 1; held.length === 0; attempt++) {
|
|
66
46
|
if (tryAcquire(primary)) {
|
|
67
47
|
try {
|
|
@@ -70,12 +50,11 @@ export async function withClaudeRefreshLock<T>(
|
|
|
70
50
|
break;
|
|
71
51
|
}
|
|
72
52
|
} catch (e) {
|
|
73
|
-
// claude proceeds on a legacy acquire ERROR; only contention aborts.
|
|
74
53
|
log("claudelock.legacy_error", { err: e instanceof Error ? e.message : String(e) });
|
|
75
54
|
held.push(primary);
|
|
76
55
|
break;
|
|
77
56
|
}
|
|
78
|
-
rmdirSync(primary);
|
|
57
|
+
rmdirSync(primary);
|
|
79
58
|
}
|
|
80
59
|
if (attempt >= attempts) {
|
|
81
60
|
log("claudelock.contested", { attempts: attempt });
|
|
@@ -86,7 +65,6 @@ export async function withClaudeRefreshLock<T>(
|
|
|
86
65
|
await delay(retryMs + Math.random() * retryMs);
|
|
87
66
|
}
|
|
88
67
|
|
|
89
|
-
// the mtime we last stored per lock; a deviation means the lock was stolen.
|
|
90
68
|
const ours = new Map<string, number>();
|
|
91
69
|
for (const d of held) ours.set(d, statSync(d).mtimeMs);
|
|
92
70
|
let compromised = false;
|
|
@@ -101,13 +79,13 @@ export async function withClaudeRefreshLock<T>(
|
|
|
101
79
|
for (const d of held) {
|
|
102
80
|
try {
|
|
103
81
|
if (statSync(d).mtimeMs !== ours.get(d)) {
|
|
104
|
-
markCompromised();
|
|
82
|
+
markCompromised();
|
|
105
83
|
continue;
|
|
106
84
|
}
|
|
107
85
|
utimesSync(d, now, now);
|
|
108
86
|
ours.set(d, statSync(d).mtimeMs);
|
|
109
87
|
} catch {
|
|
110
|
-
markCompromised();
|
|
88
|
+
markCompromised();
|
|
111
89
|
}
|
|
112
90
|
}
|
|
113
91
|
}, opts.heartbeatMs ?? HEARTBEAT_MS);
|
|
@@ -122,8 +100,8 @@ export async function withClaudeRefreshLock<T>(
|
|
|
122
100
|
clearInterval(heartbeat);
|
|
123
101
|
for (const d of held) {
|
|
124
102
|
try {
|
|
125
|
-
if (statSync(d).mtimeMs === ours.get(d)) rmdirSync(d);
|
|
126
|
-
} catch {
|
|
103
|
+
if (statSync(d).mtimeMs === ours.get(d)) rmdirSync(d);
|
|
104
|
+
} catch { }
|
|
127
105
|
}
|
|
128
106
|
}
|
|
129
107
|
}
|
package/src/lib/codexauth.ts
CHANGED
|
@@ -1,9 +1,3 @@
|
|
|
1
|
-
// Read/write codex credential blobs ($CODEX_HOME/auth.json and parked copies)
|
|
2
|
-
// and decode the id_token's identity claims. Codex has NO cross-process lock on
|
|
3
|
-
// auth.json (its refresh serialization is an in-process semaphore, verified
|
|
4
|
-
// rust-v0.144.5 manager.rs), so every mutation here runs under tokenmaxxing's
|
|
5
|
-
// own codex flock, held by the caller.
|
|
6
|
-
|
|
7
1
|
import { readFileSync, rmSync } from "node:fs";
|
|
8
2
|
import { join } from "node:path";
|
|
9
3
|
import { z } from "zod";
|
|
@@ -15,14 +9,6 @@ function isEnoent(e: unknown): boolean {
|
|
|
15
9
|
return e instanceof Error && "code" in e && e.code === "ENOENT";
|
|
16
10
|
}
|
|
17
11
|
|
|
18
|
-
/** auth.json at an explicit path (the live file, or an onboard dir's), or null
|
|
19
|
-
* when absent. Throws on a present-but-unparsable file: that is drift to
|
|
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). */
|
|
26
12
|
export function readCodexAuthAt(input: { path: string }): CodexAuthJson | null {
|
|
27
13
|
let raw: string;
|
|
28
14
|
try {
|
|
@@ -37,7 +23,6 @@ export function readCodexAuthAt(input: { path: string }): CodexAuthJson | null {
|
|
|
37
23
|
return CodexAuthJsonSchema.parse(parsed);
|
|
38
24
|
}
|
|
39
25
|
|
|
40
|
-
/** The live auth.json, or null when codex has no login here. */
|
|
41
26
|
export function readLiveCodexAuth(): CodexAuthJson | null {
|
|
42
27
|
return readCodexAuthAt({ path: codexPaths.authJson });
|
|
43
28
|
}
|
|
@@ -65,15 +50,10 @@ export function writeParkedCodexAuth(input: { credFile: string; auth: CodexAuthJ
|
|
|
65
50
|
writeFileAtomic(parkedPath(input), JSON.stringify(CodexAuthJsonSchema.parse(input.auth), null, 2), 0o600);
|
|
66
51
|
}
|
|
67
52
|
|
|
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
53
|
export function deleteParkedCodexAuth(input: { credFile: string }): void {
|
|
72
54
|
rmSync(parkedPath(input), { force: true });
|
|
73
55
|
}
|
|
74
56
|
|
|
75
|
-
/** Identity claims inside the id_token JWT payload (verified against a live
|
|
76
|
-
* 0.144.4 token: the chatgpt fields sit under the api.openai.com/auth claim). */
|
|
77
57
|
const IdClaimsSchema = z.looseObject({
|
|
78
58
|
email: z.string().optional(),
|
|
79
59
|
"https://api.openai.com/auth": z
|
|
@@ -100,12 +80,6 @@ const CodexIdentitySchema = z.object({
|
|
|
100
80
|
});
|
|
101
81
|
export type CodexIdentity = z.infer<typeof CodexIdentitySchema>;
|
|
102
82
|
|
|
103
|
-
/**
|
|
104
|
-
* The blob's own identity, from its token material alone (no network): the
|
|
105
|
-
* explicit tokens.account_id, else the id_token's chatgpt_account_id claim.
|
|
106
|
-
* Parking MUST key on this (or the usage endpoint's echo of it), never on a
|
|
107
|
-
* stored label - labels drift, tokens cannot lie.
|
|
108
|
-
*/
|
|
109
83
|
export function codexIdentityOf(input: { auth: CodexAuthJson }): CodexIdentity {
|
|
110
84
|
const { auth } = input;
|
|
111
85
|
const claims = IdClaimsSchema.parse(decodeJwtPayload({ jwt: auth.tokens.id_token }));
|
|
@@ -121,9 +95,6 @@ export function codexIdentityOf(input: { auth: CodexAuthJson }): CodexIdentity {
|
|
|
121
95
|
});
|
|
122
96
|
}
|
|
123
97
|
|
|
124
|
-
/** True when the access token is within `skewMs` of its JWT exp (or exp is
|
|
125
|
-
* unreadable). Codex's own proactive refresh margin is 5 minutes; matching it
|
|
126
|
-
* means we never install a token codex would immediately refresh. */
|
|
127
98
|
export function isCodexAccessExpiring(input: { auth: CodexAuthJson; skewMs?: number; now?: number }): boolean {
|
|
128
99
|
const { auth, skewMs = 300_000, now = Date.now() } = input;
|
|
129
100
|
let exp: number | undefined;
|
package/src/lib/codexbin.ts
CHANGED
|
@@ -1,15 +1,9 @@
|
|
|
1
|
-
// Resolve the REAL codex binary (never our shim on PATH). Same guarantees as
|
|
2
|
-
// claudebin.ts: a configured-but-missing pin fails fast instead of degrading
|
|
3
|
-
// to a scan, and anything that realpath-resolves into our binDir is refused
|
|
4
|
-
// (spawning it as codex would recurse through the codex supervisor).
|
|
5
|
-
|
|
6
1
|
import { existsSync, statSync } from "node:fs";
|
|
7
2
|
import { join } from "node:path";
|
|
8
3
|
import { LOOP_DIAGNOSIS, MAX_WRAP_DEPTH, WRAP_DEPTH_ENV, pointsBackAtUs } from "./claudebin.ts";
|
|
9
4
|
import { loadConfig } from "./state.ts";
|
|
10
5
|
import { paths } from "./paths.ts";
|
|
11
6
|
|
|
12
|
-
/** First PATH entry with a `codex` that is not us. null when PATH has none. */
|
|
13
7
|
function scanPathForCodex(): string | null {
|
|
14
8
|
for (const d of (process.env.PATH ?? "").split(":")) {
|
|
15
9
|
if (!d) continue;
|
|
@@ -41,10 +35,6 @@ export function resolveRealCodex(): string {
|
|
|
41
35
|
throw new Error("could not locate the real `codex` binary (set codexBin in config.json)");
|
|
42
36
|
}
|
|
43
37
|
|
|
44
|
-
/** Behavioral check used by `init --codex` before pinning: the binary must
|
|
45
|
-
* answer `--version` identifying itself as codex, without re-entering our
|
|
46
|
-
* wrapper (depth preset to the cap kills a poisoned pin on first entry).
|
|
47
|
-
* Returns null when the binary passes, else the failure detail. */
|
|
48
38
|
export function verifyRealCodex(input: { bin: string }): string | null {
|
|
49
39
|
const env = { ...process.env, [WRAP_DEPTH_ENV]: String(MAX_WRAP_DEPTH), TOKENMAXXING_PROBE: "1" };
|
|
50
40
|
let p: ReturnType<typeof Bun.spawnSync>;
|
package/src/lib/codexdecide.ts
CHANGED
|
@@ -1,11 +1,3 @@
|
|
|
1
|
-
// Shared codex switch decision, used by the codex Stop hook and `xx switch
|
|
2
|
-
// --codex`. Same policy as decide.ts, reshaped for codex mechanics: usage
|
|
3
|
-
// comes from the free direct GET (no statusLine tee exists), the greedy floor
|
|
4
|
-
// reads against every window class (current codex plans have no 5h window:
|
|
5
|
-
// the weekly aggregate is primary, verified live 2026-07-16), and there is no
|
|
6
|
-
// depleted pre-park (a swap only lands where a window is usable NOW; a
|
|
7
|
-
// depleted pool stays put and recovers when a cached reset passes).
|
|
8
|
-
|
|
9
1
|
import { existsSync, mkdirSync, readdirSync, rmSync } from "node:fs";
|
|
10
2
|
import { join } from "node:path";
|
|
11
3
|
import { z } from "zod";
|
|
@@ -34,18 +26,6 @@ export type CodexSwapDecision = z.infer<typeof CodexSwapDecisionSchema>;
|
|
|
34
26
|
|
|
35
27
|
const POST_SWAP_COOLDOWN_MS = 45_000;
|
|
36
28
|
|
|
37
|
-
/**
|
|
38
|
-
* Sample the LIVE credential's usage and stamp it onto its TRUE owner in the
|
|
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.
|
|
48
|
-
*/
|
|
49
29
|
async function sampleLiveOntoOwner(input: { now: number }): Promise<string | null> {
|
|
50
30
|
const { now } = input;
|
|
51
31
|
let live = readLiveCodexAuth();
|
|
@@ -55,13 +35,6 @@ async function sampleLiveOntoOwner(input: { now: number }): Promise<string | nul
|
|
|
55
35
|
const owner = index.accounts.find((account) => account.accountId === identity.accountId);
|
|
56
36
|
if (!owner) return null;
|
|
57
37
|
|
|
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
38
|
if (isCodexAccessExpiring({ auth: live, now }) && !presentCodexAccountIds().has(identity.accountId)) {
|
|
66
39
|
try {
|
|
67
40
|
live = await refreshCodexAuth({ auth: live, now });
|
|
@@ -86,33 +59,6 @@ async function sampleLiveOntoOwner(input: { now: number }): Promise<string | nul
|
|
|
86
59
|
return owner.accountId;
|
|
87
60
|
}
|
|
88
61
|
|
|
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
62
|
function reconcileNonLiveSiblings(input: {
|
|
117
63
|
index: { accounts: CodexAccount[] };
|
|
118
64
|
liveAccountId: string;
|
|
@@ -125,8 +71,6 @@ function reconcileNonLiveSiblings(input: {
|
|
|
125
71
|
const liveAccount = index.accounts.find((account) => account.accountId === liveAccountId);
|
|
126
72
|
if (!liveAccount || unusable(liveAccount)) return;
|
|
127
73
|
const living = livingCodexPresences();
|
|
128
|
-
// gc signals addressed to supervisors that no longer live (exited before
|
|
129
|
-
// promoting): nothing would ever consume them.
|
|
130
74
|
if (existsSync(codexPaths.reconcileDir)) {
|
|
131
75
|
const alive = new Set(living.map((presence) => presence.supervisorId));
|
|
132
76
|
for (const name of readdirSync(codexPaths.reconcileDir)) {
|
|
@@ -134,16 +78,8 @@ function reconcileNonLiveSiblings(input: {
|
|
|
134
78
|
}
|
|
135
79
|
}
|
|
136
80
|
for (const presence of living) {
|
|
137
|
-
if (presence.accountId === liveAccountId) continue;
|
|
81
|
+
if (presence.accountId === liveAccountId) continue;
|
|
138
82
|
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
83
|
if (!seated) continue;
|
|
148
84
|
const markerPath = join(codexPaths.reconcileDir, presence.supervisorId);
|
|
149
85
|
if (existsSync(markerPath)) continue;
|
|
@@ -153,9 +89,6 @@ function reconcileNonLiveSiblings(input: {
|
|
|
153
89
|
}
|
|
154
90
|
}
|
|
155
91
|
|
|
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
92
|
function postSwapResweep(input: { liveAccountId: string; bars: { session: number; weekly: number }; now: number }): void {
|
|
160
93
|
try {
|
|
161
94
|
reconcileNonLiveSiblings({ index: loadCodexAccounts(), liveAccountId: input.liveAccountId, bars: input.bars, now: input.now });
|
|
@@ -173,23 +106,11 @@ export async function evaluateAndMaybeSwapCodex(input: { now?: number }): Promis
|
|
|
173
106
|
let index = loadCodexAccounts();
|
|
174
107
|
if (index.accounts.length === 0) return { swapped: false, account: null, reason: "no-pool" };
|
|
175
108
|
|
|
176
|
-
// The current account is ALWAYS the live auth.json's own identity: the
|
|
177
|
-
// stored activeAccountId label drifts (manual `codex login`, a crash
|
|
178
|
-
// before saveCodexAccounts), and trusting it once let the decision target
|
|
179
|
-
// the RUNNING account (adversarial review catch, 2026-07-16). A live
|
|
180
|
-
// identity outside the pool is the org-guard analog: do nothing, a swap
|
|
181
|
-
// over an unknown credential could destroy its only copy.
|
|
182
109
|
const activeId = liveCodexAccountId();
|
|
183
110
|
if (activeId == null || !index.accounts.some((account) => account.accountId === activeId)) {
|
|
184
111
|
return { swapped: false, account: null, reason: "live-credential-not-in-pool" };
|
|
185
112
|
}
|
|
186
113
|
|
|
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
114
|
reconcileNonLiveSiblings({ index, liveAccountId: activeId, bars, now });
|
|
194
115
|
|
|
195
116
|
const lastSwapAt = loadCodexLastSwapAt();
|
|
@@ -197,8 +118,6 @@ export async function evaluateAndMaybeSwapCodex(input: { now?: number }): Promis
|
|
|
197
118
|
return { swapped: false, account: null, reason: "post-swap-cooldown" };
|
|
198
119
|
}
|
|
199
120
|
|
|
200
|
-
// Freshness: re-sample the live credential once its owner's cached
|
|
201
|
-
// snapshot ages past the poll TTL (there is no push feed in between).
|
|
202
121
|
const activeEntry = index.accounts.find((account) => account.accountId === activeId);
|
|
203
122
|
const stale = activeEntry?.lastUsageAt == null || now - activeEntry.lastUsageAt > cfg.policy.usagePollTtlMs;
|
|
204
123
|
if (stale) {
|
|
@@ -212,20 +131,12 @@ export async function evaluateAndMaybeSwapCodex(input: { now?: number }): Promis
|
|
|
212
131
|
const active = index.accounts.find((account) => account.accountId === activeId) ?? null;
|
|
213
132
|
if (!active) return { swapped: false, account: null, reason: "no-active-account" };
|
|
214
133
|
|
|
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.
|
|
218
134
|
const engaged =
|
|
219
135
|
active.needsReauth === true ||
|
|
220
136
|
isCodexEngaged({ account: active, floor: cfg.policy.greedySessionFloor, now }) ||
|
|
221
137
|
isCodexExhausted({ account: active, thresholds: bars, now });
|
|
222
138
|
if (!engaged) return { swapped: false, account: null, reason: "under-threshold-or-stale" };
|
|
223
139
|
|
|
224
|
-
// Greedy path: engaged but under every bar. Swap only onto an account
|
|
225
|
-
// that beats the seat by the respawn-cost margin, never onto one RUNNING
|
|
226
|
-
// in another session (presence files); re-rank after a dead grant
|
|
227
|
-
// (performCodexSwap persists needs-reauth before throwing, so the loop
|
|
228
|
-
// terminates).
|
|
229
140
|
if (!isCodexExhausted({ account: active, thresholds: bars, now })) {
|
|
230
141
|
while (true) {
|
|
231
142
|
const current = loadCodexAccounts();
|
|
@@ -243,30 +154,11 @@ export async function evaluateAndMaybeSwapCodex(input: { now?: number }): Promis
|
|
|
243
154
|
throw e;
|
|
244
155
|
}
|
|
245
156
|
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
157
|
postSwapResweep({ liveAccountId: best.accountId, bars, now });
|
|
257
158
|
return { swapped: true, account: best, reason: "swapped" };
|
|
258
159
|
}
|
|
259
160
|
}
|
|
260
161
|
|
|
261
|
-
// Hard path: a bar is crossed. Land on the best usable candidate, walking
|
|
262
|
-
// past dead grants; a fully depleted pool stays put (no pre-park: nothing
|
|
263
|
-
// can pause a codex session for a countdown yet). Layer 2 (the wall) is
|
|
264
|
-
// deliberately claude-only: a running codex cannot hot-adopt a swapped
|
|
265
|
-
// credential (restart IS the switch), so a last-drop-swap onto a still-
|
|
266
|
-
// under-wall account would strand any concurrent sibling on the departed
|
|
267
|
-
// account (the reconcile can only signal siblings onto a Layer-1-usable
|
|
268
|
-
// seat), and a hold-only Layer 2 is identical to codex already staying put
|
|
269
|
-
// here - so codex just rides the current account to its wall.
|
|
270
162
|
const tried = new Set<string>();
|
|
271
163
|
while (true) {
|
|
272
164
|
const current = loadCodexAccounts();
|
|
@@ -283,9 +175,6 @@ export async function evaluateAndMaybeSwapCodex(input: { now?: number }): Promis
|
|
|
283
175
|
throw e;
|
|
284
176
|
}
|
|
285
177
|
log("codexdecide.hard_swap", { account: best.accountId.slice(0, 8) });
|
|
286
|
-
// Same guarded post-swap re-sweep as the greedy branch: the departed
|
|
287
|
-
// account's siblings become signalable only once it stops being the
|
|
288
|
-
// live seat, and a sweep failure must not eat the swapped:true return.
|
|
289
178
|
postSwapResweep({ liveAccountId: best.accountId, bars, now });
|
|
290
179
|
return { swapped: true, account: best, reason: "swapped" };
|
|
291
180
|
}
|
package/src/lib/codexoauth.ts
CHANGED
|
@@ -1,10 +1,3 @@
|
|
|
1
|
-
// The one OAuth call tokenmaxxing makes for codex: the refresh_token grant.
|
|
2
|
-
// Verified against openai/codex rust-v0.144.5 (login/src/auth/manager.rs):
|
|
3
|
-
// POST auth.openai.com/oauth/token, JSON body {client_id, grant_type,
|
|
4
|
-
// refresh_token}, no auth headers. The server ROTATES the refresh token and
|
|
5
|
-
// punishes reuse of a superseded one (refresh_token_reused), so every rotation
|
|
6
|
-
// must be persisted back into the blob it came from immediately.
|
|
7
|
-
|
|
8
1
|
import { z } from "zod";
|
|
9
2
|
import { http, safeErrorDetail } from "./http.ts";
|
|
10
3
|
import { CodexAuthJsonSchema, type CodexAuthJson } from "./types.ts";
|
|
@@ -13,7 +6,6 @@ const EnvOverrideSchema = z.string().min(1).optional().catch(undefined);
|
|
|
13
6
|
const TOKEN_URL = EnvOverrideSchema.parse(process.env.TOKENMAXXING_CODEX_TOKEN_URL) ?? "https://auth.openai.com/oauth/token";
|
|
14
7
|
const CLIENT_ID = EnvOverrideSchema.parse(process.env.TOKENMAXXING_CODEX_CLIENT_ID) ?? "app_EMoamEEZ73f0CkXaXp7hrann";
|
|
15
8
|
|
|
16
|
-
/** Refresh token dead, superseded, or revoked: mark needs-reauth and move on. */
|
|
17
9
|
export class CodexInvalidGrantError extends Error {
|
|
18
10
|
constructor(detail: string) {
|
|
19
11
|
super(`codex invalid grant: ${detail}`);
|
|
@@ -21,8 +13,6 @@ export class CodexInvalidGrantError extends Error {
|
|
|
21
13
|
}
|
|
22
14
|
}
|
|
23
15
|
|
|
24
|
-
/** The refresh could not run (endpoint unreachable, throttled, drifted body):
|
|
25
|
-
* an operational miss to retry later, NOT a dead grant and NOT a bug. */
|
|
26
16
|
export class CodexRefreshFailedError extends Error {
|
|
27
17
|
constructor(detail: string) {
|
|
28
18
|
super(`codex token refresh failed: ${detail}`);
|
|
@@ -43,12 +33,6 @@ const DEAD_GRANT_MARKERS = [
|
|
|
43
33
|
"refresh_token_invalidated",
|
|
44
34
|
];
|
|
45
35
|
|
|
46
|
-
/**
|
|
47
|
-
* Exchange the blob's refresh token for fresh tokens. Returns a NEW auth.json
|
|
48
|
-
* value with rotated token material and last_refresh restamped; every sibling
|
|
49
|
-
* field rides along verbatim. Throws CodexInvalidGrantError on a dead grant.
|
|
50
|
-
* Error paths only ever surface response-body snippets, never request headers.
|
|
51
|
-
*/
|
|
52
36
|
export async function refreshCodexAuth(input: { auth: CodexAuthJson; now?: number }): Promise<CodexAuthJson> {
|
|
53
37
|
const { auth, now = Date.now() } = input;
|
|
54
38
|
let res: Response;
|
package/src/lib/codexpick.ts
CHANGED
|
@@ -1,23 +1,8 @@
|
|
|
1
|
-
// Choose the codex account to switch TO. Same policy as the claude picker:
|
|
2
|
-
// among usable accounts (no reauth, no window at/over its screening bar that
|
|
3
|
-
// has not reset), take the one furthest behind its own weekly pace (highest
|
|
4
|
-
// pacePressure), because weekly allowance is forfeited at a fixed per-account
|
|
5
|
-
// reset. Windows are duration-classified (isSessionWindow), never assumed:
|
|
6
|
-
// codex's 5h window is absent on current Plus/Pro plans (July 2026), so the
|
|
7
|
-
// weekly aggregate may be the only bar there is.
|
|
8
|
-
|
|
9
1
|
import { sortBy } from "es-toolkit";
|
|
10
2
|
import { nextWeeklyReset } from "./picker.ts";
|
|
11
3
|
import { isSessionWindow, weeklyWindowOf } from "./codexusage.ts";
|
|
12
4
|
import type { CodexAccount, CodexWindow, Thresholds } from "./types.ts";
|
|
13
5
|
|
|
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
6
|
function liveUsed(input: { window: CodexWindow; now: number; sampledAt: number | null }): number {
|
|
22
7
|
const { window, now, sampledAt } = input;
|
|
23
8
|
if (window.resetsAt != null) return window.resetsAt <= now ? 0 : window.usedPercentage;
|
|
@@ -35,7 +20,6 @@ function barFor(input: { window: CodexWindow; thresholds: Thresholds }): number
|
|
|
35
20
|
return isSessionWindow({ window: input.window }) ? input.thresholds.session : input.thresholds.weekly;
|
|
36
21
|
}
|
|
37
22
|
|
|
38
|
-
/** A window at/over its bar whose reset has not passed blocks the account. */
|
|
39
23
|
export function isCodexExhausted(input: { account: CodexAccount; thresholds: Thresholds; now: number }): boolean {
|
|
40
24
|
const { account, thresholds, now } = input;
|
|
41
25
|
const sampledAt = account.lastUsageAt ?? null;
|
|
@@ -44,9 +28,6 @@ export function isCodexExhausted(input: { account: CodexAccount; thresholds: Thr
|
|
|
44
28
|
);
|
|
45
29
|
}
|
|
46
30
|
|
|
47
|
-
/** Forward pace pressure on the weekly aggregate: the burn rate the remaining
|
|
48
|
-
* weekly quota demands before its reset forfeits it. No sampled weekly window
|
|
49
|
-
* or no reset anchor ranks last (0): unmeasured must not look urgent. */
|
|
50
31
|
export function codexPacePressure(input: { account: CodexAccount; now: number }): number {
|
|
51
32
|
const { account, now } = input;
|
|
52
33
|
const weekly = account.lastUsage ? weeklyWindowOf({ aggregate: account.lastUsage.aggregate }) : null;
|
|
@@ -83,17 +64,8 @@ export function pickBestCodex(input: {
|
|
|
83
64
|
return sortBy(usable, codexSwapPreference(now))[0] ?? null;
|
|
84
65
|
}
|
|
85
66
|
|
|
86
|
-
/** A codex greedy swap is a SIGTERM + respawn of a live session (codex cannot
|
|
87
|
-
* hot-adopt), and engagement is chronic on plans whose only window is weekly:
|
|
88
|
-
* without a margin, every hair of pace-pressure drift would visibly restart
|
|
89
|
-
* the user's session. The challenger must beat the incumbent by this factor
|
|
90
|
-
* (adversarial review catch, 2026-07-16); a crossed bar bypasses the margin
|
|
91
|
-
* entirely via the hard path. */
|
|
92
67
|
export const CODEX_SWAP_IMPROVEMENT = 1.2;
|
|
93
68
|
|
|
94
|
-
/** Greedy idempotence, mirroring picker.currentWins with the respawn-cost
|
|
95
|
-
* margin: the active account keeps its seat while usable unless a challenger
|
|
96
|
-
* beats its pace pressure by CODEX_SWAP_IMPROVEMENT. */
|
|
97
69
|
export function codexCurrentWins(input: {
|
|
98
70
|
active: CodexAccount | null;
|
|
99
71
|
accounts: CodexAccount[];
|
|
@@ -107,9 +79,6 @@ export function codexCurrentWins(input: {
|
|
|
107
79
|
return codexPacePressure({ account: best, now }) <= codexPacePressure({ account: active, now }) * CODEX_SWAP_IMPROVEMENT;
|
|
108
80
|
}
|
|
109
81
|
|
|
110
|
-
/** True once any window is at/over the greedy engagement floor: with no 5h
|
|
111
|
-
* window on current codex plans, the floor reads against every window class
|
|
112
|
-
* rather than the session window alone. */
|
|
113
82
|
export function isCodexEngaged(input: { account: CodexAccount; floor: number; now: number }): boolean {
|
|
114
83
|
const { account, floor, now } = input;
|
|
115
84
|
const sampledAt = account.lastUsageAt ?? null;
|
package/src/lib/codexpresence.ts
CHANGED
|
@@ -1,15 +1,3 @@
|
|
|
1
|
-
// Which codex accounts are RUNNING right now. Codex cannot hot-swap, so a swap
|
|
2
|
-
// respawns only the session whose Stop hook decided it; sibling supervised
|
|
3
|
-
// sessions keep running on whatever account they started with, rotating that
|
|
4
|
-
// account's token live. Such an account is a landmine for the rest of the
|
|
5
|
-
// pool: its parked copy is superseded (refresh trips reuse punishment) and
|
|
6
|
-
// installing it as live would yank the running session's grant. Supervisors
|
|
7
|
-
// therefore declare their session's account in a presence file at every
|
|
8
|
-
// (re)spawn; the picker refuses to target present accounts and the sampler
|
|
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.
|
|
12
|
-
|
|
13
1
|
import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync } from "node:fs";
|
|
14
2
|
import { join } from "node:path";
|
|
15
3
|
import { z } from "zod";
|
|
@@ -23,17 +11,9 @@ const PresenceSchema = z.object({
|
|
|
23
11
|
startedAt: z.string(),
|
|
24
12
|
});
|
|
25
13
|
|
|
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
14
|
export function writeCodexPresence(input: { supervisorId: string; accountId: string; pid?: number }): void {
|
|
33
15
|
const pid = input.pid ?? process.pid;
|
|
34
16
|
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
17
|
if (startedAt == null) throw new Error(`could not read pid ${pid}'s start time (ps lstart) - refusing to write an unverifiable presence file`);
|
|
38
18
|
mkdirSync(codexPaths.presenceDir, { recursive: true });
|
|
39
19
|
writeFileAtomic(
|
|
@@ -49,13 +29,6 @@ export function clearCodexPresence(input: { supervisorId: string }): void {
|
|
|
49
29
|
const LivingPresenceSchema = z.object({ supervisorId: z.string(), accountId: z.string() });
|
|
50
30
|
export type LivingPresence = z.infer<typeof LivingPresenceSchema>;
|
|
51
31
|
|
|
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
32
|
export function livingCodexPresences(): LivingPresence[] {
|
|
60
33
|
const living: LivingPresence[] = [];
|
|
61
34
|
if (!existsSync(codexPaths.presenceDir)) return living;
|
|
@@ -65,7 +38,6 @@ export function livingCodexPresences(): LivingPresence[] {
|
|
|
65
38
|
try {
|
|
66
39
|
raw = readFileSync(file, "utf8");
|
|
67
40
|
} catch (e) {
|
|
68
|
-
// a supervisor exiting between readdir and read clears its own file
|
|
69
41
|
const errno = z.object({ code: z.string() }).safeParse(e);
|
|
70
42
|
if (errno.success && errno.data.code === "ENOENT") continue;
|
|
71
43
|
throw e;
|
|
@@ -82,10 +54,6 @@ export function livingCodexPresences(): LivingPresence[] {
|
|
|
82
54
|
}
|
|
83
55
|
const observed = pidStartTime(parsed.data.pid);
|
|
84
56
|
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
57
|
if (observed == null && pidExists(parsed.data.pid)) {
|
|
90
58
|
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
|
}
|
|
@@ -97,13 +65,10 @@ export function livingCodexPresences(): LivingPresence[] {
|
|
|
97
65
|
return living;
|
|
98
66
|
}
|
|
99
67
|
|
|
100
|
-
/** Account ids with a LIVING supervisor - the picker/sampler exclusion view. */
|
|
101
68
|
export function presentCodexAccountIds(): Set<string> {
|
|
102
69
|
return new Set(livingCodexPresences().map((presence) => presence.accountId));
|
|
103
70
|
}
|
|
104
71
|
|
|
105
|
-
/** The accounts a swap may target: running accounts are off limits, except the
|
|
106
|
-
* seat itself (it is ranked as the incumbent, never installed over itself). */
|
|
107
72
|
export function targetableCodexAccounts<T extends { accountId: string }>(input: {
|
|
108
73
|
accounts: T[];
|
|
109
74
|
activeAccountId: string | null;
|