tokenmaxxing 1.131.0 → 1.133.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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,
@@ -1,7 +1,10 @@
1
+ import { once } from "node:events";
2
+ import { isatty } from "node:tty";
1
3
  import type { Subprocess } from "bun";
2
4
  import { LOOP_DIAGNOSIS, MAX_WRAP_DEPTH, WRAP_RATE_WINDOW_MS, wrapDepth, wrapperChainTripped } from "./claudebin.ts";
3
5
  import { errorMessage, log } from "./log.ts";
4
6
  import { paths } from "./paths.ts";
7
+ import { readLines } from "./proc.ts";
5
8
  import { writePresence } from "./presence.ts";
6
9
  import { restoreTermios } from "./tty.ts";
7
10
 
@@ -75,17 +78,64 @@ export function loopGuardTripped(product: WrappedProduct): boolean {
75
78
  return false;
76
79
  }
77
80
 
81
+ const STDOUT_QUIET_MS = 1000;
82
+
83
+ export function childStdout(): "inherit" | "pipe" {
84
+ return isatty(1) ? "inherit" : "pipe";
85
+ }
86
+
87
+ export function relayStdout(source: ReadableStream<Uint8Array>, onLine?: (line: string) => void): (abandoned?: () => boolean) => Promise<void> {
88
+ let ended = false;
89
+ let blocked = false;
90
+ let readAt = Date.now();
91
+ const forwarded = source.pipeThrough(
92
+ new TransformStream<Uint8Array, Uint8Array>({
93
+ async transform(chunk, controller) {
94
+ readAt = Date.now();
95
+ if (!process.stdout.write(chunk)) {
96
+ blocked = true;
97
+ await once(process.stdout, "drain");
98
+ blocked = false;
99
+ }
100
+ controller.enqueue(chunk);
101
+ },
102
+ }),
103
+ );
104
+ void (async () => {
105
+ if (onLine == null) return forwarded.pipeTo(new WritableStream());
106
+ for await (const line of readLines(forwarded)) {
107
+ try {
108
+ onLine(line);
109
+ } catch (e) {
110
+ log("supervisor.tee_failed", { err: errorMessage(e) });
111
+ }
112
+ }
113
+ })()
114
+ .catch((e: unknown) => log("supervisor.stdout_relay_failed", { err: errorMessage(e) }))
115
+ .finally(() => {
116
+ ended = true;
117
+ });
118
+ return async (abandoned = () => false) => {
119
+ const exitedAt = Date.now();
120
+ while (!ended && (blocked ? !abandoned() : Date.now() - Math.max(readAt, exitedAt) < STDOUT_QUIET_MS)) await Bun.sleep(50);
121
+ };
122
+ }
123
+
78
124
  export async function runPassthrough(input: { real: string; argv: string[]; env: Record<string, string | undefined>; onSpawn?: (child: Subprocess) => void | Promise<void> }): Promise<number> {
79
- const p = Bun.spawn([input.real, ...input.argv], { stdin: "inherit", stdout: "inherit", stderr: "inherit", env: input.env });
125
+ let terminated = false;
126
+ process.on("SIGTERM", () => { terminated = true; });
127
+ const p = Bun.spawn([input.real, ...input.argv], { stdin: "inherit", stdout: childStdout(), stderr: "inherit", env: input.env });
128
+ const drain = p.stdout instanceof ReadableStream ? relayStdout(p.stdout) : null;
80
129
  await input.onSpawn?.(p);
81
130
  await p.exited;
131
+ await drain?.(() => terminated);
82
132
  return exitStatus(p);
83
133
  }
84
134
 
85
- export async function recordPresenceOrStop(input: { child: ChildHandle; dir: string; id: string; accountId: string; event: string; message: string; savedTermios: string | null }): Promise<void> {
135
+ 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
136
  for (let attempt = 0; ; attempt++) {
87
137
  try {
88
- writePresence({ dir: input.dir, id: input.id, accountId: input.accountId, pid: input.child.pid });
138
+ writePresence({ dir: input.dir, id: input.id, accountId: input.accountId, pid: input.child.pid, ...(input.apiKeyId != null ? { apiKeyId: input.apiKeyId } : {}) });
89
139
  return;
90
140
  } catch (e) {
91
141
  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":