tokenmaxxing 1.56.0 → 1.57.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 +1 -1
- package/package.json +1 -1
- package/src/entries/supervisor.ts +41 -14
package/DESIGN.md
CHANGED
|
@@ -59,7 +59,7 @@ The Stop hook's stdin has no usage data, but the **statusLine does** (`rate_limi
|
|
|
59
59
|
4. Write `respawn/<session-id>` (atomic temp+rename) naming the target, with `waitUntil` now for a plain move or the soonest reset when the pool is depleted. No credential is written.
|
|
60
60
|
|
|
61
61
|
### 3.3 Move and depleted-pool pause (supervisor)
|
|
62
|
-
The supervisor sees `respawn/<sid>`, SIGTERMs its child at the already-committed turn boundary, deletes the marker, resets the terminal, compacts the conversation when the marker asks for it (`compact: true` from the Stop and SessionStart hooks and from the supervisor's own seat watch, `false` from StopFailure: it runs `claude -p --resume <sid> /compact` under the old seat's store, bounded at five minutes, and a failure only prints why), and (when `waitUntil` is in the future) shows an interruptible countdown to the reset; then it relaunches `claude --resume <sid>` with `CLAUDE_SECURESTORAGE_CONFIG_DIR` set to the target's store and rewrites the session's presence. The resumed process reads that store cold → runs on the target account, same conversation. The relaunch submits a first prompt that names the move (and the compaction, when it landed) and asks Claude to continue the task, so the conversation goes on without a keystroke: in a terminal session it is the positional prompt after the session id; in a stream-json session (`--input-format stream-json`, where the binary ignores a positional prompt) the supervisor owns stdin, relays the harness's lines to the child through a pipe, and writes the prompt as the first `user` line of the new process.
|
|
62
|
+
The supervisor sees `respawn/<sid>`, SIGTERMs its child at the already-committed turn boundary, deletes the marker, resets the terminal, compacts the conversation when the marker asks for it (`compact: true` from the Stop and SessionStart hooks and from the supervisor's own seat watch, `false` from StopFailure: it runs `claude -p --resume <sid> /compact` under the old seat's store, bounded at five minutes, and a failure only prints why), and (when `waitUntil` is in the future) shows an interruptible countdown to the reset; then it relaunches `claude --resume <sid>` with `CLAUDE_SECURESTORAGE_CONFIG_DIR` set to the target's store and rewrites the session's presence. The resumed process reads that store cold → runs on the target account, same conversation. The relaunch submits a first prompt that names the move (and the compaction, when it landed) and asks Claude to continue the task, so the conversation goes on without a keystroke: in a terminal session it is the positional prompt after the session id; in a stream-json session (`--input-format stream-json`, where the binary ignores a positional prompt) the supervisor owns stdin, relays the harness's lines to the child through a pipe, and writes the prompt as the first `user` line of the new process. In a terminal session the supervisor's own notices go to stderr. In a stream-json session (`--output-format stream-json`, read from the effective flags like the relay) there is no terminal and no statusline, so the supervisor writes each notice (the compaction and its failure, the move, the depleted-pool wait with its resume time, the resume) to stdout as a `system` line of subtype `informational` and level `warning`, the shape Claude Code emits for its own print-mode notices, which the harness renders in the conversation while the stream stays parseable; the per-second countdown ticks stay terminal-only, and the supervisor writes only while no child is running. The `SessionStart` hook (every source) re-runs the decision before the first turn and writes the same marker if the seat is already over its bar or the pool is depleted. The supervisor also watches its own seat every ten seconds while the child runs (account record plus tee, the same bars) and writes the marker itself when the seat is at or over a bar, so a move never depends on a hook firing: a harness session between turns whose background workflow burns the window, or a subagent refusal that stamped the wall, is moved within seconds. SIGTERM to the supervisor is forwarded to the child and the supervisor exits after it, so a harness that stops the wrapper (the Agent SDK does) never leaves an orphaned claude on the old seat. A marker whose `launchedAt` does not match the running child is discarded, and after a Ctrl-C on the countdown a later marker whose `waitUntil` falls inside the skipped wait is ignored. A marker that fails to parse is never deleted and never treated as absent: the supervisor stops the child, restores the terminal, and exits naming the marker path.
|
|
63
63
|
|
|
64
64
|
### 3.4 Placement (supervisor, under the pool lock)
|
|
65
65
|
1. Fold every account's tee into the index.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tokenmaxxing",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.57.0",
|
|
4
4
|
"description": "Automatic Claude Code account switching: pool multiple accounts, start each session on its own account, and resume a session on a fresher account when quota fills.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -50,6 +50,7 @@ const AnalysisSchema = z.object({
|
|
|
50
50
|
resumeId: z.string().nullable(),
|
|
51
51
|
continueLatest: z.boolean(),
|
|
52
52
|
streamInput: z.boolean(),
|
|
53
|
+
streamOutput: z.boolean(),
|
|
53
54
|
});
|
|
54
55
|
type Analysis = z.infer<typeof AnalysisSchema>;
|
|
55
56
|
|
|
@@ -58,6 +59,7 @@ export function analyzeArgs(argv: string[]): Analysis {
|
|
|
58
59
|
let resumeId: string | null = null;
|
|
59
60
|
let continueLatest = false;
|
|
60
61
|
let streamInput = false;
|
|
62
|
+
let streamOutput = false;
|
|
61
63
|
let printMode = false;
|
|
62
64
|
let invalidSessionArg = false;
|
|
63
65
|
let pickerResume = false;
|
|
@@ -66,7 +68,8 @@ export function analyzeArgs(argv: string[]): Analysis {
|
|
|
66
68
|
|
|
67
69
|
for (let i = 0; i < argv.length; i++) {
|
|
68
70
|
const a = argv[i]!;
|
|
69
|
-
if (a === "
|
|
71
|
+
if (a === "--") break;
|
|
72
|
+
else if (a === "-p" || a === "--print") printMode = true;
|
|
70
73
|
else if (a === "--version" || a === "-v" || a === "--help" || a === "-h") printMode = true;
|
|
71
74
|
else if (a === "--session-id") {
|
|
72
75
|
const next = argv[++i] ?? null;
|
|
@@ -92,6 +95,8 @@ export function analyzeArgs(argv: string[]): Analysis {
|
|
|
92
95
|
}
|
|
93
96
|
else if (a === "--input-format") streamInput = argv[++i] === "stream-json";
|
|
94
97
|
else if (a.startsWith("--input-format=")) streamInput = a.slice("--input-format=".length) === "stream-json";
|
|
98
|
+
else if (a === "--output-format") streamOutput = argv[++i] === "stream-json";
|
|
99
|
+
else if (a.startsWith("--output-format=")) streamOutput = a.slice("--output-format=".length) === "stream-json";
|
|
95
100
|
else if (VALUE_TAKING_ROOT_FLAGS.has(a)) i++;
|
|
96
101
|
else if (VARIADIC_ROOT_FLAGS.has(a)) {
|
|
97
102
|
while (i + 1 < argv.length && !argv[i + 1]!.startsWith("-")) i++;
|
|
@@ -107,7 +112,7 @@ export function analyzeArgs(argv: string[]): Analysis {
|
|
|
107
112
|
const isSubcmd = firstPositional !== null && NONINTERACTIVE_SUBCMDS.has(firstPositional);
|
|
108
113
|
const forkResume = forkSession && (resumeId !== null || continueLatest);
|
|
109
114
|
const manage = !printMode && !isSubcmd && !invalidSessionArg && !pickerResume && !forkResume && !process.env.TOKENMAXXING_PROBE;
|
|
110
|
-
return { manage, sessionId, resumeId, continueLatest, streamInput };
|
|
115
|
+
return { manage, sessionId, resumeId, continueLatest, streamInput, streamOutput };
|
|
111
116
|
}
|
|
112
117
|
|
|
113
118
|
export function stripSessionFlags(argv: string[]): string[] {
|
|
@@ -222,20 +227,34 @@ async function moveExhaustedSeat(seat: Account, sid: string, gate: MarkerGate):
|
|
|
222
227
|
return true;
|
|
223
228
|
}
|
|
224
229
|
|
|
225
|
-
|
|
230
|
+
type Say = (terminal: string, text: string) => void;
|
|
231
|
+
|
|
232
|
+
function systemLine(sid: string, text: string): string {
|
|
233
|
+
return `${JSON.stringify({ type: "system", subtype: "informational", content: text, level: "warning", uuid: crypto.randomUUID(), session_id: sid })}\n`;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const clockFmt = new Intl.DateTimeFormat("en-US", { hour: "2-digit", minute: "2-digit", hourCycle: "h23", timeZoneName: "short" });
|
|
237
|
+
|
|
238
|
+
async function countdownWait(acct: string, until: number, out: { stream: boolean; say: Say }): Promise<boolean> {
|
|
226
239
|
let aborted = false;
|
|
227
240
|
const onInt = () => { aborted = true; };
|
|
228
241
|
process.on("SIGINT", onInt);
|
|
229
|
-
|
|
242
|
+
const minutes = Math.max(1, Math.ceil((until - Date.now()) / 60000));
|
|
243
|
+
out.say(
|
|
244
|
+
`\n\x1b[36m⏳ tokenmaxxing: all accounts at their limit. Resuming on ${acct} when it resets (Ctrl-C to resume now).\x1b[0m\n`,
|
|
245
|
+
`tokenmaxxing: all accounts at their limit. Resuming at ${clockFmt.format(until)} (in ${minutes} min) on ${acct}.`,
|
|
246
|
+
);
|
|
230
247
|
while (!aborted && Date.now() < until) {
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
248
|
+
if (!out.stream) {
|
|
249
|
+
const left = until - Date.now();
|
|
250
|
+
const m = Math.floor(left / 60000);
|
|
251
|
+
const s = Math.floor((left % 60000) / 1000);
|
|
252
|
+
process.stderr.write(`\r\x1b[36m resuming in ${m}m ${String(s).padStart(2, "0")}s \x1b[0m`);
|
|
253
|
+
}
|
|
235
254
|
await Bun.sleep(1000);
|
|
236
255
|
}
|
|
237
256
|
process.removeListener("SIGINT", onInt);
|
|
238
|
-
|
|
257
|
+
out.say(`\n\x1b[36m↻ resuming on ${acct}...\x1b[0m\n`, `tokenmaxxing: resuming on ${acct}.`);
|
|
239
258
|
return aborted;
|
|
240
259
|
}
|
|
241
260
|
|
|
@@ -406,7 +425,14 @@ export async function runSupervisor(argv: string[]): Promise<number> {
|
|
|
406
425
|
process.on("SIGINT", () => {});
|
|
407
426
|
process.on("SIGHUP", () => {});
|
|
408
427
|
|
|
409
|
-
const
|
|
428
|
+
const effective = analyzeArgs(base);
|
|
429
|
+
const relay = effective.streamInput ? new StdinRelay() : null;
|
|
430
|
+
const stream = effective.streamOutput;
|
|
431
|
+
let noticeSid = sid;
|
|
432
|
+
const say: Say = (terminal, text) => {
|
|
433
|
+
if (stream) process.stdout.write(systemLine(noticeSid, text));
|
|
434
|
+
else process.stderr.write(terminal);
|
|
435
|
+
};
|
|
410
436
|
let firstLine: string | null = null;
|
|
411
437
|
let respawns = 0;
|
|
412
438
|
let overriddenUntil = 0;
|
|
@@ -475,23 +501,24 @@ export async function runSupervisor(argv: string[]): Promise<number> {
|
|
|
475
501
|
if (m) {
|
|
476
502
|
rmSync(marker, { force: true });
|
|
477
503
|
respawns++;
|
|
504
|
+
noticeSid = m.sessionId;
|
|
478
505
|
const label = loadAccounts(claudePool).accounts.find((a) => a.id === m.accountId)?.label ?? m.accountId.slice(0, 8);
|
|
479
506
|
const resumable = existsSync(transcriptPath(m.sessionId));
|
|
480
507
|
let compacted = false;
|
|
481
508
|
if (m.compact && seat && resumable) {
|
|
482
|
-
|
|
509
|
+
say(`\n\x1b[36m↻ tokenmaxxing: compacting the conversation on ${seat.label} before the move...\x1b[0m\n`, `tokenmaxxing: compacting the conversation on ${seat.label} before the move.`);
|
|
483
510
|
const compactEnv: Record<string, string | undefined> = { ...childEnv, TOKENMAXXING_PROBE: "1", CLAUDE_SECURESTORAGE_CONFIG_DIR: storeDirFor(seat.id) };
|
|
484
511
|
delete compactEnv.TOKENMAXXING_SUPERVISED;
|
|
485
512
|
delete compactEnv.TOKENMAXXING_SESSION_ID;
|
|
486
513
|
delete compactEnv.TOKENMAXXING_LAUNCHED_AT;
|
|
487
514
|
const outcome = await compactClaudeSession({ real, sid: m.sessionId, env: compactEnv });
|
|
488
515
|
log("supervisor.compact", { sid: m.sessionId.slice(0, 8), seat: seat.id.slice(0, 8), ok: outcome.ok, reason: outcome.ok ? undefined : outcome.reason });
|
|
489
|
-
if (!outcome.ok)
|
|
516
|
+
if (!outcome.ok) say(`\x1b[33m compaction did not land (${outcome.reason}) - resuming with the full context\x1b[0m\n`, `tokenmaxxing: compaction did not land; resuming with the full context. (${outcome.reason})`);
|
|
490
517
|
compacted = outcome.ok;
|
|
491
518
|
}
|
|
492
519
|
if (m.waitUntil > Date.now()) {
|
|
493
|
-
if (await countdownWait(label, m.waitUntil)) overriddenUntil = m.waitUntil;
|
|
494
|
-
} else
|
|
520
|
+
if (await countdownWait(label, m.waitUntil, { stream, say })) overriddenUntil = m.waitUntil;
|
|
521
|
+
} else say(`\n\x1b[36m↻ tokenmaxxing: moving to ${label} - resuming...\x1b[0m\n`, `tokenmaxxing: moving to ${label} and resuming.`);
|
|
495
522
|
wanted = m.accountId;
|
|
496
523
|
saveSessionFlags(m.sessionId, persistable, process.cwd());
|
|
497
524
|
const prompt = resumable ? resumePrompt(compacted) : null;
|