tokenmaxxing 0.19.1 → 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 +33 -24
- 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 +629 -115
- 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 +574 -81
- package/src/lib/slackstate.ts +159 -12
- package/src/lib/slackstream.ts +123 -20
- 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/cli/status.ts
CHANGED
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
import { sortBy } from "es-toolkit";
|
|
16
16
|
import { loadAccounts, loadConfig, loadUsage, loadModelUsage, saveAccounts } from "../lib/state.ts";
|
|
17
17
|
import { readOAuthAccount } from "../lib/claudejson.ts";
|
|
18
|
-
import { probeActiveUsage, probeParkedUsage, type SampleOutcome } from "../lib/sample.ts";
|
|
18
|
+
import { ensureLiveTokenFresh, probeActiveUsage, probeParkedUsage, type SampleOutcome } from "../lib/sample.ts";
|
|
19
19
|
import { withLock } from "../lib/lock.ts";
|
|
20
20
|
import { codexPaths, paths } from "../lib/paths.ts";
|
|
21
21
|
import { earliestReset, effectiveBars, isExhausted, nextWeeklyReset } from "../lib/picker.ts";
|
|
@@ -25,7 +25,7 @@ import { isCodexExhausted } from "../lib/codexpick.ts";
|
|
|
25
25
|
import { codexLimitLabel, isSessionWindow } from "../lib/codexusage.ts";
|
|
26
26
|
import { bar, c, claudeTierLabel, count, fmtAgo, fmtReset } from "./render.ts";
|
|
27
27
|
import type { FullUsage } from "../lib/usage.ts";
|
|
28
|
-
import type { Config, CodexWindow, UsageWindow } from "../lib/types.ts";
|
|
28
|
+
import type { Account, Config, CodexWindow, UsageWindow } from "../lib/types.ts";
|
|
29
29
|
|
|
30
30
|
/** `preRender` runs after sampling, right before the first output line: `watch`
|
|
31
31
|
* keeps the previous frame on screen through the multi-second sample and
|
|
@@ -35,8 +35,28 @@ export async function cmdStatus(force = false, preRender?: () => void): Promise<
|
|
|
35
35
|
const cfg = loadConfig();
|
|
36
36
|
const now = Date.now();
|
|
37
37
|
|
|
38
|
+
// A window whose cached reset has passed is empty again; weekly windows recur
|
|
39
|
+
// on a fixed per-account anchor, so a stale weekly reset extrapolates forward.
|
|
40
|
+
// Fresh samples pass through unchanged (their resets are in the future).
|
|
41
|
+
const row = (name: string, w: UsageWindow, weekly: boolean) => {
|
|
42
|
+
const passed = w.resetsAt != null && w.resetsAt <= now;
|
|
43
|
+
const pct = passed ? 0 : w.usedPercentage;
|
|
44
|
+
const resetsAt = weekly ? nextWeeklyReset(w.resetsAt, now) : passed ? null : w.resetsAt;
|
|
45
|
+
console.log(` ${name.padEnd(5)} ${bar(pct)} ${c.dim(fmtReset(resetsAt, now))}`);
|
|
46
|
+
};
|
|
47
|
+
|
|
38
48
|
if (idx.accounts.length === 0) {
|
|
39
|
-
|
|
49
|
+
// A codex-only pool is a documented standalone flow: the empty-claude
|
|
50
|
+
// early return must still render it, and only a fully empty install gets
|
|
51
|
+
// the init hint (closing-review catch: bare `xx` claimed "no accounts"
|
|
52
|
+
// over a populated codex pool and pointed at the wrong init).
|
|
53
|
+
if (loadCodexAccounts().accounts.length > 0) {
|
|
54
|
+
console.log(c.dim("no claude accounts (run `tokenmaxxing init` to pool claude too)"));
|
|
55
|
+
console.log();
|
|
56
|
+
await renderCodexSection({ cfg, now, row });
|
|
57
|
+
return 0;
|
|
58
|
+
}
|
|
59
|
+
console.log(c.dim("no accounts yet, run `tokenmaxxing init` (or `tokenmaxxing init --codex`)"));
|
|
40
60
|
return 0;
|
|
41
61
|
}
|
|
42
62
|
|
|
@@ -51,9 +71,13 @@ export async function cmdStatus(force = false, preRender?: () => void): Promise<
|
|
|
51
71
|
const modelUsage = loadModelUsage();
|
|
52
72
|
const liveOAuth = readOAuthAccount();
|
|
53
73
|
const activeOrg = liveOAuth?.organizationUuid ?? null;
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
74
|
+
const probeOne = async (a: Account) => {
|
|
75
|
+
// Active = the org claude's own oauthAccount names, NOT the stored
|
|
76
|
+
// label conjunction: after a manual /login the label lags, and routing
|
|
77
|
+
// the genuinely-live account through the parked prober would refresh
|
|
78
|
+
// its stale parked grant and could flag a healthy account needs-reauth.
|
|
79
|
+
// probeActiveUsage's own roles check still fail-fasts on residual drift.
|
|
80
|
+
const isActive = activeOrg != null && activeOrg === a.organizationUuid;
|
|
57
81
|
// The tee path never opens the credential blob, so the active account's
|
|
58
82
|
// tier comes from the live oauthAccount instead - it names this very org
|
|
59
83
|
// (uuid-matched above), so the tier is attributed to its own identity.
|
|
@@ -73,10 +97,14 @@ export async function cmdStatus(force = false, preRender?: () => void): Promise<
|
|
|
73
97
|
let outcome: SampleOutcome;
|
|
74
98
|
if (force) {
|
|
75
99
|
// Force: ping + live probe for everyone. The tee predates the ping,
|
|
76
|
-
// so it serves
|
|
77
|
-
//
|
|
100
|
+
// so it serves ONLY as the fallback for the fail-silent `/usage`
|
|
101
|
+
// case (the probe got past every guard and claude printed no limit
|
|
102
|
+
// lines). Any other failure - identity mismatch, dead refresh, a
|
|
103
|
+
// refused ping - must stay VISIBLE: the old unconditional fallback
|
|
104
|
+
// masked pre-ping failures as healthy tee data, silently skipping
|
|
105
|
+
// the ping with no warning (closing-review catch).
|
|
78
106
|
outcome = isActive ? await probeActiveUsage(a, { ping: true }) : await probeParkedUsage(a, { ping: true });
|
|
79
|
-
if (!outcome.ok && fromStatusLine) {
|
|
107
|
+
if (!outcome.ok && fromStatusLine && outcome.reason.includes("no limit data")) {
|
|
80
108
|
const failed = outcome;
|
|
81
109
|
outcome = { ok: true, usage: fromStatusLine };
|
|
82
110
|
if (failed.pingError != null) outcome.pingError = failed.pingError;
|
|
@@ -93,12 +121,37 @@ export async function cmdStatus(force = false, preRender?: () => void): Promise<
|
|
|
93
121
|
outcomes.set(a.accountUuid, outcome);
|
|
94
122
|
if (!outcome.ok) return;
|
|
95
123
|
a.lastUsage = { fiveHour: outcome.usage.session, sevenDay: outcome.usage.weekAll };
|
|
96
|
-
if (Object.keys(outcome.usage.perModel).length > 0) a.lastPerModel = outcome.usage.perModel;
|
|
97
124
|
// stamp when the figures were actually measured: the statusLine tee's
|
|
98
125
|
// own write time for the push-fed active account, else the probe time.
|
|
99
126
|
a.lastUsageAt = viaTee && live ? live.ts : Date.now();
|
|
100
|
-
|
|
101
|
-
|
|
127
|
+
if (Object.keys(outcome.usage.perModel).length > 0) {
|
|
128
|
+
a.lastPerModel = outcome.usage.perModel;
|
|
129
|
+
// via the tee, the per-model rows come from model-usage.json whose
|
|
130
|
+
// own sample time may be DAYS older than the aggregate tee - dating
|
|
131
|
+
// them by the tee's timestamp inflated the null-reset self-bound
|
|
132
|
+
// (closing-review catch). A fresh probe's rows date by the probe.
|
|
133
|
+
a.lastPerModelAt = viaTee && modelUsage ? (modelUsage.sampledAt ?? modelUsage.ts) : a.lastUsageAt;
|
|
134
|
+
}
|
|
135
|
+
};
|
|
136
|
+
// The ACTIVE probe runs FIRST, alone: it refreshes an expired live access
|
|
137
|
+
// token under claude's refresh lock, and every parked probe's fail-closed
|
|
138
|
+
// live-owner check reads that same live token - run concurrently they
|
|
139
|
+
// raced the refresh and, after the machine idled past the token lifetime,
|
|
140
|
+
// every parked sample 401'd on the first `xx status` (closing-review
|
|
141
|
+
// catch). Parked probes then run concurrently as before.
|
|
142
|
+
const activeAccount = idx.accounts.find((a) => activeOrg != null && activeOrg === a.organizationUuid) ?? null;
|
|
143
|
+
if (activeAccount) await probeOne(activeAccount);
|
|
144
|
+
// even when the active account's usage came from the TEE (no active
|
|
145
|
+
// probe, no refresh side effect), the parked probes still need a fresh
|
|
146
|
+
// live token for their fail-closed live-owner checks (cubic review
|
|
147
|
+
// catch, PR #35). A dead live grant is deliberately not fatal here: each
|
|
148
|
+
// parked probe reports its own honest failure reason.
|
|
149
|
+
try {
|
|
150
|
+
await ensureLiveTokenFresh();
|
|
151
|
+
} catch {
|
|
152
|
+
// per-account guards surface the failure per probe
|
|
153
|
+
}
|
|
154
|
+
await Promise.all(idx.accounts.filter((a) => a !== activeAccount).map(probeOne));
|
|
102
155
|
saveAccounts(idx);
|
|
103
156
|
});
|
|
104
157
|
|
|
@@ -106,22 +159,15 @@ export async function cmdStatus(force = false, preRender?: () => void): Promise<
|
|
|
106
159
|
console.log(c.dim(`thresholds 5h ${cfg.thresholds.session}% weekly ${cfg.thresholds.weekly}% (${count({ n: idx.accounts.length, noun: "claude account" })})`));
|
|
107
160
|
console.log();
|
|
108
161
|
|
|
109
|
-
// A window whose cached reset has passed is empty again; weekly windows recur
|
|
110
|
-
// on a fixed per-account anchor, so a stale weekly reset extrapolates forward.
|
|
111
|
-
// Fresh samples pass through unchanged (their resets are in the future).
|
|
112
|
-
const row = (name: string, w: UsageWindow, weekly: boolean) => {
|
|
113
|
-
const passed = w.resetsAt != null && w.resetsAt <= now;
|
|
114
|
-
const pct = passed ? 0 : w.usedPercentage;
|
|
115
|
-
const resetsAt = weekly ? nextWeeklyReset(w.resetsAt, now) : passed ? null : w.resetsAt;
|
|
116
|
-
console.log(` ${name.padEnd(5)} ${bar(pct)} ${c.dim(fmtReset(resetsAt, now))}`);
|
|
117
|
-
};
|
|
118
|
-
|
|
119
162
|
// Display order (user decision 2026-07-18): earliest upcoming reset first
|
|
120
163
|
// (5h or extrapolated weekly, from the just-refreshed samples), needs-reauth
|
|
121
164
|
// last; the ● marker identifies the active account wherever it sorts.
|
|
122
165
|
const displayAccounts = sortBy(idx.accounts, [(a) => (a.needsReauth ? 1 : 0), (a) => earliestReset(a, now)]);
|
|
166
|
+
// The marker matches the probe routing above: active = the org claude's own
|
|
167
|
+
// oauthAccount names (the swap label can lag after a manual /login).
|
|
168
|
+
const displayActiveOrg = readOAuthAccount()?.organizationUuid ?? null;
|
|
123
169
|
for (const a of displayAccounts) {
|
|
124
|
-
const active = a.
|
|
170
|
+
const active = displayActiveOrg != null && a.organizationUuid === displayActiveOrg;
|
|
125
171
|
const outcome = outcomes.get(a.accountUuid);
|
|
126
172
|
const failed = outcome ? !outcome.ok : false;
|
|
127
173
|
// On a failed sample, fall back to the last-known values (with a note below).
|
package/src/cli/switch.ts
CHANGED
|
@@ -18,8 +18,8 @@ import { withLock } from "../lib/lock.ts";
|
|
|
18
18
|
import { paths } from "../lib/paths.ts";
|
|
19
19
|
import { loadAccounts, loadConfig } from "../lib/state.ts";
|
|
20
20
|
import { readOAuthAccount } from "../lib/claudejson.ts";
|
|
21
|
-
import { performSwap
|
|
22
|
-
import { currentWins, effectiveBars, pickEarliestReset, weeklyExpiry, type PickCtx } from "../lib/picker.ts";
|
|
21
|
+
import { performSwap } from "../lib/swap.ts";
|
|
22
|
+
import { currentWins, effectiveBars, pickBest, pickEarliestReset, weeklyExpiry, type PickCtx } from "../lib/picker.ts";
|
|
23
23
|
import { InvalidGrantError } from "../lib/oauth.ts";
|
|
24
24
|
import { findAccount } from "./rename.ts";
|
|
25
25
|
import { c, fmtReset } from "./render.ts";
|
|
@@ -36,7 +36,13 @@ export async function cmdSwitch(selector?: string): Promise<number> {
|
|
|
36
36
|
|
|
37
37
|
return withLock(paths.lockFile, async () => {
|
|
38
38
|
const idx = loadAccounts();
|
|
39
|
-
|
|
39
|
+
// ONE claude.json read for both identity fields: two reads could straddle
|
|
40
|
+
// a concurrent /login and describe two different live identities - the
|
|
41
|
+
// drift check and the seat must come from the same snapshot (cubic review
|
|
42
|
+
// catch, PR #33).
|
|
43
|
+
const liveClaim = readOAuthAccount();
|
|
44
|
+
const claimed = liveClaim?.accountUuid ?? null;
|
|
45
|
+
const claimedOrg = liveClaim?.organizationUuid ?? null;
|
|
40
46
|
const drifted = claimed != null && claimed !== idx.activeAccountUuid;
|
|
41
47
|
|
|
42
48
|
const swapTo = async (target: Account): Promise<number> => {
|
|
@@ -54,32 +60,60 @@ export async function cmdSwitch(selector?: string): Promise<number> {
|
|
|
54
60
|
const target = findAccount(idx.accounts, selector);
|
|
55
61
|
if (!target) { console.error(c.red(`no account matches "${selector}"`)); return 1; }
|
|
56
62
|
if (target.accountUuid === idx.activeAccountUuid && !drifted) { console.log(`already on ${c.bold(target.label)}`); return 0; }
|
|
57
|
-
if (target.needsReauth) { console.error(c.red(`${target.label} needs re-auth - \`tokenmaxxing
|
|
63
|
+
if (target.needsReauth) { console.error(c.red(`${target.label} needs re-auth - run \`tokenmaxxing auth ${target.label}\``)); return 1; }
|
|
58
64
|
return swapTo(target);
|
|
59
65
|
}
|
|
60
66
|
|
|
61
67
|
// auto: greedy over everyone, current included - a no-op when current wins.
|
|
62
68
|
// No session context here, so every configured per-model family gates.
|
|
69
|
+
// Dead-token fallback mirrors decide.ts's greedy loop, NOT chooseAndSwap:
|
|
70
|
+
// its fallback lands on the next usable candidate unconditionally, which
|
|
71
|
+
// is right when over a bar but wrong here - a dead grant on the pace
|
|
72
|
+
// winner must re-check the seat, or a healthy current account gets a real
|
|
73
|
+
// swap onto a pace-WORSE account and the next periodic check bounces it
|
|
74
|
+
// straight back (closing-review catch). performSwap persists needs-reauth
|
|
75
|
+
// before throwing, so each reload shrinks the candidate set and the loop
|
|
76
|
+
// terminates.
|
|
63
77
|
const everyone: PickCtx = { now, thresholds: effectiveBars(cfg), currentAccountUuid: null, switchFamilies: cfg.policy.switchModels };
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
const
|
|
74
|
-
|
|
75
|
-
|
|
78
|
+
while (true) {
|
|
79
|
+
const cur = loadAccounts();
|
|
80
|
+
// The seat is the LIVE login's pooled account when resolvable, the
|
|
81
|
+
// stored label only as fallback - the same identity rule AND the same
|
|
82
|
+
// identity KEY as decide.ts's seatOf: the organizationUuid, since quota
|
|
83
|
+
// is metered per org and the org is what the roles endpoint verifies
|
|
84
|
+
// (bugbot review catches, PR #33). Under drift this also makes ties
|
|
85
|
+
// favor the live account: the realign swap then keeps the same
|
|
86
|
+
// credential instead of hopping accounts on a tie.
|
|
87
|
+
const active =
|
|
88
|
+
(claimedOrg != null ? cur.accounts.find((a) => a.organizationUuid === claimedOrg) : null) ??
|
|
89
|
+
cur.accounts.find((a) => a.accountUuid === cur.activeAccountUuid) ??
|
|
90
|
+
null;
|
|
91
|
+
if (active != null && currentWins(active, cur.accounts, everyone)) {
|
|
92
|
+
if (drifted) return swapTo(active);
|
|
93
|
+
const expiry = weeklyExpiry(active, now);
|
|
94
|
+
const why = Number.isFinite(expiry) ? ` (weekly ${fmtReset(expiry, now)})` : "";
|
|
95
|
+
console.log(`already on the best account: ${c.bold(active.label)}${why}`);
|
|
76
96
|
return 0;
|
|
77
97
|
}
|
|
98
|
+
const best = pickBest(cur.accounts, { ...everyone, currentAccountUuid: active?.accountUuid ?? null });
|
|
99
|
+
if (!best) break; // no usable target: the depleted path below decides
|
|
100
|
+
try {
|
|
101
|
+
await performSwap(best);
|
|
102
|
+
} catch (e) {
|
|
103
|
+
if (e instanceof InvalidGrantError) {
|
|
104
|
+
console.error(c.red(`${best.label}'s refresh token is dead - run \`tokenmaxxing auth ${best.label}\``));
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
throw e;
|
|
108
|
+
}
|
|
109
|
+
console.log(`${c.green("↻")} switched to ${c.bold(best.label)}`);
|
|
110
|
+
return 0;
|
|
78
111
|
}
|
|
79
112
|
|
|
80
113
|
// No usable target swapped in: everything is depleted, or the remaining
|
|
81
|
-
// candidates' refresh tokens just died (
|
|
82
|
-
// hence the reload). Stay on / switch to whichever
|
|
114
|
+
// candidates' refresh tokens just died (performSwap persists needs-reauth
|
|
115
|
+
// before throwing, hence the reload). Stay on / switch to whichever
|
|
116
|
+
// recovers soonest.
|
|
83
117
|
const fresh = loadAccounts();
|
|
84
118
|
const earliest = pickEarliestReset(fresh.accounts, everyone);
|
|
85
119
|
if (!earliest) {
|
|
@@ -88,7 +122,8 @@ export async function cmdSwitch(selector?: string): Promise<number> {
|
|
|
88
122
|
const reauth = fresh.accounts.filter((a) => a.needsReauth).map((a) => a.label);
|
|
89
123
|
if (reauth.length > 0) { console.error(c.yellow(`no switchable account - reauth needed (run \`tokenmaxxing auth --all\`): ${reauth.join(", ")}`)); return 1; }
|
|
90
124
|
// never freeze a label drift behind a no-op (see header).
|
|
91
|
-
|
|
125
|
+
const freshActive = fresh.accounts.find((a) => a.accountUuid === fresh.activeAccountUuid) ?? null;
|
|
126
|
+
if (drifted && freshActive) return swapTo(freshActive);
|
|
92
127
|
console.log(c.yellow("all accounts at their limit with unknown reset times (unparsed reset clocks? see tokenmaxxing.log) - staying put"));
|
|
93
128
|
return 0;
|
|
94
129
|
}
|
|
@@ -11,17 +11,114 @@
|
|
|
11
11
|
// a hook failure must never block the stop - errors are logged, not thrown.
|
|
12
12
|
|
|
13
13
|
import { join } from "node:path";
|
|
14
|
-
import { mkdirSync } from "node:fs";
|
|
14
|
+
import { existsSync, mkdirSync, readFileSync, rmSync } from "node:fs";
|
|
15
15
|
import { z } from "zod";
|
|
16
16
|
import { codexPaths } from "../lib/paths.ts";
|
|
17
|
+
import { withLock } from "../lib/lock.ts";
|
|
17
18
|
import { writeFileAtomic } from "../lib/atomic.ts";
|
|
18
19
|
import { evaluateAndMaybeSwapCodex } from "../lib/codexdecide.ts";
|
|
20
|
+
import { isCodexExhausted } from "../lib/codexpick.ts";
|
|
21
|
+
import { livingCodexPresences } from "../lib/codexpresence.ts";
|
|
22
|
+
import { liveCodexAccountId } from "../lib/codexsample.ts";
|
|
23
|
+
import { loadCodexAccounts } from "../lib/codexstate.ts";
|
|
24
|
+
import { loadConfig } from "../lib/state.ts";
|
|
25
|
+
import { effectiveBars } from "../lib/picker.ts";
|
|
19
26
|
import { CODEX_SUPERVISOR_ID_ENV } from "./codexsupervisor.ts";
|
|
20
|
-
import { CodexRespawnMarkerSchema, CodexStopStdinSchema } from "../lib/types.ts";
|
|
27
|
+
import { CodexReconcileMarkerSchema, CodexRespawnMarkerSchema, CodexStopStdinSchema, type CodexAccount } from "../lib/types.ts";
|
|
21
28
|
import { log } from "../lib/log.ts";
|
|
22
29
|
|
|
23
30
|
const SupervisorIdSchema = z.string().min(1).optional().catch(undefined);
|
|
24
31
|
|
|
32
|
+
/** Consume a cross-session reconcile signal addressed to THIS supervisor
|
|
33
|
+
* (owner-approved option b, 2026-07-20): a deciding actor saw this session
|
|
34
|
+
* running on an exhausted/dead account and asked it to respawn onto the live
|
|
35
|
+
* seat. The Stop boundary is the only safe respawn point, and only THIS
|
|
36
|
+
* hook's stdin carries the session id a resume needs, so promotion happens
|
|
37
|
+
* here: the signal becomes a normal respawn marker the supervisor already
|
|
38
|
+
* consumes. Returns true when the respawn marker was written (the session is
|
|
39
|
+
* about to die - skip the normal decision). Everything past the cheap
|
|
40
|
+
* no-marker fast path runs under the codex FLOCK (pullfrog review catch,
|
|
41
|
+
* PR #34): the usability revalidation is only as good as its atomicity with
|
|
42
|
+
* the marker write - unlocked, a concurrent `xx switch --codex` could move
|
|
43
|
+
* the live seat onto a blocked account between the check and the write. The
|
|
44
|
+
* flock serializes promotion against every tokenmaxxing actor (codex's own
|
|
45
|
+
* actions are unserialized as ever); no nesting occurs because the hook
|
|
46
|
+
* calls promote strictly before or after the evaluation's own lock. */
|
|
47
|
+
async function promoteReconcile(input: { supervisorId: string; sessionId: string | null }): Promise<boolean> {
|
|
48
|
+
const markerPath = join(codexPaths.reconcileDir, input.supervisorId);
|
|
49
|
+
if (!existsSync(markerPath)) return false;
|
|
50
|
+
return withLock(codexPaths.lockFile, async () => promoteReconcileLocked(input));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function promoteReconcileLocked(input: { supervisorId: string; sessionId: string | null }): boolean {
|
|
54
|
+
const markerPath = join(codexPaths.reconcileDir, input.supervisorId);
|
|
55
|
+
if (!existsSync(markerPath)) return false; // re-checked under the lock: a raced consumer may have taken it
|
|
56
|
+
const parsed = CodexReconcileMarkerSchema.safeParse((() => {
|
|
57
|
+
try {
|
|
58
|
+
return JSON.parse(readFileSync(markerPath, "utf8"));
|
|
59
|
+
} catch {
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
})());
|
|
63
|
+
if (!parsed.success) {
|
|
64
|
+
// unlike a presence file, a broken signal guards nothing: drop it loudly.
|
|
65
|
+
rmSync(markerPath, { force: true });
|
|
66
|
+
log("codexstop.reconcile_unparsable", {});
|
|
67
|
+
return false;
|
|
68
|
+
}
|
|
69
|
+
// Staleness guard: the signal names the account this session was seen on;
|
|
70
|
+
// if the session has since respawned onto another account the signal is
|
|
71
|
+
// moot. Same when the live seat changed to (or still is) our own account -
|
|
72
|
+
// a respawn would land right back where we are.
|
|
73
|
+
const presence = livingCodexPresences().find((p) => p.supervisorId === input.supervisorId) ?? null;
|
|
74
|
+
if (presence == null || presence.accountId !== parsed.data.accountId) {
|
|
75
|
+
rmSync(markerPath, { force: true });
|
|
76
|
+
log("codexstop.reconcile_stale", {});
|
|
77
|
+
return false;
|
|
78
|
+
}
|
|
79
|
+
const liveId = liveCodexAccountId();
|
|
80
|
+
if (liveId == null || liveId === presence.accountId) {
|
|
81
|
+
rmSync(markerPath, { force: true });
|
|
82
|
+
log("codexstop.reconcile_moot", {});
|
|
83
|
+
return false;
|
|
84
|
+
}
|
|
85
|
+
// Revalidate the DESTINATION at consumption time (bugbot/pullfrog/vercel/
|
|
86
|
+
// cubic review catches, PR #34): a signal can sit across resets and usage
|
|
87
|
+
// changes, and identity checks alone would move a session onto a live seat
|
|
88
|
+
// that has since become exhausted, needs-reauth, or left the pool entirely.
|
|
89
|
+
// The SOURCE seat's state is deliberately not re-checked (owner ruling
|
|
90
|
+
// 2026-07-20: every pooled non-live sibling follows the seat, healthy or
|
|
91
|
+
// not - a non-live session wedges at token expiry regardless of quota).
|
|
92
|
+
// Dropped signals are cheap: the sweep re-signals next evaluation.
|
|
93
|
+
const now = Date.now();
|
|
94
|
+
const bars = effectiveBars(loadConfig());
|
|
95
|
+
const index = loadCodexAccounts();
|
|
96
|
+
const unusable = (account: CodexAccount): boolean =>
|
|
97
|
+
account.needsReauth === true || isCodexExhausted({ account, thresholds: bars, now });
|
|
98
|
+
const liveAccount = index.accounts.find((a) => a.accountId === liveId);
|
|
99
|
+
if (!liveAccount || unusable(liveAccount)) {
|
|
100
|
+
rmSync(markerPath, { force: true });
|
|
101
|
+
log("codexstop.reconcile_blocked_target", {});
|
|
102
|
+
return false;
|
|
103
|
+
}
|
|
104
|
+
// blank counts as missing: the supervisor treats a falsy sessionId as
|
|
105
|
+
// "resume --last", the exact fallback this guard exists to avoid.
|
|
106
|
+
if (input.sessionId == null || input.sessionId.trim() === "") {
|
|
107
|
+
// keep the signal for the next boundary, whose stdin will carry a real
|
|
108
|
+
// id - a resume without one could revive the wrong transcript.
|
|
109
|
+
log("codexstop.reconcile_no_session", {});
|
|
110
|
+
return false;
|
|
111
|
+
}
|
|
112
|
+
mkdirSync(codexPaths.respawnDir, { recursive: true });
|
|
113
|
+
writeFileAtomic(
|
|
114
|
+
join(codexPaths.respawnDir, input.supervisorId),
|
|
115
|
+
JSON.stringify(CodexRespawnMarkerSchema.parse({ account: liveAccount.label, sessionId: input.sessionId, ts: now })),
|
|
116
|
+
);
|
|
117
|
+
rmSync(markerPath, { force: true });
|
|
118
|
+
log("codexstop.reconcile_respawn", { supervisorId: input.supervisorId.slice(0, 8), account: liveId.slice(0, 8) });
|
|
119
|
+
return true;
|
|
120
|
+
}
|
|
121
|
+
|
|
25
122
|
async function readStdin(): Promise<string> {
|
|
26
123
|
const chunks: Uint8Array[] = [];
|
|
27
124
|
for await (const chunk of Bun.stdin.stream()) chunks.push(chunk);
|
|
@@ -41,9 +138,24 @@ export async function handleCodexStop(input: { rawStdin: string }): Promise<void
|
|
|
41
138
|
const sessionId = parsed.success ? (parsed.data.session_id ?? null) : null;
|
|
42
139
|
|
|
43
140
|
try {
|
|
44
|
-
|
|
141
|
+
// No supervisor = no decision AT ALL, checked before evaluate can swap:
|
|
142
|
+
// hooks.json is global, so this hook also fires in sessions launched
|
|
143
|
+
// around the PATH shim (IDE extension, absolute path), and a swap with
|
|
144
|
+
// nobody to respawn strands that session - codex cannot hot-adopt, and
|
|
145
|
+
// its guarded reload refuses a cross-account auth.json, so the session
|
|
146
|
+
// dies on its stale token with "Please sign in again" (closing-review
|
|
147
|
+
// catch). Restart IS the switch; without a restarter, do not switch.
|
|
45
148
|
const supervisorId = SupervisorIdSchema.parse(process.env[CODEX_SUPERVISOR_ID_ENV]);
|
|
46
|
-
if (
|
|
149
|
+
if (supervisorId === undefined) {
|
|
150
|
+
log("codexstop.unsupervised_skip", {});
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
// A pending reconcile signal outranks the normal decision: this session
|
|
154
|
+
// is about to respawn onto the live seat, so evaluating it would waste a
|
|
155
|
+
// sample (and could even swap the seat out from under the respawn).
|
|
156
|
+
if (await promoteReconcile({ supervisorId, sessionId })) return;
|
|
157
|
+
const decision = await evaluateAndMaybeSwapCodex({});
|
|
158
|
+
if (decision.swapped && decision.account) {
|
|
47
159
|
mkdirSync(codexPaths.respawnDir, { recursive: true });
|
|
48
160
|
const payload = CodexRespawnMarkerSchema.parse({
|
|
49
161
|
account: decision.account.label,
|
|
@@ -52,7 +164,14 @@ export async function handleCodexStop(input: { rawStdin: string }): Promise<void
|
|
|
52
164
|
});
|
|
53
165
|
writeFileAtomic(join(codexPaths.respawnDir, supervisorId), JSON.stringify(payload));
|
|
54
166
|
log("codexstop.marker", { supervisorId: supervisorId.slice(0, 8) });
|
|
167
|
+
return;
|
|
55
168
|
}
|
|
169
|
+
// The evaluation's sweep may have signaled THIS session (its own account
|
|
170
|
+
// is the wedged one while the live seat is healthy - the lone-stranded
|
|
171
|
+
// case the removed self-skip used to lose, bugbot/cubic review catch,
|
|
172
|
+
// PR #34): consume it at this very boundary instead of burning one more
|
|
173
|
+
// turn on the dead account.
|
|
174
|
+
await promoteReconcile({ supervisorId, sessionId });
|
|
56
175
|
} catch (e) {
|
|
57
176
|
log("codexstop.error", { err: e instanceof Error ? e.message : String(e) });
|
|
58
177
|
}
|
|
@@ -8,9 +8,11 @@
|
|
|
8
8
|
// sessions pair correctly); the supervisor then SIGTERMs its child and
|
|
9
9
|
// relaunches `codex resume <session-id>` on the freshly-installed account.
|
|
10
10
|
|
|
11
|
-
import { existsSync, mkdirSync, rmSync } from "node:fs";
|
|
11
|
+
import { existsSync, mkdirSync, readFileSync, rmSync } from "node:fs";
|
|
12
12
|
import { join } from "node:path";
|
|
13
|
+
import { z } from "zod";
|
|
13
14
|
import { codexPaths, paths } from "../lib/paths.ts";
|
|
15
|
+
import { withLock } from "../lib/lock.ts";
|
|
14
16
|
import { LOOP_DIAGNOSIS, MAX_WRAP_DEPTH, WRAP_DEPTH_ENV, WRAP_RATE_MAX, WRAP_RATE_WINDOW_MS, wrapDepth, wrapperEntryRateTripped } from "../lib/claudebin.ts";
|
|
15
17
|
import { resolveRealCodex } from "../lib/codexbin.ts";
|
|
16
18
|
import { clearCodexPresence, writeCodexPresence } from "../lib/codexpresence.ts";
|
|
@@ -30,6 +32,21 @@ const NONINTERACTIVE_SUBCMDS = new Set([
|
|
|
30
32
|
|
|
31
33
|
const PASSTHROUGH_FLAGS = new Set(["--version", "-V", "--help", "-h"]);
|
|
32
34
|
|
|
35
|
+
/** Read + validate a codex respawn marker. An unparseable one (version-skew
|
|
36
|
+
* hook, corruption) is dropped loudly and reported as absent: the watcher
|
|
37
|
+
* checks validity BEFORE the SIGTERM, so garbage never kills the session, and
|
|
38
|
+
* the post-exit consume never throws after the child is already dead (PR #36
|
|
39
|
+
* review catch, mirroring the claude supervisor). */
|
|
40
|
+
function consumableCodexMarker(marker: string): z.infer<typeof CodexRespawnMarkerSchema> | null {
|
|
41
|
+
try {
|
|
42
|
+
return CodexRespawnMarkerSchema.parse(JSON.parse(readFileSync(marker, "utf8")));
|
|
43
|
+
} catch (e) {
|
|
44
|
+
rmSync(marker, { force: true });
|
|
45
|
+
log("codexsupervisor.marker_invalid", { err: e instanceof Error ? e.message : String(e) });
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
33
50
|
/** Root options that consume the NEXT token as their value (verified against
|
|
34
51
|
* `codex --help` 0.144.4): without skipping them, `codex -m gpt exec ...`
|
|
35
52
|
* would read "gpt" as the subcommand and wrongly supervise an exec run. */
|
|
@@ -76,7 +93,18 @@ export async function runCodexSupervisor(input: { argv: string[] }): Promise<num
|
|
|
76
93
|
const childEnv = { ...process.env, [WRAP_DEPTH_ENV]: String(depth + 1) };
|
|
77
94
|
|
|
78
95
|
if (!shouldManageCodex({ argv })) {
|
|
79
|
-
|
|
96
|
+
// STRIP the supervisor pairing env from unmanaged spawns: a nested codex
|
|
97
|
+
// launched from inside a supervised session (e.g. its agent running
|
|
98
|
+
// `codex exec ...`) would otherwise inherit the OUTER session's id, and
|
|
99
|
+
// its global Stop hook could then write a respawn marker that SIGTERMs
|
|
100
|
+
// the outer session MID-TURN and resumes it onto the nested transcript
|
|
101
|
+
// (closing-review catch). A managed nested launch is already safe - it
|
|
102
|
+
// exports its own fresh id below; only a shim-bypassed absolute-path
|
|
103
|
+
// nested launch keeps the inherited env, the same accepted gap as
|
|
104
|
+
// claude's bg-daemon bypass.
|
|
105
|
+
const passthroughEnv: Record<string, string | undefined> = { ...childEnv };
|
|
106
|
+
delete passthroughEnv[CODEX_SUPERVISOR_ID_ENV];
|
|
107
|
+
const p = Bun.spawn([real, ...argv], { stdin: "inherit", stdout: "inherit", stderr: "inherit", env: passthroughEnv });
|
|
80
108
|
await p.exited;
|
|
81
109
|
return p.exitCode ?? (p.signalCode ? 1 : 0);
|
|
82
110
|
}
|
|
@@ -103,20 +131,63 @@ export async function runCodexSupervisor(input: { argv: string[] }): Promise<num
|
|
|
103
131
|
// the picker must never target it and the sampler must never rotate its
|
|
104
132
|
// parked token while the session lives. Rewritten every respawn (the swap
|
|
105
133
|
// changed the live identity); cleared on exit; PID-validated by readers.
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
134
|
+
// Read + presence-write + spawn run under the codex FLOCK (closing-review
|
|
135
|
+
// catch): unlocked, a swap could land between the read and the child's
|
|
136
|
+
// auth.json read, seating the child on the NEW account while presence
|
|
137
|
+
// named the old one for the session's whole life - un-benching the
|
|
138
|
+
// running account for samplers and the picker. Under the flock no swap
|
|
139
|
+
// can interleave until after the spawn; the residual window (child
|
|
140
|
+
// startup vs a swap acquiring the lock immediately after) is sub-ms in
|
|
141
|
+
// practice against a swap's network-bound critical section.
|
|
142
|
+
const child = await withLock(codexPaths.lockFile, async () => {
|
|
143
|
+
const spawnAccountId = liveCodexAccountId();
|
|
144
|
+
const spawned = Bun.spawn([real, ...launchArgs], {
|
|
145
|
+
stdin: "inherit",
|
|
146
|
+
stdout: "inherit",
|
|
147
|
+
stderr: "inherit",
|
|
148
|
+
env: { ...childEnv, [CODEX_SUPERVISOR_ID_ENV]: supervisorId },
|
|
149
|
+
});
|
|
150
|
+
// Presence pins the CHILD's pid, written after the spawn (still inside
|
|
151
|
+
// the flock): the session IS the codex process, and pinning this
|
|
152
|
+
// supervisor's pid let a SIGKILLed supervisor prune the presence while
|
|
153
|
+
// its orphaned codex kept rotating the account's token (closing-review
|
|
154
|
+
// catch). Brief retries cover ps visibility lag on a just-spawned pid.
|
|
155
|
+
// FAIL CLOSED on final failure (PR #36 review catch): a session running
|
|
156
|
+
// without presence is exactly the unprotected state presence exists to
|
|
157
|
+
// prevent - its account would look swappable and samplable - so kill
|
|
158
|
+
// the just-spawned child (nothing is in flight yet) and surface the
|
|
159
|
+
// error instead of running unprotected.
|
|
160
|
+
if (spawnAccountId) {
|
|
161
|
+
for (let attempt = 0; attempt < 10; attempt++) {
|
|
162
|
+
try {
|
|
163
|
+
writeCodexPresence({ supervisorId, accountId: spawnAccountId, pid: spawned.pid });
|
|
164
|
+
break;
|
|
165
|
+
} catch (e) {
|
|
166
|
+
// a child that already exited needs no presence (its absence is
|
|
167
|
+
// correct) and must keep its own exit result - the normal exit
|
|
168
|
+
// path below handles it (PR #36 second-round catch)
|
|
169
|
+
if (spawned.exitCode !== null || spawned.signalCode !== null) break;
|
|
170
|
+
if (attempt === 9) {
|
|
171
|
+
log("codexsupervisor.presence_failed", { err: e instanceof Error ? e.message : String(e) });
|
|
172
|
+
spawned.kill();
|
|
173
|
+
// the child may have entered raw mode during the retries: await
|
|
174
|
+
// its death and restore the terminal before surfacing (PR #36
|
|
175
|
+
// second-round catch)
|
|
176
|
+
await spawned.exited;
|
|
177
|
+
restoreTermios(savedTermios);
|
|
178
|
+
throw new Error("could not write the codex presence file - refusing to run an unprotected session (its account would look like a swap target)");
|
|
179
|
+
}
|
|
180
|
+
await Bun.sleep(100);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
return spawned;
|
|
114
185
|
});
|
|
115
186
|
|
|
116
187
|
let done = false;
|
|
117
188
|
const markerWatch = (async () => {
|
|
118
189
|
while (!done) {
|
|
119
|
-
if (
|
|
190
|
+
if (existsSync(marker) && consumableCodexMarker(marker) != null) return true;
|
|
120
191
|
await Bun.sleep(150);
|
|
121
192
|
}
|
|
122
193
|
return false;
|
|
@@ -135,8 +206,8 @@ export async function runCodexSupervisor(input: { argv: string[] }): Promise<num
|
|
|
135
206
|
await markerWatch.catch(() => false);
|
|
136
207
|
restoreTermios(savedTermios);
|
|
137
208
|
|
|
138
|
-
|
|
139
|
-
|
|
209
|
+
const payload = existsSync(marker) ? consumableCodexMarker(marker) : null;
|
|
210
|
+
if (payload) {
|
|
140
211
|
rmSync(marker, { force: true });
|
|
141
212
|
respawns++;
|
|
142
213
|
process.stdout.write(`\n\x1b[36m↻ tokenmaxxing: switched codex to ${payload.account} - resuming...\x1b[0m\n`);
|
|
@@ -144,6 +215,9 @@ export async function runCodexSupervisor(input: { argv: string[] }): Promise<num
|
|
|
144
215
|
continue;
|
|
145
216
|
}
|
|
146
217
|
clearCodexPresence({ supervisorId });
|
|
218
|
+
// a reconcile signal addressed to this now-gone session is moot; the
|
|
219
|
+
// deciding actor's sweep would gc it eventually, this is just prompt.
|
|
220
|
+
rmSync(join(codexPaths.reconcileDir, supervisorId), { force: true });
|
|
147
221
|
log("codexsupervisor.exit", { supervisorId: supervisorId.slice(0, 8), respawns, code: child.exitCode, signal: child.signalCode });
|
|
148
222
|
return child.exitCode ?? (child.signalCode ? 1 : 0);
|
|
149
223
|
}
|
|
@@ -33,7 +33,7 @@ export async function runSessionStart(): Promise<number> {
|
|
|
33
33
|
log("sessionstart.swapped", { source, account: decision.account.accountUuid.slice(0, 8) });
|
|
34
34
|
}
|
|
35
35
|
} catch (e) {
|
|
36
|
-
log("sessionstart.error", { err:
|
|
36
|
+
log("sessionstart.error", { err: e instanceof Error ? e.message : String(e) });
|
|
37
37
|
}
|
|
38
38
|
return 0;
|
|
39
39
|
}
|