tokenmaxxing 1.34.0 → 1.38.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.
Files changed (54) hide show
  1. package/DESIGN.md +3 -5
  2. package/README.md +1 -1
  3. package/package.json +1 -1
  4. package/src/cli/add.ts +23 -40
  5. package/src/cli/auth.ts +38 -52
  6. package/src/cli/check.ts +2 -1
  7. package/src/cli/doctor.ts +13 -13
  8. package/src/cli/init.ts +29 -126
  9. package/src/cli/rename.ts +11 -45
  10. package/src/cli/render.ts +0 -7
  11. package/src/cli/rm.ts +24 -39
  12. package/src/cli/setuptoken.ts +16 -16
  13. package/src/cli/status.ts +95 -238
  14. package/src/cli/switch.ts +54 -46
  15. package/src/entries/codexstophook.ts +23 -23
  16. package/src/entries/codexsupervisor.ts +3 -3
  17. package/src/entries/sessionstart.ts +5 -3
  18. package/src/entries/statusline.ts +17 -19
  19. package/src/entries/stopfailurehook.ts +9 -7
  20. package/src/entries/stophook.ts +5 -4
  21. package/src/entries/subagentstatusline.ts +2 -7
  22. package/src/lib/atomic.ts +2 -8
  23. package/src/lib/claude.ts +320 -0
  24. package/src/lib/claudelock.ts +2 -3
  25. package/src/lib/codex.ts +345 -0
  26. package/src/lib/codexauth.ts +9 -7
  27. package/src/lib/codexoauth.ts +5 -11
  28. package/src/lib/codexpresence.ts +3 -19
  29. package/src/lib/codexusage.ts +17 -41
  30. package/src/lib/credstore.ts +3 -7
  31. package/src/lib/decide.ts +86 -174
  32. package/src/lib/http.ts +3 -6
  33. package/src/lib/install.ts +4 -7
  34. package/src/lib/oauth.ts +14 -12
  35. package/src/lib/paths.ts +15 -7
  36. package/src/lib/picker.ts +56 -37
  37. package/src/lib/proc.ts +4 -4
  38. package/src/lib/provider.ts +34 -0
  39. package/src/lib/sample.ts +33 -33
  40. package/src/lib/state.ts +51 -18
  41. package/src/lib/swap.ts +41 -38
  42. package/src/lib/types.ts +31 -51
  43. package/src/lib/usage.ts +28 -20
  44. package/src/main.ts +20 -20
  45. package/src/cli/codexadd.ts +0 -89
  46. package/src/cli/codexinit.ts +0 -120
  47. package/src/cli/codexrm.ts +0 -36
  48. package/src/cli/codexswitch.ts +0 -113
  49. package/src/cli/onboard.ts +0 -92
  50. package/src/lib/codexdecide.ts +0 -157
  51. package/src/lib/codexpick.ts +0 -80
  52. package/src/lib/codexsample.ts +0 -48
  53. package/src/lib/codexstate.ts +0 -22
  54. package/src/lib/codexswap.ts +0 -70
package/src/cli/status.ts CHANGED
@@ -1,158 +1,113 @@
1
1
  import { chunk, sortBy } from "es-toolkit";
2
2
  import { z } from "zod";
3
- import { loadAccounts, loadConfig, loadUsage, loadUsageSnapshot, saveAccounts } from "../lib/state.ts";
4
- import { readOAuthAccount } from "../lib/claudejson.ts";
5
- import { ensureLiveTokenFresh, probeActiveUsage, probeParkedUsage, type SampleOutcome } from "../lib/sample.ts";
3
+ import { claude } from "../lib/claude.ts";
4
+ import { codex } from "../lib/codex.ts";
5
+ import { loadAccounts, loadConfig, saveAccounts } from "../lib/state.ts";
6
6
  import { withLock } from "../lib/lock.ts";
7
- import { codexPaths, paths } from "../lib/paths.ts";
8
- import { earliestReset, isExhausted, nextWeeklyReset, thresholdBars } from "../lib/picker.ts";
9
- import { loadCodexAccounts, saveCodexAccounts } from "../lib/codexstate.ts";
10
- import { liveCodexAccountId, sampleCodexAccount, type CodexSampleOutcome } from "../lib/codexsample.ts";
11
- import { isCodexExhausted } from "../lib/codexpick.ts";
12
- import { codexLimitLabel, isSessionWindow } from "../lib/codexusage.ts";
13
- import { bar, c, claudeTierLabel, count, emitJson, fmtAgo } from "./render.ts";
14
- import { gatedFamilies, keepRows } from "../lib/usage.ts";
15
- import { ThresholdsSchema, UsageWindowSchema, type Account, type CodexWindow, type Config, type UsageWindow, type UsageWindows } from "../lib/types.ts";
7
+ import { codexPool } from "../lib/paths.ts";
8
+ import { earliestReset, isExhausted, isSessionWindow, limitWindows, nextWeeklyReset, sessionWindow, thresholdBars, weeklyWindow } from "../lib/picker.ts";
9
+ import type { Provider, SampleReport } from "../lib/provider.ts";
10
+ import { bar, c, count, emitJson, fmtAgo } from "./render.ts";
11
+ import { ThresholdsSchema, type Account, type Config, type Window } from "../lib/types.ts";
16
12
 
17
13
  const SampleReportSchema = z.discriminatedUnion("ok", [
18
14
  z.object({ ok: z.literal(true), source: z.enum(["statusline", "probe", "cached"]) }),
19
15
  z.object({ ok: z.literal(false), reason: z.string() }),
20
16
  ]);
21
17
 
22
- const ClaudeStatusAccountSchema = z.object({
23
- label: z.string(),
24
- email: z.string(),
25
- accountUuid: z.string(),
26
- organizationUuid: z.string(),
27
- tier: z.string().nullable(),
28
- active: z.boolean(),
29
- needsReauth: z.boolean(),
30
- exhausted: z.boolean(),
31
- usage: z.object({ fiveHour: UsageWindowSchema, week: UsageWindowSchema }).nullable(),
32
- perModel: z.record(z.string(), UsageWindowSchema),
33
- usageAt: z.number().nullable(),
34
- perModelAt: z.number().nullable(),
35
- sample: SampleReportSchema,
18
+ const WindowReportSchema = z.object({
19
+ usedPercentage: z.number(),
20
+ resetsAt: z.number().nullable(),
21
+ windowSeconds: z.number().nullable(),
36
22
  });
37
- type ClaudeStatusAccount = z.infer<typeof ClaudeStatusAccountSchema>;
38
-
39
- const CodexWindowReportSchema = UsageWindowSchema.extend({ windowSeconds: z.number().nullable() });
23
+ type WindowReport = z.infer<typeof WindowReportSchema>;
40
24
 
41
- const CodexStatusAccountSchema = z.object({
25
+ const StatusAccountSchema = z.object({
42
26
  label: z.string(),
43
27
  email: z.string().nullable(),
44
- accountId: z.string(),
45
- planType: z.string().nullable(),
28
+ id: z.string(),
29
+ tier: z.string().nullable(),
46
30
  active: z.boolean(),
47
31
  needsReauth: z.boolean(),
48
32
  exhausted: z.boolean(),
49
33
  usage: z
50
34
  .object({
51
- aggregate: z.array(CodexWindowReportSchema),
52
- perLimit: z.record(z.string(), z.array(CodexWindowReportSchema)),
35
+ fiveHour: WindowReportSchema.nullable(),
36
+ week: WindowReportSchema.nullable(),
37
+ limits: z.array(WindowReportSchema.extend({ name: z.string() })),
53
38
  })
54
39
  .nullable(),
55
40
  usageAt: z.number().nullable(),
41
+ limitsAt: z.number().nullable(),
56
42
  sample: SampleReportSchema,
57
43
  });
58
- type CodexStatusAccount = z.infer<typeof CodexStatusAccountSchema>;
44
+ type StatusAccount = z.infer<typeof StatusAccountSchema>;
59
45
 
60
- const StatusReportSchema = z.object({
61
- now: z.number(),
62
- claude: z.object({
63
- thresholds: ThresholdsSchema,
64
- bars: ThresholdsSchema,
65
- projectionMargin: z.number(),
66
- accounts: z.array(ClaudeStatusAccountSchema),
67
- }),
68
- codex: z.object({
69
- bars: ThresholdsSchema,
70
- accounts: z.array(CodexStatusAccountSchema),
71
- }),
46
+ const PoolReportSchema = z.object({
47
+ thresholds: ThresholdsSchema,
48
+ bars: ThresholdsSchema,
49
+ projectionMargin: z.number(),
50
+ accounts: z.array(StatusAccountSchema),
72
51
  });
52
+ type PoolReport = z.infer<typeof PoolReportSchema>;
53
+
54
+ const StatusReportSchema = z.object({ now: z.number(), claude: PoolReportSchema, codex: PoolReportSchema });
73
55
  export type StatusReport = z.infer<typeof StatusReportSchema>;
74
56
 
75
- function currentWindow(w: UsageWindow, weekly: boolean, now: number): UsageWindow {
57
+ function currentWindow(w: Window, now: number): WindowReport {
76
58
  const passed = w.resetsAt != null && w.resetsAt <= now;
77
59
  return {
78
60
  usedPercentage: passed ? 0 : w.usedPercentage,
79
- resetsAt: weekly ? nextWeeklyReset(w.resetsAt, now) : passed ? null : w.resetsAt,
61
+ resetsAt: isSessionWindow(w) ? (passed ? null : w.resetsAt) : nextWeeklyReset(w.resetsAt, now),
62
+ windowSeconds: w.windowSeconds,
80
63
  };
81
64
  }
82
65
 
83
- function currentCodexWindow(w: CodexWindow, now: number): CodexWindow {
84
- return { ...currentWindow(w, !isSessionWindow({ window: w }), now), windowSeconds: w.windowSeconds };
66
+ function usageReport(a: Account, now: number): StatusAccount["usage"] {
67
+ if (a.lastUsageAt == null) return null;
68
+ const session = sessionWindow(a);
69
+ const week = weeklyWindow(a);
70
+ return {
71
+ fiveHour: session ? currentWindow(session, now) : null,
72
+ week: week ? currentWindow(week, now) : null,
73
+ limits: limitWindows(a).map((w) => ({ name: w.name ?? "", ...currentWindow(w, now) })),
74
+ };
85
75
  }
86
76
 
87
- async function collectClaude(input: { cfg: Config; now: number; cached: boolean }): Promise<StatusReport["claude"]> {
88
- const { cfg, now, cached } = input;
89
- let idx = loadAccounts();
90
- const samples = new Map<string, { outcome: SampleOutcome; viaTee: boolean }>();
91
- if (idx.accounts.length > 0 && !cached) {
92
- await withLock(paths.lockFile, async () => {
93
- idx = loadAccounts();
94
- console.error(c.dim("sampling live usage..."));
95
- const tee = loadUsageSnapshot();
96
- const live = tee?.state ?? null;
97
- const teeAt = tee?.at ?? null;
98
- const liveOAuth = readOAuthAccount();
99
- const liveAccount = liveOAuth?.accountUuid ?? null;
100
- const probeOne = async (a: Account) => {
101
- const isActive = liveAccount != null && liveAccount === a.accountUuid;
102
- if (isActive && liveOAuth?.organizationRateLimitTier != null) a.rateLimitTier = liveOAuth.organizationRateLimitTier;
103
- const teeCurrent = teeAt != null && (a.lastUsageAt == null || teeAt >= a.lastUsageAt);
104
- const fromStatusLine: UsageWindows | null =
105
- isActive && live && teeCurrent && live.account === a.accountUuid ? { fiveHour: live.fiveHour, sevenDay: live.sevenDay, perModel: {} } : null;
106
- const viaTee = fromStatusLine != null;
107
- const outcome: SampleOutcome = fromStatusLine
108
- ? { ok: true, usage: fromStatusLine }
109
- : isActive
110
- ? await probeActiveUsage(a)
111
- : await probeParkedUsage(a);
112
- samples.set(a.accountUuid, { outcome, viaTee });
113
- if (!outcome.ok) return;
114
- a.lastUsageAt = viaTee && teeAt != null ? (live?.sampledAt ?? teeAt) : Date.now();
115
- a.lastUsage = keepRows(outcome.usage, a.lastUsage, a.lastUsageAt);
116
- };
117
- const activeAccount = idx.accounts.find((a) => liveAccount != null && liveAccount === a.accountUuid) ?? null;
118
- if (activeAccount) await probeOne(activeAccount);
119
- try {
120
- await ensureLiveTokenFresh();
121
- } catch {
122
- }
123
- await Promise.all(idx.accounts.filter((a) => a !== activeAccount).map(probeOne));
124
- saveAccounts(idx);
125
- });
77
+ async function collect(p: Provider, cfg: Config, now: number, cached: boolean): Promise<PoolReport> {
78
+ let idx = loadAccounts(p.pool);
79
+ let reports = new Map<string, SampleReport>();
80
+ let liveId: string | null = idx.activeId;
81
+ if (!cached) {
82
+ liveId = null;
83
+ if (idx.accounts.length > 0) {
84
+ await withLock(p.pool.lockFile, async () => {
85
+ idx = loadAccounts(p.pool);
86
+ console.error(c.dim(`sampling ${p.name} usage...`));
87
+ liveId = p.liveId();
88
+ reports = await p.samplePool(idx.accounts, liveId, now);
89
+ saveAccounts(p.pool, idx);
90
+ });
91
+ }
126
92
  }
127
93
 
128
- const families = gatedFamilies(loadUsage()?.model ?? null, cfg.policy.switchModels);
129
94
  const bars = thresholdBars(cfg);
130
- const liveAccount = cached ? idx.activeAccountUuid : (readOAuthAccount()?.accountUuid ?? null);
95
+ const ctx = { now, thresholds: bars, currentId: idx.activeId, families: p.gatedFamilies(cfg) };
131
96
  const ordered = sortBy(idx.accounts, [(a) => (a.needsReauth ? 1 : 0), (a) => earliestReset(a, now)]);
132
- const accounts = ordered.map((a): ClaudeStatusAccount => {
133
- const sampled = samples.get(a.accountUuid);
134
- const aggregate = a.lastUsage;
135
- const perModel = aggregate?.perModel ?? {};
97
+ const accounts = ordered.map((a): StatusAccount => {
98
+ const limits = limitWindows(a);
136
99
  return {
137
100
  label: a.label,
138
101
  email: a.email,
139
- accountUuid: a.accountUuid,
140
- organizationUuid: a.organizationUuid,
141
- tier: claudeTierLabel(a),
142
- active: liveAccount != null && a.accountUuid === liveAccount,
102
+ id: a.id,
103
+ tier: a.tier,
104
+ active: liveId != null && a.id === liveId,
143
105
  needsReauth: a.needsReauth === true,
144
- exhausted: isExhausted(a, { now, thresholds: bars, currentAccountUuid: idx.activeAccountUuid, switchFamilies: families }),
145
- usage: aggregate ? { fiveHour: currentWindow(aggregate.fiveHour, false, now), week: currentWindow(aggregate.sevenDay, true, now) } : null,
146
- perModel: Object.fromEntries(Object.entries(perModel).map(([name, w]) => [name, currentWindow(w, true, now)])),
106
+ exhausted: isExhausted(a, ctx),
107
+ usage: usageReport(a, now),
147
108
  usageAt: a.lastUsageAt ?? null,
148
- perModelAt: aggregate?.rowsAt ?? null,
149
- sample: sampled
150
- ? sampled.outcome.ok
151
- ? { ok: true, source: sampled.viaTee ? "statusline" : "probe" }
152
- : { ok: false, reason: sampled.outcome.reason }
153
- : cached
154
- ? { ok: true, source: "cached" }
155
- : { ok: false, reason: "not sampled" },
109
+ limitsAt: limits.length > 0 ? Math.max(...limits.map((w) => w.sampledAt)) : null,
110
+ sample: reports.get(a.id) ?? (cached ? { ok: true, source: "cached" } : { ok: false, reason: "not sampled" }),
156
111
  };
157
112
  });
158
113
  return {
@@ -163,79 +118,6 @@ async function collectClaude(input: { cfg: Config; now: number; cached: boolean
163
118
  };
164
119
  }
165
120
 
166
- async function collectCodex(input: { cfg: Config; now: number; cached: boolean }): Promise<StatusReport["codex"]> {
167
- const { cfg, now, cached } = input;
168
- const bars = thresholdBars(cfg);
169
- let index = loadCodexAccounts();
170
- if (index.accounts.length === 0) return { bars, accounts: [] };
171
-
172
- const outcomes = new Map<string, CodexSampleOutcome>();
173
- let liveId: string | null = null;
174
- if (cached) {
175
- liveId = index.activeAccountId;
176
- } else {
177
- console.error(c.dim("sampling codex usage..."));
178
- await withLock(codexPaths.lockFile, async () => {
179
- index = loadCodexAccounts();
180
- liveId = liveCodexAccountId();
181
- await Promise.all(
182
- index.accounts.map(async (account) => {
183
- const outcome = await sampleCodexAccount({ account, liveAccountId: liveId, now });
184
- outcomes.set(account.accountId, outcome);
185
- if (outcome.ok) {
186
- account.lastUsage = { aggregate: outcome.usage.aggregate, perLimit: outcome.usage.perLimit };
187
- account.lastUsageAt = Date.now();
188
- if (outcome.usage.email != null) account.email = outcome.usage.email;
189
- if (outcome.usage.planType != null) account.planType = outcome.usage.planType;
190
- } else if (outcome.deadGrant) {
191
- account.needsReauth = true;
192
- }
193
- }),
194
- );
195
- saveCodexAccounts({ index });
196
- });
197
- }
198
-
199
- const ordered = sortBy(index.accounts, [
200
- (a) => (a.needsReauth ? 1 : 0),
201
- (a) => {
202
- const windows = [...(a.lastUsage?.aggregate ?? []), ...Object.values(a.lastUsage?.perLimit ?? {}).flat()];
203
- const resets = windows.flatMap((w) => (w.resetsAt != null && w.resetsAt > now ? [w.resetsAt] : []));
204
- return resets.length > 0 ? Math.min(...resets) : Number.POSITIVE_INFINITY;
205
- },
206
- ]);
207
- const accounts = ordered.map((account): CodexStatusAccount => {
208
- const outcome = outcomes.get(account.accountId);
209
- const usage = account.lastUsage;
210
- return {
211
- label: account.label,
212
- email: account.email,
213
- accountId: account.accountId,
214
- planType: account.planType,
215
- active: account.accountId === liveId,
216
- needsReauth: account.needsReauth === true,
217
- exhausted: isCodexExhausted({ account, thresholds: bars, now }),
218
- usage: usage
219
- ? {
220
- aggregate: usage.aggregate.map((w) => currentCodexWindow(w, now)),
221
- perLimit: Object.fromEntries(
222
- Object.entries(usage.perLimit).map(([name, windows]) => [name, windows.map((w) => currentCodexWindow(w, now))]),
223
- ),
224
- }
225
- : null,
226
- usageAt: account.lastUsageAt ?? null,
227
- sample: outcome
228
- ? outcome.ok
229
- ? { ok: true, source: "probe" }
230
- : { ok: false, reason: outcome.reason }
231
- : cached
232
- ? { ok: true, source: "cached" }
233
- : { ok: false, reason: "not sampled" },
234
- };
235
- });
236
- return { bars, accounts };
237
- }
238
-
239
121
  const CARD_GAP = 3;
240
122
  const NOTE_INDENT = " ";
241
123
 
@@ -292,7 +174,7 @@ function renderGrid(cards: Card[]): void {
292
174
  }
293
175
  }
294
176
 
295
- function usageRow(name: string, w: UsageWindow): string {
177
+ function usageRow(name: string, w: WindowReport): string {
296
178
  return `${NOTE_INDENT}${name.padEnd(5)} ${bar(w.usedPercentage)}`;
297
179
  }
298
180
 
@@ -317,39 +199,13 @@ function headerLine(input: { active: boolean; needsReauth: boolean; exhausted: b
317
199
  return `${marker} ${c.bold(input.name)}${input.tier ? ` ${c.dim(input.tier)}` : ""}${badges.length ? ` ${badges.join(" ")}` : ""}`;
318
200
  }
319
201
 
320
- function codexCard(account: CodexStatusAccount, now: number): Card {
321
- const windowLabel = (window: CodexWindow) =>
322
- isSessionWindow({ window }) ? `${Math.round((window.windowSeconds ?? 0) / 3600)}h` : "week";
323
- const lines = [headerLine({ ...account, name: account.label, tier: account.planType })];
324
- if (account.usage) {
325
- for (const window of account.usage.aggregate) lines.push(usageRow(windowLabel(window), window));
326
- for (const [name, windows] of Object.entries(account.usage.perLimit)) {
327
- for (const window of windows) lines.push(usageRow(codexLimitLabel({ limitName: name }), window));
328
- }
329
- }
330
- const notes = account.sample.ok
331
- ? account.sample.source === "cached"
332
- ? [cachedNote(account.usageAt, now)]
333
- : []
334
- : sampleFailedNotes({ cached: account.usage != null, usageAt: account.usageAt, reason: account.sample.reason, now });
335
- return { lines, notes };
336
- }
337
-
338
- function renderCodex(input: { codex: StatusReport["codex"]; now: number }): void {
339
- const { codex, now } = input;
340
- if (codex.accounts.length === 0) return;
341
- console.log(c.dim(`codex (${count({ n: codex.accounts.length, noun: "account" })})`));
342
- console.log();
343
- renderGrid(codex.accounts.map((account) => codexCard(account, now)));
344
- }
345
-
346
- function claudeCard(a: ClaudeStatusAccount, now: number, staleAfterMs: number): Card {
347
- const lines = [headerLine({ ...a, name: a.label || a.email })];
202
+ function card(p: Provider, a: StatusAccount, now: number, staleAfterMs: number): Card {
203
+ const lines = [headerLine({ ...a, name: a.label })];
348
204
  if (a.usage) {
349
- lines.push(usageRow("5h", a.usage.fiveHour));
350
- lines.push(usageRow("week", a.usage.week));
205
+ if (a.usage.fiveHour) lines.push(usageRow(`${Math.round((a.usage.fiveHour.windowSeconds ?? 0) / 3600)}h`, a.usage.fiveHour));
206
+ if (a.usage.week) lines.push(usageRow("week", a.usage.week));
207
+ for (const w of a.usage.limits) lines.push(usageRow(p.windowLabel(w.name), w));
351
208
  }
352
- for (const [name, w] of Object.entries(a.perModel)) lines.push(usageRow(name.toLowerCase(), w));
353
209
  const notes: Note[] = [];
354
210
  if (a.sample.ok && a.sample.source === "statusline") {
355
211
  const stale = a.usageAt == null || now - a.usageAt > staleAfterMs;
@@ -358,46 +214,47 @@ function claudeCard(a: ClaudeStatusAccount, now: number, staleAfterMs: number):
358
214
  }
359
215
  if (a.sample.ok && a.sample.source === "cached") {
360
216
  const note = cachedNote(a.usageAt, now);
361
- const perModelAged = Object.keys(a.perModel).length > 0 && a.perModelAt !== a.usageAt;
362
- notes.push(perModelAged ? { ...note, text: `${note.text}, per-model ${a.perModelAt != null ? fmtAgo(a.perModelAt, now) : "age unknown"}` } : note);
217
+ notes.push(a.limitsAt != null && a.limitsAt !== a.usageAt ? { ...note, text: `${note.text}, per-model ${fmtAgo(a.limitsAt, now)}` } : note);
363
218
  }
364
219
  if (!a.sample.ok) {
365
- notes.push(...sampleFailedNotes({ cached: a.usage != null || Object.keys(a.perModel).length > 0, usageAt: a.usageAt, reason: a.sample.reason, now }));
220
+ notes.push(...sampleFailedNotes({ cached: a.usage != null, usageAt: a.usageAt, reason: a.sample.reason, now }));
366
221
  }
367
222
  return { lines, notes };
368
223
  }
369
224
 
370
- function renderClaude(input: { claude: StatusReport["claude"]; codexPooled: boolean; now: number; staleAfterMs: number }): void {
371
- const { claude, codexPooled, now, staleAfterMs } = input;
372
- if (claude.accounts.length === 0) {
373
- if (!codexPooled) {
374
- console.log(c.dim("no accounts yet, run `tokenmaxxing init` (or `tokenmaxxing init --codex`)"));
375
- return;
376
- }
377
- console.log(c.dim("no claude accounts (run `tokenmaxxing init` to pool claude too)"));
378
- console.log();
379
- return;
380
- }
381
-
382
- console.log(c.dim(`thresholds 5h ${claude.thresholds.session}% weekly ${claude.thresholds.weekly}% (${count({ n: claude.accounts.length, noun: "claude account" })})`));
225
+ function renderPool(p: Provider, pool: PoolReport, header: string, now: number, staleAfterMs: number): void {
226
+ console.log(c.dim(header));
383
227
  console.log();
384
- renderGrid(claude.accounts.map((a) => claudeCard(a, now, staleAfterMs)));
228
+ renderGrid(pool.accounts.map((a) => card(p, a, now, staleAfterMs)));
385
229
  }
386
230
 
387
231
  export async function cmdStatus(opts: { json?: boolean; cached?: boolean } = {}): Promise<number> {
388
232
  const { json = false, cached = false } = opts;
389
233
  const cfg = loadConfig();
390
234
  const now = Date.now();
391
- const claude = await collectClaude({ cfg, now, cached });
235
+ const claudeReport = await collect(claude, cfg, now, cached);
392
236
  if (!json) {
393
- renderClaude({ claude, codexPooled: loadCodexAccounts().accounts.length > 0, now: Date.now(), staleAfterMs: cfg.policy.usagePollTtlMs });
237
+ const codexPooled = loadAccounts(codexPool).accounts.length > 0;
238
+ if (claudeReport.accounts.length === 0) {
239
+ if (!codexPooled) {
240
+ console.log(c.dim("no accounts yet, run `tokenmaxxing init` (or `tokenmaxxing init --codex`)"));
241
+ return 0;
242
+ }
243
+ console.log(c.dim("no claude accounts (run `tokenmaxxing init` to pool claude too)"));
244
+ console.log();
245
+ } else {
246
+ const header = `thresholds 5h ${claudeReport.thresholds.session}% weekly ${claudeReport.thresholds.weekly}% (${count({ n: claudeReport.accounts.length, noun: "claude account" })})`;
247
+ renderPool(claude, claudeReport, header, Date.now(), cfg.policy.usagePollTtlMs);
248
+ }
394
249
  }
395
- const codex = await collectCodex({ cfg, now, cached });
250
+ const codexReport = await collect(codex, cfg, now, cached);
396
251
  if (json) {
397
- const report: StatusReport = { now, claude, codex };
252
+ const report: StatusReport = { now, claude: claudeReport, codex: codexReport };
398
253
  emitJson({ ok: true, ...report });
399
254
  return 0;
400
255
  }
401
- renderCodex({ codex, now: Date.now() });
256
+ if (codexReport.accounts.length > 0) {
257
+ renderPool(codex, codexReport, `codex (${count({ n: codexReport.accounts.length, noun: "account" })})`, Date.now(), cfg.policy.usagePollTtlMs);
258
+ }
402
259
  return 0;
403
260
  }
package/src/cli/switch.ts CHANGED
@@ -1,17 +1,13 @@
1
1
  import { withLock } from "../lib/lock.ts";
2
- import { paths } from "../lib/paths.ts";
3
- import { loadAccounts, loadConfig, loadUsage } from "../lib/state.ts";
4
- import { readOAuthAccount } from "../lib/claudejson.ts";
5
- import { isSkippableSwapError, performSwap } from "../lib/swap.ts";
2
+ import type { Provider } from "../lib/provider.ts";
3
+ import { loadAccounts, loadConfig } from "../lib/state.ts";
6
4
  import { currentWins, pickBest, pickEarliestReset, thresholdBars, weeklyExpiry, type PickCtx } from "../lib/picker.ts";
7
- import { InvalidGrantError } from "../lib/oauth.ts";
8
- import { gatedFamilies } from "../lib/usage.ts";
9
5
  import { findAccount } from "./rename.ts";
10
6
  import { log } from "../lib/log.ts";
11
7
  import { c, emitError, emitJson, fmtReset } from "./render.ts";
12
8
  import type { Account } from "../lib/types.ts";
13
9
 
14
- export async function cmdSwitch(selector?: string, json = false): Promise<number> {
10
+ export async function cmdSwitch(p: Provider, selector?: string, json = false): Promise<number> {
15
11
  const deadGrants: string[] = [];
16
12
  const withDeadGrants = (report: Record<string, unknown>) => (deadGrants.length > 0 ? { ...report, deadGrants } : report);
17
13
  const emit = (text: string, report: Record<string, unknown>): void => {
@@ -22,56 +18,61 @@ export async function cmdSwitch(selector?: string, json = false): Promise<number
22
18
  emitError({ json, message, paint: opts.paint, extra: withDeadGrants(opts.extra ?? {}) });
23
19
  return 1;
24
20
  };
25
- const deadGrantMessage = (a: Account) => `${a.label}'s refresh token is dead - run \`tokenmaxxing auth ${a.label}\``;
21
+ const reauthHint = (a: Account) => `run \`tokenmaxxing auth${p.flag} ${a.label}\``;
22
+ const deadGrantMessage = (a: Account) => `${a.label}'s refresh token is dead - ${reauthHint(a)}`;
23
+ const switched = (target: Account) => `${c.green("↻")} switched to ${c.bold(target.label)}${p.switchNote}`;
26
24
 
27
- const idx0 = loadAccounts();
28
- if (idx0.accounts.length < 2) return fail("need at least 2 accounts to switch - add one with `tokenmaxxing add`", { paint: c.yellow });
25
+ const idx0 = loadAccounts(p.pool);
26
+ if (idx0.accounts.length < 2) return fail(`need at least 2 accounts to switch - add one with \`tokenmaxxing add${p.flag}\``, { paint: c.yellow });
29
27
  const cfg = loadConfig();
30
28
  const now = Date.now();
31
29
 
32
- return withLock(paths.lockFile, async () => {
33
- const idx = loadAccounts();
34
- const liveClaim = readOAuthAccount();
35
- const claimed = liveClaim?.accountUuid ?? null;
36
- const drifted = claimed != null && claimed !== idx.activeAccountUuid;
30
+ return withLock(p.pool.lockFile, async () => {
31
+ const idx = loadAccounts(p.pool);
32
+ const claimed = p.liveId();
33
+ const drifted = claimed != null && claimed !== idx.activeId;
34
+ const present = p.presentIds();
37
35
 
38
36
  const swapTo = async (target: Account, reason: string, extra: Record<string, unknown> = {}): Promise<number> => {
39
37
  try {
40
- await performSwap(target);
38
+ await p.swap(target);
41
39
  } catch (e) {
42
- if (e instanceof InvalidGrantError) {
40
+ if (p.classifySwapError(e) === "dead-grant") {
43
41
  deadGrants.push(target.label);
44
42
  return fail(deadGrantMessage(target));
45
43
  }
46
44
  throw e;
47
45
  }
48
- log("switch.manual", { account: target.accountUuid.slice(0, 8), reason });
49
- emit(`${c.green("↻")} switched to ${c.bold(target.label)}`, { switched: true, account: target.label, reason, ...extra });
46
+ log("switch.manual", { account: target.id.slice(0, 8), reason });
47
+ emit(switched(target), { switched: true, account: target.label, reason, ...extra });
50
48
  return 0;
51
49
  };
52
50
 
53
51
  if (selector) {
54
52
  const target = findAccount(idx.accounts, selector);
55
53
  if (!target) return fail(`no account matches "${selector}"`);
56
- if (target.accountUuid === idx.activeAccountUuid && !drifted) {
54
+ if (target.id === idx.activeId && !drifted) {
57
55
  emit(`already on ${c.bold(target.label)}`, { switched: false, account: target.label, reason: "already-on" });
58
56
  return 0;
59
57
  }
60
- if (target.needsReauth) return fail(`${target.label} needs re-auth - run \`tokenmaxxing auth ${target.label}\``);
58
+ if (target.id !== claimed && present.has(target.id)) {
59
+ return fail(`${target.label} is running in a live ${p.name} session - swapping onto it would break that session's credential`);
60
+ }
61
+ if (target.needsReauth) return fail(`${target.label} needs re-auth - ${reauthHint(target)}`);
61
62
  return swapTo(target, "selected");
62
63
  }
63
64
 
64
- const switchFamilies = gatedFamilies(loadUsage()?.model ?? null, cfg.policy.switchModels);
65
- const everyone: PickCtx = { now, thresholds: thresholdBars(cfg), currentAccountUuid: null, switchFamilies };
65
+ const everyone: PickCtx = { now, thresholds: thresholdBars(cfg), currentId: null, families: p.gatedFamilies(cfg) };
66
66
  const rejected = new Set<string>();
67
+ const candidatesOf = (accounts: Account[]): Account[] => accounts.filter((a) => !rejected.has(a.id) && (a.id === claimed || !present.has(a.id)));
67
68
  while (true) {
68
- const cur = loadAccounts();
69
- const pool = cur.accounts.filter((a) => !rejected.has(a.accountUuid));
69
+ const cur = loadAccounts(p.pool);
70
+ const pool = candidatesOf(cur.accounts);
70
71
  const active =
71
- (claimed != null ? cur.accounts.find((a) => a.accountUuid === claimed) : null) ??
72
- cur.accounts.find((a) => a.accountUuid === cur.activeAccountUuid) ??
72
+ (claimed != null ? cur.accounts.find((a) => a.id === claimed) : null) ??
73
+ cur.accounts.find((a) => a.id === cur.activeId) ??
73
74
  null;
74
- if (active != null && currentWins(active, pool, everyone)) {
75
+ if (active != null && currentWins(active, pool, everyone, p.switchMargin)) {
75
76
  if (drifted) return swapTo(active, "drift-reconciled");
76
77
  const expiry = weeklyExpiry(active, now);
77
78
  const why = Number.isFinite(expiry) ? ` (weekly ${fmtReset(expiry, now)})` : "";
@@ -83,41 +84,47 @@ export async function cmdSwitch(selector?: string, json = false): Promise<number
83
84
  });
84
85
  return 0;
85
86
  }
86
- const best = pickBest(pool, { ...everyone, currentAccountUuid: active?.accountUuid ?? null });
87
+ const best = pickBest(pool, { ...everyone, currentId: active?.id ?? null });
87
88
  if (!best) break;
88
89
  try {
89
- await performSwap(best);
90
+ await p.swap(best);
90
91
  } catch (e) {
91
- rejected.add(best.accountUuid);
92
- if (e instanceof InvalidGrantError) {
92
+ rejected.add(best.id);
93
+ const kind = p.classifySwapError(e);
94
+ if (kind === "dead-grant") {
93
95
  deadGrants.push(best.label);
94
96
  if (!json) console.error(c.red(deadGrantMessage(best)));
95
97
  continue;
96
98
  }
97
- if (isSkippableSwapError(e)) {
99
+ if (kind === "skip") {
98
100
  if (!json) console.error(c.yellow(`${best.label}: ${e instanceof Error ? e.message : String(e)} - skipped for this run`));
99
101
  continue;
100
102
  }
101
103
  throw e;
102
104
  }
103
- log("switch.manual", { account: best.accountUuid.slice(0, 8), reason: "best" });
104
- emit(`${c.green("↻")} switched to ${c.bold(best.label)}`, { switched: true, account: best.label, reason: "best" });
105
+ log("switch.manual", { account: best.id.slice(0, 8), reason: "best" });
106
+ emit(switched(best), { switched: true, account: best.label, reason: "best" });
105
107
  return 0;
106
108
  }
107
109
 
110
+ if (!p.waitsWhenDepleted) {
111
+ const reauth = loadAccounts(p.pool).accounts.filter((a) => a.needsReauth).map((a) => a.label);
112
+ return fail(`no usable ${p.name} switch target (all at their bars, unmeasured, or needing reauth)`, { paint: c.yellow, extra: { reauthNeeded: reauth } });
113
+ }
114
+
108
115
  while (true) {
109
- const fresh = loadAccounts();
110
- const pool = fresh.accounts.filter((a) => !rejected.has(a.accountUuid));
116
+ const fresh = loadAccounts(p.pool);
117
+ const pool = candidatesOf(fresh.accounts);
111
118
  const earliest = pickEarliestReset(pool, everyone);
112
119
  const reauth = fresh.accounts.filter((a) => a.needsReauth).map((a) => a.label);
113
120
  if (!earliest) {
114
121
  if (reauth.length > 0) {
115
- return fail(`no switchable account - reauth needed (run \`tokenmaxxing auth --all\`): ${reauth.join(", ")}`, {
122
+ return fail(`no switchable account - reauth needed (run \`tokenmaxxing auth${p.flag} --all\`): ${reauth.join(", ")}`, {
116
123
  paint: c.yellow,
117
124
  extra: { reauthNeeded: reauth },
118
125
  });
119
126
  }
120
- const freshActive = fresh.accounts.find((a) => a.accountUuid === fresh.activeAccountUuid) ?? null;
127
+ const freshActive = fresh.accounts.find((a) => a.id === fresh.activeId) ?? null;
121
128
  if (drifted && freshActive) return swapTo(freshActive, "drift-reconciled");
122
129
  emit(c.yellow("all accounts at their limit with unknown reset times (unparsed reset clocks? see tokenmaxxing.log) - staying put"), {
123
130
  switched: false,
@@ -128,7 +135,7 @@ export async function cmdSwitch(selector?: string, json = false): Promise<number
128
135
  }
129
136
  const reauthNote = reauth.length ? ` - re-auth needed: ${reauth.join(", ")}` : "";
130
137
  const availableAt = earliest.availableAt > now ? earliest.availableAt : null;
131
- if (earliest.account.accountUuid === fresh.activeAccountUuid && !drifted) {
138
+ if (earliest.account.id === fresh.activeId && !drifted) {
132
139
  const msg = availableAt == null
133
140
  ? `staying on ${c.bold(earliest.account.label)} - no usable switch target${reauthNote}`
134
141
  : `all accounts at limit - staying on ${c.bold(earliest.account.label)} (${fmtReset(availableAt, now)})${reauthNote}`;
@@ -142,22 +149,23 @@ export async function cmdSwitch(selector?: string, json = false): Promise<number
142
149
  return 0;
143
150
  }
144
151
  try {
145
- await performSwap(earliest.account);
152
+ await p.swap(earliest.account);
146
153
  } catch (e) {
147
- rejected.add(earliest.account.accountUuid);
148
- if (e instanceof InvalidGrantError) {
154
+ rejected.add(earliest.account.id);
155
+ const kind = p.classifySwapError(e);
156
+ if (kind === "dead-grant") {
149
157
  deadGrants.push(earliest.account.label);
150
158
  if (!json) console.error(c.red(deadGrantMessage(earliest.account)));
151
159
  continue;
152
160
  }
153
- if (isSkippableSwapError(e)) {
161
+ if (kind === "skip") {
154
162
  if (!json) console.error(c.yellow(`${earliest.account.label}: ${e instanceof Error ? e.message : String(e)} - skipped for this run`));
155
163
  continue;
156
164
  }
157
165
  throw e;
158
166
  }
159
- log("switch.manual", { account: earliest.account.accountUuid.slice(0, 8), reason: "earliest-reset" });
160
- emit(`${c.green("↻")} switched to ${c.bold(earliest.account.label)}`, { switched: true, account: earliest.account.label, reason: "earliest-reset", availableAt, reauthNeeded: reauth });
167
+ log("switch.manual", { account: earliest.account.id.slice(0, 8), reason: "earliest-reset" });
168
+ emit(switched(earliest.account), { switched: true, account: earliest.account.label, reason: "earliest-reset", availableAt, reauthNeeded: reauth });
161
169
  if (availableAt != null && !json) {
162
170
  console.log(c.yellow(`all accounts at limit - ${c.bold(earliest.account.label)} recovers soonest (${fmtReset(availableAt, now)})${reauthNote}`));
163
171
  }