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/entries/stophook.ts
CHANGED
|
@@ -1,26 +1,11 @@
|
|
|
1
|
-
// Stop hook. Fires when claude finishes a turn (transcript already committed).
|
|
2
|
-
// A plain swap needs no respawn: the running session adopts the swapped
|
|
3
|
-
// credential on its own (<=30s on macOS, next request on Linux). Only a
|
|
4
|
-
// depleted-pool wait - when running under the supervisor - drops a respawn
|
|
5
|
-
// marker keyed by this session id: the supervisor SIGTERMs its child at this
|
|
6
|
-
// clean boundary, counts down to the reset, then relaunches `--resume`. We
|
|
7
|
-
// never kill claude ourselves.
|
|
8
|
-
|
|
9
1
|
import { join } from "node:path";
|
|
10
2
|
import { z } from "zod";
|
|
11
3
|
import { paths } from "../lib/paths.ts";
|
|
12
4
|
import { writeFileAtomic } from "../lib/atomic.ts";
|
|
13
5
|
import { evaluateAndMaybeSwap } from "../lib/decide.ts";
|
|
14
|
-
import { writeTurnDoneMarker } from "../lib/relay/markers.ts";
|
|
15
|
-
import { registryHas } from "../lib/relay/registry.ts";
|
|
16
|
-
import { RELAY_SESSION_ENV } from "../lib/relay/worker.ts";
|
|
17
6
|
import { RespawnMarkerSchema } from "../lib/types.ts";
|
|
18
7
|
import { log } from "../lib/log.ts";
|
|
19
8
|
|
|
20
|
-
// session_id must be a real transcript UUID: a malformed value would ride the
|
|
21
|
-
// respawn marker into `--resume <garbage>`, which claude treats as a picker
|
|
22
|
-
// search term (PR #36 review catch); non-UUID input drops to undefined and the
|
|
23
|
-
// marker falls back to the pinned sid.
|
|
24
9
|
const StopStdin = z.looseObject({ session_id: z.uuid().optional().catch(undefined) });
|
|
25
10
|
|
|
26
11
|
async function readStdin(): Promise<string> {
|
|
@@ -30,43 +15,27 @@ async function readStdin(): Promise<string> {
|
|
|
30
15
|
}
|
|
31
16
|
|
|
32
17
|
export async function runStopHook(): Promise<number> {
|
|
33
|
-
// recursion guard - our own `-p '/usage'` probe re-enters hooks.
|
|
34
18
|
if (process.env.TOKENMAXXING_PROBE) return 0;
|
|
35
19
|
|
|
36
20
|
const raw = await readStdin();
|
|
37
21
|
const parsed = StopStdin.safeParse((() => { try { return JSON.parse(raw); } catch { return {}; } })());
|
|
38
|
-
// TWO session ids with different jobs (closing-review HIGH catch): the
|
|
39
|
-
// PINNED id (env, set once by the supervisor) names the marker file the
|
|
40
|
-
// supervisor actually watches and survives /clear; the STDIN id names the
|
|
41
|
-
// CURRENT transcript to resume and drifts to a new value after /clear.
|
|
42
|
-
// Keying the file by the stdin id orphaned every post-/clear marker.
|
|
43
22
|
const stdinSid = parsed.success ? parsed.data.session_id : undefined;
|
|
44
23
|
const pinnedSid = process.env.TOKENMAXXING_SESSION_ID;
|
|
45
24
|
|
|
46
25
|
try {
|
|
47
|
-
// Additive relay turn-done marker (never writes into respawn/). Only when
|
|
48
|
-
// a registry entry exists for this relay session.
|
|
49
|
-
const relaySid = process.env[RELAY_SESSION_ENV];
|
|
50
|
-
if (relaySid != null && registryHas({ sessionId: relaySid })) {
|
|
51
|
-
writeTurnDoneMarker({ sessionId: relaySid, source: "claude-stop" });
|
|
52
|
-
log("stop.relay_turn_done", { session: relaySid.slice(0, 8) });
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
// Anticipatory depleted swaps are only sane when the respawn marker below
|
|
56
|
-
// will actually pause the session until the reset.
|
|
57
26
|
const canPause = process.env.TOKENMAXXING_SUPERVISED === "1" && pinnedSid != null;
|
|
58
27
|
const decision = await evaluateAndMaybeSwap(Date.now(), canPause);
|
|
59
28
|
if (decision.account && (decision.swapped || decision.waitUntil !== undefined)) {
|
|
60
29
|
log(decision.swapped ? "stop.swapped" : "stop.wait", { account: decision.account.accountUuid.slice(0, 8), waitUntil: decision.waitUntil });
|
|
61
|
-
// Respawn only for a depleted-pool wait: pausing until the reset requires
|
|
62
|
-
// killing the child. A plain swap leaves the session running to adopt.
|
|
63
30
|
if (decision.waitUntil !== undefined && canPause && pinnedSid) {
|
|
64
31
|
const marker = join(paths.respawnDir, pinnedSid);
|
|
32
|
+
const launchedAt = z.coerce.number().finite().optional().catch(undefined).parse(process.env.TOKENMAXXING_LAUNCHED_AT);
|
|
65
33
|
const payload = RespawnMarkerSchema.parse({
|
|
66
34
|
account: decision.account.label,
|
|
67
35
|
ts: Date.now(),
|
|
68
36
|
waitUntil: decision.waitUntil,
|
|
69
37
|
sessionId: stdinSid ?? pinnedSid,
|
|
38
|
+
...(launchedAt != null ? { launchedAt } : {}),
|
|
70
39
|
});
|
|
71
40
|
writeFileAtomic(marker, JSON.stringify(payload));
|
|
72
41
|
log("stop.marker", { session: (stdinSid ?? pinnedSid).slice(0, 8) });
|
|
@@ -75,5 +44,5 @@ export async function runStopHook(): Promise<number> {
|
|
|
75
44
|
} catch (e) {
|
|
76
45
|
log("stop.error", { err: e instanceof Error ? e.message : String(e) });
|
|
77
46
|
}
|
|
78
|
-
return 0;
|
|
47
|
+
return 0;
|
|
79
48
|
}
|
|
@@ -1,16 +1,3 @@
|
|
|
1
|
-
// Renders claude's subagentStatusLine: one {id, content} JSON line per active
|
|
2
|
-
// subagent task, so the agents panel shows each subagent's model, effort, and
|
|
3
|
-
// ctx fill the way the main statusline shows the session's (user ask
|
|
4
|
-
// 2026-07-18). This per-row panel surface is the only place subagent info can
|
|
5
|
-
// appear: the main statusLine command never learns which subagent the UI is
|
|
6
|
-
// viewing (verified against the 2.1.214 bundle). Row shape mirrors the main
|
|
7
|
-
// info block, info first so claude's end-truncation eats the label instead:
|
|
8
|
-
// "fable (high) <task label>" - the family name painted by the task's
|
|
9
|
-
// context fill (colorless mode keeps a numeric ctx token).
|
|
10
|
-
// A task we emit nothing for keeps claude's default row (emitting empty
|
|
11
|
-
// content would HIDE it). Must NEVER break the panel: render what parses,
|
|
12
|
-
// skip what doesn't.
|
|
13
|
-
|
|
14
1
|
import { z } from "zod";
|
|
15
2
|
import { makeColors, makeUsagePaint } from "../cli/render.ts";
|
|
16
3
|
import { readStdin } from "./statusline.ts";
|
|
@@ -19,14 +6,11 @@ import { SubagentStatusLineStdinSchema } from "../lib/types.ts";
|
|
|
19
6
|
const RowCtxSchema = z.object({ color: z.boolean(), truecolor: z.boolean() });
|
|
20
7
|
export type RowCtx = z.infer<typeof RowCtxSchema>;
|
|
21
8
|
|
|
22
|
-
/** "claude-fable-5" -> "fable" (lowercase family, matching the chart-label
|
|
23
|
-
* convention); an id without the claude- prefix passes through whole. */
|
|
24
9
|
function modelFamily(id: string): string {
|
|
25
10
|
const [head, family] = id.split("-");
|
|
26
11
|
return head === "claude" && family ? family : id;
|
|
27
12
|
}
|
|
28
13
|
|
|
29
|
-
/** Pure renderer: subagentStatusLine stdin -> {id, content} JSON lines. */
|
|
30
14
|
export function renderSubagentRows(stdinObj: unknown, ctx: RowCtx): string[] {
|
|
31
15
|
const parsed = SubagentStatusLineStdinSchema.safeParse(stdinObj);
|
|
32
16
|
if (!parsed.success) return [];
|
|
@@ -41,8 +25,6 @@ export function renderSubagentRows(stdinObj: unknown, ctx: RowCtx): string[] {
|
|
|
41
25
|
? Math.round((t.tokenCount / t.contextWindowSize) * 100)
|
|
42
26
|
: null;
|
|
43
27
|
const info: string[] = [];
|
|
44
|
-
// The model name carries the task's context-fill color (same rule as the
|
|
45
|
-
// main line); colorless mode keeps the numeric ctx token instead.
|
|
46
28
|
if (t.model != null) {
|
|
47
29
|
const family = modelFamily(t.model);
|
|
48
30
|
const body = pct != null ? col.bold(paint(pct)(family)) : col.bold(family);
|
|
@@ -66,7 +48,6 @@ export async function runSubagentStatusline(): Promise<number> {
|
|
|
66
48
|
try {
|
|
67
49
|
obj = JSON.parse(raw);
|
|
68
50
|
} catch {
|
|
69
|
-
// malformed stdin - emit nothing, claude keeps its default rows
|
|
70
51
|
}
|
|
71
52
|
const colorterm = z.string().optional().parse(process.env.COLORTERM);
|
|
72
53
|
const rows = renderSubagentRows(obj, {
|
|
@@ -1,12 +1,3 @@
|
|
|
1
|
-
// The `claude` supervisor. Invoked in place of claude (via ~/.config/tokenmaxxing/
|
|
2
|
-
// bin/claude on PATH). Runs the REAL claude with inherited stdio (claude owns the
|
|
3
|
-
// real terminal exactly as if run directly), pins a session id, and watches for a
|
|
4
|
-
// respawn marker dropped by the Stop hook on a depleted-pool wait (plain swaps
|
|
5
|
-
// adopt in place and never respawn). When the marker appears it SIGTERMs its own
|
|
6
|
-
// child at the (already-committed) turn boundary, counts down to the reset, and
|
|
7
|
-
// relaunches `claude --resume <id>`. Process/terminal manager only - it never
|
|
8
|
-
// reads or proxies tokens.
|
|
9
|
-
|
|
10
1
|
import { existsSync, mkdirSync, readFileSync, rmSync, readdirSync, statSync } from "node:fs";
|
|
11
2
|
import { join } from "node:path";
|
|
12
3
|
import { maxBy } from "es-toolkit";
|
|
@@ -23,13 +14,6 @@ const NONINTERACTIVE_SUBCMDS = new Set([
|
|
|
23
14
|
"setup-token", "plugin", "agents", "completion", "help",
|
|
24
15
|
]);
|
|
25
16
|
|
|
26
|
-
// Root flags whose VALUE tokens must never be read as the subcommand (e.g.
|
|
27
|
-
// `--settings config` is an interactive session, not `claude config`): the
|
|
28
|
-
// same hardening class shouldManageCodex got. Split by arity, mirroring
|
|
29
|
-
// claude's own commander declarations (--help-verified 2.1.215; claude changes
|
|
30
|
-
// monthly - a newly added value-taking flag regresses only that flag's
|
|
31
|
-
// collision case). `--session-id` / `-r` / `--resume` consume their values in
|
|
32
|
-
// dedicated branches below.
|
|
33
17
|
const VALUE_TAKING_ROOT_FLAGS = new Set([
|
|
34
18
|
"--agent", "--agents", "--append-system-prompt", "--append-system-prompt-file",
|
|
35
19
|
"--debug-file", "--effort", "--fallback-model", "--input-format",
|
|
@@ -38,12 +22,10 @@ const VALUE_TAKING_ROOT_FLAGS = new Set([
|
|
|
38
22
|
"--remote-control-session-name-prefix", "--setting-sources", "--settings",
|
|
39
23
|
"--system-prompt",
|
|
40
24
|
]);
|
|
41
|
-
// Variadic (`<x...>`): commander consumes EVERY following non-dash token.
|
|
42
25
|
const VARIADIC_ROOT_FLAGS = new Set([
|
|
43
26
|
"--add-dir", "--allowedTools", "--allowed-tools", "--betas",
|
|
44
27
|
"--disallowedTools", "--disallowed-tools", "--file", "--mcp-config", "--tools",
|
|
45
28
|
]);
|
|
46
|
-
// Optional value (`[x]`): commander consumes the next token unless it is a flag.
|
|
47
29
|
const OPTIONAL_VALUE_ROOT_FLAGS = new Set([
|
|
48
30
|
"-d", "--debug", "--from-pr", "--prompt-suggestions", "--remote-control", "-w", "--worktree",
|
|
49
31
|
]);
|
|
@@ -73,33 +55,17 @@ export function analyzeArgs(argv: string[]): Analysis {
|
|
|
73
55
|
if (a === "-p" || a === "--print") printMode = true;
|
|
74
56
|
else if (a === "--version" || a === "-v" || a === "--help" || a === "-h") printMode = true;
|
|
75
57
|
else if (a === "--session-id") {
|
|
76
|
-
// A non-UUID here must never become supervisor state: the sid names the
|
|
77
|
-
// respawn-marker and session-flag paths (an unvalidated value could
|
|
78
|
-
// traverse out of them), and real claude rejects a malformed id anyway -
|
|
79
|
-
// pass it through unmanaged and let claude do the rejecting.
|
|
80
58
|
const next = argv[++i] ?? null;
|
|
81
59
|
if (next && isUuid(next)) sessionId = next;
|
|
82
60
|
else invalidSessionArg = true;
|
|
83
61
|
}
|
|
84
62
|
else if (a === "-c" || a === "--continue") continueLatest = true;
|
|
85
63
|
else if (a === "-r" || a === "--resume") {
|
|
86
|
-
// A UUID resume is managed (the sid is known, marker paths can be
|
|
87
|
-
// pinned). Bare `-r`, or `-r <term>` (binary-verified 2.1.214: a non-id
|
|
88
|
-
// value is an interactive-picker SEARCH TERM), choose the sid INSIDE
|
|
89
|
-
// claude - the supervisor cannot pin marker paths for an unknown sid, so
|
|
90
|
-
// those pass through unmanaged and claude behaves exactly as without the
|
|
91
|
-
// wrapper (same accepted state as claude's bg-daemon sessions: swaps
|
|
92
|
-
// still adopt in place, hooks still fire; only the depleted-pool
|
|
93
|
-
// countdown is absent).
|
|
94
64
|
const next = argv[i + 1];
|
|
95
65
|
if (next && !next.startsWith("-") && isUuid(next)) { resumeId = next; i++; }
|
|
96
66
|
else pickerResume = true;
|
|
97
67
|
}
|
|
98
68
|
else if (a === "--fork-session") forkSession = true;
|
|
99
|
-
// commander's `--flag=value` forms (closing-review catch: unrecognized,
|
|
100
|
-
// they were skipped as unknown dash-args, so the supervisor pinned a
|
|
101
|
-
// fresh random sid while claude ran the flag-selected session - markers
|
|
102
|
-
// and session flags landed under an id nothing was running).
|
|
103
69
|
else if (a.startsWith("--session-id=")) {
|
|
104
70
|
const value = a.slice("--session-id=".length);
|
|
105
71
|
if (isUuid(value)) sessionId = value;
|
|
@@ -123,18 +89,11 @@ export function analyzeArgs(argv: string[]): Analysis {
|
|
|
123
89
|
}
|
|
124
90
|
|
|
125
91
|
const isSubcmd = firstPositional !== null && NONINTERACTIVE_SUBCMDS.has(firstPositional);
|
|
126
|
-
// A forked resume gets a NEW session id chosen inside claude, so the
|
|
127
|
-
// supervisor cannot pin marker paths - pass through unmanaged like
|
|
128
|
-
// picker-mode resume (closing-review catch: managing it paired the marker
|
|
129
|
-
// to the stale pre-fork sid, and a respawn would fork yet another session).
|
|
130
92
|
const forkResume = forkSession && (resumeId !== null || continueLatest);
|
|
131
93
|
const manage = !printMode && !isSubcmd && !invalidSessionArg && !pickerResume && !forkResume && !process.env.TOKENMAXXING_PROBE;
|
|
132
94
|
return { manage, sessionId, resumeId, continueLatest };
|
|
133
95
|
}
|
|
134
96
|
|
|
135
|
-
/** Remove session-selecting flags so we can inject our own on respawn. Managed
|
|
136
|
-
* argv can only carry a UUID-valued resume (picker-mode passes through
|
|
137
|
-
* unmanaged), so the value is always consumed with its flag. */
|
|
138
97
|
export function stripSessionFlags(argv: string[]): string[] {
|
|
139
98
|
const out: string[] = [];
|
|
140
99
|
for (let i = 0; i < argv.length; i++) {
|
|
@@ -142,43 +101,17 @@ export function stripSessionFlags(argv: string[]): string[] {
|
|
|
142
101
|
if (a === "--session-id") { i++; continue; }
|
|
143
102
|
if (a === "-c" || a === "--continue") continue;
|
|
144
103
|
if (a === "-r" || a === "--resume") { i++; continue; }
|
|
145
|
-
// the commander `=` forms carry their value in the same token
|
|
146
104
|
if (a.startsWith("--session-id=") || a.startsWith("--resume=")) continue;
|
|
147
|
-
// --fork-session must not survive into respawn args: bare `--fork-session`
|
|
148
|
-
// is inert and stays managed, but a depleted-pool respawn injects
|
|
149
|
-
// `--resume <sid>` - with the flag still present claude would FORK to a
|
|
150
|
-
// NEW session id, permanently unpairing the supervisor's marker path from
|
|
151
|
-
// the running session (closing-review catch).
|
|
152
105
|
if (a === "--fork-session") continue;
|
|
153
106
|
out.push(a);
|
|
154
107
|
}
|
|
155
108
|
return out;
|
|
156
109
|
}
|
|
157
110
|
|
|
158
|
-
/** Remove positional tokens (the one-shot initial prompt) while keeping every
|
|
159
|
-
* flag and its consumed value(s). A positional is a submit-once user turn:
|
|
160
|
-
* persisting or replaying it on a respawn / later `--resume` re-injects the
|
|
161
|
-
* original instruction into an already-progressed session (adversarial-review
|
|
162
|
-
* HIGH catch) - only real flags like --model belong in sessions/ files and
|
|
163
|
-
* respawn args.
|
|
164
|
-
*
|
|
165
|
-
* TRADEOFF (WONTFIX, flagged PR #37): when claude adds a value-taking root
|
|
166
|
-
* flag before the sets above are updated, that value reads as a positional
|
|
167
|
-
* and is dropped, so the respawn launches without it and claude errors on the
|
|
168
|
-
* missing argument - loud, and the user relaunches. The alternative default
|
|
169
|
-
* (treat a bare token after an UNRECOGNIZED flag as that flag's value) turns
|
|
170
|
-
* the same staleness silent: a newly added BOOLEAN flag sitting before the
|
|
171
|
-
* prompt would make the prompt look like a value and replay a submit-once
|
|
172
|
-
* turn, which is the exact harm this function exists to prevent. The
|
|
173
|
-
* ambiguity is irreducible without claude's own option table, and a loud
|
|
174
|
-
* broken launch beats a silent re-submit. */
|
|
175
111
|
export function stripPositionals(argv: string[]): string[] {
|
|
176
112
|
const out: string[] = [];
|
|
177
113
|
for (let i = 0; i < argv.length; i++) {
|
|
178
114
|
const a = argv[i]!;
|
|
179
|
-
// `--` ends option parsing: everything after it is positional (a prompt
|
|
180
|
-
// deliberately starting with "-"), never a flag to persist (PR #37
|
|
181
|
-
// review catch). The delimiter itself is dropped with them.
|
|
182
115
|
if (a === "--") break;
|
|
183
116
|
if (!a.startsWith("-")) continue;
|
|
184
117
|
out.push(a);
|
|
@@ -193,12 +126,6 @@ export function stripPositionals(argv: string[]): string[] {
|
|
|
193
126
|
return out;
|
|
194
127
|
}
|
|
195
128
|
|
|
196
|
-
/** Newest transcript session id for the current cwd (for `-c`). claude's
|
|
197
|
-
* project-dir slug maps EVERY non-alphanumeric char to "-": the regex below
|
|
198
|
-
* mirrors claude's own, byte for byte (binary-verified 2.1.215, the external-
|
|
199
|
-
* contract regex exception). The old [/.]-only mapping missed underscores
|
|
200
|
-
* etc., so `-c` in such a cwd silently opened a brand-new session instead of
|
|
201
|
-
* continuing (closing-review catch). */
|
|
202
129
|
function latestSessionForCwd(): string | null {
|
|
203
130
|
const slug = process.cwd().replace(/[^a-zA-Z0-9]/g, "-");
|
|
204
131
|
const projDir = join(paths.claudeDir, "projects", slug);
|
|
@@ -214,24 +141,35 @@ function latestSessionForCwd(): string | null {
|
|
|
214
141
|
}
|
|
215
142
|
}
|
|
216
143
|
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
144
|
+
const MarkerGateSchema = z.object({
|
|
145
|
+
launchedAt: z.number(),
|
|
146
|
+
overriddenUntil: z.number(),
|
|
147
|
+
});
|
|
148
|
+
type MarkerGate = z.infer<typeof MarkerGateSchema>;
|
|
149
|
+
|
|
150
|
+
function consumableMarker(marker: string, gate: MarkerGate): z.infer<typeof RespawnMarkerSchema> | null {
|
|
151
|
+
let m: z.infer<typeof RespawnMarkerSchema>;
|
|
223
152
|
try {
|
|
224
|
-
|
|
153
|
+
m = RespawnMarkerSchema.parse(JSON.parse(readFileSync(marker, "utf8")));
|
|
225
154
|
} catch (e) {
|
|
226
155
|
rmSync(marker, { force: true });
|
|
227
156
|
log("supervisor.marker_invalid", { err: e instanceof Error ? e.message : String(e) });
|
|
228
157
|
return null;
|
|
229
158
|
}
|
|
159
|
+
if (m.launchedAt !== undefined && m.launchedAt !== gate.launchedAt) {
|
|
160
|
+
rmSync(marker, { force: true });
|
|
161
|
+
log("supervisor.marker_stale", { markerLaunch: m.launchedAt, childLaunch: gate.launchedAt });
|
|
162
|
+
return null;
|
|
163
|
+
}
|
|
164
|
+
if (m.waitUntil > Date.now() && m.waitUntil <= gate.overriddenUntil) {
|
|
165
|
+
rmSync(marker, { force: true });
|
|
166
|
+
log("supervisor.marker_overridden", { waitUntil: m.waitUntil });
|
|
167
|
+
return null;
|
|
168
|
+
}
|
|
169
|
+
return m;
|
|
230
170
|
}
|
|
231
171
|
|
|
232
|
-
|
|
233
|
-
* so the statusLine can't render it). Ctrl-C resumes immediately. */
|
|
234
|
-
async function countdownWait(acct: string, until: number): Promise<void> {
|
|
172
|
+
async function countdownWait(acct: string, until: number): Promise<boolean> {
|
|
235
173
|
let aborted = false;
|
|
236
174
|
const onInt = () => { aborted = true; };
|
|
237
175
|
process.on("SIGINT", onInt);
|
|
@@ -245,13 +183,10 @@ async function countdownWait(acct: string, until: number): Promise<void> {
|
|
|
245
183
|
}
|
|
246
184
|
process.removeListener("SIGINT", onInt);
|
|
247
185
|
process.stdout.write(`\n\x1b[36m↻ resuming on ${acct}...\x1b[0m\n`);
|
|
186
|
+
return aborted;
|
|
248
187
|
}
|
|
249
188
|
|
|
250
|
-
/** Entry point: `claude ...args`. */
|
|
251
189
|
export async function runSupervisor(argv: string[]): Promise<number> {
|
|
252
|
-
// Depth cap: every spawn below tags its child, so a claudeBin that leads back
|
|
253
|
-
// here (pinned shim re-execing `claude` from PATH) dies at a handful of
|
|
254
|
-
// processes instead of fork-bombing the machine (2026-07-12 incident).
|
|
255
190
|
const depth = wrapDepth();
|
|
256
191
|
if (depth >= MAX_WRAP_DEPTH) {
|
|
257
192
|
console.error(
|
|
@@ -260,8 +195,6 @@ export async function runSupervisor(argv: string[]): Promise<number> {
|
|
|
260
195
|
log("supervisor.loop_abort", { depth });
|
|
261
196
|
return 1;
|
|
262
197
|
}
|
|
263
|
-
// Rate backstop: an env-sanitizing shim in the loop strips the sentinel, but
|
|
264
|
-
// it cannot erase the on-disk entry counter.
|
|
265
198
|
if (wrapperEntryRateTripped(Date.now())) {
|
|
266
199
|
console.error(
|
|
267
200
|
`tokenmaxxing: ${LOOP_DIAGNOSIS} (over ${WRAP_RATE_MAX} wrapper entries in ${WRAP_RATE_WINDOW_MS / 1000}s) - claudeBin in ${paths.configJson} does not launch the real Claude binary. Fix claudeBin, then run \`tokenmaxxing doctor\`.`,
|
|
@@ -273,21 +206,7 @@ export async function runSupervisor(argv: string[]): Promise<number> {
|
|
|
273
206
|
const info = analyzeArgs(argv);
|
|
274
207
|
const childEnv = { ...process.env, [WRAP_DEPTH_ENV]: String(depth + 1) };
|
|
275
208
|
|
|
276
|
-
// Pass-through: no session management, no respawn - exact stock behavior.
|
|
277
|
-
// The unmanaged-zone sentinel (pooledSpawnEnv) forces it regardless of argv:
|
|
278
|
-
// everything below an SDK-driven session runs the real claude unsupervised,
|
|
279
|
-
// so a repo script invoking `claude` from inside a serve turn works instead
|
|
280
|
-
// of dying at the depth cap. The sentinel rides childEnv, so the whole
|
|
281
|
-
// subtree stays unmanaged, and depth still counts wrapper entries toward
|
|
282
|
-
// the cap above, keeping a poisoned pin inside the zone bounded.
|
|
283
209
|
if (!info.manage || process.env[UNMANAGED_ENV]) {
|
|
284
|
-
// STRIP the supervision pairing env (mirrors the codex shim's passthrough
|
|
285
|
-
// arm, closing-review catch): a nested unmanaged claude inside a
|
|
286
|
-
// supervised session (e.g. the agent running `claude -p ...`) would
|
|
287
|
-
// otherwise inherit TOKENMAXXING_SUPERVISED/TOKENMAXXING_SESSION_ID, and
|
|
288
|
-
// its Stop hooks - which DO fire in print mode - would compute
|
|
289
|
-
// canPause=true and could anticipatorily pre-park the pool against a
|
|
290
|
-
// marker path the OUTER supervisor owns.
|
|
291
210
|
const passthroughEnv: Record<string, string | undefined> = { ...childEnv };
|
|
292
211
|
delete passthroughEnv.TOKENMAXXING_SUPERVISED;
|
|
293
212
|
delete passthroughEnv.TOKENMAXXING_SESSION_ID;
|
|
@@ -296,7 +215,6 @@ export async function runSupervisor(argv: string[]): Promise<number> {
|
|
|
296
215
|
return p.exitCode ?? (p.signalCode ? 1 : 0);
|
|
297
216
|
}
|
|
298
217
|
|
|
299
|
-
// Decide the managed session id + whether we're resuming an existing one.
|
|
300
218
|
let base = stripSessionFlags(argv);
|
|
301
219
|
let sid: string;
|
|
302
220
|
let resuming = false;
|
|
@@ -312,19 +230,10 @@ export async function runSupervisor(argv: string[]): Promise<number> {
|
|
|
312
230
|
sid = crypto.randomUUID();
|
|
313
231
|
}
|
|
314
232
|
|
|
315
|
-
// Restore the original launch flags when resuming a session with none given
|
|
316
|
-
// this time (a bare `claude --resume <id>`, or the depleted-pool recovery).
|
|
317
233
|
if (resuming && base.length === 0) {
|
|
318
234
|
const persisted = loadSessionFlags(sid);
|
|
319
|
-
// enforce the flags-only contract at the trust boundary, not just at
|
|
320
|
-
// write: a sessions/ file written before stripPositionals existed can
|
|
321
|
-
// still carry the original prompt, and restoring it verbatim would
|
|
322
|
-
// re-submit that prompt on a bare `claude --resume` (PR #37 review
|
|
323
|
-
// catch). Idempotent on well-formed files.
|
|
324
235
|
if (persisted) base = stripPositionals(persisted);
|
|
325
236
|
}
|
|
326
|
-
// The FIRST launch keeps a positional prompt (the user just typed it);
|
|
327
|
-
// everything persisted or respawned carries flags only.
|
|
328
237
|
const persistable = stripPositionals(base);
|
|
329
238
|
saveSessionFlags(sid, persistable, process.cwd());
|
|
330
239
|
pruneStaleSessions(Date.now());
|
|
@@ -335,28 +244,27 @@ export async function runSupervisor(argv: string[]): Promise<number> {
|
|
|
335
244
|
const marker = join(paths.respawnDir, sid);
|
|
336
245
|
const savedTermios = saveTermios();
|
|
337
246
|
|
|
338
|
-
// Supervisor survives the SIGINT/SIGHUP that flow to the foreground group;
|
|
339
|
-
// claude (the child, same pgrp) receives and handles them itself.
|
|
340
247
|
process.on("SIGINT", () => {});
|
|
341
248
|
process.on("SIGHUP", () => {});
|
|
342
249
|
|
|
343
250
|
let respawns = 0;
|
|
251
|
+
let overriddenUntil = 0;
|
|
344
252
|
while (true) {
|
|
345
253
|
if (existsSync(marker)) rmSync(marker, { force: true });
|
|
346
254
|
log("supervisor.launch", { sid, respawns, args: launchArgs.join(" ") });
|
|
347
255
|
|
|
256
|
+
const gate: MarkerGate = { launchedAt: Date.now(), overriddenUntil };
|
|
348
257
|
const child = Bun.spawn([real, ...launchArgs], {
|
|
349
258
|
stdin: "inherit",
|
|
350
259
|
stdout: "inherit",
|
|
351
260
|
stderr: "inherit",
|
|
352
|
-
env: { ...childEnv, TOKENMAXXING_SUPERVISED: "1", TOKENMAXXING_SESSION_ID: sid },
|
|
261
|
+
env: { ...childEnv, TOKENMAXXING_SUPERVISED: "1", TOKENMAXXING_SESSION_ID: sid, TOKENMAXXING_LAUNCHED_AT: String(gate.launchedAt) },
|
|
353
262
|
});
|
|
354
263
|
|
|
355
|
-
// Race the child's own exit against the appearance of a respawn marker.
|
|
356
264
|
let done = false;
|
|
357
265
|
const markerWatch = (async () => {
|
|
358
266
|
while (!done) {
|
|
359
|
-
if (existsSync(marker) && consumableMarker(marker) != null) return true;
|
|
267
|
+
if (existsSync(marker) && consumableMarker(marker, gate) != null) return true;
|
|
360
268
|
await Bun.sleep(150);
|
|
361
269
|
}
|
|
362
270
|
return false;
|
|
@@ -365,32 +273,24 @@ export async function runSupervisor(argv: string[]): Promise<number> {
|
|
|
365
273
|
const winner = await Promise.race([exited, markerWatch.then((m) => (m ? "marker" : "exit"))]);
|
|
366
274
|
|
|
367
275
|
if (winner === "marker") {
|
|
368
|
-
child.kill();
|
|
276
|
+
child.kill();
|
|
369
277
|
}
|
|
370
278
|
await child.exited;
|
|
371
279
|
done = true;
|
|
372
280
|
await markerWatch.catch(() => {});
|
|
373
281
|
restoreTermios(savedTermios);
|
|
374
282
|
|
|
375
|
-
const m = existsSync(marker) ? consumableMarker(marker) : null;
|
|
283
|
+
const m = existsSync(marker) ? consumableMarker(marker, gate) : null;
|
|
376
284
|
if (m) {
|
|
377
285
|
rmSync(marker, { force: true });
|
|
378
286
|
respawns++;
|
|
379
|
-
if (m.waitUntil > Date.now())
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
// /clear they differ, and resuming the pinned id would revive the
|
|
383
|
-
// pre-/clear conversation (closing-review HIGH catch). Persist the
|
|
384
|
-
// flags under that transcript id too, so a later bare
|
|
385
|
-
// `claude --resume <id>` restores them (PR #36 review catch). FLAGS
|
|
386
|
-
// only: replaying a positional prompt would re-submit it as a fresh
|
|
387
|
-
// turn on the progressed session (adversarial-review HIGH catch).
|
|
287
|
+
if (m.waitUntil > Date.now()) {
|
|
288
|
+
if (await countdownWait(m.account, m.waitUntil)) overriddenUntil = m.waitUntil;
|
|
289
|
+
} else process.stdout.write(`\n\x1b[36m↻ tokenmaxxing: switched to ${m.account} - resuming...\x1b[0m\n`);
|
|
388
290
|
saveSessionFlags(m.sessionId, persistable, process.cwd());
|
|
389
|
-
launchArgs = ["--resume", m.sessionId, ...persistable];
|
|
291
|
+
launchArgs = ["--resume", m.sessionId, ...persistable, ...(m.prompt ? ["--", m.prompt] : [])];
|
|
390
292
|
continue;
|
|
391
293
|
}
|
|
392
|
-
// No marker: claude exited on its own (quit, crash, resume refused). Log it -
|
|
393
|
-
// "the process just exited" is undiagnosable without the code/signal.
|
|
394
294
|
log("supervisor.exit", { sid, respawns, code: child.exitCode, signal: child.signalCode });
|
|
395
295
|
return child.exitCode ?? (child.signalCode ? 1 : 0);
|
|
396
296
|
}
|
package/src/lib/atomic.ts
CHANGED
|
@@ -1,13 +1,6 @@
|
|
|
1
|
-
// Atomic file writes (temp + rename on the same filesystem) and small fs utils.
|
|
2
|
-
|
|
3
1
|
import { closeSync, mkdirSync, openSync, renameSync, rmSync, writeSync, fsyncSync } from "node:fs";
|
|
4
2
|
import { dirname } from "node:path";
|
|
5
3
|
|
|
6
|
-
/**
|
|
7
|
-
* Write `data` to `file` atomically: write a sibling temp file, fsync it, then
|
|
8
|
-
* rename over the target. A concurrent reader sees either the old or new file,
|
|
9
|
-
* never a partial one.
|
|
10
|
-
*/
|
|
11
4
|
export function writeFileAtomic(file: string, data: string | Uint8Array, mode = 0o600): void {
|
|
12
5
|
const dir = dirname(file);
|
|
13
6
|
mkdirSync(dir, { recursive: true });
|
|
@@ -16,11 +9,6 @@ export function writeFileAtomic(file: string, data: string | Uint8Array, mode =
|
|
|
16
9
|
const fd = openSync(tmp, "wx", mode);
|
|
17
10
|
try {
|
|
18
11
|
try {
|
|
19
|
-
// write(2) may write FEWER bytes than asked without throwing (ENOSPC mid-
|
|
20
|
-
// write, signal interruption): loop until done and fail loudly on a stuck
|
|
21
|
-
// fd, or a truncated temp file gets fsynced and renamed over the target
|
|
22
|
-
// as a successful-looking corrupt file (closing-review catch - for a
|
|
23
|
-
// parked credential that silently loses a just-rotated refresh token).
|
|
24
12
|
let offset = 0;
|
|
25
13
|
while (offset < bytes.length) {
|
|
26
14
|
const written = writeSync(fd, bytes, offset);
|
|
@@ -33,13 +21,9 @@ export function writeFileAtomic(file: string, data: string | Uint8Array, mode =
|
|
|
33
21
|
}
|
|
34
22
|
renameSync(tmp, file);
|
|
35
23
|
} catch (e) {
|
|
36
|
-
// a failed write must not strand the partial temp file - it can hold a
|
|
37
|
-
// truncated credential (PR #36 review catch) - and a failed CLEANUP must
|
|
38
|
-
// not mask the write error the caller needs (second-round catch)
|
|
39
24
|
try {
|
|
40
25
|
rmSync(tmp, { force: true });
|
|
41
26
|
} catch {
|
|
42
|
-
// the original write error below is the one that matters
|
|
43
27
|
}
|
|
44
28
|
throw e;
|
|
45
29
|
}
|
package/src/lib/claudebin.ts
CHANGED
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
// Resolve the REAL claude binary (never our shim on PATH).
|
|
2
|
-
|
|
3
1
|
import { existsSync, mkdirSync, readFileSync, realpathSync, statSync } from "node:fs";
|
|
4
2
|
import { dirname, join } from "node:path";
|
|
5
3
|
import { uniq } from "es-toolkit";
|
|
@@ -8,23 +6,9 @@ import { paths } from "./paths.ts";
|
|
|
8
6
|
import { loadConfig } from "./state.ts";
|
|
9
7
|
import { writeFileAtomic } from "./atomic.ts";
|
|
10
8
|
|
|
11
|
-
/** How many tokenmaxxing wrappers sit above this process. Every supervisor
|
|
12
|
-
* spawn increments it in the child env; the wrapper refuses to run at the cap,
|
|
13
|
-
* so ANY claudeBin indirection that leads back to the wrapper (a pinned shim
|
|
14
|
-
* that re-execs `claude` from PATH) dies in a handful of processes instead of
|
|
15
|
-
* fork-bombing the machine (2026-07-12: ~1800 runaway bun processes). */
|
|
16
9
|
export const WRAP_DEPTH_ENV = "TOKENMAXXING_WRAP_DEPTH";
|
|
17
10
|
export const MAX_WRAP_DEPTH = 5;
|
|
18
|
-
/** "No session management anywhere below this env": the wrapper passes a
|
|
19
|
-
* `claude`/`codex` invocation straight through to the real binary, unmanaged.
|
|
20
|
-
* Set by pooledSpawnEnv so an SDK-driven session's descendants (an agent
|
|
21
|
-
* running `bun run validate` whose script invokes `claude -p`) still work
|
|
22
|
-
* instead of dying at the depth cap, which is a LOOP diagnostic, not an
|
|
23
|
-
* unmanaged-zone marker. Depth keeps incrementing per wrapper entry under
|
|
24
|
-
* this sentinel, so a poisoned pin inside the zone still aborts at the cap. */
|
|
25
11
|
export const UNMANAGED_ENV = "TOKENMAXXING_UNMANAGED";
|
|
26
|
-
/** Stable fragment of the loop-abort diagnostic; verifyRealClaude greps a
|
|
27
|
-
* child's stderr for it to name the failure precisely. */
|
|
28
12
|
export const LOOP_DIAGNOSIS = "wrapper re-entered without reaching the real claude";
|
|
29
13
|
|
|
30
14
|
export function wrapDepth(env: Record<string, string | undefined> = process.env): number {
|
|
@@ -32,12 +16,6 @@ export function wrapDepth(env: Record<string, string | undefined> = process.env)
|
|
|
32
16
|
return Number.isFinite(n) && n > 0 ? Math.floor(n) : 0;
|
|
33
17
|
}
|
|
34
18
|
|
|
35
|
-
/** Non-env backstop for the depth sentinel: an env-sanitizing shim in the loop
|
|
36
|
-
* (env -i / corporate launchers) strips the sentinel every cycle, so the
|
|
37
|
-
* wrapper ALSO counts its own entries in an on-disk sliding window that no
|
|
38
|
-
* child environment can erase. A self-spawn loop sustains several entries per
|
|
39
|
-
* second indefinitely; legitimate bursts (a tmux session restore launching
|
|
40
|
-
* dozens of panes) land once and go quiet, staying far under the cap. */
|
|
41
19
|
export const WRAP_RATE_MAX = 60;
|
|
42
20
|
export const WRAP_RATE_WINDOW_MS = 30_000;
|
|
43
21
|
const SpawnRateSchema = z.object({ entries: z.array(z.number()) });
|
|
@@ -47,13 +25,13 @@ export function wrapperEntryRateTripped(now: number): boolean {
|
|
|
47
25
|
let entries: number[] = [];
|
|
48
26
|
try {
|
|
49
27
|
entries = SpawnRateSchema.parse(JSON.parse(readFileSync(file, "utf8"))).entries;
|
|
50
|
-
} catch {
|
|
28
|
+
} catch { }
|
|
51
29
|
entries = entries.filter((t) => now - t < WRAP_RATE_WINDOW_MS);
|
|
52
30
|
entries.push(now);
|
|
53
31
|
try {
|
|
54
32
|
mkdirSync(paths.home, { recursive: true });
|
|
55
33
|
writeFileAtomic(file, JSON.stringify({ entries }));
|
|
56
|
-
} catch {
|
|
34
|
+
} catch { }
|
|
57
35
|
return entries.length > WRAP_RATE_MAX;
|
|
58
36
|
}
|
|
59
37
|
|
|
@@ -65,21 +43,12 @@ function realpathOrNull(p: string): string | null {
|
|
|
65
43
|
}
|
|
66
44
|
}
|
|
67
45
|
|
|
68
|
-
/** True when `bin` IS tokenmaxxing (the `claude` wrapper, the `xx` alias, or the
|
|
69
|
-
* installed binary, via any symlink): spawning it as claude recurses through
|
|
70
|
-
* the supervisor. Realpath-based - a trailing slash, a symlinked dir, or a
|
|
71
|
-
* symlink to the wrapper must not defeat it the way the old exact-string
|
|
72
|
-
* binDir compare could. */
|
|
73
46
|
export function pointsBackAtUs(bin: string): boolean {
|
|
74
47
|
const resolved = realpathOrNull(bin);
|
|
75
48
|
const binDir = realpathOrNull(paths.binDir);
|
|
76
49
|
return resolved != null && binDir != null && dirname(resolved) === binDir;
|
|
77
50
|
}
|
|
78
51
|
|
|
79
|
-
/** Every PATH `claude` that is not us, in PATH order, deduped by realpath.
|
|
80
|
-
* All of them, not just the first: a user-made wrapper script named claude can
|
|
81
|
-
* sit ahead of the real binary, and verified resolution must be able to walk
|
|
82
|
-
* past it. */
|
|
83
52
|
function scanPathForClaudeCandidates(): string[] {
|
|
84
53
|
const seen = new Set<string>();
|
|
85
54
|
const out: string[] = [];
|
|
@@ -94,12 +63,11 @@ function scanPathForClaudeCandidates(): string[] {
|
|
|
94
63
|
out.push(cand);
|
|
95
64
|
}
|
|
96
65
|
}
|
|
97
|
-
} catch {
|
|
66
|
+
} catch { }
|
|
98
67
|
}
|
|
99
68
|
return out;
|
|
100
69
|
}
|
|
101
70
|
|
|
102
|
-
/** First PATH entry with a `claude` that is not us. null when PATH has none. */
|
|
103
71
|
export function scanPathForClaude(): string | null {
|
|
104
72
|
return scanPathForClaudeCandidates()[0] ?? null;
|
|
105
73
|
}
|
|
@@ -107,14 +75,9 @@ export function scanPathForClaude(): string | null {
|
|
|
107
75
|
export function resolveRealClaude(): string {
|
|
108
76
|
const cfg = loadConfig();
|
|
109
77
|
if (cfg.claudeBin) {
|
|
110
|
-
// A configured-but-vanished binary must not silently degrade to the PATH
|
|
111
|
-
// scan: under a relocated TOKENMAXXING_HOME the scan's binDir guard misses
|
|
112
|
-
// the installed wrapper, which then recurses through the supervisor
|
|
113
|
-
// (observed 2026-07-12 as a forever-hung `/usage` probe).
|
|
114
78
|
if (!existsSync(cfg.claudeBin)) {
|
|
115
79
|
throw new Error(`configured claudeBin does not exist: ${cfg.claudeBin} - fix config.json`);
|
|
116
80
|
}
|
|
117
|
-
// A pin that leads back to us is the recursion incident, not a claude.
|
|
118
81
|
if (pointsBackAtUs(cfg.claudeBin)) {
|
|
119
82
|
throw new Error(
|
|
120
83
|
`configured claudeBin (${cfg.claudeBin}) is tokenmaxxing's own wrapper - spawning it recurses. Point claudeBin at the real claude binary in ${paths.configJson}`,
|
|
@@ -127,18 +90,10 @@ export function resolveRealClaude(): string {
|
|
|
127
90
|
throw new Error("could not locate the real `claude` binary (set claudeBin in config.json)");
|
|
128
91
|
}
|
|
129
92
|
|
|
130
|
-
/** Behavioral check used by init/doctor before trusting a pin: the binary must
|
|
131
|
-
* answer `--version` without re-entering this wrapper. Depth is preset to the
|
|
132
|
-
* cap so an indirection back into us aborts on its FIRST wrapper entry instead
|
|
133
|
-
* of recursing. Returns null when the binary passes, else the failure detail. */
|
|
134
93
|
export function verifyRealClaude(bin: string): string | null {
|
|
135
94
|
const env = { ...process.env, [WRAP_DEPTH_ENV]: String(MAX_WRAP_DEPTH), TOKENMAXXING_PROBE: "1" };
|
|
136
95
|
let p: ReturnType<typeof Bun.spawnSync>;
|
|
137
96
|
try {
|
|
138
|
-
// spawnSync throws on an unrunnable path (ENOENT/EACCES) - that is a
|
|
139
|
-
// verification failure to report, not a crash. SIGKILL: a TERM-trapping
|
|
140
|
-
// candidate must not hang the very repair commands (init/doctor) that the
|
|
141
|
-
// loop-abort diagnostic points the user at.
|
|
142
97
|
p = Bun.spawnSync([bin, "--version"], { env, stdout: "pipe", stderr: "pipe", timeout: 15_000, killSignal: "SIGKILL" });
|
|
143
98
|
} catch (e) {
|
|
144
99
|
return e instanceof Error ? e.message : String(e);
|
|
@@ -146,8 +101,6 @@ export function verifyRealClaude(bin: string): string | null {
|
|
|
146
101
|
const outText = (p.stdout?.toString() ?? "").trim();
|
|
147
102
|
const err = (p.stderr?.toString() ?? "").trim();
|
|
148
103
|
if (p.exitCode === 0) {
|
|
149
|
-
// exit 0 only proves something ran; the output must identify as claude
|
|
150
|
-
// ("2.1.207 (Claude Code)" on 2.1.207) or the pin is some other program.
|
|
151
104
|
if (/claude/i.test(outText)) return null;
|
|
152
105
|
return `--version output does not identify claude: "${outText.slice(0, 80)}"`;
|
|
153
106
|
}
|
|
@@ -155,15 +108,11 @@ export function verifyRealClaude(bin: string): string | null {
|
|
|
155
108
|
return `--version exited ${p.exitCode ?? "on signal/timeout"}: ${(err || outText).slice(0, 160)}`;
|
|
156
109
|
}
|
|
157
110
|
|
|
158
|
-
/** Resolution for `init`'s pin: resolve, then behaviorally verify. A configured
|
|
159
|
-
* bin that fails verification (e.g. a shim pinned by an old version) falls back
|
|
160
|
-
* to the PATH scan, and the scan walks past failing candidates (a user-made
|
|
161
|
-
* claude wrapper ahead of the real binary) instead of giving up on the first. */
|
|
162
111
|
export function resolveVerifiedClaude(): string {
|
|
163
112
|
const candidates: string[] = [];
|
|
164
113
|
try {
|
|
165
114
|
candidates.push(resolveRealClaude());
|
|
166
|
-
} catch {
|
|
115
|
+
} catch { }
|
|
167
116
|
candidates.push(...scanPathForClaudeCandidates());
|
|
168
117
|
|
|
169
118
|
const failures: string[] = [];
|