tokenmaxxing 1.131.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tokenmaxxing",
3
- "version": "1.131.0",
3
+ "version": "1.132.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",
package/src/cli/key.ts ADDED
@@ -0,0 +1,147 @@
1
+ import { z } from "zod";
2
+ import { keySessions, loadApiKeys, removeApiKeySecret, saveApiKeys, writeApiKeySecret } from "../lib/apikeys.ts";
3
+ import { withLock } from "../lib/lock.ts";
4
+ import { claudePool } from "../lib/paths.ts";
5
+ import { readStdin } from "../lib/proc.ts";
6
+ import type { ApiKey } from "../lib/types.ts";
7
+ import { c, emitError, plain } from "./render.ts";
8
+
9
+ const USAGE = "usage: tokenmaxxing key add <label> [credit-usd] [workspace-id] (the key on stdin) | key credit <label> <usd> | key rm <label>";
10
+
11
+ const UsdSchema = z.coerce.number().finite().min(0);
12
+
13
+ const WorkspaceIdSchema = z
14
+ .string()
15
+ .min(1)
16
+ .refine((id) => id.trim() === id && !id.includes("\n") && !id.includes("\r"), "a workspace id is one line with no surrounding whitespace");
17
+
18
+ export function usd(amount: number): string {
19
+ return `$${amount.toFixed(2)}`;
20
+ }
21
+
22
+ function findKey(keys: ApiKey[], label: string): ApiKey | undefined {
23
+ return keys.find((k) => k.label.toLowerCase() === label.toLowerCase());
24
+ }
25
+
26
+ function parseUsd(raw: string): number | null {
27
+ const parsed = UsdSchema.safeParse(raw);
28
+ if (parsed.success) return parsed.data;
29
+ emitError({ message: `"${raw}" is not a dollar amount (a number at or above 0)` });
30
+ return null;
31
+ }
32
+
33
+ async function add(args: string[]): Promise<number> {
34
+ const [label, creditRaw, workspaceRaw] = args;
35
+ if (!label) {
36
+ emitError({ message: USAGE, paint: plain });
37
+ return 2;
38
+ }
39
+ const credit = creditRaw == null ? undefined : parseUsd(creditRaw);
40
+ if (credit === null) return 2;
41
+ const workspace = workspaceRaw == null ? undefined : WorkspaceIdSchema.safeParse(workspaceRaw);
42
+ if (workspace != null && !workspace.success) {
43
+ emitError({ message: `"${workspaceRaw}" is not a workspace id: ${workspace.error.issues[0]?.message ?? "invalid"}` });
44
+ return 2;
45
+ }
46
+ const workspaceId = workspace?.data;
47
+ if (process.stdin.isTTY) {
48
+ emitError({ message: "refusing to read the API key from a terminal, which would echo it - pipe it in: read -rs KEY && printf '%s' \"$KEY\" | tokenmaxxing key add <label> ...; unset KEY" });
49
+ return 2;
50
+ }
51
+ const secret = (await readStdin()).trim();
52
+ if (secret === "") {
53
+ emitError({ message: "no API key on stdin - pipe the key in, for example from your password manager" });
54
+ return 2;
55
+ }
56
+ if (secret.includes("\n") || secret.includes("\r")) {
57
+ emitError({ message: "stdin holds more than one line - pass one API key per `key add`" });
58
+ return 2;
59
+ }
60
+ if (secret.startsWith("sk-ant-admin")) {
61
+ emitError({ message: "that is an Admin API key, which cannot run inference - create a regular API key in the Console" });
62
+ return 2;
63
+ }
64
+ return withLock(claudePool.lockFile, () => {
65
+ const idx = loadApiKeys();
66
+ const taken = findKey(idx.keys, label);
67
+ if (taken) {
68
+ emitError({ message: `label "${label}" is already used by another API key - labels must be unique` });
69
+ return 1;
70
+ }
71
+ let id = crypto.randomUUID().slice(0, 8);
72
+ while (idx.keys.some((k) => k.id === id)) id = crypto.randomUUID().slice(0, 8);
73
+ const now = Date.now();
74
+ writeApiKeySecret(id, secret);
75
+ idx.keys.push({
76
+ id,
77
+ label,
78
+ ...(workspaceId != null ? { workspaceId } : {}),
79
+ ...(credit != null ? { creditUsd: credit } : {}),
80
+ spentUsd: 0,
81
+ creditSetAt: now,
82
+ addedAt: now,
83
+ });
84
+ saveApiKeys(idx);
85
+ console.log(`added API key ${c.bold(label)} (${credit != null ? `credit ${usd(credit)}` : "credit unknown, ranks after keys with a balance"}${workspaceId != null ? `, workspace ${workspaceId}` : ""})`);
86
+ return 0;
87
+ });
88
+ }
89
+
90
+ async function credit(args: string[]): Promise<number> {
91
+ const [label, amountRaw] = args;
92
+ if (!label || amountRaw == null || args.length > 2) {
93
+ emitError({ message: USAGE, paint: plain });
94
+ return 2;
95
+ }
96
+ const amount = parseUsd(amountRaw);
97
+ if (amount === null) return 2;
98
+ return withLock(claudePool.lockFile, () => {
99
+ const idx = loadApiKeys();
100
+ const key = findKey(idx.keys, label);
101
+ if (!key) {
102
+ emitError({ message: `no API key is labeled "${label}"` });
103
+ return 1;
104
+ }
105
+ key.creditUsd = amount;
106
+ key.spentUsd = 0;
107
+ key.creditSetAt = Date.now();
108
+ key.refusedAt = undefined;
109
+ saveApiKeys(idx);
110
+ console.log(`set ${c.bold(key.label)} credit to ${usd(amount)}`);
111
+ return 0;
112
+ });
113
+ }
114
+
115
+ async function rm(args: string[]): Promise<number> {
116
+ const [label] = args;
117
+ if (!label || args.length > 1) {
118
+ emitError({ message: USAGE, paint: plain });
119
+ return 2;
120
+ }
121
+ return withLock(claudePool.lockFile, () => {
122
+ const idx = loadApiKeys();
123
+ const key = findKey(idx.keys, label);
124
+ if (!key) {
125
+ emitError({ message: `no API key is labeled "${label}"` });
126
+ return 1;
127
+ }
128
+ if (keySessions().has(key.id)) {
129
+ emitError({ message: `${key.label} is running in a live session - close that session before removing the key.` });
130
+ return 1;
131
+ }
132
+ removeApiKeySecret(key.id);
133
+ idx.keys = idx.keys.filter((k) => k.id !== key.id);
134
+ saveApiKeys(idx);
135
+ console.log(`removed API key ${c.bold(key.label)} (${idx.keys.length} left)`);
136
+ return 0;
137
+ });
138
+ }
139
+
140
+ export async function cmdKey(args: string[]): Promise<number> {
141
+ const [action, ...rest] = args;
142
+ if (action === "add") return add(rest);
143
+ if (action === "credit") return credit(rest);
144
+ if (action === "rm") return rm(rest);
145
+ emitError({ message: USAGE, paint: plain });
146
+ return 2;
147
+ }
package/src/cli/status.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { chunk, sortBy } from "es-toolkit";
2
+ import { creditLeft, keySessions, loadApiKeys } from "../lib/apikeys.ts";
2
3
  import { claude } from "../lib/claude.ts";
3
4
  import { codex } from "../lib/codex.ts";
4
5
  import { grok } from "../lib/grok.ts";
@@ -7,6 +8,7 @@ import { withLock } from "../lib/lock.ts";
7
8
  import { codexPool, grokPool } from "../lib/paths.ts";
8
9
  import { barFor, bufferedUsed, earliestReset, gatedWindows, landGrant, landWindows, isExhausted, isSessionWindow, limitWindows, liveUsed, nextWeeklyReset, sessionWindow, thresholdBars, weeklyWindow } from "../lib/picker.ts";
9
10
  import type { Provider, SampleReport } from "../lib/provider.ts";
11
+ import { usd } from "./key.ts";
10
12
  import { bar, c, count, emitJson, fmtAgo } from "./render.ts";
11
13
  import type { Account, AccountThresholds, Bars, Config, Thresholds, Window } from "../lib/types.ts";
12
14
 
@@ -38,7 +40,48 @@ type PoolReport = {
38
40
  accounts: StatusAccount[];
39
41
  };
40
42
 
41
- export type StatusReport = { now: number; claude: PoolReport; codex: PoolReport; grok: PoolReport };
43
+ type StatusApiKey = {
44
+ label: string;
45
+ id: string;
46
+ workspaceId: string | null;
47
+ creditUsd: number | null;
48
+ spentUsd: number;
49
+ creditLeftUsd: number | null;
50
+ refused: boolean;
51
+ refusedAt: number | null;
52
+ sessions: number;
53
+ };
54
+
55
+ export type StatusReport = { now: number; claude: PoolReport; codex: PoolReport; grok: PoolReport; apiKeys?: StatusApiKey[] };
56
+
57
+ function apiKeyReport(): StatusApiKey[] {
58
+ const sessions = keySessions();
59
+ return loadApiKeys().keys.map((k) => ({
60
+ label: k.label,
61
+ id: k.id,
62
+ workspaceId: k.workspaceId ?? null,
63
+ creditUsd: k.creditUsd ?? null,
64
+ spentUsd: k.spentUsd,
65
+ creditLeftUsd: creditLeft(k),
66
+ refused: k.refusedAt != null,
67
+ refusedAt: k.refusedAt ?? null,
68
+ sessions: sessions.get(k.id) ?? 0,
69
+ }));
70
+ }
71
+
72
+ function renderApiKeys(keys: StatusApiKey[]): void {
73
+ console.log(c.dim(`api keys (${count({ n: keys.length, noun: "key" })}, used only while every claude account is at its limit)`));
74
+ console.log();
75
+ for (const k of keys) {
76
+ const badges: string[] = [];
77
+ if (k.sessions > 0) badges.push(c.green(count({ n: k.sessions, noun: "session" })));
78
+ if (k.refused) badges.push(c.red("refused"));
79
+ else if (k.creditLeftUsd != null && k.creditLeftUsd <= 0) badges.push(c.yellow("spent"));
80
+ const credit = k.creditLeftUsd == null ? c.dim("credit unknown") : `credit left ${usd(k.creditLeftUsd)}`;
81
+ console.log(`${k.sessions > 0 ? c.green("●") : c.dim("○")} ${c.bold(k.label)} ${credit}${badges.length ? ` ${badges.join(" ")}` : ""}`);
82
+ }
83
+ console.log();
84
+ }
42
85
 
43
86
  function currentWindow(a: Account, w: Window, bars: Bars | null, now: number): WindowReport {
44
87
  const passed = w.resetsAt != null && w.resetsAt <= now;
@@ -259,11 +302,12 @@ export async function cmdStatus(opts: { json?: boolean; cached?: boolean } = {})
259
302
  const cfg = loadConfig();
260
303
  const now = Date.now();
261
304
  const claudeReport = await collect(claude, cfg, now, cached);
305
+ const apiKeys = apiKeyReport();
262
306
  if (!json) {
263
307
  const codexPooled = loadAccounts(codexPool).accounts.length > 0;
264
308
  const grokPooled = loadAccounts(grokPool).accounts.length > 0;
265
309
  if (claudeReport.accounts.length === 0) {
266
- if (!codexPooled && !grokPooled) {
310
+ if (!codexPooled && !grokPooled && apiKeys.length === 0) {
267
311
  console.log(c.dim("no accounts yet, run `tokenmaxxing init` (or `tokenmaxxing init --codex`, `--grok`)"));
268
312
  return 0;
269
313
  }
@@ -273,11 +317,12 @@ export async function cmdStatus(opts: { json?: boolean; cached?: boolean } = {})
273
317
  const header = `thresholds 5h ${claudeReport.thresholds.session}% weekly ${claudeReport.thresholds.weekly}% bars 5h ${claudeReport.bars.session}% weekly ${claudeReport.bars.weekly}% (${count({ n: claudeReport.accounts.length, noun: "claude account" })})`;
274
318
  renderPool(claude, claudeReport, header, Date.now(), cfg.policy.usagePollTtlMs);
275
319
  }
320
+ if (apiKeys.length > 0) renderApiKeys(apiKeys);
276
321
  }
277
322
  const codexReport = await collect(codex, cfg, now, cached);
278
323
  const grokReport = await collect(grok, cfg, now, cached);
279
324
  if (json) {
280
- const report: StatusReport = { now, claude: claudeReport, codex: codexReport, grok: grokReport };
325
+ const report: StatusReport = { now, claude: claudeReport, codex: codexReport, grok: grokReport, ...(apiKeys.length > 0 ? { apiKeys } : {}) };
281
326
  emitJson({ ok: true, ...report });
282
327
  return 0;
283
328
  }
@@ -1,5 +1,8 @@
1
+ import { existsSync } from "node:fs";
1
2
  import { sortBy } from "es-toolkit";
3
+ import { writeSessionCost } from "../lib/apikeys.ts";
2
4
  import { claudePool, seatFromEnv } from "../lib/paths.ts";
5
+ import { supervisedSession } from "../lib/sessions.ts";
3
6
  import { errorMessage } from "../lib/log.ts";
4
7
  import { loadAccounts, loadConfig, writeUsage } from "../lib/state.ts";
5
8
  import { familyTokens, matchedFamily, parseStatusLineStdin, parseStatusLineModel } from "../lib/usage.ts";
@@ -8,6 +11,7 @@ import { readStdin } from "../lib/proc.ts";
8
11
  import { worktreeName } from "../lib/worktree.ts";
9
12
  import { fmtResetShort, makeColors, makeUsagePaint, statuslineColor } from "../cli/render.ts";
10
13
  import {
14
+ CostLineSchema,
11
15
  JsonTextSchema,
12
16
  StatusLineStdinSchema,
13
17
  type Account,
@@ -127,6 +131,12 @@ export async function runStatusline(): Promise<number> {
127
131
  }
128
132
  } catch {
129
133
  }
134
+ try {
135
+ const session = existsSync(claudePool.apiKeysJson) ? supervisedSession() : null;
136
+ const cost = session == null ? null : (CostLineSchema.safeParse(obj).data ?? null);
137
+ if (session != null && cost != null && cost.sessionId === session.live) writeSessionCost(session.sid, cost);
138
+ } catch {
139
+ }
130
140
 
131
141
  let line: string;
132
142
  try {
@@ -2,9 +2,10 @@ import { existsSync, readdirSync } from "node:fs";
2
2
  import { basename, dirname, join } from "node:path";
3
3
  import { z } from "zod";
4
4
  import { claude } from "../lib/claude.ts";
5
- import { evaluateAndMaybeSwap } from "../lib/decide.ts";
5
+ import { liveApiKeyId, refuseApiKey } from "../lib/apikeys.ts";
6
+ import { evaluateAndMaybeSwap, moveTarget } from "../lib/decide.ts";
6
7
  import { readStdin } from "../lib/proc.ts";
7
- import { adoptLiveSession, refusedAccounts, supervisedSession, writeRespawnMarker } from "../lib/sessions.ts";
8
+ import { adoptLiveSession, keyMovesSupported, refusedAccounts, supervisedSession, writeRespawnMarker } from "../lib/sessions.ts";
8
9
  import { loadConfig } from "../lib/state.ts";
9
10
  import { classifyEnforcedLimit, ENFORCED_ERRORS, findEnforcedRow, parseErrorBody, readTranscriptTail, type TranscriptRow } from "../lib/usage.ts";
10
11
  import { paths } from "../lib/paths.ts";
@@ -60,6 +61,22 @@ export async function runStopFailureHook(): Promise<number> {
60
61
  return 0;
61
62
  }
62
63
  const canRespawn = session != null && mainLoop;
64
+ const keyId = account == null ? liveApiKeyId() : null;
65
+ if (keyId != null) {
66
+ if (error !== "billing_error") return 0;
67
+ const known = await refuseApiKey(keyId, now);
68
+ log("stopfailure.key_refused", { key: keyId, known, subagent: !mainLoop });
69
+ const decision = await evaluateAndMaybeSwap(claude, now, canRespawn, null, { waiterId: canRespawn ? session?.sid : undefined, refused: refusedAccounts(), apiKeys: true, apiKeyId: keyId });
70
+ const target = moveTarget(decision);
71
+ if (session && canRespawn && target) {
72
+ writeRespawnMarker({ session, ...target, waitUntil: target.waitUntil ?? now, compact: false, origin: "stopfailure" });
73
+ log("stopfailure.marker", { session: session.sid.slice(0, 8), live: session.live.slice(0, 8), account: target.accountId.slice(0, 8), key: target.apiKeyId, waitUntil: target.waitUntil ?? now });
74
+ } else {
75
+ log("stopfailure.decision", { reason: decision.reason, swapped: decision.swapped, account: decision.account?.id.slice(0, 8), waitUntil: decision.waitUntil });
76
+ }
77
+ return 0;
78
+ }
79
+ if (error === "billing_error") return 0;
63
80
  const cfg = loadConfig();
64
81
  const row = stdin.transcript_path
65
82
  ? await awaitEnforcedRow({ transcriptPath: stdin.transcript_path, agentId: stdin.agent_id, error, lastAssistantMessage: stdin.last_assistant_message })
@@ -92,10 +109,11 @@ export async function runStopFailureHook(): Promise<number> {
92
109
  }
93
110
 
94
111
  const refused = enforced?.kind === "credits" ? [...new Set([...refusedAccounts(), enforced.account])] : undefined;
95
- const decision = await evaluateAndMaybeSwap(claude, now, canRespawn, enforced, { waiterId: canRespawn ? session?.sid : undefined, exclude: refused });
96
- if (session && canRespawn && decision.account && (decision.swapped || decision.waitUntil !== undefined)) {
97
- writeRespawnMarker({ session, accountId: decision.account.id, waitUntil: decision.waitUntil ?? now, compact: false, origin: "stopfailure", refused });
98
- log("stopfailure.marker", { session: session.sid.slice(0, 8), live: session.live.slice(0, 8), account: decision.account.id.slice(0, 8), waitUntil: decision.waitUntil ?? now });
112
+ const decision = await evaluateAndMaybeSwap(claude, now, canRespawn, enforced, { waiterId: canRespawn ? session?.sid : undefined, exclude: refused, apiKeys: canRespawn && keyMovesSupported() });
113
+ const target = moveTarget(decision);
114
+ if (session && canRespawn && target) {
115
+ writeRespawnMarker({ session, ...target, waitUntil: target.waitUntil ?? now, compact: false, origin: "stopfailure", refused });
116
+ log("stopfailure.marker", { session: session.sid.slice(0, 8), live: session.live.slice(0, 8), account: target.accountId.slice(0, 8), key: target.apiKeyId, waitUntil: target.waitUntil ?? now });
99
117
  } else {
100
118
  log("stopfailure.decision", { reason: decision.reason, swapped: decision.swapped, account: decision.account?.id.slice(0, 8), waitUntil: decision.waitUntil });
101
119
  }
@@ -1,8 +1,8 @@
1
1
  import { z } from "zod";
2
2
  import { claude } from "../lib/claude.ts";
3
- import { evaluateAndMaybeSwap } from "../lib/decide.ts";
3
+ import { evaluateAndMaybeSwap, moveTarget } from "../lib/decide.ts";
4
4
  import { readStdin } from "../lib/proc.ts";
5
- import { adoptLiveSession, refusedAccounts, supervisedSession, writeRespawnMarker } from "../lib/sessions.ts";
5
+ import { adoptLiveSession, keyMovesSupported, refusedAccounts, supervisedSession, writeRespawnMarker } from "../lib/sessions.ts";
6
6
  import { JsonTextSchema } from "../lib/types.ts";
7
7
  import { errorMessage, log } from "../lib/log.ts";
8
8
 
@@ -24,10 +24,11 @@ export async function runBoundaryHook(event: "stop" | "sessionstart"): Promise<n
24
24
  log(`${event}.nested_session`, { stdin: stdin.session_id?.slice(0, 8), supervised: supervised.live.slice(0, 8), source: stdin.source });
25
25
  return 0;
26
26
  }
27
- const decision = await evaluateAndMaybeSwap(claude, Date.now(), session != null, null, { waiterId: session?.sid, refused: refusedAccounts() });
28
- if (session && decision.account && (decision.swapped || decision.waitUntil !== undefined)) {
29
- writeRespawnMarker({ session, accountId: decision.account.id, waitUntil: decision.waitUntil ?? Date.now(), compact: true, origin: event });
30
- log(`${event}.${decision.waitUntil !== undefined ? "wait" : "move"}`, { source: stdin.source, account: decision.account.id.slice(0, 8), waitUntil: decision.waitUntil, session: session.sid.slice(0, 8), live: session.live.slice(0, 8) });
27
+ const decision = await evaluateAndMaybeSwap(claude, Date.now(), session != null, null, { waiterId: session?.sid, refused: refusedAccounts(), apiKeys: session != null && keyMovesSupported() });
28
+ const target = moveTarget(decision);
29
+ if (session && target) {
30
+ writeRespawnMarker({ session, ...target, waitUntil: target.waitUntil ?? Date.now(), compact: true, origin: event });
31
+ log(`${event}.${target.waitUntil !== undefined ? "wait" : "move"}`, { source: stdin.source, account: target.accountId.slice(0, 8), key: target.apiKeyId, waitUntil: target.waitUntil, session: session.sid.slice(0, 8), live: session.live.slice(0, 8) });
31
32
  }
32
33
  } catch (e) {
33
34
  log(`${event}.error`, { err: errorMessage(e) });
@@ -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" };
package/src/lib/paths.ts CHANGED
@@ -28,6 +28,8 @@ export const paths = {
28
28
  onboardDir: join(TM_HOME, "onboard"),
29
29
  storesDir: join(TM_HOME, "stores"),
30
30
  hubKeyFile: join(TM_HOME, "hub-key"),
31
+ apiKeysDir: join(TM_HOME, "api-keys"),
32
+ costDir: join(TM_HOME, "cost"),
31
33
 
32
34
  claudeSettings: env(
33
35
  "TOKENMAXXING_CLAUDE_SETTINGS",
@@ -43,6 +45,7 @@ export const claudePool = {
43
45
  accountsJson: join(TM_HOME, "accounts.json"),
44
46
  lockFile: join(TM_HOME, "lock"),
45
47
  waitQueueJson: join(TM_HOME, "wait-queue.json"),
48
+ apiKeysJson: join(TM_HOME, "api-keys.json"),
46
49
  } as const;
47
50
 
48
51
  export const codexPool = {
package/src/lib/pi.ts CHANGED
@@ -98,6 +98,7 @@ export async function piLogin(pool: PiPool): Promise<PiLogin | null> {
98
98
  function piMover(base: Provider, pool: PiPool): Provider {
99
99
  return {
100
100
  ...base,
101
+ apiKeys: undefined,
101
102
  swap: async (target) => {
102
103
  if (!piStoreUsable(pool, target.id)) {
103
104
  throw new StoreUnusableError(`${target.label} has no pi login - run \`tokenmaxxing auth --pi${base.flag} ${target.label}\``);
@@ -13,12 +13,13 @@ const PresenceSchema = z.object({
13
13
  accountId: z.string(),
14
14
  pid: z.number(),
15
15
  startedAt: z.string(),
16
+ apiKeyId: z.string().optional(),
16
17
  });
17
18
 
18
- export function writePresence(input: { dir: string; id: string; accountId: string; pid: number }): void {
19
+ export function writePresence(input: { dir: string; id: string; accountId: string; pid: number; apiKeyId?: string }): void {
19
20
  const startedAt = pidStartTimes([input.pid]).get(input.pid);
20
21
  if (startedAt == null) throw new Error(`could not read pid ${input.pid}'s start time (ps lstart) - refusing to write an unverifiable presence file`);
21
- writeFileAtomic(join(input.dir, input.id), JSON.stringify(PresenceSchema.parse({ accountId: input.accountId, pid: input.pid, startedAt })));
22
+ writeFileAtomic(join(input.dir, input.id), JSON.stringify(PresenceSchema.parse({ accountId: input.accountId, pid: input.pid, startedAt, ...(input.apiKeyId != null ? { apiKeyId: input.apiKeyId } : {}) })));
22
23
  }
23
24
 
24
25
  export function presencePid(input: { dir: string; id: string }): number | null {
@@ -34,7 +35,7 @@ export function clearPresence(input: { dir: string; id: string }): void {
34
35
  rmSync(join(input.dir, input.id), { force: true });
35
36
  }
36
37
 
37
- export type LivingPresence = { id: string; accountId: string };
38
+ export type LivingPresence = { id: string; accountId: string; apiKeyId?: string };
38
39
 
39
40
  export function livingPresences(dir: string): LivingPresence[] {
40
41
  if (!existsSync(dir)) return [];
@@ -65,11 +66,11 @@ export function livingPresences(dir: string): LivingPresence[] {
65
66
  rmSync(file, { force: true });
66
67
  continue;
67
68
  }
68
- living.push({ id: name, accountId: record.accountId });
69
+ living.push({ id: name, accountId: record.accountId, ...(record.apiKeyId != null ? { apiKeyId: record.apiKeyId } : {}) });
69
70
  }
70
71
  return living;
71
72
  }
72
73
 
73
74
  export function seatCounts(dir: string): Map<string, number> {
74
- return new Map(Object.entries(countBy(livingPresences(dir), (p) => p.accountId)));
75
+ return new Map(Object.entries(countBy(livingPresences(dir).filter((p) => p.apiKeyId == null), (p) => p.accountId)));
75
76
  }
@@ -1,6 +1,12 @@
1
1
  import type { PoolPaths } from "./paths.ts";
2
2
  import type { Harvest } from "./state.ts";
3
- import type { Account, Config, Window } from "./types.ts";
3
+ import type { Account, ApiKey, Config, Window } from "./types.ts";
4
+
5
+ export type ApiKeyPool = {
6
+ liveId(): string | null;
7
+ holds(id: string): boolean;
8
+ pick(exclude: string[]): ApiKey | null;
9
+ };
4
10
 
5
11
  export type Observation = { windows: Window[]; at: number };
6
12
 
@@ -26,6 +32,7 @@ export type Provider = {
26
32
  seats: "shared" | "live";
27
33
  waitsWhenDepleted: boolean;
28
34
  statusOnly: boolean;
35
+ apiKeys?: ApiKeyPool;
29
36
  liveId(): string | null;
30
37
  presence(): Map<string, number>;
31
38
  gatedFamilies(cfg: Config): string[] | null;
@@ -60,7 +60,7 @@ function listDir(dir: string, root: string): Dirent[] {
60
60
  }
61
61
 
62
62
  function tmpSweepDirs(root: string): string[] {
63
- const dirs = [paths.home, paths.usageDir, paths.presenceDir, paths.respawnDir, sessionsDir(), paths.binDir, codexPaths.presenceDir, codexPaths.respawnDir, codexPaths.onboardDir];
63
+ const dirs = [paths.home, paths.usageDir, paths.presenceDir, paths.respawnDir, sessionsDir(), paths.binDir, paths.apiKeysDir, paths.costDir, codexPaths.presenceDir, codexPaths.respawnDir, codexPaths.onboardDir];
64
64
  for (const parent of STORE_PARENTS) {
65
65
  for (const child of listDir(parent, root)) {
66
66
  if (child.isDirectory()) dirs.push(join(parent, child.name));
@@ -113,6 +113,10 @@ export function supervisedSession(env: Record<string, string | undefined> = proc
113
113
  return { sid, launchedAt: LaunchedAtSchema.parse(env.TOKENMAXXING_LAUNCHED_AT) ?? null, live: liveSessionId(sid) };
114
114
  }
115
115
 
116
+ export function keyMovesSupported(env: Record<string, string | undefined> = process.env): boolean {
117
+ return env.TOKENMAXXING_API_KEY_ID !== undefined;
118
+ }
119
+
116
120
  export function refusedAccounts(env: Record<string, string | undefined> = process.env): string[] {
117
121
  return RefusedSchema.parse(env.TOKENMAXXING_REFUSED);
118
122
  }
@@ -129,9 +133,10 @@ export function adoptLiveSession(session: SupervisedSession, stdinSid: string |
129
133
  return { ...session, live: stdinSid };
130
134
  }
131
135
 
132
- export function writeRespawnMarker(input: { session: SupervisedSession; accountId: string; waitUntil: number; compact: boolean; origin: z.infer<typeof RespawnMarkerSchema>["origin"]; refused?: string[] }): void {
136
+ export function writeRespawnMarker(input: { session: SupervisedSession; accountId: string; apiKeyId?: string; waitUntil: number; compact: boolean; origin: z.infer<typeof RespawnMarkerSchema>["origin"]; refused?: string[] }): void {
133
137
  const payload: z.infer<typeof RespawnMarkerSchema> = {
134
138
  accountId: input.accountId,
139
+ ...(input.apiKeyId != null ? { apiKeyId: input.apiKeyId } : {}),
135
140
  ts: Date.now(),
136
141
  waitUntil: input.waitUntil,
137
142
  sessionId: input.session.live,
@@ -82,10 +82,10 @@ export async function runPassthrough(input: { real: string; argv: string[]; env:
82
82
  return exitStatus(p);
83
83
  }
84
84
 
85
- export async function recordPresenceOrStop(input: { child: ChildHandle; dir: string; id: string; accountId: string; event: string; message: string; savedTermios: string | null }): Promise<void> {
85
+ export async function recordPresenceOrStop(input: { child: ChildHandle; dir: string; id: string; accountId: string; apiKeyId?: string; event: string; message: string; savedTermios: string | null }): Promise<void> {
86
86
  for (let attempt = 0; ; attempt++) {
87
87
  try {
88
- writePresence({ dir: input.dir, id: input.id, accountId: input.accountId, pid: input.child.pid });
88
+ writePresence({ dir: input.dir, id: input.id, accountId: input.accountId, pid: input.child.pid, ...(input.apiKeyId != null ? { apiKeyId: input.apiKeyId } : {}) });
89
89
  return;
90
90
  } catch (e) {
91
91
  if (input.child.exitCode !== null || input.child.signalCode !== null) return;
package/src/lib/types.ts CHANGED
@@ -169,8 +169,38 @@ export const RespawnMarkerSchema = z.object({
169
169
  origin: z.enum(["stop", "sessionstart", "stopfailure", "seatwatch"]),
170
170
  launchedAt: z.number().optional(),
171
171
  refused: z.array(z.string()).optional(),
172
+ apiKeyId: z.string().optional(),
172
173
  });
173
174
 
175
+ export const ApiKeySchema = z.object({
176
+ id: z.string(),
177
+ label: z.string(),
178
+ workspaceId: z.string().optional(),
179
+ creditUsd: z.number().optional(),
180
+ spentUsd: z.number(),
181
+ creditSetAt: z.number(),
182
+ refusedAt: z.number().optional(),
183
+ addedAt: z.number(),
184
+ });
185
+ export type ApiKey = z.infer<typeof ApiKeySchema>;
186
+
187
+ export const ApiKeysIndexSchema = z.object({
188
+ version: z.literal(1),
189
+ keys: z.array(ApiKeySchema).default([]),
190
+ baselines: z.record(z.string(), z.object({ usd: z.number(), at: z.number() })).default({}),
191
+ });
192
+ export type ApiKeysIndex = z.infer<typeof ApiKeysIndexSchema>;
193
+
194
+ export const SessionCostSchema = z.object({ sessionId: z.string(), usd: z.number() });
195
+ export type SessionCost = z.infer<typeof SessionCostSchema>;
196
+
197
+ export const CostLineSchema = z
198
+ .looseObject({ session_id: z.string(), total_cost_usd: z.number().optional(), cost: z.looseObject({ total_cost_usd: z.number() }).nullable().optional() })
199
+ .transform((line): SessionCost | null => {
200
+ const usd = line.total_cost_usd ?? line.cost?.total_cost_usd;
201
+ return usd == null ? null : { sessionId: line.session_id, usd };
202
+ });
203
+
174
204
  export const EpochSecondsSchema = z.number().transform((seconds) => seconds * 1000);
175
205
 
176
206
  export const InstantSchema = z.iso.datetime({ offset: true }).transform((iso) => Date.parse(iso));
package/src/lib/usage.ts CHANGED
@@ -105,7 +105,7 @@ export function transcriptRowText(row: TranscriptRow): string {
105
105
  return blocks.data.filter((b) => b.type === "text").map((b) => b.text ?? "").join("\n").trim();
106
106
  }
107
107
 
108
- export const ENFORCED_ERRORS = ["rate_limit", "oauth_org_not_allowed"];
108
+ export const ENFORCED_ERRORS = ["rate_limit", "oauth_org_not_allowed", "billing_error"];
109
109
 
110
110
  export function findEnforcedRow(input: { rows: TranscriptRow[]; error: string; lastAssistantMessage: string | undefined }): TranscriptRow | null {
111
111
  const { rows, error, lastAssistantMessage } = input;
package/src/main.ts CHANGED
@@ -20,6 +20,7 @@ import { cmdStatus } from "./cli/status.ts";
20
20
  import { cmdDoctor } from "./cli/doctor.ts";
21
21
  import { cmdRm } from "./cli/rm.ts";
22
22
  import { cmdRename } from "./cli/rename.ts";
23
+ import { cmdKey } from "./cli/key.ts";
23
24
  import { cmdCheck } from "./cli/check.ts";
24
25
  import { cmdConfig } from "./cli/config.ts";
25
26
  import { cmdSeat } from "./cli/seat.ts";
@@ -38,7 +39,7 @@ const PI_FLAG = "--pi";
38
39
  const JSON_COMMANDS = new Set(["status", "config", "check"]);
39
40
  const CODEX_COMMANDS = new Set(["init", "add", "auth", "rm", "rename", "seat"]);
40
41
  const STATUS_ONLY_COMMANDS = new Set(["init", "add", "auth", "rm", "rename"]);
41
- const MAX_OPERANDS = new Map([["init", 0], ["add", 0], ["rm", 1], ["rename", 2]]);
42
+ const MAX_OPERANDS = new Map([["init", 0], ["add", 0], ["rm", 1], ["rename", 2], ["key", 4]]);
42
43
 
43
44
  function printHelp(): void {
44
45
  console.log(`${c.bold("tokenmaxxing")} - automatic Claude Code account switching
@@ -58,6 +59,9 @@ function printHelp(): void {
58
59
  ${c.cyan("tokenmaxxing doctor")} verify the install is intact
59
60
  ${c.cyan("tokenmaxxing rename")} [--codex | --grok] <sel> <label>
60
61
  ${c.cyan("tokenmaxxing rm")} [--codex | --grok] <sel>
62
+ ${c.cyan("tokenmaxxing key add")} <label> [credit-usd] [workspace-id] store an Anthropic API key read from stdin; Claude sessions run on it only while every pooled Claude account is at its limit (workspace-id: for a key that spans several workspaces)
63
+ ${c.cyan("tokenmaxxing key credit")} <label> <usd> set the key's balance as the Console shows it and restart its spend count
64
+ ${c.cyan("tokenmaxxing key rm")} <label> delete the key and its stored secret (refused while a session runs on it)
61
65
  ${c.cyan("tokenmaxxing seat")} <pid> lend one pooled Claude account to an unattended consumer until <pid> exits: prints the store directory to set as CLAUDE_SECURESTORAGE_CONFIG_DIR (the directory itself, as its owner, never a copy of its credential); host sessions and other borrowers share the account; exit 1 = none usable
62
66
  ${c.cyan("tokenmaxxing seat --codex")} <pid> borrow one pooled codex account for an unattended consumer (plugin, script): prints the CODEX_HOME to set, lent to <pid> until it exits; codex sessions and other borrowers share the account, and a refresh race between them can sign it out until auth --codex runs again; exit 1 = none usable, fall back to the ambient login
63
67
  ${c.cyan("tokenmaxxing serve")} serve the CLIProxyAPI-compatible usage API on http://localhost:<hub.port> (default 8317) so a dashboard such as T3 Code's "Add a CLIProxyAPI hub" shows every pooled Claude and Codex account's quota; the management key is the contents of hub-key in the state directory
@@ -173,6 +177,7 @@ async function main(): Promise<number> {
173
177
  case "doctor": return cmdDoctor();
174
178
  case "rm": return cmdRm(provider, args[1]);
175
179
  case "rename": return cmdRename(provider, args.slice(1));
180
+ case "key": return cmdKey(args.slice(1));
176
181
  case "seat": return cmdSeat(provider === codex ? "codex" : "claude", args[1], args.slice(2));
177
182
  case "serve": return cmdServe(args.slice(1));
178
183
  case "uninstall": {
@@ -198,7 +203,7 @@ async function main(): Promise<number> {
198
203
  if (out.timer === "still-loaded") console.log(c.yellow(`⚠ the check job may still be loaded - run: ${deactivationHint(CHECK_JOB)}`));
199
204
  if (out.hub === "still-loaded") console.log(c.yellow(`⚠ the usage hub job may still be loaded - run: ${deactivationHint(HUB_JOB)}`));
200
205
  if (!out.pathLineRemoved) console.log(c.dim("(no tokenmaxxing PATH line found in the shell rc)"));
201
- console.log(`kept: accounts.json, config.json, and every account credential store (claude: stores/ and its keychain items on macOS; codex: codex-stores/; pi: pi-stores/; grok: grok-stores/) - remove accounts with \`xx rm\` to delete their credentials`);
206
+ console.log(`kept: accounts.json, config.json, and every account credential store (claude: stores/ and its keychain items on macOS; codex: codex-stores/; pi: pi-stores/; grok: grok-stores/), plus api-keys.json and the API keys in api-keys/ - remove accounts with \`xx rm\` and keys with \`xx key rm\` to delete their credentials`);
202
207
  return 0;
203
208
  }
204
209
  case "help":