tokenmaxxing 1.51.0 → 1.52.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 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, `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. The supervisor's own notices go to stderr, so a stream-json stdout stays parseable. 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. 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.
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. The supervisor's own notices go to stderr, so a stream-json stdout stays parseable. 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.51.0",
3
+ "version": "1.52.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",
@@ -3,7 +3,7 @@ import { claude } from "../lib/claude.ts";
3
3
  import { evaluateAndMaybeSwap } from "../lib/decide.ts";
4
4
  import { supervisedSession, writeRespawnMarker } from "../lib/sessions.ts";
5
5
  import { loadConfig } from "../lib/state.ts";
6
- import { classifyEnforcedLimit, findEnforcedRow, parseErrorBody, readTranscriptTail } from "../lib/usage.ts";
6
+ import { classifyEnforcedLimit, findEnforcedRow, parseErrorBody, readTranscriptTail, type TranscriptRow } from "../lib/usage.ts";
7
7
  import { paths } from "../lib/paths.ts";
8
8
  import { JsonTextSchema, type EnforcedLimit } from "../lib/types.ts";
9
9
  import { log } from "../lib/log.ts";
@@ -17,6 +17,18 @@ const StopFailureStdin = z.looseObject({
17
17
  last_assistant_message: z.string().optional().catch(undefined),
18
18
  });
19
19
 
20
+ const ROW_WAIT_MS = 3_000;
21
+ const ROW_POLL_MS = 200;
22
+
23
+ async function awaitEnforcedRow(input: { transcriptPath: string; lastAssistantMessage: string | undefined; now: number }): Promise<TranscriptRow | null> {
24
+ const deadline = Date.now() + ROW_WAIT_MS;
25
+ while (true) {
26
+ const row = findEnforcedRow({ rows: readTranscriptTail(input.transcriptPath), lastAssistantMessage: input.lastAssistantMessage, now: input.now });
27
+ if (row || Date.now() >= deadline) return row;
28
+ await Bun.sleep(ROW_POLL_MS);
29
+ }
30
+ }
31
+
20
32
  export async function runStopFailureHook(): Promise<number> {
21
33
  if (process.env.TOKENMAXXING_PROBE) return 0;
22
34
 
@@ -35,7 +47,7 @@ export async function runStopFailureHook(): Promise<number> {
35
47
  try {
36
48
  const cfg = loadConfig();
37
49
  const row = stdin.transcript_path
38
- ? findEnforcedRow({ rows: readTranscriptTail(stdin.transcript_path), lastAssistantMessage: stdin.last_assistant_message, now })
50
+ ? await awaitEnforcedRow({ transcriptPath: stdin.transcript_path, lastAssistantMessage: stdin.last_assistant_message, now })
39
51
  : null;
40
52
  const limit = row ? classifyEnforcedLimit(row, cfg.policy.switchModels) : null;
41
53
 
@@ -46,6 +58,7 @@ export async function runStopFailureHook(): Promise<number> {
46
58
  } else {
47
59
  log("stopfailure.unclassified", {
48
60
  seat: account != null,
61
+ subagent: !mainLoop,
49
62
  row: row != null,
50
63
  type: row?.quotaLimits?.rateLimitType,
51
64
  transient: row?.apiErrorIsTransient,
@@ -5,13 +5,16 @@ import { maxBy } from "es-toolkit";
5
5
  import { z } from "zod";
6
6
  import { claudePool, paths, storeDirFor } from "../lib/paths.ts";
7
7
  import { LOOP_DIAGNOSIS, MAX_WRAP_DEPTH, UNMANAGED_ENV, WRAP_DEPTH_ENV, WRAP_RATE_MAX, WRAP_RATE_WINDOW_MS, resolveRealClaude, wrapDepth, wrapperEntryRateTripped } from "../lib/claudebin.ts";
8
- import { pickSeat } from "../lib/claude.ts";
8
+ import { claude, pickSeat } from "../lib/claude.ts";
9
9
  import { compactClaudeSession } from "../lib/compact.ts";
10
+ import { evaluateAndMaybeSwap } from "../lib/decide.ts";
10
11
  import { withLock } from "../lib/lock.ts";
12
+ import { thresholdBars, usableAt } from "../lib/picker.ts";
11
13
  import { clearPresence, writePresence } from "../lib/presence.ts";
14
+ import { teeObservation } from "../lib/sample.ts";
12
15
  import { saveTermios, restoreTermios } from "../lib/tty.ts";
13
- import { loadSessionFlags, pruneStaleSessions, saveSessionFlags } from "../lib/sessions.ts";
14
- import { loadAccounts } from "../lib/state.ts";
16
+ import { loadSessionFlags, pruneStaleSessions, saveSessionFlags, writeRespawnMarker } from "../lib/sessions.ts";
17
+ import { loadAccounts, loadConfig } from "../lib/state.ts";
15
18
  import { RespawnMarkerSchema, type Account } from "../lib/types.ts";
16
19
  import { log } from "../lib/log.ts";
17
20
 
@@ -190,6 +193,35 @@ function consumableMarker(marker: string, gate: MarkerGate): z.infer<typeof Resp
190
193
  return m;
191
194
  }
192
195
 
196
+ const SEAT_POLL_MS = 10_000;
197
+ const SEAT_RETRY_MS = 60_000;
198
+
199
+ function seatBlockedUntil(seatId: string, now: number): number | null {
200
+ const account = loadAccounts(claudePool).accounts.find((a) => a.id === seatId);
201
+ if (!account) return null;
202
+ const cfg = loadConfig();
203
+ const observed = teeObservation(account);
204
+ const current = observed ? { ...account, windows: observed.windows } : account;
205
+ const until = usableAt(current, { now, thresholds: thresholdBars(cfg), currentId: seatId, families: cfg.policy.switchModels, seats: null });
206
+ return until > now ? until : null;
207
+ }
208
+
209
+ async function moveExhaustedSeat(seat: Account, sid: string, gate: MarkerGate): Promise<boolean> {
210
+ try {
211
+ const now = Date.now();
212
+ const until = seatBlockedUntil(seat.id, now);
213
+ if (until == null || until <= gate.overriddenUntil) return false;
214
+ const decision = await evaluateAndMaybeSwap(claude, now, true, null, seat.id);
215
+ log("supervisor.seat_exhausted", { seat: seat.id.slice(0, 8), until, reason: decision.reason, account: decision.account?.id.slice(0, 8), waitUntil: decision.waitUntil });
216
+ if (decision.account && (decision.swapped || decision.waitUntil !== undefined)) {
217
+ writeRespawnMarker({ session: { sid, launchedAt: gate.launchedAt }, sessionId: sid, accountId: decision.account.id, waitUntil: decision.waitUntil ?? now, compact: true });
218
+ }
219
+ } catch (e) {
220
+ log("supervisor.seat_watch_error", { err: e instanceof Error ? e.message : String(e) });
221
+ }
222
+ return true;
223
+ }
224
+
193
225
  async function countdownWait(acct: string, until: number): Promise<boolean> {
194
226
  let aborted = false;
195
227
  const onInt = () => { aborted = true; };
@@ -324,11 +356,20 @@ export async function runSupervisor(argv: string[]): Promise<number> {
324
356
  const info = analyzeArgs(argv);
325
357
  const childEnv = { ...process.env, [WRAP_DEPTH_ENV]: String(depth + 1) };
326
358
 
359
+ let child: Subprocess | null = null;
360
+ let terminating = false;
361
+ process.on("SIGTERM", () => {
362
+ terminating = true;
363
+ if (child) child.kill("SIGTERM");
364
+ else process.exit(143);
365
+ });
366
+
327
367
  if (!info.manage || process.env[UNMANAGED_ENV]) {
328
368
  const passthroughEnv: Record<string, string | undefined> = { ...childEnv };
329
369
  delete passthroughEnv.TOKENMAXXING_SUPERVISED;
330
370
  delete passthroughEnv.TOKENMAXXING_SESSION_ID;
331
371
  const p = Bun.spawn([real, ...argv], { stdin: "inherit", stdout: "inherit", stderr: "inherit", env: passthroughEnv });
372
+ child = p;
332
373
  await p.exited;
333
374
  return p.exitCode ?? (p.signalCode ? 1 : 0);
334
375
  }
@@ -374,7 +415,7 @@ export async function runSupervisor(argv: string[]): Promise<number> {
374
415
  if (existsSync(marker)) rmSync(marker, { force: true });
375
416
 
376
417
  const gate: MarkerGate = { launchedAt: Date.now(), overriddenUntil };
377
- const { child, seat } = await withLock(claudePool.lockFile, async () => {
418
+ const { child: proc, seat } = await withLock(claudePool.lockFile, async () => {
378
419
  const picked = (wanted == null ? null : (loadAccounts(claudePool).accounts.find((a) => a.id === wanted) ?? null)) ?? pickSeat(gate.launchedAt);
379
420
  log("supervisor.launch", { sid, respawns, seat: picked?.id.slice(0, 8) ?? null, args: launchArgs.join(" "), injected: firstLine !== null });
380
421
  const spawned = Bun.spawn([real, ...launchArgs], {
@@ -395,35 +436,42 @@ export async function runSupervisor(argv: string[]): Promise<number> {
395
436
  return { child: spawned, seat: picked };
396
437
  });
397
438
 
439
+ child = proc;
398
440
  let done = false;
441
+ let seatCheckAt = 0;
399
442
  const markerWatch = (async () => {
400
443
  while (!done) {
401
444
  if (existsSync(marker) && consumableMarker(marker, gate) != null) return true;
445
+ if (seat && !terminating && Date.now() >= seatCheckAt) {
446
+ const decided = await moveExhaustedSeat(seat, sid, gate);
447
+ seatCheckAt = Date.now() + (decided ? SEAT_RETRY_MS : SEAT_POLL_MS);
448
+ }
402
449
  await Bun.sleep(150);
403
450
  }
404
451
  return false;
405
452
  })();
406
- const exited = child.exited.then(() => { done = true; return "exit" as const; });
453
+ const exited = proc.exited.then(() => { done = true; return "exit" as const; });
407
454
  try {
408
455
  const winner = await Promise.race([exited, markerWatch.then((m) => (m ? "marker" : "exit"))]);
409
456
  if (winner === "marker") {
410
457
  relay?.detach();
411
- child.kill();
458
+ proc.kill();
412
459
  }
413
460
  } catch (e) {
414
461
  relay?.detach();
415
- child.kill();
462
+ proc.kill();
416
463
  clearPresence({ dir: paths.presenceDir, id: sid });
417
464
  throw e;
418
465
  } finally {
419
- await child.exited;
466
+ await proc.exited;
467
+ child = null;
420
468
  relay?.detach();
421
469
  done = true;
422
470
  await markerWatch.catch(() => {});
423
471
  restoreTermios(savedTermios);
424
472
  }
425
473
 
426
- const m = existsSync(marker) ? consumableMarker(marker, gate) : null;
474
+ const m = !terminating && existsSync(marker) ? consumableMarker(marker, gate) : null;
427
475
  if (m) {
428
476
  rmSync(marker, { force: true });
429
477
  respawns++;
@@ -452,7 +500,7 @@ export async function runSupervisor(argv: string[]): Promise<number> {
452
500
  continue;
453
501
  }
454
502
  clearPresence({ dir: paths.presenceDir, id: sid });
455
- log("supervisor.exit", { sid, respawns, code: child.exitCode, signal: child.signalCode });
456
- return child.exitCode ?? (child.signalCode ? 1 : 0);
503
+ log("supervisor.exit", { sid, respawns, code: proc.exitCode, signal: proc.signalCode, terminated: terminating || undefined });
504
+ return proc.exitCode ?? (proc.signalCode ? 1 : 0);
457
505
  }
458
506
  }
package/src/lib/decide.ts CHANGED
@@ -43,8 +43,8 @@ function enforcedWall(limit: EnforcedLimit, account: Account, now: number): numb
43
43
  return limit.resetsAt ?? cachedReset ?? now + (limit.kind === "session" ? FIVE_HOURS_MS : WEEK_MS);
44
44
  }
45
45
 
46
- export async function evaluateAndMaybeSwap(p: Provider, now = Date.now(), canRespawn = false, enforced: EnforcedLimit | null = null): Promise<SwapDecision> {
47
- const activeId = p.liveId();
46
+ export async function evaluateAndMaybeSwap(p: Provider, now = Date.now(), canRespawn = false, enforced: EnforcedLimit | null = null, seatId: string | null = p.liveId()): Promise<SwapDecision> {
47
+ const activeId = seatId;
48
48
 
49
49
  const lastSwapAt = loadLastSwapAt(p.pool);
50
50
  if (!enforced && lastSwapAt != null && now - lastSwapAt < POST_SWAP_COOLDOWN_MS) {
@@ -68,7 +68,7 @@ export async function evaluateAndMaybeSwap(p: Provider, now = Date.now(), canRes
68
68
  return { swapped: false, account: null, reason: "raced-already-swapped" };
69
69
  }
70
70
  const idx = loadAccounts(p.pool);
71
- const id2 = p.liveId();
71
+ const id2 = seatId;
72
72
  const active = id2 ? idx.accounts.find((a) => a.id === id2) : undefined;
73
73
 
74
74
  const origin = enforced ? idx.accounts.find((a) => a.id === enforced.account) : undefined;
package/src/lib/sample.ts CHANGED
@@ -83,9 +83,10 @@ export async function sampleOldest(cfg: Config): Promise<void> {
83
83
  let dirty = false;
84
84
  for (const a of idx.accounts) dirty = foldTee(a) || dirty;
85
85
  const sampledAt = (a: Account) => Math.max(a.lastUsageAt ?? 0, a.lastProbeAt ?? 0);
86
+ const seats = seatCounts(paths.presenceDir);
86
87
  const stale = idx.accounts
87
88
  .filter((a) => a.needsReauth !== true && now - sampledAt(a) > cfg.policy.usagePollTtlMs)
88
- .sort((a, b) => sampledAt(a) - sampledAt(b))
89
+ .sort((a, b) => Number(seats.has(b.id)) - Number(seats.has(a.id)) || sampledAt(a) - sampledAt(b))
89
90
  .slice(0, SAMPLE_BATCH);
90
91
  if (stale.length === 0) {
91
92
  if (dirty) saveAccounts(claudePool, idx);
@@ -103,7 +104,7 @@ export async function sampleOldest(cfg: Config): Promise<void> {
103
104
  }
104
105
  target.storeFails = 0;
105
106
  let token: string | null = null;
106
- if (!seatCounts(paths.presenceDir).has(target.id)) {
107
+ if (!seats.has(target.id)) {
107
108
  const creds = await readStore(target.id).catch(() => null);
108
109
  if (creds && !isDeadCredential(creds) && !isAccessTokenExpiring(creds)) token = creds.accessToken;
109
110
  }