tokenmaxxing 1.130.0 → 1.132.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.
@@ -6,9 +6,10 @@ import { maxBy, omit } from "es-toolkit";
6
6
  import { z } from "zod";
7
7
  import { claudePool, paths, storeDirFor } from "../lib/paths.ts";
8
8
  import { CLAUDE_BIN, UNMANAGED_ENV, WRAP_DEPTH_ENV, resolveRealBin, wrapDepth } from "../lib/claudebin.ts";
9
- import { claude, pickSeat } from "../lib/claude.ts";
9
+ import { apiKeyEnv, clearSessionCost, foldSessionCost, loadApiKeys, readApiKey, sendApiKey, usableApiKey, writeSessionCost } from "../lib/apikeys.ts";
10
+ import { claude, pickLaunch, type LaunchTarget } from "../lib/claude.ts";
10
11
  import { compactClaudeSession } from "../lib/compact.ts";
11
- import { evaluateAndMaybeSwap } from "../lib/decide.ts";
12
+ import { evaluateAndMaybeSwap, moveTarget } from "../lib/decide.ts";
12
13
  import { withLock } from "../lib/lock.ts";
13
14
  import { thresholdBars, usableAt } from "../lib/picker.ts";
14
15
  import { clearPresence } from "../lib/presence.ts";
@@ -19,7 +20,7 @@ import { saveTermios } from "../lib/tty.ts";
19
20
  import { liveSessionId, loadSessionFlags, pruneStaleSessions, saveSessionFlags, writeRespawnMarker } from "../lib/sessions.ts";
20
21
  import { loadAccounts, loadConfig, readJsonFile, releaseWaitClaim, writeUsage } from "../lib/state.ts";
21
22
  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";
23
+ import { CostLineSchema, JsonTextSchema, RespawnMarkerSchema, type Account, type ApiKey, type Config, type ModelInfo, type UsageWindows } from "../lib/types.ts";
23
24
  import { errorMessage, log } from "../lib/log.ts";
24
25
 
25
26
  const SUBCOMMANDS = new Set([
@@ -254,10 +255,11 @@ async function moveExhaustedSeat(seat: Account, sid: string, gate: MarkerGate, m
254
255
  const families = gatedFamilies(model, cfg.policy.switchModels);
255
256
  const until = seatBlockedUntil(seat.id, now, families, cfg);
256
257
  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" });
258
+ const decision = await evaluateAndMaybeSwap(claude, now, true, null, { seatId: seat.id, sessionFamilies: families, waiterId: sid, refused, apiKeys: true });
259
+ 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 });
260
+ const target = moveTarget(decision);
261
+ if (target) {
262
+ writeRespawnMarker({ session: { sid, launchedAt: gate.launchedAt, live: liveSessionId(sid) }, ...target, waitUntil: target.waitUntil ?? now, compact: true, origin: "seatwatch" });
261
263
  }
262
264
  } catch (e) {
263
265
  log("supervisor.seat_watch_error", { err: errorMessage(e) });
@@ -265,14 +267,54 @@ async function moveExhaustedSeat(seat: Account, sid: string, gate: MarkerGate, m
265
267
  return true;
266
268
  }
267
269
 
270
+ async function moveKeySession(key: ApiKey, sid: string, gate: MarkerGate, model: ModelInfo | null, refused: string[], resumed: Set<string>): Promise<boolean> {
271
+ await foldCost(sid, key.id, resumed);
272
+ try {
273
+ if (usableApiKey(key.id) != null) return false;
274
+ const now = Date.now();
275
+ const cfg = loadConfig();
276
+ const families = gatedFamilies(model, cfg.policy.switchModels);
277
+ const decision = await evaluateAndMaybeSwap(claude, now, true, null, { seatId: null, sessionFamilies: families, waiterId: sid, refused, apiKeys: true, apiKeyId: key.id });
278
+ log("supervisor.key_watch", { key: key.id, reason: decision.reason, account: decision.account?.id.slice(0, 8), next: decision.apiKey?.id, waitUntil: decision.waitUntil });
279
+ const target = moveTarget(decision);
280
+ if (target && (target.waitUntil === undefined || target.waitUntil > gate.overriddenUntil)) {
281
+ writeRespawnMarker({ session: { sid, launchedAt: gate.launchedAt, live: liveSessionId(sid) }, ...target, waitUntil: target.waitUntil ?? now, compact: true, origin: "seatwatch" });
282
+ }
283
+ } catch (e) {
284
+ log("supervisor.key_watch_error", { err: errorMessage(e) });
285
+ }
286
+ return true;
287
+ }
288
+
289
+ function resolveLaunch(wanted: { accountId: string | null; apiKeyId: string | null } | null, now: number, model: ModelInfo | null): LaunchTarget | null {
290
+ if (wanted?.apiKeyId != null) {
291
+ const key = usableApiKey(wanted.apiKeyId);
292
+ if (key) return { account: null, apiKey: key };
293
+ } else if (wanted?.accountId != null) {
294
+ const id = wanted.accountId;
295
+ const account = loadAccounts(claudePool).accounts.find((a) => a.id === id);
296
+ if (account) return { account, apiKey: null };
297
+ }
298
+ return pickLaunch(now, model);
299
+ }
300
+
301
+ async function foldCost(sid: string, keyId: string | null, resumed: Set<string>): Promise<void> {
302
+ try {
303
+ await foldSessionCost(sid, keyId, resumed);
304
+ } catch (e) {
305
+ log("supervisor.cost_fold_failed", { err: errorMessage(e) });
306
+ }
307
+ }
308
+
268
309
  function systemLine(sid: string, text: string): string {
269
310
  return `${JSON.stringify({ type: "system", subtype: "informational", content: text, level: "warning", uuid: crypto.randomUUID(), session_id: sid })}\n`;
270
311
  }
271
312
 
272
- function resumePrompt(input: { compacted: boolean; origin: z.infer<typeof RespawnMarkerSchema>["origin"] }): string {
313
+ function resumePrompt(input: { compacted: boolean; origin: z.infer<typeof RespawnMarkerSchema>["origin"]; onKey: boolean }): string {
314
+ 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
315
  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.";
316
+ ? `tokenmaxxing compacted this conversation and resumed the session ${where}.`
317
+ : `tokenmaxxing resumed this session ${where}.`;
276
318
  if (input.origin === "stop") {
277
319
  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
320
  }
@@ -304,11 +346,14 @@ function stoppedTaskLines(open: OpenTasks): string[] {
304
346
  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
347
  }
306
348
 
307
- function seatTee(seatId: string, model: ModelInfo | null, open: OpenTasks): (line: string) => void {
349
+ function seatTee(seatId: string | null, model: ModelInfo | null, open: OpenTasks, costSid: string | null): (line: string) => void {
308
350
  let last: UsageWindows | null = null;
309
351
  return (line) => {
310
352
  const value = JsonTextSchema.safeParse(line).data;
311
353
  trackTask(open, value);
354
+ const cost = costSid == null ? null : (CostLineSchema.safeParse(value).data ?? null);
355
+ if (costSid != null && cost != null) writeSessionCost(costSid, cost);
356
+ if (seatId == null) return;
312
357
  const msg = parseStreamLine(value);
313
358
  if (msg?.windows) last = msg.windows;
314
359
  else if (msg?.type !== "assistant") return;
@@ -508,7 +553,7 @@ export async function runSupervisor(argv: string[]): Promise<number> {
508
553
  });
509
554
 
510
555
  if (!info.manage || process.env[UNMANAGED_ENV]) {
511
- const passthroughEnv = omit(childEnv, ["TOKENMAXXING_SUPERVISED", "TOKENMAXXING_SESSION_ID", "TOKENMAXXING_MODEL", "TOKENMAXXING_REFUSED"]);
556
+ const passthroughEnv = omit(childEnv, ["TOKENMAXXING_SUPERVISED", "TOKENMAXXING_SESSION_ID", "TOKENMAXXING_MODEL", "TOKENMAXXING_REFUSED", "TOKENMAXXING_API_KEY_ID"]);
512
557
  return runPassthrough({ real, argv, env: passthroughEnv, onSpawn: (p) => { child = p; } });
513
558
  }
514
559
 
@@ -536,6 +581,7 @@ export async function runSupervisor(argv: string[]): Promise<number> {
536
581
  pruneStaleSessions(Date.now());
537
582
 
538
583
  let launchArgs = resume ? ["--resume", sid, ...base] : ["--session-id", sid, ...base];
584
+ const resumed = new Set<string>(resume ? [sid] : []);
539
585
  let pendingTranscript = resume ? null : transcriptPath(sid);
540
586
 
541
587
  mkdirSync(paths.respawnDir, { recursive: true });
@@ -557,7 +603,7 @@ export async function runSupervisor(argv: string[]): Promise<number> {
557
603
  let firstLine: string | null = null;
558
604
  let respawns = 0;
559
605
  let overriddenUntil = 0;
560
- let wanted: string | null = null;
606
+ let wanted: { accountId: string | null; apiKeyId: string | null } | null = null;
561
607
  let refused: string[] = [];
562
608
  let drainStdout: (() => Promise<void>) | null = null;
563
609
  try {
@@ -567,55 +613,62 @@ export async function runSupervisor(argv: string[]): Promise<number> {
567
613
  const gate: MarkerGate = { launchedAt: Date.now(), overriddenUntil };
568
614
  const launched = await withLock(claudePool.lockFile, async () => {
569
615
  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 });
616
+ const target = resolveLaunch(wanted, gate.launchedAt, model);
617
+ const picked = target?.account ?? null;
618
+ const key = target?.apiKey ?? null;
619
+ log("supervisor.launch", { sid, respawns, seat: picked?.id.slice(0, 8) ?? null, key: key?.id, args: launchArgs.join(" "), injected: firstLine !== null });
572
620
  const open: OpenTasks = new Map();
573
- const tee = stream && picked ? seatTee(picked.id, model, open) : null;
621
+ const costSid = existsSync(claudePool.apiKeysJson) ? sid : null;
622
+ const tee = stream && (picked || costSid != null) ? seatTee(picked?.id ?? null, model, open, costSid) : null;
623
+ const secret = key ? readApiKey(key) : null;
624
+ const extra: "pipe"[] = secret == null ? [] : ["pipe"];
625
+ const scrubbed = scrubCredEnv(childEnv);
574
626
  const spawned = Bun.spawn([real, ...launchArgs], {
575
- stdin: relay === null ? "inherit" : "pipe",
576
- stdout: tee === null ? "inherit" : "pipe",
577
- stderr: "inherit",
627
+ stdio: [relay === null ? "inherit" : "pipe", tee === null ? "inherit" : "pipe", "inherit", ...extra],
578
628
  env: {
579
- ...(picked ? { ...scrubCredEnv(childEnv), CLAUDE_SECURESTORAGE_CONFIG_DIR: storeDirFor(picked.id) } : childEnv),
629
+ ...(picked ? { ...scrubbed, CLAUDE_SECURESTORAGE_CONFIG_DIR: storeDirFor(picked.id) } : key ? apiKeyEnv(scrubbed, key) : childEnv),
580
630
  TOKENMAXXING_SUPERVISED: "1",
581
631
  TOKENMAXXING_SESSION_ID: sid,
582
632
  TOKENMAXXING_LAUNCHED_AT: String(gate.launchedAt),
583
633
  TOKENMAXXING_MODEL: effective.model ?? "",
584
634
  TOKENMAXXING_REFUSED: refused.join(","),
635
+ TOKENMAXXING_API_KEY_ID: key?.id ?? "",
585
636
  },
586
637
  });
587
638
  child = spawned;
639
+ if (secret != null) sendApiKey(spawned, secret);
588
640
  const drain = tee !== null && spawned.stdout instanceof ReadableStream ? relayStdout(spawned.stdout, tee) : null;
589
641
  if (relay !== null) relay.attach(spawned.stdin!, firstLine, pendingTranscript);
590
642
  firstLine = null;
591
- if (picked) {
643
+ if (picked || key) {
592
644
  await recordPresenceOrStop({
593
645
  child: spawned,
594
646
  dir: paths.presenceDir,
595
647
  id: sid,
596
- accountId: picked.id,
648
+ accountId: picked?.id ?? "",
649
+ ...(key ? { apiKeyId: key.id } : {}),
597
650
  event: "supervisor.presence_failed",
598
651
  message: "could not write the session presence file - refusing to run a session whose seat placement cannot see",
599
652
  savedTermios,
600
653
  });
601
654
  }
602
655
  releaseClaimLocked(sid);
603
- return { child: spawned, seat: picked, drain, open };
656
+ return { child: spawned, seat: picked, key, drain, open };
604
657
  });
605
658
  if (launched == null) {
606
659
  await releaseClaim();
607
660
  return 143;
608
661
  }
609
662
 
610
- const { child: proc, seat, open } = launched;
663
+ const { child: proc, seat, key, open } = launched;
611
664
  drainStdout = launched.drain;
612
665
  let seatCheckAt = 0;
613
666
  await raceMarkerOrExit({
614
667
  child: proc,
615
668
  tick: async () => {
616
669
  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);
670
+ if ((seat || key) && !terminating && Date.now() >= seatCheckAt) {
671
+ const decided = seat ? await moveExhaustedSeat(seat, sid, gate, model, refused) : key ? await moveKeySession(key, sid, gate, model, refused, resumed) : false;
619
672
  seatCheckAt = Date.now() + (decided ? SEAT_RETRY_MS : SEAT_POLL_MS);
620
673
  }
621
674
  return false;
@@ -630,6 +683,7 @@ export async function runSupervisor(argv: string[]): Promise<number> {
630
683
  });
631
684
  await drainStdout?.();
632
685
  drainStdout = null;
686
+ await foldCost(sid, key?.id ?? null, resumed);
633
687
 
634
688
  const m = !terminating && existsSync(marker) ? await consumableMarker(marker, gate) : null;
635
689
  if (m) {
@@ -638,7 +692,8 @@ export async function runSupervisor(argv: string[]): Promise<number> {
638
692
  noticeSid = m.sessionId;
639
693
  for (const line of stoppedTaskLines(open)) process.stdout.write(line);
640
694
  const accounts = loadAccounts(claudePool).accounts;
641
- const label = accounts.find((a) => a.id === m.accountId)?.label ?? m.accountId.slice(0, 8);
695
+ const nextKey = m.apiKeyId == null ? null : (loadApiKeys().keys.find((k) => k.id === m.apiKeyId) ?? null);
696
+ 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
697
  const walledUntil = accounts.find((a) => a.id === seat?.id)?.enforcedUntil ?? 0;
643
698
  const transcript = findTranscript(m.sessionId);
644
699
  const resumable = transcript !== null;
@@ -646,34 +701,50 @@ export async function runSupervisor(argv: string[]): Promise<number> {
646
701
  for (const id of relay.replay()) process.stdout.write(controlErrorLine(id));
647
702
  }
648
703
  let compacted = false;
704
+ const compactKey = key != null && usableApiKey(key.id) != null ? key : null;
705
+ const compactOn = seat?.label ?? (compactKey ? `API key ${compactKey.label}` : null);
649
706
  if (m.compact && seat && resumable && walledUntil > Date.now()) {
650
707
  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; } });
708
+ } else if (m.compact && compactOn != null && resumable) {
709
+ 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.`);
710
+ const baseEnv = { ...omit(scrubCredEnv(childEnv), ["TOKENMAXXING_SUPERVISED", "TOKENMAXXING_SESSION_ID", "TOKENMAXXING_LAUNCHED_AT", "TOKENMAXXING_MODEL", "TOKENMAXXING_REFUSED", "TOKENMAXXING_API_KEY_ID"]), TOKENMAXXING_PROBE: "1" };
711
+ const compactEnv = seat ? { ...baseEnv, CLAUDE_SECURESTORAGE_CONFIG_DIR: storeDirFor(seat.id) } : compactKey ? apiKeyEnv(baseEnv, compactKey) : baseEnv;
712
+ const compactSecret = seat == null && compactKey ? readApiKey(compactKey) : null;
713
+ const outcome = await compactClaudeSession({
714
+ real,
715
+ sid: m.sessionId,
716
+ transcript,
717
+ env: compactEnv,
718
+ keyPipe: compactSecret != null,
719
+ onSpawn: (p) => {
720
+ child = p;
721
+ if (compactSecret != null) sendApiKey(p, compactSecret);
722
+ },
723
+ });
655
724
  child = null;
656
725
  if (terminating) {
657
726
  await releaseClaim();
658
727
  return 143;
659
728
  }
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 });
729
+ 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
730
  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
731
  compacted = outcome.ok;
663
732
  }
664
733
  if (m.waitUntil > Date.now()) {
665
734
  if (await countdownWait(label, m.waitUntil, { stream, say })) overriddenUntil = m.waitUntil;
666
735
  } else say(`\n\x1b[36m↻ tokenmaxxing: moving to ${label} - resuming...\x1b[0m\n`, `tokenmaxxing: moving to ${label} and resuming.`);
667
- wanted = m.accountId;
736
+ wanted = m.apiKeyId != null ? { accountId: null, apiKeyId: m.apiKeyId } : { accountId: m.accountId, apiKeyId: null };
668
737
  refused = m.refused ?? refused;
669
738
  saveSessionFlags(m.sessionId, persistable, process.cwd());
670
- const prompt = resumable ? resumePrompt({ compacted, origin: m.origin }) : null;
739
+ const prompt = resumable ? resumePrompt({ compacted, origin: m.origin, onKey: m.apiKeyId != null }) : null;
671
740
  firstLine = relay !== null && prompt !== null ? userLine(prompt) : null;
672
741
  pendingTranscript = resumable ? null : transcriptPath(m.sessionId);
742
+ if (resumable) resumed.add(m.sessionId);
673
743
  launchArgs = [resumable ? "--resume" : "--session-id", m.sessionId, ...(relay === null && prompt !== null ? [prompt] : []), ...persistable];
674
744
  continue;
675
745
  }
676
746
  clearPresence({ dir: paths.presenceDir, id: sid });
747
+ clearSessionCost(sid);
677
748
  await releaseClaim();
678
749
  log("supervisor.exit", { sid, respawns, code: proc.exitCode, signal: proc.signalCode, terminated: terminating || undefined });
679
750
  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
  });
package/src/lib/decide.ts CHANGED
@@ -6,9 +6,9 @@ import { MAX_WAITERS_PER_ACCOUNT, isExhausted, landWindows, limitWindows, liveUs
6
6
  import { familyTokens } from "./usage.ts";
7
7
  import { errorMessage, log } from "./log.ts";
8
8
  import type { Observation, Provider, ResetClaim } from "./provider.ts";
9
- import type { Account, Bars, Config, EnforcedLimit } from "./types.ts";
9
+ import type { Account, ApiKey, Bars, Config, EnforcedLimit } from "./types.ts";
10
10
 
11
- export type SwapDecision = { swapped: boolean; account: Account | null; reason: string; waitUntil?: number };
11
+ export type SwapDecision = { swapped: boolean; account: Account | null; apiKey?: ApiKey; reason: string; waitUntil?: number };
12
12
 
13
13
  export type EvalOpts = {
14
14
  seatId?: string | null;
@@ -16,8 +16,18 @@ export type EvalOpts = {
16
16
  waiterId?: string;
17
17
  exclude?: string[];
18
18
  refused?: string[];
19
+ apiKeys?: boolean;
20
+ apiKeyId?: string;
19
21
  };
20
22
 
23
+ export type MoveTarget = { accountId: string; apiKeyId?: string; waitUntil: number | undefined };
24
+
25
+ export function moveTarget(d: SwapDecision): MoveTarget | null {
26
+ if (d.apiKey) return { accountId: "", apiKeyId: d.apiKey.id, waitUntil: undefined };
27
+ if (d.account && (d.swapped || d.waitUntil !== undefined)) return { accountId: d.account.id, waitUntil: d.waitUntil };
28
+ return null;
29
+ }
30
+
21
31
  const MOVE_CLAIM_MS = 2 * CLAUDE_COMPACT_KILL_MS;
22
32
 
23
33
  const WEEK_MS = 7 * 24 * 60 * 60 * 1000;
@@ -106,6 +116,8 @@ export async function useBankedResets(p: Provider, cfg: Config, now: number): Pr
106
116
 
107
117
  export async function evaluateAndMaybeSwap(p: Provider, now = Date.now(), canRespawn = false, enforced: EnforcedLimit | null = null, opts: EvalOpts = {}): Promise<SwapDecision> {
108
118
  const activeId = opts.seatId === undefined ? p.liveId() : opts.seatId;
119
+ const keys = opts.apiKeys === true ? p.apiKeys : undefined;
120
+ const keyId = activeId == null && keys ? (opts.apiKeyId ?? keys.liveId()) : null;
109
121
  const waiterId = canRespawn ? opts.waiterId : undefined;
110
122
  const cfg = loadConfig();
111
123
  const bars = thresholdBars(cfg);
@@ -116,7 +128,7 @@ export async function evaluateAndMaybeSwap(p: Provider, now = Date.now(), canRes
116
128
  const observed = stored0 ? await p.observeLive(stored0, cfg, now, { probe: enforced == null && !walled0, perModel: gated == null || gated.length > 0 }) : null;
117
129
  const stored = loadAccounts(p.pool).accounts.find((a) => a.id === activeId);
118
130
 
119
- if (!enforced && !isOver(stored, observed, { now, thresholds: bars, currentId: activeId, families: gated, seats: null })) {
131
+ if (!enforced && keyId == null && !isOver(stored, observed, { now, thresholds: bars, currentId: activeId, families: gated, seats: null })) {
120
132
  if (waiterId != null && liveWaitClaims(now).some((c) => c.sessionId === waiterId)) {
121
133
  await withLock(p.pool.lockFile, () => {
122
134
  releaseWaitClaim(waiterId);
@@ -168,7 +180,7 @@ export async function evaluateAndMaybeSwap(p: Provider, now = Date.now(), canRes
168
180
  const seats = p.seats === "shared" ? present : null;
169
181
 
170
182
  const seatExhausted = active != null && isExhausted(active, { now, thresholds: bars, currentId: id2, families: switchFamilies, seats });
171
- if (!enforced2 && !isOver(active, obs2, { now, thresholds: bars, currentId: id2, families, seats }) && !(enforced && seatExhausted)) {
183
+ if (keyId == null && !enforced2 && !isOver(active, obs2, { now, thresholds: bars, currentId: id2, families, seats }) && !(enforced && seatExhausted)) {
172
184
  return { swapped: false, account: null, reason: "raced-already-swapped" };
173
185
  }
174
186
  if (p.seats === "shared" && !canRespawn) {
@@ -189,9 +201,9 @@ export async function evaluateAndMaybeSwap(p: Provider, now = Date.now(), canRes
189
201
  const seat = seatOf(cur);
190
202
  const ctx: PickCtx = { now, thresholds: bars, currentId: seat?.id ?? null, families: switchFamilies, seats };
191
203
  const credited = !enforced2 && seat != null && !seat.needsReauth && !isExhausted(seat, ctx) && onCredits(seat, ctx);
192
- const best = pickBest(usable(cur.accounts).filter((a) => !(credited && opts.refused?.includes(a.id))), ctx);
204
+ const best = pickBest(usable(cur.accounts).filter((a) => !((credited || keyId != null) && opts.refused?.includes(a.id))), ctx);
193
205
  if (credited && (best == null || onCredits(best, ctx))) return { swapped: false, account: null, reason: "no-plan-headroom" };
194
- if (!best) break;
206
+ if (!best || (keyId != null && onCredits(best, ctx))) break;
195
207
  try {
196
208
  await p.swap(best);
197
209
  } catch (e) {
@@ -203,6 +215,15 @@ export async function evaluateAndMaybeSwap(p: Provider, now = Date.now(), canRes
203
215
  return { swapped: true, account: best, reason: "swapped" };
204
216
  }
205
217
 
218
+ if (keys) {
219
+ if (keyId != null && keys.holds(keyId)) return { swapped: false, account: null, reason: "api-key-holds" };
220
+ const key = keys.pick(keyId == null ? [] : [keyId]);
221
+ if (key) {
222
+ log("decide.api_key", { key: key.id, from: keyId ?? id2?.slice(0, 8) });
223
+ return { swapped: true, account: null, apiKey: key, reason: "api-key" };
224
+ }
225
+ }
226
+
206
227
  if (!p.waitsWhenDepleted) {
207
228
  log("decide.depleted", { waitUntil: 0 });
208
229
  return { swapped: false, account: null, reason: "all-depleted" };