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
|
@@ -1,15 +1,3 @@
|
|
|
1
|
-
// Codex Stop hook (installed in ~/.codex/hooks.json by `init --codex`). Fires
|
|
2
|
-
// when codex finishes a turn: the transcript is committed and the process is
|
|
3
|
-
// idle, the one boundary where killing it loses nothing. If the decision swaps
|
|
4
|
-
// accounts and this session runs under the codex supervisor, drop a respawn
|
|
5
|
-
// marker keyed by the supervisor's id; the supervisor SIGTERMs codex and
|
|
6
|
-
// relaunches `codex resume <session-id>` on the new account (a running codex
|
|
7
|
-
// never adopts a different account's credential: restart IS the switch).
|
|
8
|
-
//
|
|
9
|
-
// Contract with codex (verified against the 0.144.4 binary + hooks reference):
|
|
10
|
-
// stdin carries session_id, stdout `{}` on exit 0 is the documented no-op, and
|
|
11
|
-
// a hook failure must never block the stop - errors are logged, not thrown.
|
|
12
|
-
|
|
13
1
|
import { join } from "node:path";
|
|
14
2
|
import { existsSync, mkdirSync, readFileSync, rmSync } from "node:fs";
|
|
15
3
|
import { z } from "zod";
|
|
@@ -25,28 +13,10 @@ import { loadConfig } from "../lib/state.ts";
|
|
|
25
13
|
import { effectiveBars } from "../lib/picker.ts";
|
|
26
14
|
import { CODEX_SUPERVISOR_ID_ENV } from "./codexsupervisor.ts";
|
|
27
15
|
import { CodexReconcileMarkerSchema, CodexRespawnMarkerSchema, CodexStopStdinSchema, type CodexAccount } from "../lib/types.ts";
|
|
28
|
-
import { writeTurnDoneMarker } from "../lib/relay/markers.ts";
|
|
29
|
-
import { registryHas } from "../lib/relay/registry.ts";
|
|
30
|
-
import { RELAY_SESSION_ENV } from "../lib/relay/worker.ts";
|
|
31
16
|
import { log } from "../lib/log.ts";
|
|
32
17
|
|
|
33
18
|
const SupervisorIdSchema = z.string().min(1).optional().catch(undefined);
|
|
34
19
|
|
|
35
|
-
/** Consume a cross-session reconcile signal addressed to THIS supervisor
|
|
36
|
-
* (owner-approved option b, 2026-07-20): a deciding actor saw this session
|
|
37
|
-
* running on an exhausted/dead account and asked it to respawn onto the live
|
|
38
|
-
* seat. The Stop boundary is the only safe respawn point, and only THIS
|
|
39
|
-
* hook's stdin carries the session id a resume needs, so promotion happens
|
|
40
|
-
* here: the signal becomes a normal respawn marker the supervisor already
|
|
41
|
-
* consumes. Returns true when the respawn marker was written (the session is
|
|
42
|
-
* about to die - skip the normal decision). Everything past the cheap
|
|
43
|
-
* no-marker fast path runs under the codex FLOCK (pullfrog review catch,
|
|
44
|
-
* PR #34): the usability revalidation is only as good as its atomicity with
|
|
45
|
-
* the marker write - unlocked, a concurrent `xx switch --codex` could move
|
|
46
|
-
* the live seat onto a blocked account between the check and the write. The
|
|
47
|
-
* flock serializes promotion against every tokenmaxxing actor (codex's own
|
|
48
|
-
* actions are unserialized as ever); no nesting occurs because the hook
|
|
49
|
-
* calls promote strictly before or after the evaluation's own lock. */
|
|
50
20
|
async function promoteReconcile(input: { supervisorId: string; sessionId: string | null }): Promise<boolean> {
|
|
51
21
|
const markerPath = join(codexPaths.reconcileDir, input.supervisorId);
|
|
52
22
|
if (!existsSync(markerPath)) return false;
|
|
@@ -55,7 +25,7 @@ async function promoteReconcile(input: { supervisorId: string; sessionId: string
|
|
|
55
25
|
|
|
56
26
|
function promoteReconcileLocked(input: { supervisorId: string; sessionId: string | null }): boolean {
|
|
57
27
|
const markerPath = join(codexPaths.reconcileDir, input.supervisorId);
|
|
58
|
-
if (!existsSync(markerPath)) return false;
|
|
28
|
+
if (!existsSync(markerPath)) return false;
|
|
59
29
|
const parsed = CodexReconcileMarkerSchema.safeParse((() => {
|
|
60
30
|
try {
|
|
61
31
|
return JSON.parse(readFileSync(markerPath, "utf8"));
|
|
@@ -64,15 +34,10 @@ function promoteReconcileLocked(input: { supervisorId: string; sessionId: string
|
|
|
64
34
|
}
|
|
65
35
|
})());
|
|
66
36
|
if (!parsed.success) {
|
|
67
|
-
// unlike a presence file, a broken signal guards nothing: drop it loudly.
|
|
68
37
|
rmSync(markerPath, { force: true });
|
|
69
38
|
log("codexstop.reconcile_unparsable", {});
|
|
70
39
|
return false;
|
|
71
40
|
}
|
|
72
|
-
// Staleness guard: the signal names the account this session was seen on;
|
|
73
|
-
// if the session has since respawned onto another account the signal is
|
|
74
|
-
// moot. Same when the live seat changed to (or still is) our own account -
|
|
75
|
-
// a respawn would land right back where we are.
|
|
76
41
|
const presence = livingCodexPresences().find((p) => p.supervisorId === input.supervisorId) ?? null;
|
|
77
42
|
if (presence == null || presence.accountId !== parsed.data.accountId) {
|
|
78
43
|
rmSync(markerPath, { force: true });
|
|
@@ -85,14 +50,6 @@ function promoteReconcileLocked(input: { supervisorId: string; sessionId: string
|
|
|
85
50
|
log("codexstop.reconcile_moot", {});
|
|
86
51
|
return false;
|
|
87
52
|
}
|
|
88
|
-
// Revalidate the DESTINATION at consumption time (bugbot/pullfrog/vercel/
|
|
89
|
-
// cubic review catches, PR #34): a signal can sit across resets and usage
|
|
90
|
-
// changes, and identity checks alone would move a session onto a live seat
|
|
91
|
-
// that has since become exhausted, needs-reauth, or left the pool entirely.
|
|
92
|
-
// The SOURCE seat's state is deliberately not re-checked (owner ruling
|
|
93
|
-
// 2026-07-20: every pooled non-live sibling follows the seat, healthy or
|
|
94
|
-
// not - a non-live session wedges at token expiry regardless of quota).
|
|
95
|
-
// Dropped signals are cheap: the sweep re-signals next evaluation.
|
|
96
53
|
const now = Date.now();
|
|
97
54
|
const bars = effectiveBars(loadConfig());
|
|
98
55
|
const index = loadCodexAccounts();
|
|
@@ -104,11 +61,7 @@ function promoteReconcileLocked(input: { supervisorId: string; sessionId: string
|
|
|
104
61
|
log("codexstop.reconcile_blocked_target", {});
|
|
105
62
|
return false;
|
|
106
63
|
}
|
|
107
|
-
// blank counts as missing: the supervisor treats a falsy sessionId as
|
|
108
|
-
// "resume --last", the exact fallback this guard exists to avoid.
|
|
109
64
|
if (input.sessionId == null || input.sessionId.trim() === "") {
|
|
110
|
-
// keep the signal for the next boundary, whose stdin will carry a real
|
|
111
|
-
// id - a resume without one could revive the wrong transcript.
|
|
112
65
|
log("codexstop.reconcile_no_session", {});
|
|
113
66
|
return false;
|
|
114
67
|
}
|
|
@@ -128,8 +81,6 @@ async function readStdin(): Promise<string> {
|
|
|
128
81
|
return Buffer.concat(chunks).toString("utf8");
|
|
129
82
|
}
|
|
130
83
|
|
|
131
|
-
/** The testable core: decide, and on a swap under a supervisor, drop the
|
|
132
|
-
* respawn marker. Never throws (a hook failure must not block the stop). */
|
|
133
84
|
export async function handleCodexStop(input: { rawStdin: string }): Promise<void> {
|
|
134
85
|
const parsed = CodexStopStdinSchema.safeParse((() => {
|
|
135
86
|
try {
|
|
@@ -141,28 +92,11 @@ export async function handleCodexStop(input: { rawStdin: string }): Promise<void
|
|
|
141
92
|
const sessionId = parsed.success ? (parsed.data.session_id ?? null) : null;
|
|
142
93
|
|
|
143
94
|
try {
|
|
144
|
-
// Additive relay turn-done marker (never writes into respawn/).
|
|
145
|
-
const relaySid = process.env[RELAY_SESSION_ENV];
|
|
146
|
-
if (relaySid != null && registryHas({ sessionId: relaySid })) {
|
|
147
|
-
writeTurnDoneMarker({ sessionId: relaySid, source: "codex-stop" });
|
|
148
|
-
log("codexstop.relay_turn_done", { session: relaySid.slice(0, 8) });
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
// No supervisor = no decision AT ALL, checked before evaluate can swap:
|
|
152
|
-
// hooks.json is global, so this hook also fires in sessions launched
|
|
153
|
-
// around the PATH shim (IDE extension, absolute path), and a swap with
|
|
154
|
-
// nobody to respawn strands that session - codex cannot hot-adopt, and
|
|
155
|
-
// its guarded reload refuses a cross-account auth.json, so the session
|
|
156
|
-
// dies on its stale token with "Please sign in again" (closing-review
|
|
157
|
-
// catch). Restart IS the switch; without a restarter, do not switch.
|
|
158
95
|
const supervisorId = SupervisorIdSchema.parse(process.env[CODEX_SUPERVISOR_ID_ENV]);
|
|
159
96
|
if (supervisorId === undefined) {
|
|
160
97
|
log("codexstop.unsupervised_skip", {});
|
|
161
98
|
return;
|
|
162
99
|
}
|
|
163
|
-
// A pending reconcile signal outranks the normal decision: this session
|
|
164
|
-
// is about to respawn onto the live seat, so evaluating it would waste a
|
|
165
|
-
// sample (and could even swap the seat out from under the respawn).
|
|
166
100
|
if (await promoteReconcile({ supervisorId, sessionId })) return;
|
|
167
101
|
const decision = await evaluateAndMaybeSwapCodex({});
|
|
168
102
|
if (decision.swapped && decision.account) {
|
|
@@ -176,11 +110,6 @@ export async function handleCodexStop(input: { rawStdin: string }): Promise<void
|
|
|
176
110
|
log("codexstop.marker", { supervisorId: supervisorId.slice(0, 8) });
|
|
177
111
|
return;
|
|
178
112
|
}
|
|
179
|
-
// The evaluation's sweep may have signaled THIS session (its own account
|
|
180
|
-
// is the wedged one while the live seat is healthy - the lone-stranded
|
|
181
|
-
// case the removed self-skip used to lose, bugbot/cubic review catch,
|
|
182
|
-
// PR #34): consume it at this very boundary instead of burning one more
|
|
183
|
-
// turn on the dead account.
|
|
184
113
|
await promoteReconcile({ supervisorId, sessionId });
|
|
185
114
|
} catch (e) {
|
|
186
115
|
log("codexstop.error", { err: e instanceof Error ? e.message : String(e) });
|
|
@@ -191,6 +120,6 @@ export async function runCodexStopHook(): Promise<number> {
|
|
|
191
120
|
if (!process.env.TOKENMAXXING_PROBE) {
|
|
192
121
|
await handleCodexStop({ rawStdin: await readStdin() });
|
|
193
122
|
}
|
|
194
|
-
process.stdout.write("{}");
|
|
123
|
+
process.stdout.write("{}");
|
|
195
124
|
return 0;
|
|
196
125
|
}
|
|
@@ -1,13 +1,3 @@
|
|
|
1
|
-
// The `codex` supervisor. Invoked in place of codex (via ~/.config/tokenmaxxing/
|
|
2
|
-
// bin/codex on PATH). Codex REQUIRES a restart to change accounts: a running
|
|
3
|
-
// process refuses an auth.json swap to a different account (verified
|
|
4
|
-
// rust-v0.144.5 reload_if_account_id_matches), so unlike the claude supervisor
|
|
5
|
-
// (whose child hot-adopts swaps) this respawn IS the switch mechanism, not just
|
|
6
|
-
// UX. The codex Stop hook performs the swap at an idle turn boundary and drops
|
|
7
|
-
// a marker keyed by THIS supervisor's id (passed down via env, so N concurrent
|
|
8
|
-
// sessions pair correctly); the supervisor then SIGTERMs its child and
|
|
9
|
-
// relaunches `codex resume <session-id>` on the freshly-installed account.
|
|
10
|
-
|
|
11
1
|
import { existsSync, mkdirSync, readFileSync, rmSync } from "node:fs";
|
|
12
2
|
import { join } from "node:path";
|
|
13
3
|
import { z } from "zod";
|
|
@@ -23,7 +13,6 @@ import { log } from "../lib/log.ts";
|
|
|
23
13
|
|
|
24
14
|
export const CODEX_SUPERVISOR_ID_ENV = "TOKENMAXXING_CODEX_SUPERVISOR_ID";
|
|
25
15
|
|
|
26
|
-
/** Subcommands that never host an interactive session worth managing. */
|
|
27
16
|
const NONINTERACTIVE_SUBCMDS = new Set([
|
|
28
17
|
"exec", "review", "login", "logout", "mcp", "plugin", "mcp-server", "app-server",
|
|
29
18
|
"remote-control", "app", "completion", "update", "doctor", "sandbox", "debug",
|
|
@@ -32,11 +21,6 @@ const NONINTERACTIVE_SUBCMDS = new Set([
|
|
|
32
21
|
|
|
33
22
|
const PASSTHROUGH_FLAGS = new Set(["--version", "-V", "--help", "-h"]);
|
|
34
23
|
|
|
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
24
|
function consumableCodexMarker(marker: string): z.infer<typeof CodexRespawnMarkerSchema> | null {
|
|
41
25
|
try {
|
|
42
26
|
return CodexRespawnMarkerSchema.parse(JSON.parse(readFileSync(marker, "utf8")));
|
|
@@ -47,9 +31,6 @@ function consumableCodexMarker(marker: string): z.infer<typeof CodexRespawnMarke
|
|
|
47
31
|
}
|
|
48
32
|
}
|
|
49
33
|
|
|
50
|
-
/** Root options that consume the NEXT token as their value (verified against
|
|
51
|
-
* `codex --help` 0.144.4): without skipping them, `codex -m gpt exec ...`
|
|
52
|
-
* would read "gpt" as the subcommand and wrongly supervise an exec run. */
|
|
53
34
|
const VALUE_TAKING_ROOT_FLAGS = new Set([
|
|
54
35
|
"-c", "--config", "-i", "--image", "-m", "--model", "--local-provider", "-p", "--profile",
|
|
55
36
|
"-s", "--sandbox", "-a", "--ask-for-approval", "-C", "--cd", "--add-dir", "--enable",
|
|
@@ -70,7 +51,6 @@ export function shouldManageCodex(input: { argv: string[] }): boolean {
|
|
|
70
51
|
return firstPositional === null || !NONINTERACTIVE_SUBCMDS.has(firstPositional);
|
|
71
52
|
}
|
|
72
53
|
|
|
73
|
-
/** Entry point: `codex ...args` through the on-PATH shim. */
|
|
74
54
|
export async function runCodexSupervisor(input: { argv: string[] }): Promise<number> {
|
|
75
55
|
const { argv } = input;
|
|
76
56
|
const depth = wrapDepth();
|
|
@@ -92,19 +72,7 @@ export async function runCodexSupervisor(input: { argv: string[] }): Promise<num
|
|
|
92
72
|
const real = resolveRealCodex();
|
|
93
73
|
const childEnv = { ...process.env, [WRAP_DEPTH_ENV]: String(depth + 1) };
|
|
94
74
|
|
|
95
|
-
// The unmanaged-zone sentinel forces passthrough regardless of argv, exactly
|
|
96
|
-
// like the claude shim: a serve turn's agent running `codex exec` must reach
|
|
97
|
-
// the real codex instead of dying at the shared depth cap.
|
|
98
75
|
if (!shouldManageCodex({ argv }) || process.env[UNMANAGED_ENV]) {
|
|
99
|
-
// STRIP the supervisor pairing env from unmanaged spawns: a nested codex
|
|
100
|
-
// launched from inside a supervised session (e.g. its agent running
|
|
101
|
-
// `codex exec ...`) would otherwise inherit the OUTER session's id, and
|
|
102
|
-
// its global Stop hook could then write a respawn marker that SIGTERMs
|
|
103
|
-
// the outer session MID-TURN and resumes it onto the nested transcript
|
|
104
|
-
// (closing-review catch). A managed nested launch is already safe - it
|
|
105
|
-
// exports its own fresh id below; only a shim-bypassed absolute-path
|
|
106
|
-
// nested launch keeps the inherited env, the same accepted gap as
|
|
107
|
-
// claude's bg-daemon bypass.
|
|
108
76
|
const passthroughEnv: Record<string, string | undefined> = { ...childEnv };
|
|
109
77
|
delete passthroughEnv[CODEX_SUPERVISOR_ID_ENV];
|
|
110
78
|
const p = Bun.spawn([real, ...argv], { stdin: "inherit", stdout: "inherit", stderr: "inherit", env: passthroughEnv });
|
|
@@ -120,28 +88,12 @@ export async function runCodexSupervisor(input: { argv: string[] }): Promise<num
|
|
|
120
88
|
process.on("SIGINT", () => {});
|
|
121
89
|
process.on("SIGHUP", () => {});
|
|
122
90
|
|
|
123
|
-
// On respawn the ONLY reliable relaunch is `codex resume <session-id>`:
|
|
124
|
-
// codex generates its own session ids (there is no flag to pin one at
|
|
125
|
-
// launch), so original launch args are used verbatim only for the first
|
|
126
|
-
// spawn. Model/sandbox preferences persist in config.toml either way.
|
|
127
91
|
let launchArgs = argv;
|
|
128
92
|
let respawns = 0;
|
|
129
93
|
while (true) {
|
|
130
94
|
if (existsSync(marker)) rmSync(marker, { force: true });
|
|
131
95
|
log("codexsupervisor.launch", { supervisorId: supervisorId.slice(0, 8), respawns, args: launchArgs.join(" ") });
|
|
132
96
|
|
|
133
|
-
// Declare which account THIS session runs on (the live identity at spawn):
|
|
134
|
-
// the picker must never target it and the sampler must never rotate its
|
|
135
|
-
// parked token while the session lives. Rewritten every respawn (the swap
|
|
136
|
-
// changed the live identity); cleared on exit; PID-validated by readers.
|
|
137
|
-
// Read + presence-write + spawn run under the codex FLOCK (closing-review
|
|
138
|
-
// catch): unlocked, a swap could land between the read and the child's
|
|
139
|
-
// auth.json read, seating the child on the NEW account while presence
|
|
140
|
-
// named the old one for the session's whole life - un-benching the
|
|
141
|
-
// running account for samplers and the picker. Under the flock no swap
|
|
142
|
-
// can interleave until after the spawn; the residual window (child
|
|
143
|
-
// startup vs a swap acquiring the lock immediately after) is sub-ms in
|
|
144
|
-
// practice against a swap's network-bound critical section.
|
|
145
97
|
const child = await withLock(codexPaths.lockFile, async () => {
|
|
146
98
|
const spawnAccountId = liveCodexAccountId();
|
|
147
99
|
const spawned = Bun.spawn([real, ...launchArgs], {
|
|
@@ -150,32 +102,16 @@ export async function runCodexSupervisor(input: { argv: string[] }): Promise<num
|
|
|
150
102
|
stderr: "inherit",
|
|
151
103
|
env: { ...childEnv, [CODEX_SUPERVISOR_ID_ENV]: supervisorId },
|
|
152
104
|
});
|
|
153
|
-
// Presence pins the CHILD's pid, written after the spawn (still inside
|
|
154
|
-
// the flock): the session IS the codex process, and pinning this
|
|
155
|
-
// supervisor's pid let a SIGKILLed supervisor prune the presence while
|
|
156
|
-
// its orphaned codex kept rotating the account's token (closing-review
|
|
157
|
-
// catch). Brief retries cover ps visibility lag on a just-spawned pid.
|
|
158
|
-
// FAIL CLOSED on final failure (PR #36 review catch): a session running
|
|
159
|
-
// without presence is exactly the unprotected state presence exists to
|
|
160
|
-
// prevent - its account would look swappable and samplable - so kill
|
|
161
|
-
// the just-spawned child (nothing is in flight yet) and surface the
|
|
162
|
-
// error instead of running unprotected.
|
|
163
105
|
if (spawnAccountId) {
|
|
164
106
|
for (let attempt = 0; attempt < 10; attempt++) {
|
|
165
107
|
try {
|
|
166
108
|
writeCodexPresence({ supervisorId, accountId: spawnAccountId, pid: spawned.pid });
|
|
167
109
|
break;
|
|
168
110
|
} catch (e) {
|
|
169
|
-
// a child that already exited needs no presence (its absence is
|
|
170
|
-
// correct) and must keep its own exit result - the normal exit
|
|
171
|
-
// path below handles it (PR #36 second-round catch)
|
|
172
111
|
if (spawned.exitCode !== null || spawned.signalCode !== null) break;
|
|
173
112
|
if (attempt === 9) {
|
|
174
113
|
log("codexsupervisor.presence_failed", { err: e instanceof Error ? e.message : String(e) });
|
|
175
114
|
spawned.kill();
|
|
176
|
-
// the child may have entered raw mode during the retries: await
|
|
177
|
-
// its death and restore the terminal before surfacing (PR #36
|
|
178
|
-
// second-round catch)
|
|
179
115
|
await spawned.exited;
|
|
180
116
|
restoreTermios(savedTermios);
|
|
181
117
|
throw new Error("could not write the codex presence file - refusing to run an unprotected session (its account would look like a swap target)");
|
|
@@ -202,7 +138,7 @@ export async function runCodexSupervisor(input: { argv: string[] }): Promise<num
|
|
|
202
138
|
const winner = await Promise.race([exited, markerWatch.then((found) => (found ? "marker" : "exit"))]);
|
|
203
139
|
|
|
204
140
|
if (winner === "marker") {
|
|
205
|
-
child.kill();
|
|
141
|
+
child.kill();
|
|
206
142
|
}
|
|
207
143
|
await child.exited;
|
|
208
144
|
done = true;
|
|
@@ -218,8 +154,6 @@ export async function runCodexSupervisor(input: { argv: string[] }): Promise<num
|
|
|
218
154
|
continue;
|
|
219
155
|
}
|
|
220
156
|
clearCodexPresence({ supervisorId });
|
|
221
|
-
// a reconcile signal addressed to this now-gone session is moot; the
|
|
222
|
-
// deciding actor's sweep would gc it eventually, this is just prompt.
|
|
223
157
|
rmSync(join(codexPaths.reconcileDir, supervisorId), { force: true });
|
|
224
158
|
log("codexsupervisor.exit", { supervisorId: supervisorId.slice(0, 8), respawns, code: child.exitCode, signal: child.signalCode });
|
|
225
159
|
return child.exitCode ?? (child.signalCode ? 1 : 0);
|
package/src/entries/mcp.ts
CHANGED
|
@@ -1,7 +1,3 @@
|
|
|
1
|
-
// Stdio MCP entry for the portable Agent Plugin (agent-plugin/).
|
|
2
|
-
// Tools wrap existing CLI commands. stdout is reserved for MCP JSON-RPC, so
|
|
3
|
-
// every CLI call captures console.log / console.error and returns the text.
|
|
4
|
-
|
|
5
1
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
6
2
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
7
3
|
import { z } from "zod";
|
|
@@ -27,13 +23,11 @@ function packageVersion(): string {
|
|
|
27
23
|
}
|
|
28
24
|
}
|
|
29
25
|
|
|
30
|
-
/** Refuse ambient Claude store overrides the same way the CLI and SDK do. */
|
|
31
26
|
export function refuseAmbientStoreEnv(): string | null {
|
|
32
27
|
const nonEmpty = (v: string | undefined) => (v != null && v !== "" ? v : null);
|
|
33
28
|
return nonEmpty(process.env.CLAUDE_SECURESTORAGE_CONFIG_DIR) ?? nonEmpty(process.env.CLAUDE_CONFIG_DIR);
|
|
34
29
|
}
|
|
35
30
|
|
|
36
|
-
/** Redact token-shaped spans so tool results never echo credentials. */
|
|
37
31
|
export function scrubSecrets(text: string): string {
|
|
38
32
|
return text
|
|
39
33
|
.replace(/\b(Bearer\s+)[A-Za-z0-9._\-+/=]+/gi, "$1[redacted]")
|
|
@@ -43,11 +37,8 @@ export function scrubSecrets(text: string): string {
|
|
|
43
37
|
|
|
44
38
|
type CaptureResult = { code: number; stdout: string; stderr: string };
|
|
45
39
|
|
|
46
|
-
/** Serialize captureCli: console.log/error are process-global, so concurrent
|
|
47
|
-
* tool calls would interleave stdout into each other and corrupt JSON-RPC. */
|
|
48
40
|
let captureChain: Promise<unknown> = Promise.resolve();
|
|
49
41
|
|
|
50
|
-
/** Run a CLI cmd while keeping stdout clean for the MCP transport. */
|
|
51
42
|
export async function captureCli(run: () => number | Promise<number>): Promise<CaptureResult> {
|
|
52
43
|
const job = async (): Promise<CaptureResult> => {
|
|
53
44
|
const out: string[] = [];
|
|
@@ -264,8 +255,6 @@ export function createTokenmaxxingMcpServer(): McpServer {
|
|
|
264
255
|
return server;
|
|
265
256
|
}
|
|
266
257
|
|
|
267
|
-
/** Entrypoint for the Agent Plugin launcher (`agent-plugin/bin/tokenmaxxing-mcp`).
|
|
268
|
-
* Exported because that bin imports this module (so `import.meta.main` is false here). */
|
|
269
258
|
export async function main(): Promise<void> {
|
|
270
259
|
const ambient = refuseAmbientStoreEnv();
|
|
271
260
|
if (ambient != null) {
|
|
@@ -1,15 +1,9 @@
|
|
|
1
|
-
// SessionStart hook. A launch/resume backstop: if the active account is already
|
|
2
|
-
// over threshold with FRESH usage (e.g. a prior session left it exhausted), swap
|
|
3
|
-
// the credential before this session's first turn so it starts on a good account.
|
|
4
|
-
// Right after a respawn, the post-swap cooldown in evaluateAndMaybeSwap makes
|
|
5
|
-
// this correctly no-op.
|
|
6
|
-
|
|
7
1
|
import { z } from "zod";
|
|
8
2
|
import { evaluateAndMaybeSwap } from "../lib/decide.ts";
|
|
9
3
|
import { log } from "../lib/log.ts";
|
|
10
4
|
|
|
11
5
|
const SessionStartStdin = z.looseObject({
|
|
12
|
-
source: z.string().optional(),
|
|
6
|
+
source: z.string().optional(),
|
|
13
7
|
session_id: z.string().optional(),
|
|
14
8
|
});
|
|
15
9
|
|
|
@@ -29,7 +23,6 @@ export async function runSessionStart(): Promise<number> {
|
|
|
29
23
|
try {
|
|
30
24
|
const decision = await evaluateAndMaybeSwap();
|
|
31
25
|
if (decision.swapped && decision.account) {
|
|
32
|
-
// fresh session → it will read the new credential on its first API call.
|
|
33
26
|
log("sessionstart.swapped", { source, account: decision.account.accountUuid.slice(0, 8) });
|
|
34
27
|
}
|
|
35
28
|
} catch (e) {
|
|
@@ -1,22 +1,3 @@
|
|
|
1
|
-
// Native statusLine. Reads Claude's statusLine stdin, tees the rate-limit data
|
|
2
|
-
// to usage.json (write-on-change, O(ms)), then renders ONE line:
|
|
3
|
-
// worktree name (linked worktrees only), model name painted by context fill
|
|
4
|
-
// (effort in parens), +added/-removed, then quota as COLOR (user decisions
|
|
5
|
-
// 2026-07-18): each window is its time-to-reset painted on a continuous
|
|
6
|
-
// green->yellow->red ramp by used% - "◆ 𝒇 2h 1d ◇ 2d ◇ full" - the
|
|
7
|
-
// active account's windows after a green ◆ (per-model by bare initial
|
|
8
|
-
// first, fable as 𝒇, other families uppercased; "𝒇?" unpainted when the
|
|
9
|
-
// cap applies but is unmeasured, then session/5h, then week), then EVERY
|
|
10
|
-
// parked account's week after its own cyan ◇ (red ✗ if needs-reauth),
|
|
11
|
-
// sorted by earliest upcoming reset (needs-reauth last). A window with no
|
|
12
|
-
// upcoming reset renders "0" (empty again); measured usage with an unknown
|
|
13
|
-
// reset clock renders a painted "?". With color disabled the numeric format
|
|
14
|
-
// returns ("2h5" = resets in 2h, 5 used; "ctx 42"): without color a drained
|
|
15
|
-
// window must not look fresh. Blocks are joined by TWO spaces, tokens
|
|
16
|
-
// within a block by one. Per-model resets are omitted (they match the
|
|
17
|
-
// weekly reset).
|
|
18
|
-
// Must NEVER break the status line: render what parses, skip what doesn't.
|
|
19
|
-
|
|
20
1
|
import { sortBy } from "es-toolkit";
|
|
21
2
|
import { z } from "zod";
|
|
22
3
|
import { readOAuthAccount } from "../lib/claudejson.ts";
|
|
@@ -36,27 +17,16 @@ import {
|
|
|
36
17
|
type UsageWindow,
|
|
37
18
|
} from "../lib/types.ts";
|
|
38
19
|
|
|
39
|
-
/** A session that hasn't adopted a fresh swap yet (<=30s keychain cache) pushes
|
|
40
|
-
* the OLD account's windows while ~/.claude.json already names the NEW org.
|
|
41
|
-
* Suppress the tee for this long after a swap so that mislabel never lands. */
|
|
42
20
|
const ADOPTION_GRACE_MS = 45_000;
|
|
43
21
|
|
|
44
22
|
const RenderCtxSchema = z.object({
|
|
45
23
|
accounts: AccountsIndexSchema,
|
|
46
|
-
/** active account's per-model weekly windows ({} unless fresh for the live org). */
|
|
47
24
|
perModel: z.record(z.string(), UsageWindowSchema),
|
|
48
|
-
/** families (lowercased) whose per-model weekly cap gates a switch. */
|
|
49
25
|
switchModels: z.array(z.string()),
|
|
50
|
-
/** linked-worktree basename, null in a main checkout. */
|
|
51
26
|
worktree: z.string().nullable(),
|
|
52
|
-
/** the LIVE login's org from claude.json, the seat's identity - the
|
|
53
|
-
* activeAccountUuid label drifts after a manual /login (the same rule as
|
|
54
|
-
* decide.ts's seatOf; closing-review catch: the label-keyed split rendered
|
|
55
|
-
* the live account twice and hid the stale-labeled one). */
|
|
56
27
|
liveOrg: z.string().nullable(),
|
|
57
28
|
now: z.number(),
|
|
58
29
|
color: z.boolean(),
|
|
59
|
-
/** terminal advertises 24-bit color (COLORTERM); false steps the ramp to the 256-color cube. */
|
|
60
30
|
truecolor: z.boolean(),
|
|
61
31
|
});
|
|
62
32
|
export type RenderCtx = z.infer<typeof RenderCtxSchema>;
|
|
@@ -67,22 +37,17 @@ export async function readStdin(): Promise<string> {
|
|
|
67
37
|
return Buffer.concat(chunks).toString("utf8");
|
|
68
38
|
}
|
|
69
39
|
|
|
70
|
-
/** Pure renderer: statusLine stdin + tokenmaxxing state → the emitted line. */
|
|
71
40
|
export function renderStatusline(stdinObj: unknown, ctx: RenderCtx): string {
|
|
72
41
|
const col = makeColors(ctx.color);
|
|
73
42
|
const paint = makeUsagePaint({ enabled: ctx.color, truecolor: ctx.truecolor });
|
|
74
|
-
// Used quota in a window; one whose reset has passed is empty again.
|
|
75
43
|
const used = (w: UsageWindow) => (w.resetsAt != null && w.resetsAt <= ctx.now ? 0 : w.usedPercentage);
|
|
76
44
|
const reset = (epochMs: number | null) => fmtResetShort(epochMs, ctx.now);
|
|
77
45
|
|
|
78
46
|
const parsed = StatusLineStdinSchema.safeParse(stdinObj);
|
|
79
47
|
const d = parsed.success ? parsed.data : null;
|
|
80
48
|
|
|
81
|
-
// ---- info block: worktree, model, ctx, diff
|
|
82
49
|
const info: string[] = [];
|
|
83
50
|
if (ctx.worktree != null) info.push(ctx.worktree);
|
|
84
|
-
// The model name carries the context-fill color (user rule 2026-07-18, no
|
|
85
|
-
// ctx token); colorless mode keeps the numeric ctx token instead.
|
|
86
51
|
const modelName = d?.model?.display_name ?? d?.model?.id;
|
|
87
52
|
const ctxUsed = d?.context_window?.used_percentage;
|
|
88
53
|
if (modelName) {
|
|
@@ -93,26 +58,17 @@ export function renderStatusline(stdinObj: unknown, ctx: RenderCtx): string {
|
|
|
93
58
|
if (!ctx.color && ctxUsed != null) info.push(`ctx ${Math.round(ctxUsed)}`);
|
|
94
59
|
const added = d?.cost?.total_lines_added ?? 0;
|
|
95
60
|
const removed = d?.cost?.total_lines_removed ?? 0;
|
|
96
|
-
// -removed stays unpainted: red means quota alarm and nothing else.
|
|
97
61
|
if (added > 0 || removed > 0) info.push(`${col.green(`+${added}`)}/-${removed}`);
|
|
98
62
|
|
|
99
|
-
// ---- active account block
|
|
100
|
-
// A window token: color carries the used%, the text is the reset countdown
|
|
101
|
-
// (or the bare per-model initial - per-model resets are omitted). "0" = no
|
|
102
|
-
// upcoming reset (the window is empty again); a painted "?" = measured usage
|
|
103
|
-
// whose reset clock is unknown. Colorless mode glues the number back on.
|
|
104
63
|
const seg = (label: string, w: UsageWindow, resetAt: number | null) => {
|
|
105
64
|
const u = used(w);
|
|
106
65
|
if (!ctx.color) return `${label}${reset(resetAt)}${Math.round(u)}`;
|
|
107
66
|
const body = label !== "" ? label : reset(resetAt);
|
|
108
67
|
return col.bold(paint(u)(body !== "" ? body : u > 0 ? "?" : "0"));
|
|
109
68
|
};
|
|
110
|
-
// Per-model initial: fable renders 𝒇 (user rule 2026-07-18), family-matched
|
|
111
|
-
// as always; other families keep their uppercased first letter.
|
|
112
69
|
const initial = (name: string) => (familyTokens(name).includes("fable") ? "𝒇" : name.slice(0, 1).toUpperCase());
|
|
113
70
|
const wins = parseStatusLineStdin(stdinObj);
|
|
114
71
|
const windows: string[] = [];
|
|
115
|
-
// A capacity-constrained model whose cap is unmeasured must not look safe.
|
|
116
72
|
const family = matchedFamily(parseStatusLineModel(stdinObj), ctx.switchModels);
|
|
117
73
|
if (family && !Object.keys(ctx.perModel).some((k) => familyTokens(k).includes(family))) {
|
|
118
74
|
windows.push(`${initial(family)}?`);
|
|
@@ -122,7 +78,6 @@ export function renderStatusline(stdinObj: unknown, ctx: RenderCtx): string {
|
|
|
122
78
|
windows.push(seg("", wins.fiveHour, wins.fiveHour.resetsAt));
|
|
123
79
|
windows.push(seg("", wins.sevenDay, wins.sevenDay.resetsAt));
|
|
124
80
|
}
|
|
125
|
-
// The seat: live-org first, stored label fallback (unknown live identity).
|
|
126
81
|
const seatUuid =
|
|
127
82
|
(ctx.liveOrg != null ? ctx.accounts.accounts.find((a) => a.organizationUuid === ctx.liveOrg)?.accountUuid : undefined) ??
|
|
128
83
|
ctx.accounts.activeAccountUuid;
|
|
@@ -133,13 +88,10 @@ export function renderStatusline(stdinObj: unknown, ctx: RenderCtx): string {
|
|
|
133
88
|
? `${col.green("◆")} ?`
|
|
134
89
|
: "";
|
|
135
90
|
|
|
136
|
-
// ---- parked accounts, earliest upcoming reset first (needs-reauth last)
|
|
137
91
|
const parked = sortBy(
|
|
138
92
|
ctx.accounts.accounts.filter((a) => a.accountUuid !== seatUuid),
|
|
139
93
|
[(a) => (a.needsReauth ? 1 : 0), (a) => earliestReset(a, ctx.now)],
|
|
140
94
|
);
|
|
141
|
-
// Every parked account renders its own marker (user rule 2026-07-18: the
|
|
142
|
-
// old counted collapse "◇ 3 full" hid the pool size and read as confusing).
|
|
143
95
|
const poolSeg = (a: Account): string => {
|
|
144
96
|
const marker = a.needsReauth ? col.red("✗") : col.cyan("◇");
|
|
145
97
|
const week = a.lastUsage?.sevenDay;
|
|
@@ -148,7 +100,6 @@ export function renderStatusline(stdinObj: unknown, ctx: RenderCtx): string {
|
|
|
148
100
|
if (Math.round(weekUsed) <= 0) return `${marker} ${paint(0)("full")}`;
|
|
149
101
|
|
|
150
102
|
const parts: string[] = [];
|
|
151
|
-
// A per-model weekly cap with more used than the aggregate is the binding constraint - surface it.
|
|
152
103
|
for (const [name, w] of Object.entries(a.lastPerModel ?? {})) {
|
|
153
104
|
if (used(w) > weekUsed) parts.push(seg(initial(name), w, null));
|
|
154
105
|
}
|
|
@@ -166,25 +117,11 @@ export async function runStatusline(): Promise<number> {
|
|
|
166
117
|
try {
|
|
167
118
|
obj = JSON.parse(raw);
|
|
168
119
|
} catch {
|
|
169
|
-
// malformed stdin - render from state alone
|
|
170
120
|
}
|
|
171
121
|
const now = Date.now();
|
|
172
122
|
|
|
173
|
-
// The stdin payload's own org label: those rate_limits and that
|
|
174
|
-
// organizationUuid ride the SAME API response, so the label can never lie
|
|
175
|
-
// about whose windows these are. claude.json's org is only the fallback -
|
|
176
|
-
// it flips at the swap while a session that has made no post-swap request
|
|
177
|
-
// keeps rendering the OLD account's windows indefinitely, and the 45s
|
|
178
|
-
// ADOPTION_GRACE_MS bounds nothing for such a session (closing-review
|
|
179
|
-
// catch: a stale-window tee labeled with the new org could hard-swap a
|
|
180
|
-
// healthy account and stamp foreign usage into it). The render below uses
|
|
181
|
-
// the same preference so the active ◆ seat matches the windows painted
|
|
182
|
-
// beside it (PR #36 review catch). Trusted ONLY when the payload's windows
|
|
183
|
-
// parsed too: an org label without windows would re-label state the payload
|
|
184
|
-
// did not carry (second-round catch).
|
|
185
123
|
let stdinOrg: string | null = null;
|
|
186
124
|
|
|
187
|
-
// tee usage for the Stop hook / status - best effort, never blocks rendering.
|
|
188
125
|
let org: string | null = null;
|
|
189
126
|
try {
|
|
190
127
|
org = readOAuthAccount()?.organizationUuid ?? null;
|
|
@@ -197,7 +134,6 @@ export async function runStatusline(): Promise<number> {
|
|
|
197
134
|
writeUsage(state);
|
|
198
135
|
}
|
|
199
136
|
} catch {
|
|
200
|
-
// skip the tee, still render below
|
|
201
137
|
}
|
|
202
138
|
|
|
203
139
|
let line: string;
|
|
@@ -219,8 +155,6 @@ export async function runStatusline(): Promise<number> {
|
|
|
219
155
|
};
|
|
220
156
|
line = renderStatusline(obj, ctx);
|
|
221
157
|
} catch (e) {
|
|
222
|
-
// Corrupt local state (the loaders throw on it) must stay VISIBLE: render
|
|
223
|
-
// the failure as the statusline itself, never abort into a blank line.
|
|
224
158
|
line = `tokenmaxxing: ${e instanceof Error ? e.message : String(e)}`;
|
|
225
159
|
}
|
|
226
160
|
process.stdout.write(line + "\n");
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { paths } from "../lib/paths.ts";
|
|
4
|
+
import { writeFileAtomic } from "../lib/atomic.ts";
|
|
5
|
+
import { readOAuthAccount } from "../lib/claudejson.ts";
|
|
6
|
+
import { enforcedWindowMs, evaluateAndMaybeSwap, postSwapProof, recordEnforcedLimit } from "../lib/decide.ts";
|
|
7
|
+
import { loadConfig, loadLastSwapAt } from "../lib/state.ts";
|
|
8
|
+
import { classifyEnforcedLimit, findEnforcedRow, parseErrorBody, readTranscriptTail } from "../lib/usage.ts";
|
|
9
|
+
import { RespawnMarkerSchema, type EnforcedLimit } from "../lib/types.ts";
|
|
10
|
+
import { log } from "../lib/log.ts";
|
|
11
|
+
|
|
12
|
+
export const RETRIGGER_PROMPT = "Continue where the previous turn left off; it was interrupted by a usage limit and tokenmaxxing switched accounts.";
|
|
13
|
+
|
|
14
|
+
const StopFailureStdin = z.looseObject({
|
|
15
|
+
session_id: z.uuid().optional().catch(undefined),
|
|
16
|
+
transcript_path: z.string().optional().catch(undefined),
|
|
17
|
+
error: z.string().optional().catch(undefined),
|
|
18
|
+
agent_id: z.string().optional().catch(undefined),
|
|
19
|
+
last_assistant_message: z.string().optional().catch(undefined),
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
const LaunchedAtSchema = z.coerce.number().finite().optional().catch(undefined);
|
|
23
|
+
|
|
24
|
+
async function readStdin(): Promise<string> {
|
|
25
|
+
const chunks: Uint8Array[] = [];
|
|
26
|
+
for await (const c of Bun.stdin.stream()) chunks.push(c);
|
|
27
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export async function runStopFailureHook(): Promise<number> {
|
|
31
|
+
if (process.env.TOKENMAXXING_PROBE) return 0;
|
|
32
|
+
|
|
33
|
+
const org = readOAuthAccount()?.organizationUuid ?? null;
|
|
34
|
+
const now = Date.now();
|
|
35
|
+
const raw = await readStdin();
|
|
36
|
+
const parsed = StopFailureStdin.safeParse((() => { try { return JSON.parse(raw); } catch { return {}; } })());
|
|
37
|
+
const stdin = parsed.success ? parsed.data : {};
|
|
38
|
+
if (stdin.error !== undefined && stdin.error !== "rate_limit") return 0;
|
|
39
|
+
|
|
40
|
+
const stdinSid = stdin.session_id;
|
|
41
|
+
const pinnedSid = process.env.TOKENMAXXING_SESSION_ID;
|
|
42
|
+
const launchedAt = LaunchedAtSchema.parse(process.env.TOKENMAXXING_LAUNCHED_AT) ?? null;
|
|
43
|
+
const mainLoop = stdin.agent_id === undefined;
|
|
44
|
+
const canPause = process.env.TOKENMAXXING_SUPERVISED === "1" && pinnedSid != null && mainLoop;
|
|
45
|
+
|
|
46
|
+
try {
|
|
47
|
+
const cfg = loadConfig();
|
|
48
|
+
const found = stdin.transcript_path
|
|
49
|
+
? findEnforcedRow({ rows: readTranscriptTail(stdin.transcript_path), lastAssistantMessage: stdin.last_assistant_message, now })
|
|
50
|
+
: null;
|
|
51
|
+
const limit = found ? classifyEnforcedLimit(found.row, cfg.policy.switchModels) : null;
|
|
52
|
+
|
|
53
|
+
let enforced: EnforcedLimit | null = null;
|
|
54
|
+
if (limit && found && org) {
|
|
55
|
+
if (postSwapProof({ swapAt: loadLastSwapAt(), launchedAt, errorAt: found.errorAt, now })) {
|
|
56
|
+
const stamp = await recordEnforcedLimit({ limit, org, now });
|
|
57
|
+
log("stopfailure.enforced", { kind: limit.kind, family: limit.kind === "model" ? limit.family : undefined, outcome: stamp.outcome, resetsAt: stamp.resetsAt, subagent: !mainLoop });
|
|
58
|
+
if (stamp.outcome !== "org-moved") {
|
|
59
|
+
enforced = { org, family: limit.kind === "model" ? limit.family : null, resetsAt: stamp.resetsAt, windowMs: enforcedWindowMs(limit) };
|
|
60
|
+
}
|
|
61
|
+
} else {
|
|
62
|
+
log("stopfailure.unproven", { kind: limit.kind });
|
|
63
|
+
}
|
|
64
|
+
} else {
|
|
65
|
+
log("stopfailure.unclassified", {
|
|
66
|
+
row: found != null,
|
|
67
|
+
type: found?.row.quotaLimits?.rateLimitType,
|
|
68
|
+
transient: found?.row.apiErrorIsTransient,
|
|
69
|
+
body: found ? parseErrorBody(found.row.errorDetails)?.error?.type : undefined,
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const decision = await evaluateAndMaybeSwap(now, canPause && enforced != null, enforced);
|
|
74
|
+
if (enforced && canPause && pinnedSid && decision.account && (decision.swapped || decision.waitUntil !== undefined)) {
|
|
75
|
+
const marker = join(paths.respawnDir, pinnedSid);
|
|
76
|
+
const payload = RespawnMarkerSchema.parse({
|
|
77
|
+
account: decision.account.label,
|
|
78
|
+
ts: Date.now(),
|
|
79
|
+
waitUntil: decision.waitUntil ?? now,
|
|
80
|
+
sessionId: stdinSid ?? pinnedSid,
|
|
81
|
+
prompt: RETRIGGER_PROMPT,
|
|
82
|
+
...(launchedAt != null ? { launchedAt } : {}),
|
|
83
|
+
});
|
|
84
|
+
writeFileAtomic(marker, JSON.stringify(payload));
|
|
85
|
+
log("stopfailure.marker", { session: (stdinSid ?? pinnedSid).slice(0, 8), account: decision.account.accountUuid.slice(0, 8), waitUntil: payload.waitUntil });
|
|
86
|
+
} else {
|
|
87
|
+
log("stopfailure.decision", { reason: decision.reason, swapped: decision.swapped, account: decision.account?.accountUuid.slice(0, 8), waitUntil: decision.waitUntil });
|
|
88
|
+
}
|
|
89
|
+
} catch (e) {
|
|
90
|
+
log("stopfailure.error", { err: e instanceof Error ? e.message : String(e) });
|
|
91
|
+
}
|
|
92
|
+
return 0;
|
|
93
|
+
}
|