tokenmaxxing 1.131.0 → 1.133.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.
@@ -1,4 +1,3 @@
1
- import { once } from "node:events";
2
1
  import { existsSync, mkdirSync, rmSync, readdirSync, statSync } from "node:fs";
3
2
  import { basename, join } from "node:path";
4
3
  import type { FileSink, Subprocess } from "bun";
@@ -6,20 +5,21 @@ import { maxBy, omit } from "es-toolkit";
6
5
  import { z } from "zod";
7
6
  import { claudePool, paths, storeDirFor } from "../lib/paths.ts";
8
7
  import { CLAUDE_BIN, UNMANAGED_ENV, WRAP_DEPTH_ENV, resolveRealBin, wrapDepth } from "../lib/claudebin.ts";
9
- import { claude, pickSeat } from "../lib/claude.ts";
8
+ import { apiKeyEnv, clearSessionCost, foldSessionCost, loadApiKeys, readApiKey, sendApiKey, usableApiKey, writeSessionCost } from "../lib/apikeys.ts";
9
+ import { claude, pickLaunch, type LaunchTarget } from "../lib/claude.ts";
10
10
  import { compactClaudeSession } from "../lib/compact.ts";
11
- import { evaluateAndMaybeSwap } from "../lib/decide.ts";
11
+ import { evaluateAndMaybeSwap, moveTarget } from "../lib/decide.ts";
12
12
  import { withLock } from "../lib/lock.ts";
13
13
  import { thresholdBars, usableAt } from "../lib/picker.ts";
14
14
  import { clearPresence } from "../lib/presence.ts";
15
15
  import { readLines } from "../lib/proc.ts";
16
16
  import { teeObservation } from "../lib/sample.ts";
17
- import { countdownWait, exitStatus, loopGuardTripped, raceMarkerOrExit, recordPresenceOrStop, runPassthrough, SEAT_POLL_MS, SEAT_RETRY_MS, type Say } from "../lib/supervise.ts";
17
+ import { countdownWait, exitStatus, loopGuardTripped, raceMarkerOrExit, recordPresenceOrStop, relayStdout, runPassthrough, SEAT_POLL_MS, SEAT_RETRY_MS, type Say } from "../lib/supervise.ts";
18
18
  import { saveTermios } from "../lib/tty.ts";
19
19
  import { liveSessionId, loadSessionFlags, pruneStaleSessions, saveSessionFlags, writeRespawnMarker } from "../lib/sessions.ts";
20
20
  import { loadAccounts, loadConfig, readJsonFile, releaseWaitClaim, writeUsage } from "../lib/state.ts";
21
21
  import { gatedFamilies, modelFromFlag, parseStreamLine, scrubCredEnv } from "../lib/usage.ts";
22
- import { JsonTextSchema, RespawnMarkerSchema, type Account, type Config, type ModelInfo, type UsageWindows } from "../lib/types.ts";
22
+ import { CostLineSchema, JsonTextSchema, RespawnMarkerSchema, type Account, type ApiKey, type Config, type ModelInfo, type UsageWindows } from "../lib/types.ts";
23
23
  import { errorMessage, log } from "../lib/log.ts";
24
24
 
25
25
  const SUBCOMMANDS = new Set([
@@ -254,10 +254,11 @@ async function moveExhaustedSeat(seat: Account, sid: string, gate: MarkerGate, m
254
254
  const families = gatedFamilies(model, cfg.policy.switchModels);
255
255
  const until = seatBlockedUntil(seat.id, now, families, cfg);
256
256
  if (until == null || until <= gate.overriddenUntil) return false;
257
- const decision = await evaluateAndMaybeSwap(claude, now, true, null, { seatId: seat.id, sessionFamilies: families, waiterId: sid, refused });
258
- log("supervisor.seat_exhausted", { seat: seat.id.slice(0, 8), until, reason: decision.reason, account: decision.account?.id.slice(0, 8), waitUntil: decision.waitUntil });
259
- if (decision.account && (decision.swapped || decision.waitUntil !== undefined)) {
260
- writeRespawnMarker({ session: { sid, launchedAt: gate.launchedAt, live: liveSessionId(sid) }, accountId: decision.account.id, waitUntil: decision.waitUntil ?? now, compact: true, origin: "seatwatch" });
257
+ const decision = await evaluateAndMaybeSwap(claude, now, true, null, { seatId: seat.id, sessionFamilies: families, waiterId: sid, refused, apiKeys: true });
258
+ log("supervisor.seat_exhausted", { seat: seat.id.slice(0, 8), until, reason: decision.reason, account: decision.account?.id.slice(0, 8), key: decision.apiKey?.id, waitUntil: decision.waitUntil });
259
+ const target = moveTarget(decision);
260
+ if (target) {
261
+ writeRespawnMarker({ session: { sid, launchedAt: gate.launchedAt, live: liveSessionId(sid) }, ...target, waitUntil: target.waitUntil ?? now, compact: true, origin: "seatwatch" });
261
262
  }
262
263
  } catch (e) {
263
264
  log("supervisor.seat_watch_error", { err: errorMessage(e) });
@@ -265,14 +266,54 @@ async function moveExhaustedSeat(seat: Account, sid: string, gate: MarkerGate, m
265
266
  return true;
266
267
  }
267
268
 
269
+ async function moveKeySession(key: ApiKey, sid: string, gate: MarkerGate, model: ModelInfo | null, refused: string[], resumed: Set<string>): Promise<boolean> {
270
+ await foldCost(sid, key.id, resumed);
271
+ try {
272
+ if (usableApiKey(key.id) != null) return false;
273
+ const now = Date.now();
274
+ const cfg = loadConfig();
275
+ const families = gatedFamilies(model, cfg.policy.switchModels);
276
+ const decision = await evaluateAndMaybeSwap(claude, now, true, null, { seatId: null, sessionFamilies: families, waiterId: sid, refused, apiKeys: true, apiKeyId: key.id });
277
+ log("supervisor.key_watch", { key: key.id, reason: decision.reason, account: decision.account?.id.slice(0, 8), next: decision.apiKey?.id, waitUntil: decision.waitUntil });
278
+ const target = moveTarget(decision);
279
+ if (target && (target.waitUntil === undefined || target.waitUntil > gate.overriddenUntil)) {
280
+ writeRespawnMarker({ session: { sid, launchedAt: gate.launchedAt, live: liveSessionId(sid) }, ...target, waitUntil: target.waitUntil ?? now, compact: true, origin: "seatwatch" });
281
+ }
282
+ } catch (e) {
283
+ log("supervisor.key_watch_error", { err: errorMessage(e) });
284
+ }
285
+ return true;
286
+ }
287
+
288
+ function resolveLaunch(wanted: { accountId: string | null; apiKeyId: string | null } | null, now: number, model: ModelInfo | null): LaunchTarget | null {
289
+ if (wanted?.apiKeyId != null) {
290
+ const key = usableApiKey(wanted.apiKeyId);
291
+ if (key) return { account: null, apiKey: key };
292
+ } else if (wanted?.accountId != null) {
293
+ const id = wanted.accountId;
294
+ const account = loadAccounts(claudePool).accounts.find((a) => a.id === id);
295
+ if (account) return { account, apiKey: null };
296
+ }
297
+ return pickLaunch(now, model);
298
+ }
299
+
300
+ async function foldCost(sid: string, keyId: string | null, resumed: Set<string>): Promise<void> {
301
+ try {
302
+ await foldSessionCost(sid, keyId, resumed);
303
+ } catch (e) {
304
+ log("supervisor.cost_fold_failed", { err: errorMessage(e) });
305
+ }
306
+ }
307
+
268
308
  function systemLine(sid: string, text: string): string {
269
309
  return `${JSON.stringify({ type: "system", subtype: "informational", content: text, level: "warning", uuid: crypto.randomUUID(), session_id: sid })}\n`;
270
310
  }
271
311
 
272
- function resumePrompt(input: { compacted: boolean; origin: z.infer<typeof RespawnMarkerSchema>["origin"] }): string {
312
+ function resumePrompt(input: { compacted: boolean; origin: z.infer<typeof RespawnMarkerSchema>["origin"]; onKey: boolean }): string {
313
+ const where = input.onKey ? "on an Anthropic API key, because every pooled account is at its limit or the previous key ran out" : "on an account with quota headroom";
273
314
  const moved = input.compacted
274
- ? "tokenmaxxing compacted this conversation and resumed the session on an account with quota headroom."
275
- : "tokenmaxxing resumed this session on an account with quota headroom.";
315
+ ? `tokenmaxxing compacted this conversation and resumed the session ${where}.`
316
+ : `tokenmaxxing resumed this session ${where}.`;
276
317
  if (input.origin === "stop") {
277
318
  return `${moved} The previous turn finished before the move. If it waited on the user or completed the task, end this turn without restating it. If it waited on a background Bash task, Monitor, or Workflow run, relaunch that work, because the move ended it.`;
278
319
  }
@@ -283,8 +324,6 @@ function userLine(text: string): string {
283
324
  return `${JSON.stringify({ type: "user", message: { role: "user", content: [{ type: "text", text }] } })}\n`;
284
325
  }
285
326
 
286
- const STDOUT_QUIET_MS = 1000;
287
-
288
327
  const TaskLineSchema = z.discriminatedUnion("subtype", [
289
328
  z.looseObject({ type: z.literal("system"), subtype: z.literal("task_started"), task_id: z.string(), description: z.string(), session_id: z.string() }),
290
329
  z.looseObject({ type: z.literal("system"), subtype: z.literal("task_notification"), task_id: z.string() }),
@@ -304,11 +343,14 @@ function stoppedTaskLines(open: OpenTasks): string[] {
304
343
  return [...open].map(([id, task]) => `${JSON.stringify({ type: "system", subtype: "task_notification", task_id: id, status: "stopped", reason: "worker_restart", output_file: "", summary: `Stopped by a tokenmaxxing restart: ${task.description || id}`, uuid: crypto.randomUUID(), session_id: task.sessionId })}\n`);
305
344
  }
306
345
 
307
- function seatTee(seatId: string, model: ModelInfo | null, open: OpenTasks): (line: string) => void {
346
+ function seatTee(seatId: string | null, model: ModelInfo | null, open: OpenTasks, costSid: string | null): (line: string) => void {
308
347
  let last: UsageWindows | null = null;
309
348
  return (line) => {
310
349
  const value = JsonTextSchema.safeParse(line).data;
311
350
  trackTask(open, value);
351
+ const cost = costSid == null ? null : (CostLineSchema.safeParse(value).data ?? null);
352
+ if (costSid != null && cost != null) writeSessionCost(costSid, cost);
353
+ if (seatId == null) return;
312
354
  const msg = parseStreamLine(value);
313
355
  if (msg?.windows) last = msg.windows;
314
356
  else if (msg?.type !== "assistant") return;
@@ -316,42 +358,6 @@ function seatTee(seatId: string, model: ModelInfo | null, open: OpenTasks): (lin
316
358
  };
317
359
  }
318
360
 
319
- function relayStdout(source: ReadableStream<Uint8Array>, onLine: (line: string) => void): () => Promise<void> {
320
- let ended = false;
321
- let blocked = false;
322
- let readAt = Date.now();
323
- const forwarded = source.pipeThrough(
324
- new TransformStream<Uint8Array, Uint8Array>({
325
- async transform(chunk, controller) {
326
- readAt = Date.now();
327
- if (!process.stdout.write(chunk)) {
328
- blocked = true;
329
- await once(process.stdout, "drain");
330
- blocked = false;
331
- }
332
- controller.enqueue(chunk);
333
- },
334
- }),
335
- );
336
- void (async () => {
337
- for await (const line of readLines(forwarded)) {
338
- try {
339
- onLine(line);
340
- } catch (e) {
341
- log("supervisor.tee_failed", { err: errorMessage(e) });
342
- }
343
- }
344
- })()
345
- .catch((e: unknown) => log("supervisor.stdout_relay_failed", { err: errorMessage(e) }))
346
- .finally(() => {
347
- ended = true;
348
- });
349
- return async () => {
350
- const exitedAt = Date.now();
351
- while (!ended && (blocked || Date.now() - Math.max(readAt, exitedAt) < STDOUT_QUIET_MS)) await Bun.sleep(50);
352
- };
353
- }
354
-
355
361
  const REPLAYED_REQUESTS = new Set(["initialize", "set_max_thinking_tokens", "set_model", "set_permission_mode"]);
356
362
 
357
363
  const RelayLineSchema = z.discriminatedUnion("type", [
@@ -508,7 +514,7 @@ export async function runSupervisor(argv: string[]): Promise<number> {
508
514
  });
509
515
 
510
516
  if (!info.manage || process.env[UNMANAGED_ENV]) {
511
- const passthroughEnv = omit(childEnv, ["TOKENMAXXING_SUPERVISED", "TOKENMAXXING_SESSION_ID", "TOKENMAXXING_MODEL", "TOKENMAXXING_REFUSED"]);
517
+ const passthroughEnv = omit(childEnv, ["TOKENMAXXING_SUPERVISED", "TOKENMAXXING_SESSION_ID", "TOKENMAXXING_MODEL", "TOKENMAXXING_REFUSED", "TOKENMAXXING_API_KEY_ID"]);
512
518
  return runPassthrough({ real, argv, env: passthroughEnv, onSpawn: (p) => { child = p; } });
513
519
  }
514
520
 
@@ -536,6 +542,7 @@ export async function runSupervisor(argv: string[]): Promise<number> {
536
542
  pruneStaleSessions(Date.now());
537
543
 
538
544
  let launchArgs = resume ? ["--resume", sid, ...base] : ["--session-id", sid, ...base];
545
+ const resumed = new Set<string>(resume ? [sid] : []);
539
546
  let pendingTranscript = resume ? null : transcriptPath(sid);
540
547
 
541
548
  mkdirSync(paths.respawnDir, { recursive: true });
@@ -557,7 +564,7 @@ export async function runSupervisor(argv: string[]): Promise<number> {
557
564
  let firstLine: string | null = null;
558
565
  let respawns = 0;
559
566
  let overriddenUntil = 0;
560
- let wanted: string | null = null;
567
+ let wanted: { accountId: string | null; apiKeyId: string | null } | null = null;
561
568
  let refused: string[] = [];
562
569
  let drainStdout: (() => Promise<void>) | null = null;
563
570
  try {
@@ -567,55 +574,62 @@ export async function runSupervisor(argv: string[]): Promise<number> {
567
574
  const gate: MarkerGate = { launchedAt: Date.now(), overriddenUntil };
568
575
  const launched = await withLock(claudePool.lockFile, async () => {
569
576
  if (terminating) return null;
570
- const picked = (wanted == null ? null : (loadAccounts(claudePool).accounts.find((a) => a.id === wanted) ?? null)) ?? pickSeat(gate.launchedAt, model);
571
- log("supervisor.launch", { sid, respawns, seat: picked?.id.slice(0, 8) ?? null, args: launchArgs.join(" "), injected: firstLine !== null });
577
+ const target = resolveLaunch(wanted, gate.launchedAt, model);
578
+ const picked = target?.account ?? null;
579
+ const key = target?.apiKey ?? null;
580
+ log("supervisor.launch", { sid, respawns, seat: picked?.id.slice(0, 8) ?? null, key: key?.id, args: launchArgs.join(" "), injected: firstLine !== null });
572
581
  const open: OpenTasks = new Map();
573
- const tee = stream && picked ? seatTee(picked.id, model, open) : null;
582
+ const costSid = existsSync(claudePool.apiKeysJson) ? sid : null;
583
+ const tee = stream && (picked || costSid != null) ? seatTee(picked?.id ?? null, model, open, costSid) : null;
584
+ const secret = key ? readApiKey(key) : null;
585
+ const extra: "pipe"[] = secret == null ? [] : ["pipe"];
586
+ const scrubbed = scrubCredEnv(childEnv);
574
587
  const spawned = Bun.spawn([real, ...launchArgs], {
575
- stdin: relay === null ? "inherit" : "pipe",
576
- stdout: tee === null ? "inherit" : "pipe",
577
- stderr: "inherit",
588
+ stdio: [relay === null ? "inherit" : "pipe", tee === null ? "inherit" : "pipe", "inherit", ...extra],
578
589
  env: {
579
- ...(picked ? { ...scrubCredEnv(childEnv), CLAUDE_SECURESTORAGE_CONFIG_DIR: storeDirFor(picked.id) } : childEnv),
590
+ ...(picked ? { ...scrubbed, CLAUDE_SECURESTORAGE_CONFIG_DIR: storeDirFor(picked.id) } : key ? apiKeyEnv(scrubbed, key) : childEnv),
580
591
  TOKENMAXXING_SUPERVISED: "1",
581
592
  TOKENMAXXING_SESSION_ID: sid,
582
593
  TOKENMAXXING_LAUNCHED_AT: String(gate.launchedAt),
583
594
  TOKENMAXXING_MODEL: effective.model ?? "",
584
595
  TOKENMAXXING_REFUSED: refused.join(","),
596
+ TOKENMAXXING_API_KEY_ID: key?.id ?? "",
585
597
  },
586
598
  });
587
599
  child = spawned;
600
+ if (secret != null) sendApiKey(spawned, secret);
588
601
  const drain = tee !== null && spawned.stdout instanceof ReadableStream ? relayStdout(spawned.stdout, tee) : null;
589
602
  if (relay !== null) relay.attach(spawned.stdin!, firstLine, pendingTranscript);
590
603
  firstLine = null;
591
- if (picked) {
604
+ if (picked || key) {
592
605
  await recordPresenceOrStop({
593
606
  child: spawned,
594
607
  dir: paths.presenceDir,
595
608
  id: sid,
596
- accountId: picked.id,
609
+ accountId: picked?.id ?? "",
610
+ ...(key ? { apiKeyId: key.id } : {}),
597
611
  event: "supervisor.presence_failed",
598
612
  message: "could not write the session presence file - refusing to run a session whose seat placement cannot see",
599
613
  savedTermios,
600
614
  });
601
615
  }
602
616
  releaseClaimLocked(sid);
603
- return { child: spawned, seat: picked, drain, open };
617
+ return { child: spawned, seat: picked, key, drain, open };
604
618
  });
605
619
  if (launched == null) {
606
620
  await releaseClaim();
607
621
  return 143;
608
622
  }
609
623
 
610
- const { child: proc, seat, open } = launched;
624
+ const { child: proc, seat, key, open } = launched;
611
625
  drainStdout = launched.drain;
612
626
  let seatCheckAt = 0;
613
627
  await raceMarkerOrExit({
614
628
  child: proc,
615
629
  tick: async () => {
616
630
  if (existsSync(marker) && (await consumableMarker(marker, gate)) != null) return true;
617
- if (seat && !terminating && Date.now() >= seatCheckAt) {
618
- const decided = await moveExhaustedSeat(seat, sid, gate, model, refused);
631
+ if ((seat || key) && !terminating && Date.now() >= seatCheckAt) {
632
+ const decided = seat ? await moveExhaustedSeat(seat, sid, gate, model, refused) : key ? await moveKeySession(key, sid, gate, model, refused, resumed) : false;
619
633
  seatCheckAt = Date.now() + (decided ? SEAT_RETRY_MS : SEAT_POLL_MS);
620
634
  }
621
635
  return false;
@@ -630,6 +644,7 @@ export async function runSupervisor(argv: string[]): Promise<number> {
630
644
  });
631
645
  await drainStdout?.();
632
646
  drainStdout = null;
647
+ await foldCost(sid, key?.id ?? null, resumed);
633
648
 
634
649
  const m = !terminating && existsSync(marker) ? await consumableMarker(marker, gate) : null;
635
650
  if (m) {
@@ -638,7 +653,8 @@ export async function runSupervisor(argv: string[]): Promise<number> {
638
653
  noticeSid = m.sessionId;
639
654
  for (const line of stoppedTaskLines(open)) process.stdout.write(line);
640
655
  const accounts = loadAccounts(claudePool).accounts;
641
- const label = accounts.find((a) => a.id === m.accountId)?.label ?? m.accountId.slice(0, 8);
656
+ const nextKey = m.apiKeyId == null ? null : (loadApiKeys().keys.find((k) => k.id === m.apiKeyId) ?? null);
657
+ const label = m.apiKeyId != null ? `API key ${nextKey?.label ?? m.apiKeyId}` : (accounts.find((a) => a.id === m.accountId)?.label ?? m.accountId.slice(0, 8));
642
658
  const walledUntil = accounts.find((a) => a.id === seat?.id)?.enforcedUntil ?? 0;
643
659
  const transcript = findTranscript(m.sessionId);
644
660
  const resumable = transcript !== null;
@@ -646,34 +662,50 @@ export async function runSupervisor(argv: string[]): Promise<number> {
646
662
  for (const id of relay.replay()) process.stdout.write(controlErrorLine(id));
647
663
  }
648
664
  let compacted = false;
665
+ const compactKey = key != null && usableApiKey(key.id) != null ? key : null;
666
+ const compactOn = seat?.label ?? (compactKey ? `API key ${compactKey.label}` : null);
649
667
  if (m.compact && seat && resumable && walledUntil > Date.now()) {
650
668
  log("supervisor.compact_skipped", { sid: m.sessionId.slice(0, 8), seat: seat.id.slice(0, 8), until: walledUntil });
651
- } else if (m.compact && seat && resumable) {
652
- 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.`);
653
- const compactEnv = { ...omit(scrubCredEnv(childEnv), ["TOKENMAXXING_SUPERVISED", "TOKENMAXXING_SESSION_ID", "TOKENMAXXING_LAUNCHED_AT", "TOKENMAXXING_MODEL", "TOKENMAXXING_REFUSED"]), TOKENMAXXING_PROBE: "1", CLAUDE_SECURESTORAGE_CONFIG_DIR: storeDirFor(seat.id) };
654
- const outcome = await compactClaudeSession({ real, sid: m.sessionId, transcript, env: compactEnv, onSpawn: (p) => { child = p; } });
669
+ } else if (m.compact && compactOn != null && resumable) {
670
+ say(`\n\x1b[36m↻ tokenmaxxing: compacting the conversation on ${compactOn} before the move...\x1b[0m\n`, `tokenmaxxing: compacting the conversation on ${compactOn} before the move.`);
671
+ const baseEnv = { ...omit(scrubCredEnv(childEnv), ["TOKENMAXXING_SUPERVISED", "TOKENMAXXING_SESSION_ID", "TOKENMAXXING_LAUNCHED_AT", "TOKENMAXXING_MODEL", "TOKENMAXXING_REFUSED", "TOKENMAXXING_API_KEY_ID"]), TOKENMAXXING_PROBE: "1" };
672
+ const compactEnv = seat ? { ...baseEnv, CLAUDE_SECURESTORAGE_CONFIG_DIR: storeDirFor(seat.id) } : compactKey ? apiKeyEnv(baseEnv, compactKey) : baseEnv;
673
+ const compactSecret = seat == null && compactKey ? readApiKey(compactKey) : null;
674
+ const outcome = await compactClaudeSession({
675
+ real,
676
+ sid: m.sessionId,
677
+ transcript,
678
+ env: compactEnv,
679
+ keyPipe: compactSecret != null,
680
+ onSpawn: (p) => {
681
+ child = p;
682
+ if (compactSecret != null) sendApiKey(p, compactSecret);
683
+ },
684
+ });
655
685
  child = null;
656
686
  if (terminating) {
657
687
  await releaseClaim();
658
688
  return 143;
659
689
  }
660
- log("supervisor.compact", { sid: m.sessionId.slice(0, 8), seat: seat.id.slice(0, 8), ok: outcome.ok, reason: outcome.ok ? undefined : outcome.reason });
690
+ log("supervisor.compact", { sid: m.sessionId.slice(0, 8), seat: seat?.id.slice(0, 8), key: compactKey?.id, ok: outcome.ok, reason: outcome.ok ? undefined : outcome.reason });
661
691
  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})`);
662
692
  compacted = outcome.ok;
663
693
  }
664
694
  if (m.waitUntil > Date.now()) {
665
695
  if (await countdownWait(label, m.waitUntil, { stream, say })) overriddenUntil = m.waitUntil;
666
696
  } else say(`\n\x1b[36m↻ tokenmaxxing: moving to ${label} - resuming...\x1b[0m\n`, `tokenmaxxing: moving to ${label} and resuming.`);
667
- wanted = m.accountId;
697
+ wanted = m.apiKeyId != null ? { accountId: null, apiKeyId: m.apiKeyId } : { accountId: m.accountId, apiKeyId: null };
668
698
  refused = m.refused ?? refused;
669
699
  saveSessionFlags(m.sessionId, persistable, process.cwd());
670
- const prompt = resumable ? resumePrompt({ compacted, origin: m.origin }) : null;
700
+ const prompt = resumable ? resumePrompt({ compacted, origin: m.origin, onKey: m.apiKeyId != null }) : null;
671
701
  firstLine = relay !== null && prompt !== null ? userLine(prompt) : null;
672
702
  pendingTranscript = resumable ? null : transcriptPath(m.sessionId);
703
+ if (resumable) resumed.add(m.sessionId);
673
704
  launchArgs = [resumable ? "--resume" : "--session-id", m.sessionId, ...(relay === null && prompt !== null ? [prompt] : []), ...persistable];
674
705
  continue;
675
706
  }
676
707
  clearPresence({ dir: paths.presenceDir, id: sid });
708
+ clearSessionCost(sid);
677
709
  await releaseClaim();
678
710
  log("supervisor.exit", { sid, respawns, code: proc.exitCode, signal: proc.signalCode, terminated: terminating || undefined });
679
711
  return exitStatus(proc);
@@ -0,0 +1,142 @@
1
+ import { chmodSync, closeSync, existsSync, mkdirSync, readFileSync, rmSync, writeSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { countBy, isEqual, sortBy } from "es-toolkit";
4
+ import { writeFileAtomic } from "./atomic.ts";
5
+ import { withLock } from "./lock.ts";
6
+ import { claudePool, optionalEnv, paths } from "./paths.ts";
7
+ import { livingPresences } from "./presence.ts";
8
+ import type { ApiKeyPool } from "./provider.ts";
9
+ import { readJsonFile } from "./state.ts";
10
+ import { ApiKeysIndexSchema, SessionCostSchema, type ApiKey, type ApiKeysIndex, type SessionCost } from "./types.ts";
11
+
12
+ export const API_KEY_ENV = "TOKENMAXXING_API_KEY_ID";
13
+ export const API_KEY_FD = 3;
14
+
15
+ export function apiKeyEnv(env: Record<string, string | undefined>, key: ApiKey): Record<string, string | undefined> {
16
+ const header = key.workspaceId == null ? null : `anthropic-workspace-id: ${key.workspaceId}`;
17
+ const headers = header == null ? env.ANTHROPIC_CUSTOM_HEADERS : env.ANTHROPIC_CUSTOM_HEADERS ? `${env.ANTHROPIC_CUSTOM_HEADERS}\n${header}` : header;
18
+ return { ...env, CLAUDE_CODE_API_KEY_FILE_DESCRIPTOR: String(API_KEY_FD), ...(headers != null ? { ANTHROPIC_CUSTOM_HEADERS: headers } : {}) };
19
+ }
20
+
21
+ export function readApiKey(key: ApiKey): string {
22
+ return `${readFileSync(apiKeySecretFor(key.id), "utf8").trim()}\n`;
23
+ }
24
+
25
+ export function sendApiKey(child: { stdio: readonly (number | null)[] }, secret: string): void {
26
+ const fd = child.stdio[API_KEY_FD];
27
+ if (fd == null) throw new Error(`the child has no descriptor ${API_KEY_FD} to receive the API key`);
28
+ try {
29
+ const written = writeSync(fd, secret);
30
+ if (written !== Buffer.byteLength(secret)) throw new Error(`wrote ${written} of ${Buffer.byteLength(secret)} bytes of the API key to descriptor ${API_KEY_FD}`);
31
+ } finally {
32
+ closeSync(fd);
33
+ }
34
+ }
35
+
36
+ export function apiKeySecretFor(id: string): string {
37
+ return join(paths.apiKeysDir, id);
38
+ }
39
+
40
+ export function loadApiKeys(): ApiKeysIndex {
41
+ if (!existsSync(claudePool.apiKeysJson)) return { version: 1, keys: [], baselines: {} };
42
+ return readJsonFile(claudePool.apiKeysJson, ApiKeysIndexSchema);
43
+ }
44
+
45
+ export function saveApiKeys(idx: ApiKeysIndex): void {
46
+ writeFileAtomic(claudePool.apiKeysJson, JSON.stringify(ApiKeysIndexSchema.parse(idx), null, 2) + "\n");
47
+ }
48
+
49
+ export function writeApiKeySecret(id: string, secret: string): void {
50
+ mkdirSync(paths.apiKeysDir, { recursive: true, mode: 0o700 });
51
+ chmodSync(paths.apiKeysDir, 0o700);
52
+ writeFileAtomic(apiKeySecretFor(id), secret);
53
+ }
54
+
55
+ export function removeApiKeySecret(id: string): void {
56
+ rmSync(apiKeySecretFor(id), { force: true });
57
+ }
58
+
59
+ export function creditLeft(k: ApiKey): number | null {
60
+ return k.creditUsd == null ? null : k.creditUsd - k.spentUsd;
61
+ }
62
+
63
+ export function keyUsable(k: ApiKey): boolean {
64
+ const left = creditLeft(k);
65
+ return k.refusedAt == null && (left == null || left > 0);
66
+ }
67
+
68
+ export function keySessions(): Map<string, number> {
69
+ const ids = livingPresences(paths.presenceDir).flatMap((p) => (p.apiKeyId == null ? [] : [p.apiKeyId]));
70
+ return new Map(Object.entries(countBy(ids, (id) => id)));
71
+ }
72
+
73
+ export function pickApiKey(keys: ApiKey[], sessions: Map<string, number>, exclude: string[]): ApiKey | null {
74
+ const candidates = keys.filter((k) => keyUsable(k) && !exclude.includes(k.id));
75
+ return sortBy(candidates, [(k) => (creditLeft(k) == null ? 1 : 0), (k) => -(creditLeft(k) ?? 0), (k) => sessions.get(k.id) ?? 0, (k) => k.addedAt])[0] ?? null;
76
+ }
77
+
78
+ function availableKeys(): ApiKey[] {
79
+ return loadApiKeys().keys.filter((k) => existsSync(apiKeySecretFor(k.id)));
80
+ }
81
+
82
+ export function usableApiKey(id: string): ApiKey | null {
83
+ const key = availableKeys().find((k) => k.id === id);
84
+ return key != null && keyUsable(key) ? key : null;
85
+ }
86
+
87
+ export function launchApiKey(): ApiKey | null {
88
+ return pickApiKey(availableKeys(), keySessions(), []);
89
+ }
90
+
91
+ export function liveApiKeyId(env: Record<string, string | undefined> = process.env): string | null {
92
+ return optionalEnv(API_KEY_ENV, env) ?? null;
93
+ }
94
+
95
+ export const claudeApiKeys: ApiKeyPool = {
96
+ liveId: () => liveApiKeyId(),
97
+ holds: (id) => usableApiKey(id) != null,
98
+ pick: (exclude) => pickApiKey(availableKeys(), keySessions(), exclude),
99
+ };
100
+
101
+ export async function refuseApiKey(id: string, now: number): Promise<boolean> {
102
+ return withLock(claudePool.lockFile, () => {
103
+ const idx = loadApiKeys();
104
+ const key = idx.keys.find((k) => k.id === id);
105
+ if (!key) return false;
106
+ key.refusedAt = now;
107
+ saveApiKeys(idx);
108
+ return true;
109
+ });
110
+ }
111
+
112
+ const costFileFor = (sid: string): string => join(paths.costDir, `${sid}.json`);
113
+
114
+ export function writeSessionCost(sid: string, cost: SessionCost): void {
115
+ const file = costFileFor(sid);
116
+ if (existsSync(file) && isEqual(readJsonFile(file, SessionCostSchema), cost)) return;
117
+ writeFileAtomic(file, JSON.stringify(cost));
118
+ }
119
+
120
+ export function clearSessionCost(sid: string): void {
121
+ rmSync(costFileFor(sid), { force: true });
122
+ }
123
+
124
+ const BASELINE_RETENTION_MS = 30 * 24 * 60 * 60 * 1000;
125
+
126
+ export async function foldSessionCost(sid: string, keyId: string | null, resumed: Set<string>): Promise<void> {
127
+ const file = costFileFor(sid);
128
+ if (!existsSync(file) || !existsSync(claudePool.apiKeysJson)) return;
129
+ const seen = readJsonFile(file, SessionCostSchema);
130
+ await withLock(claudePool.lockFile, () => {
131
+ const idx = loadApiKeys();
132
+ const prev = idx.baselines[seen.sessionId];
133
+ if (prev?.usd === seen.usd) return;
134
+ const delta = prev != null ? Math.max(0, seen.usd - prev.usd) : resumed.has(seen.sessionId) ? 0 : seen.usd;
135
+ const key = keyId == null ? undefined : idx.keys.find((k) => k.id === keyId);
136
+ if (key) key.spentUsd += delta;
137
+ const now = Date.now();
138
+ idx.baselines = Object.fromEntries(Object.entries(idx.baselines).filter(([, b]) => now - b.at <= BASELINE_RETENTION_MS));
139
+ idx.baselines[seen.sessionId] = { usd: seen.usd, at: now };
140
+ saveApiKeys(idx);
141
+ });
142
+ }
package/src/lib/claude.ts CHANGED
@@ -2,6 +2,7 @@ import { mkdirSync, readFileSync, rmSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { countBy } from "es-toolkit";
4
4
  import { z } from "zod";
5
+ import { claudeApiKeys, launchApiKey } from "./apikeys.ts";
5
6
  import { readItem, writeItem, deleteItem, isolatedTarget, readStore, storeTarget, claudeAiOauthOnly } from "./credstore.ts";
6
7
  import { CLAUDE_BIN, resolveRealBin, resolveVerifiedClaude } from "./claudebin.ts";
7
8
  import { activationHint, CHECK_JOB, ensurePathInRc, HUB_JOB, installSupervisor, managedShellRcSkipLines, shellRcPath } from "./install.ts";
@@ -17,7 +18,7 @@ import { claimSample, foldTee, landRead, readyToSample, runSample, sampleAccount
17
18
  import { clearUsageSnapshot, liveWaitClaims, loadAccounts, loadConfig, loadUsageSnapshot, pinBinOverride, readJsonFile, saveAccounts, type Harvest } from "./state.ts";
18
19
  import { saveTermios, restoreTermios } from "./tty.ts";
19
20
  import { claimResetGrant, fetchUsageDirect, gatedFamilies, mergeWindows, modelFromFlag, scrubCredEnv, windowsOf } from "./usage.ts";
20
- import { CredentialBlobSchema, JsonTextSchema, OAuthAccountSchema, type Account, type Config, type ModelInfo } from "./types.ts";
21
+ import { CredentialBlobSchema, JsonTextSchema, OAuthAccountSchema, type Account, type ApiKey, type Config, type ModelInfo } from "./types.ts";
21
22
  import { c } from "../cli/render.ts";
22
23
 
23
24
  function liveId(): string | null {
@@ -25,7 +26,7 @@ function liveId(): string | null {
25
26
  }
26
27
 
27
28
  function presence(living = livingPresences(paths.presenceDir)): Map<string, number> {
28
- const seats = new Map(living.map((s) => [s.id, s.accountId]));
29
+ const seats = new Map(living.filter((s) => s.apiKeyId == null).map((s) => [s.id, s.accountId]));
29
30
  for (const claim of liveWaitClaims(Date.now())) seats.set(claim.sessionId, claim.accountId);
30
31
  return new Map(Object.entries(countBy([...seats.values()], (id) => id)));
31
32
  }
@@ -138,7 +139,7 @@ async function storeUsable(a: Account): Promise<boolean> {
138
139
  }
139
140
  }
140
141
 
141
- export function pickSeat(now: number, model: ModelInfo | null, eligible: (a: Account) => boolean = () => true): Account | null {
142
+ function seatContext(now: number, model: ModelInfo | null, eligible: (a: Account) => boolean): { candidates: Account[]; ctx: PickCtx; fallback: () => Account | null } {
142
143
  const cfg = loadConfig();
143
144
  const bars = thresholdBars(cfg);
144
145
  const idx = loadAccounts(claudePool);
@@ -148,12 +149,28 @@ export function pickSeat(now: number, model: ModelInfo | null, eligible: (a: Acc
148
149
  const seats = presence();
149
150
  const ctx: PickCtx = { now, thresholds: bars, currentId: null, families: gatedFamilies(model, cfg.policy.switchModels), seats };
150
151
  const candidates = idx.accounts.filter(eligible);
151
- return (
152
- pickBest(candidates, ctx) ??
152
+ const fallback = () =>
153
153
  pickWaitTarget(candidates, ctx, seats, MAX_WAITERS_PER_ACCOUNT, now + cfg.policy.maxWaitMs, now + 2 * cfg.policy.maxWaitMs)?.account ??
154
154
  pickEarliestReset(candidates, ctx)?.account ??
155
- null
156
- );
155
+ null;
156
+ return { candidates, ctx, fallback };
157
+ }
158
+
159
+ export function pickSeat(now: number, model: ModelInfo | null, eligible: (a: Account) => boolean = () => true): Account | null {
160
+ const { candidates, ctx, fallback } = seatContext(now, model, eligible);
161
+ return pickBest(candidates, ctx) ?? fallback();
162
+ }
163
+
164
+ export type LaunchTarget = { account: Account; apiKey: null } | { account: null; apiKey: ApiKey };
165
+
166
+ export function pickLaunch(now: number, model: ModelInfo | null): LaunchTarget | null {
167
+ const { candidates, ctx, fallback } = seatContext(now, model, () => true);
168
+ const best = pickBest(candidates, ctx);
169
+ if (best) return { account: best, apiKey: null };
170
+ const key = launchApiKey();
171
+ if (key) return { account: null, apiKey: key };
172
+ const account = fallback();
173
+ return account ? { account, apiKey: null } : null;
157
174
  }
158
175
 
159
176
  export async function borrowClaudeSeat(pid: number): Promise<SeatBorrow> {
@@ -335,6 +352,7 @@ export const claude: Provider = {
335
352
  seats: "shared",
336
353
  waitsWhenDepleted: true,
337
354
  statusOnly: false,
355
+ apiKeys: claudeApiKeys,
338
356
  liveId,
339
357
  presence,
340
358
  gatedFamilies: (cfg) => {
@@ -13,13 +13,12 @@ const PIPE_GRACE_MS = 2_000;
13
13
 
14
14
  const CompactBoundarySchema = z.looseObject({ type: z.literal("system"), subtype: z.literal("compact_boundary") });
15
15
 
16
- export async function compactClaudeSession(input: { real: string; sid: string; transcript: string; env: Record<string, string | undefined>; onSpawn: (child: Subprocess) => void }): Promise<CompactOutcome> {
16
+ export async function compactClaudeSession(input: { real: string; sid: string; transcript: string; env: Record<string, string | undefined>; keyPipe: boolean; onSpawn: (child: Subprocess) => void }): Promise<CompactOutcome> {
17
17
  const offset = statSync(input.transcript).size;
18
+ const extra: "pipe"[] = input.keyPipe ? ["pipe"] : [];
18
19
  const p = Bun.spawn([input.real, "-p", "--resume", input.sid, "/compact"], {
19
20
  env: input.env,
20
- stdin: "ignore",
21
- stdout: "pipe",
22
- stderr: "pipe",
21
+ stdio: ["ignore", "pipe", "pipe", ...extra],
23
22
  timeout: CLAUDE_COMPACT_KILL_MS,
24
23
  killSignal: "SIGKILL",
25
24
  });