tokenmaxxing 0.19.1 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/DESIGN.md +34 -25
- package/README.md +6 -5
- 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/codexrm.ts +49 -0
- package/src/cli/codexswitch.ts +20 -2
- package/src/cli/config.ts +25 -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/rename.ts +20 -0
- package/src/cli/render.ts +0 -16
- package/src/cli/rm.ts +40 -2
- package/src/cli/serve.ts +638 -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 +184 -20
- 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 +18 -3
- 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 +136 -49
- 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 +581 -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 +92 -38
- package/src/lib/usage.ts +114 -16
- package/src/main.ts +70 -9
- 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
|
@@ -24,9 +24,11 @@ import { loadAccounts, loadConfig, loadLastSwapAt, loadModelUsage, writeUsage }
|
|
|
24
24
|
import { familyTokens, matchedFamily, parseStatusLineStdin, parseStatusLineModel } from "../lib/usage.ts";
|
|
25
25
|
import { earliestReset, weeklyExpiry } from "../lib/picker.ts";
|
|
26
26
|
import { worktreeName } from "../lib/worktree.ts";
|
|
27
|
-
import {
|
|
27
|
+
import { makeColors, makeUsagePaint } from "../cli/render.ts";
|
|
28
|
+
import { fmtResetShort } from "../lib/usage.ts";
|
|
28
29
|
import {
|
|
29
30
|
AccountsIndexSchema,
|
|
31
|
+
RateLimitsStdinSchema,
|
|
30
32
|
StatusLineStdinSchema,
|
|
31
33
|
UsageWindowSchema,
|
|
32
34
|
type Account,
|
|
@@ -47,6 +49,11 @@ const RenderCtxSchema = z.object({
|
|
|
47
49
|
switchModels: z.array(z.string()),
|
|
48
50
|
/** linked-worktree basename, null in a main checkout. */
|
|
49
51
|
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
|
+
liveOrg: z.string().nullable(),
|
|
50
57
|
now: z.number(),
|
|
51
58
|
color: z.boolean(),
|
|
52
59
|
/** terminal advertises 24-bit color (COLORTERM); false steps the ramp to the 256-color cube. */
|
|
@@ -115,16 +122,20 @@ export function renderStatusline(stdinObj: unknown, ctx: RenderCtx): string {
|
|
|
115
122
|
windows.push(seg("", wins.fiveHour, wins.fiveHour.resetsAt));
|
|
116
123
|
windows.push(seg("", wins.sevenDay, wins.sevenDay.resetsAt));
|
|
117
124
|
}
|
|
125
|
+
// The seat: live-org first, stored label fallback (unknown live identity).
|
|
126
|
+
const seatUuid =
|
|
127
|
+
(ctx.liveOrg != null ? ctx.accounts.accounts.find((a) => a.organizationUuid === ctx.liveOrg)?.accountUuid : undefined) ??
|
|
128
|
+
ctx.accounts.activeAccountUuid;
|
|
118
129
|
const active =
|
|
119
130
|
windows.length > 0
|
|
120
131
|
? `${col.green("◆")} ${windows.join(" ")}`
|
|
121
|
-
:
|
|
132
|
+
: seatUuid != null
|
|
122
133
|
? `${col.green("◆")} ?`
|
|
123
134
|
: "";
|
|
124
135
|
|
|
125
136
|
// ---- parked accounts, earliest upcoming reset first (needs-reauth last)
|
|
126
137
|
const parked = sortBy(
|
|
127
|
-
ctx.accounts.accounts.filter((a) => a.accountUuid !==
|
|
138
|
+
ctx.accounts.accounts.filter((a) => a.accountUuid !== seatUuid),
|
|
128
139
|
[(a) => (a.needsReauth ? 1 : 0), (a) => earliestReset(a, ctx.now)],
|
|
129
140
|
);
|
|
130
141
|
// Every parked account renders its own marker (user rule 2026-07-18: the
|
|
@@ -159,34 +170,59 @@ export async function runStatusline(): Promise<number> {
|
|
|
159
170
|
}
|
|
160
171
|
const now = Date.now();
|
|
161
172
|
|
|
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
|
+
let stdinOrg: string | null = null;
|
|
186
|
+
|
|
162
187
|
// tee usage for the Stop hook / status - best effort, never blocks rendering.
|
|
163
188
|
let org: string | null = null;
|
|
164
189
|
try {
|
|
165
190
|
org = readOAuthAccount()?.organizationUuid ?? null;
|
|
166
191
|
const windows = obj == null ? null : parseStatusLineStdin(obj);
|
|
167
192
|
const lastSwapAt = loadLastSwapAt();
|
|
168
|
-
|
|
169
|
-
|
|
193
|
+
stdinOrg = windows != null ? (RateLimitsStdinSchema.safeParse(obj).data?.organizationUuid ?? null) : null;
|
|
194
|
+
const teeOrg = stdinOrg ?? org;
|
|
195
|
+
if (windows && (stdinOrg != null || lastSwapAt == null || now - lastSwapAt >= ADOPTION_GRACE_MS)) {
|
|
196
|
+
const state: UsageState = { ...windows, org: teeOrg, ts: now, model: parseStatusLineModel(obj) };
|
|
170
197
|
writeUsage(state);
|
|
171
198
|
}
|
|
172
199
|
} catch {
|
|
173
200
|
// skip the tee, still render below
|
|
174
201
|
}
|
|
175
202
|
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
203
|
+
let line: string;
|
|
204
|
+
try {
|
|
205
|
+
const cfg = loadConfig();
|
|
206
|
+
const modelUsage = loadModelUsage();
|
|
207
|
+
const stdin = StatusLineStdinSchema.safeParse(obj);
|
|
208
|
+
const dir = stdin.success ? (stdin.data.workspace?.current_dir ?? stdin.data.workspace?.project_dir ?? null) : null;
|
|
209
|
+
const colorterm = z.string().optional().parse(process.env.COLORTERM);
|
|
210
|
+
const ctx: RenderCtx = {
|
|
211
|
+
accounts: loadAccounts(),
|
|
212
|
+
perModel: modelUsage && modelUsage.org === (stdinOrg ?? org) ? modelUsage.perModel : {},
|
|
213
|
+
switchModels: cfg.policy.switchModels,
|
|
214
|
+
worktree: dir == null ? null : worktreeName(dir),
|
|
215
|
+
liveOrg: stdinOrg ?? org,
|
|
216
|
+
now,
|
|
217
|
+
color: !process.env.NO_COLOR,
|
|
218
|
+
truecolor: colorterm != null && (colorterm.includes("truecolor") || colorterm.includes("24bit")),
|
|
219
|
+
};
|
|
220
|
+
line = renderStatusline(obj, ctx);
|
|
221
|
+
} 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
|
+
line = `tokenmaxxing: ${e instanceof Error ? e.message : String(e)}`;
|
|
225
|
+
}
|
|
226
|
+
process.stdout.write(line + "\n");
|
|
191
227
|
return 0;
|
|
192
228
|
}
|
package/src/entries/stophook.ts
CHANGED
|
@@ -14,7 +14,11 @@ import { evaluateAndMaybeSwap } from "../lib/decide.ts";
|
|
|
14
14
|
import { RespawnMarkerSchema } from "../lib/types.ts";
|
|
15
15
|
import { log } from "../lib/log.ts";
|
|
16
16
|
|
|
17
|
-
|
|
17
|
+
// session_id must be a real transcript UUID: a malformed value would ride the
|
|
18
|
+
// respawn marker into `--resume <garbage>`, which claude treats as a picker
|
|
19
|
+
// search term (PR #36 review catch); non-UUID input drops to undefined and the
|
|
20
|
+
// marker falls back to the pinned sid.
|
|
21
|
+
const StopStdin = z.looseObject({ session_id: z.uuid().optional().catch(undefined) });
|
|
18
22
|
|
|
19
23
|
async function readStdin(): Promise<string> {
|
|
20
24
|
const chunks: Uint8Array[] = [];
|
|
@@ -28,27 +32,37 @@ export async function runStopHook(): Promise<number> {
|
|
|
28
32
|
|
|
29
33
|
const raw = await readStdin();
|
|
30
34
|
const parsed = StopStdin.safeParse((() => { try { return JSON.parse(raw); } catch { return {}; } })());
|
|
31
|
-
|
|
32
|
-
|
|
35
|
+
// TWO session ids with different jobs (closing-review HIGH catch): the
|
|
36
|
+
// PINNED id (env, set once by the supervisor) names the marker file the
|
|
37
|
+
// supervisor actually watches and survives /clear; the STDIN id names the
|
|
38
|
+
// CURRENT transcript to resume and drifts to a new value after /clear.
|
|
39
|
+
// Keying the file by the stdin id orphaned every post-/clear marker.
|
|
40
|
+
const stdinSid = parsed.success ? parsed.data.session_id : undefined;
|
|
41
|
+
const pinnedSid = process.env.TOKENMAXXING_SESSION_ID;
|
|
33
42
|
|
|
34
43
|
try {
|
|
35
44
|
// Anticipatory depleted swaps are only sane when the respawn marker below
|
|
36
45
|
// will actually pause the session until the reset.
|
|
37
|
-
const canPause = process.env.TOKENMAXXING_SUPERVISED === "1" &&
|
|
46
|
+
const canPause = process.env.TOKENMAXXING_SUPERVISED === "1" && pinnedSid != null;
|
|
38
47
|
const decision = await evaluateAndMaybeSwap(Date.now(), canPause);
|
|
39
48
|
if (decision.account && (decision.swapped || decision.waitUntil !== undefined)) {
|
|
40
49
|
log(decision.swapped ? "stop.swapped" : "stop.wait", { account: decision.account.accountUuid.slice(0, 8), waitUntil: decision.waitUntil });
|
|
41
50
|
// Respawn only for a depleted-pool wait: pausing until the reset requires
|
|
42
51
|
// killing the child. A plain swap leaves the session running to adopt.
|
|
43
|
-
if (decision.waitUntil !== undefined &&
|
|
44
|
-
const marker = join(paths.respawnDir,
|
|
45
|
-
const payload = RespawnMarkerSchema.parse({
|
|
52
|
+
if (decision.waitUntil !== undefined && canPause && pinnedSid) {
|
|
53
|
+
const marker = join(paths.respawnDir, pinnedSid);
|
|
54
|
+
const payload = RespawnMarkerSchema.parse({
|
|
55
|
+
account: decision.account.label,
|
|
56
|
+
ts: Date.now(),
|
|
57
|
+
waitUntil: decision.waitUntil,
|
|
58
|
+
sessionId: stdinSid ?? pinnedSid,
|
|
59
|
+
});
|
|
46
60
|
writeFileAtomic(marker, JSON.stringify(payload));
|
|
47
|
-
log("stop.marker", { session:
|
|
61
|
+
log("stop.marker", { session: (stdinSid ?? pinnedSid).slice(0, 8) });
|
|
48
62
|
}
|
|
49
63
|
}
|
|
50
64
|
} catch (e) {
|
|
51
|
-
log("stop.error", { err:
|
|
65
|
+
log("stop.error", { err: e instanceof Error ? e.message : String(e) });
|
|
52
66
|
}
|
|
53
67
|
return 0; // never block the stop
|
|
54
68
|
}
|
|
@@ -7,14 +7,14 @@
|
|
|
7
7
|
// relaunches `claude --resume <id>`. Process/terminal manager only - it never
|
|
8
8
|
// reads or proxies tokens.
|
|
9
9
|
|
|
10
|
-
import { existsSync, mkdirSync, rmSync, readdirSync, statSync } from "node:fs";
|
|
10
|
+
import { existsSync, mkdirSync, readFileSync, rmSync, readdirSync, statSync } from "node:fs";
|
|
11
11
|
import { join } from "node:path";
|
|
12
12
|
import { maxBy } from "es-toolkit";
|
|
13
13
|
import { z } from "zod";
|
|
14
14
|
import { paths } from "../lib/paths.ts";
|
|
15
15
|
import { LOOP_DIAGNOSIS, MAX_WRAP_DEPTH, WRAP_DEPTH_ENV, WRAP_RATE_MAX, WRAP_RATE_WINDOW_MS, resolveRealClaude, wrapDepth, wrapperEntryRateTripped } from "../lib/claudebin.ts";
|
|
16
16
|
import { saveTermios, restoreTermios } from "../lib/tty.ts";
|
|
17
|
-
import { loadSessionFlags, saveSessionFlags } from "../lib/sessions.ts";
|
|
17
|
+
import { loadSessionFlags, pruneStaleSessions, saveSessionFlags } from "../lib/sessions.ts";
|
|
18
18
|
import { RespawnMarkerSchema } from "../lib/types.ts";
|
|
19
19
|
import { log } from "../lib/log.ts";
|
|
20
20
|
|
|
@@ -23,6 +23,31 @@ const NONINTERACTIVE_SUBCMDS = new Set([
|
|
|
23
23
|
"setup-token", "plugin", "agents", "completion", "help",
|
|
24
24
|
]);
|
|
25
25
|
|
|
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
|
+
const VALUE_TAKING_ROOT_FLAGS = new Set([
|
|
34
|
+
"--agent", "--agents", "--append-system-prompt", "--append-system-prompt-file",
|
|
35
|
+
"--debug-file", "--effort", "--fallback-model", "--input-format",
|
|
36
|
+
"--json-schema", "--max-budget-usd", "--model", "-n", "--name",
|
|
37
|
+
"--output-format", "--permission-mode", "--plugin-dir", "--plugin-url",
|
|
38
|
+
"--remote-control-session-name-prefix", "--setting-sources", "--settings",
|
|
39
|
+
"--system-prompt",
|
|
40
|
+
]);
|
|
41
|
+
// Variadic (`<x...>`): commander consumes EVERY following non-dash token.
|
|
42
|
+
const VARIADIC_ROOT_FLAGS = new Set([
|
|
43
|
+
"--add-dir", "--allowedTools", "--allowed-tools", "--betas",
|
|
44
|
+
"--disallowedTools", "--disallowed-tools", "--file", "--mcp-config", "--tools",
|
|
45
|
+
]);
|
|
46
|
+
// Optional value (`[x]`): commander consumes the next token unless it is a flag.
|
|
47
|
+
const OPTIONAL_VALUE_ROOT_FLAGS = new Set([
|
|
48
|
+
"-d", "--debug", "--from-pr", "--prompt-suggestions", "--remote-control", "-w", "--worktree",
|
|
49
|
+
]);
|
|
50
|
+
|
|
26
51
|
const isUuid = (s: string) => z.uuid().safeParse(s).success;
|
|
27
52
|
|
|
28
53
|
const AnalysisSchema = z.object({
|
|
@@ -38,47 +63,144 @@ export function analyzeArgs(argv: string[]): Analysis {
|
|
|
38
63
|
let resumeId: string | null = null;
|
|
39
64
|
let continueLatest = false;
|
|
40
65
|
let printMode = false;
|
|
66
|
+
let invalidSessionArg = false;
|
|
67
|
+
let pickerResume = false;
|
|
68
|
+
let forkSession = false;
|
|
41
69
|
let firstPositional: string | null = null;
|
|
42
70
|
|
|
43
71
|
for (let i = 0; i < argv.length; i++) {
|
|
44
72
|
const a = argv[i]!;
|
|
45
73
|
if (a === "-p" || a === "--print") printMode = true;
|
|
46
74
|
else if (a === "--version" || a === "-v" || a === "--help" || a === "-h") printMode = true;
|
|
47
|
-
else if (a === "--session-id")
|
|
75
|
+
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
|
+
const next = argv[++i] ?? null;
|
|
81
|
+
if (next && isUuid(next)) sessionId = next;
|
|
82
|
+
else invalidSessionArg = true;
|
|
83
|
+
}
|
|
48
84
|
else if (a === "-c" || a === "--continue") continueLatest = true;
|
|
49
85
|
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).
|
|
50
94
|
const next = argv[i + 1];
|
|
51
95
|
if (next && !next.startsWith("-") && isUuid(next)) { resumeId = next; i++; }
|
|
52
|
-
|
|
96
|
+
else pickerResume = true;
|
|
97
|
+
}
|
|
98
|
+
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
|
+
else if (a.startsWith("--session-id=")) {
|
|
104
|
+
const value = a.slice("--session-id=".length);
|
|
105
|
+
if (isUuid(value)) sessionId = value;
|
|
106
|
+
else invalidSessionArg = true;
|
|
107
|
+
}
|
|
108
|
+
else if (a.startsWith("--resume=")) {
|
|
109
|
+
const value = a.slice("--resume=".length);
|
|
110
|
+
if (isUuid(value)) resumeId = value;
|
|
111
|
+
else pickerResume = true;
|
|
112
|
+
}
|
|
113
|
+
else if (VALUE_TAKING_ROOT_FLAGS.has(a)) i++;
|
|
114
|
+
else if (VARIADIC_ROOT_FLAGS.has(a)) {
|
|
115
|
+
while (i + 1 < argv.length && !argv[i + 1]!.startsWith("-")) i++;
|
|
116
|
+
}
|
|
117
|
+
else if (OPTIONAL_VALUE_ROOT_FLAGS.has(a)) {
|
|
118
|
+
if (argv[i + 1] !== undefined && !argv[i + 1]!.startsWith("-")) i++;
|
|
119
|
+
}
|
|
120
|
+
else if (!a.startsWith("-") && firstPositional === null) {
|
|
53
121
|
firstPositional = a;
|
|
54
122
|
}
|
|
55
123
|
}
|
|
56
124
|
|
|
57
125
|
const isSubcmd = firstPositional !== null && NONINTERACTIVE_SUBCMDS.has(firstPositional);
|
|
58
|
-
|
|
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
|
+
const forkResume = forkSession && (resumeId !== null || continueLatest);
|
|
131
|
+
const manage = !printMode && !isSubcmd && !invalidSessionArg && !pickerResume && !forkResume && !process.env.TOKENMAXXING_PROBE;
|
|
59
132
|
return { manage, sessionId, resumeId, continueLatest };
|
|
60
133
|
}
|
|
61
134
|
|
|
62
|
-
/** Remove session-selecting flags so we can inject our own on respawn.
|
|
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. */
|
|
63
138
|
export function stripSessionFlags(argv: string[]): string[] {
|
|
64
139
|
const out: string[] = [];
|
|
65
140
|
for (let i = 0; i < argv.length; i++) {
|
|
66
141
|
const a = argv[i]!;
|
|
67
142
|
if (a === "--session-id") { i++; continue; }
|
|
68
143
|
if (a === "-c" || a === "--continue") continue;
|
|
69
|
-
if (a === "-r" || a === "--resume") {
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
144
|
+
if (a === "-r" || a === "--resume") { i++; continue; }
|
|
145
|
+
// the commander `=` forms carry their value in the same token
|
|
146
|
+
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
|
+
if (a === "--fork-session") continue;
|
|
74
153
|
out.push(a);
|
|
75
154
|
}
|
|
76
155
|
return out;
|
|
77
156
|
}
|
|
78
157
|
|
|
79
|
-
/**
|
|
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
|
+
export function stripPositionals(argv: string[]): string[] {
|
|
176
|
+
const out: string[] = [];
|
|
177
|
+
for (let i = 0; i < argv.length; i++) {
|
|
178
|
+
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
|
+
if (a === "--") break;
|
|
183
|
+
if (!a.startsWith("-")) continue;
|
|
184
|
+
out.push(a);
|
|
185
|
+
if (VALUE_TAKING_ROOT_FLAGS.has(a)) {
|
|
186
|
+
if (i + 1 < argv.length) out.push(argv[++i]!);
|
|
187
|
+
} else if (VARIADIC_ROOT_FLAGS.has(a)) {
|
|
188
|
+
while (i + 1 < argv.length && !argv[i + 1]!.startsWith("-")) out.push(argv[++i]!);
|
|
189
|
+
} else if (OPTIONAL_VALUE_ROOT_FLAGS.has(a)) {
|
|
190
|
+
if (i + 1 < argv.length && !argv[i + 1]!.startsWith("-")) out.push(argv[++i]!);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
return out;
|
|
194
|
+
}
|
|
195
|
+
|
|
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). */
|
|
80
202
|
function latestSessionForCwd(): string | null {
|
|
81
|
-
const slug = process.cwd().replace(/[
|
|
203
|
+
const slug = process.cwd().replace(/[^a-zA-Z0-9]/g, "-");
|
|
82
204
|
const projDir = join(paths.claudeDir, "projects", slug);
|
|
83
205
|
if (!existsSync(projDir)) return null;
|
|
84
206
|
try {
|
|
@@ -92,6 +214,21 @@ function latestSessionForCwd(): string | null {
|
|
|
92
214
|
}
|
|
93
215
|
}
|
|
94
216
|
|
|
217
|
+
/** Read + validate a respawn marker. An unparseable one (a version-skew hook,
|
|
218
|
+
* corruption) is dropped loudly and reported as absent: the watcher checks
|
|
219
|
+
* validity BEFORE the SIGTERM, so garbage can never kill the session, and the
|
|
220
|
+
* post-exit consume can never throw after the child is already dead (PR #36
|
|
221
|
+
* review catch). */
|
|
222
|
+
function consumableMarker(marker: string): z.infer<typeof RespawnMarkerSchema> | null {
|
|
223
|
+
try {
|
|
224
|
+
return RespawnMarkerSchema.parse(JSON.parse(readFileSync(marker, "utf8")));
|
|
225
|
+
} catch (e) {
|
|
226
|
+
rmSync(marker, { force: true });
|
|
227
|
+
log("supervisor.marker_invalid", { err: e instanceof Error ? e.message : String(e) });
|
|
228
|
+
return null;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
95
232
|
/** Interruptible countdown until `until`, shown in the terminal (claude is dead,
|
|
96
233
|
* so the statusLine can't render it). Ctrl-C resumes immediately. */
|
|
97
234
|
async function countdownWait(acct: string, until: number): Promise<void> {
|
|
@@ -138,7 +275,17 @@ export async function runSupervisor(argv: string[]): Promise<number> {
|
|
|
138
275
|
|
|
139
276
|
// Pass-through: no session management, no respawn - exact stock behavior.
|
|
140
277
|
if (!info.manage) {
|
|
141
|
-
|
|
278
|
+
// STRIP the supervision pairing env (mirrors the codex shim's passthrough
|
|
279
|
+
// arm, closing-review catch): a nested unmanaged claude inside a
|
|
280
|
+
// supervised session (e.g. the agent running `claude -p ...`) would
|
|
281
|
+
// otherwise inherit TOKENMAXXING_SUPERVISED/TOKENMAXXING_SESSION_ID, and
|
|
282
|
+
// its Stop hooks - which DO fire in print mode - would compute
|
|
283
|
+
// canPause=true and could anticipatorily pre-park the pool against a
|
|
284
|
+
// marker path the OUTER supervisor owns.
|
|
285
|
+
const passthroughEnv: Record<string, string | undefined> = { ...childEnv };
|
|
286
|
+
delete passthroughEnv.TOKENMAXXING_SUPERVISED;
|
|
287
|
+
delete passthroughEnv.TOKENMAXXING_SESSION_ID;
|
|
288
|
+
const p = Bun.spawn([real, ...argv], { stdin: "inherit", stdout: "inherit", stderr: "inherit", env: passthroughEnv });
|
|
142
289
|
await p.exited;
|
|
143
290
|
return p.exitCode ?? (p.signalCode ? 1 : 0);
|
|
144
291
|
}
|
|
@@ -163,9 +310,18 @@ export async function runSupervisor(argv: string[]): Promise<number> {
|
|
|
163
310
|
// this time (a bare `claude --resume <id>`, or the depleted-pool recovery).
|
|
164
311
|
if (resuming && base.length === 0) {
|
|
165
312
|
const persisted = loadSessionFlags(sid);
|
|
166
|
-
|
|
313
|
+
// enforce the flags-only contract at the trust boundary, not just at
|
|
314
|
+
// write: a sessions/ file written before stripPositionals existed can
|
|
315
|
+
// still carry the original prompt, and restoring it verbatim would
|
|
316
|
+
// re-submit that prompt on a bare `claude --resume` (PR #37 review
|
|
317
|
+
// catch). Idempotent on well-formed files.
|
|
318
|
+
if (persisted) base = stripPositionals(persisted);
|
|
167
319
|
}
|
|
168
|
-
|
|
320
|
+
// The FIRST launch keeps a positional prompt (the user just typed it);
|
|
321
|
+
// everything persisted or respawned carries flags only.
|
|
322
|
+
const persistable = stripPositionals(base);
|
|
323
|
+
saveSessionFlags(sid, persistable, process.cwd());
|
|
324
|
+
pruneStaleSessions(Date.now());
|
|
169
325
|
|
|
170
326
|
let launchArgs = resuming ? ["--resume", sid, ...base] : ["--session-id", sid, ...base];
|
|
171
327
|
|
|
@@ -194,7 +350,7 @@ export async function runSupervisor(argv: string[]): Promise<number> {
|
|
|
194
350
|
let done = false;
|
|
195
351
|
const markerWatch = (async () => {
|
|
196
352
|
while (!done) {
|
|
197
|
-
if (
|
|
353
|
+
if (existsSync(marker) && consumableMarker(marker) != null) return true;
|
|
198
354
|
await Bun.sleep(150);
|
|
199
355
|
}
|
|
200
356
|
return false;
|
|
@@ -210,13 +366,21 @@ export async function runSupervisor(argv: string[]): Promise<number> {
|
|
|
210
366
|
await markerWatch.catch(() => {});
|
|
211
367
|
restoreTermios(savedTermios);
|
|
212
368
|
|
|
213
|
-
|
|
214
|
-
|
|
369
|
+
const m = existsSync(marker) ? consumableMarker(marker) : null;
|
|
370
|
+
if (m) {
|
|
215
371
|
rmSync(marker, { force: true });
|
|
216
372
|
respawns++;
|
|
217
373
|
if (m.waitUntil > Date.now()) await countdownWait(m.account, m.waitUntil);
|
|
218
374
|
else process.stdout.write(`\n\x1b[36m↻ tokenmaxxing: switched to ${m.account} - resuming...\x1b[0m\n`);
|
|
219
|
-
|
|
375
|
+
// resume the marker's CURRENT transcript, not the pinned id: after
|
|
376
|
+
// /clear they differ, and resuming the pinned id would revive the
|
|
377
|
+
// pre-/clear conversation (closing-review HIGH catch). Persist the
|
|
378
|
+
// flags under that transcript id too, so a later bare
|
|
379
|
+
// `claude --resume <id>` restores them (PR #36 review catch). FLAGS
|
|
380
|
+
// only: replaying a positional prompt would re-submit it as a fresh
|
|
381
|
+
// turn on the progressed session (adversarial-review HIGH catch).
|
|
382
|
+
saveSessionFlags(m.sessionId, persistable, process.cwd());
|
|
383
|
+
launchArgs = ["--resume", m.sessionId, ...persistable];
|
|
220
384
|
continue;
|
|
221
385
|
}
|
|
222
386
|
// No marker: claude exited on its own (quit, crash, resume refused). Log it -
|
package/src/lib/atomic.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// Atomic file writes (temp + rename on the same filesystem) and small fs utils.
|
|
2
2
|
|
|
3
|
-
import { closeSync, mkdirSync, openSync, renameSync, writeSync, fsyncSync } from "node:fs";
|
|
3
|
+
import { closeSync, mkdirSync, openSync, renameSync, rmSync, writeSync, fsyncSync } from "node:fs";
|
|
4
4
|
import { dirname } from "node:path";
|
|
5
5
|
|
|
6
6
|
/**
|
|
@@ -15,10 +15,32 @@ export function writeFileAtomic(file: string, data: string | Uint8Array, mode =
|
|
|
15
15
|
const bytes = data instanceof Uint8Array ? data : new TextEncoder().encode(data);
|
|
16
16
|
const fd = openSync(tmp, "wx", mode);
|
|
17
17
|
try {
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
18
|
+
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
|
+
let offset = 0;
|
|
25
|
+
while (offset < bytes.length) {
|
|
26
|
+
const written = writeSync(fd, bytes, offset);
|
|
27
|
+
if (written <= 0) throw new Error(`short write on ${tmp}: ${offset}/${bytes.length} bytes (disk full?)`);
|
|
28
|
+
offset += written;
|
|
29
|
+
}
|
|
30
|
+
fsyncSync(fd);
|
|
31
|
+
} finally {
|
|
32
|
+
closeSync(fd);
|
|
33
|
+
}
|
|
34
|
+
renameSync(tmp, file);
|
|
35
|
+
} 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
|
+
try {
|
|
40
|
+
rmSync(tmp, { force: true });
|
|
41
|
+
} catch {
|
|
42
|
+
// the original write error below is the one that matters
|
|
43
|
+
}
|
|
44
|
+
throw e;
|
|
22
45
|
}
|
|
23
|
-
renameSync(tmp, file);
|
|
24
46
|
}
|
package/src/lib/claudebin.ts
CHANGED
|
@@ -72,7 +72,7 @@ export function pointsBackAtUs(bin: string): boolean {
|
|
|
72
72
|
* All of them, not just the first: a user-made wrapper script named claude can
|
|
73
73
|
* sit ahead of the real binary, and verified resolution must be able to walk
|
|
74
74
|
* past it. */
|
|
75
|
-
|
|
75
|
+
function scanPathForClaudeCandidates(): string[] {
|
|
76
76
|
const seen = new Set<string>();
|
|
77
77
|
const out: string[] = [];
|
|
78
78
|
for (const d of (process.env.PATH ?? "").split(":")) {
|
|
@@ -133,7 +133,7 @@ export function verifyRealClaude(bin: string): string | null {
|
|
|
133
133
|
// loop-abort diagnostic points the user at.
|
|
134
134
|
p = Bun.spawnSync([bin, "--version"], { env, stdout: "pipe", stderr: "pipe", timeout: 15_000, killSignal: "SIGKILL" });
|
|
135
135
|
} catch (e) {
|
|
136
|
-
return
|
|
136
|
+
return e instanceof Error ? e.message : String(e);
|
|
137
137
|
}
|
|
138
138
|
const outText = (p.stdout?.toString() ?? "").trim();
|
|
139
139
|
const err = (p.stderr?.toString() ?? "").trim();
|
package/src/lib/claudejson.ts
CHANGED
|
@@ -7,9 +7,11 @@ import { paths } from "./paths.ts";
|
|
|
7
7
|
import { writeFileAtomic } from "./atomic.ts";
|
|
8
8
|
import { OAuthAccountSchema, type OAuthAccount } from "./types.ts";
|
|
9
9
|
|
|
10
|
-
|
|
10
|
+
const ClaudeJsonSchema = z.record(z.string(), z.unknown());
|
|
11
|
+
|
|
12
|
+
function readClaudeJson(): Record<string, unknown> {
|
|
11
13
|
if (!existsSync(paths.claudeJson)) return {};
|
|
12
|
-
return JSON.parse(readFileSync(paths.claudeJson, "utf8"))
|
|
14
|
+
return ClaudeJsonSchema.parse(JSON.parse(readFileSync(paths.claudeJson, "utf8")));
|
|
13
15
|
}
|
|
14
16
|
|
|
15
17
|
export function readOAuthAccount(): OAuthAccount | null {
|
|
@@ -32,9 +34,7 @@ export function isApiKeyMode(): boolean {
|
|
|
32
34
|
* to unrelated keys with a stale in-memory copy.
|
|
33
35
|
*/
|
|
34
36
|
export function swapOAuthAccount(next: OAuthAccount): void {
|
|
35
|
-
const j =
|
|
36
|
-
? (JSON.parse(readFileSync(paths.claudeJson, "utf8")) as Record<string, unknown>)
|
|
37
|
-
: {};
|
|
37
|
+
const j = readClaudeJson();
|
|
38
38
|
j["oauthAccount"] = next;
|
|
39
39
|
writeFileAtomic(paths.claudeJson, JSON.stringify(j, null, 2) + "\n", 0o600);
|
|
40
40
|
}
|