tokenmaxxing 1.10.0 → 1.12.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/cli/status.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { sortBy } from "es-toolkit";
2
+ import { z } from "zod";
2
3
  import { loadAccounts, loadConfig, loadUsage, loadModelUsage, saveAccounts } from "../lib/state.ts";
3
4
  import { readOAuthAccount } from "../lib/claudejson.ts";
4
5
  import { ensureLiveTokenFresh, probeActiveUsage, probeParkedUsage, type SampleOutcome } from "../lib/sample.ts";
@@ -9,42 +10,94 @@ import { loadCodexAccounts, saveCodexAccounts } from "../lib/codexstate.ts";
9
10
  import { liveCodexAccountId, sampleCodexAccount, type CodexSampleOutcome } from "../lib/codexsample.ts";
10
11
  import { isCodexExhausted } from "../lib/codexpick.ts";
11
12
  import { codexLimitLabel, isSessionWindow } from "../lib/codexusage.ts";
12
- import { bar, c, claudeTierLabel, count, fmtAgo, fmtReset } from "./render.ts";
13
+ import { bar, c, claudeTierLabel, count, emitJson, fmtAgo, fmtReset } from "./render.ts";
13
14
  import { gatedFamilies, type FullUsage } from "../lib/usage.ts";
14
- import type { Account, Config, CodexWindow, UsageWindow } from "../lib/types.ts";
15
+ import { ThresholdsSchema, UsageWindowSchema, type Account, type CodexWindow, type Config, type UsageWindow } from "../lib/types.ts";
15
16
 
16
- export async function cmdStatus(force = false, preRender?: () => void): Promise<number> {
17
- let idx = loadAccounts();
18
- const cfg = loadConfig();
19
- const now = Date.now();
17
+ const SampleReportSchema = z.discriminatedUnion("ok", [
18
+ z.object({ ok: z.literal(true), source: z.enum(["statusline", "probe"]) }),
19
+ z.object({ ok: z.literal(false), reason: z.string() }),
20
+ ]);
21
+
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
+ sample: SampleReportSchema,
35
+ pingError: z.string().nullable(),
36
+ pinged: z.boolean(),
37
+ });
38
+ type ClaudeStatusAccount = z.infer<typeof ClaudeStatusAccountSchema>;
39
+
40
+ const CodexWindowReportSchema = UsageWindowSchema.extend({ windowSeconds: z.number().nullable() });
20
41
 
21
- const row = (name: string, w: UsageWindow, weekly: boolean) => {
22
- const passed = w.resetsAt != null && w.resetsAt <= now;
23
- const pct = passed ? 0 : w.usedPercentage;
24
- const resetsAt = weekly ? nextWeeklyReset(w.resetsAt, now) : passed ? null : w.resetsAt;
25
- console.log(` ${name.padEnd(5)} ${bar(pct)} ${c.dim(fmtReset(resetsAt, now))}`);
42
+ const CodexStatusAccountSchema = z.object({
43
+ label: z.string(),
44
+ email: z.string().nullable(),
45
+ accountId: z.string(),
46
+ planType: z.string().nullable(),
47
+ active: z.boolean(),
48
+ needsReauth: z.boolean(),
49
+ exhausted: z.boolean(),
50
+ usage: z
51
+ .object({
52
+ aggregate: z.array(CodexWindowReportSchema),
53
+ perLimit: z.record(z.string(), z.array(CodexWindowReportSchema)),
54
+ })
55
+ .nullable(),
56
+ usageAt: z.number().nullable(),
57
+ sample: SampleReportSchema,
58
+ });
59
+ type CodexStatusAccount = z.infer<typeof CodexStatusAccountSchema>;
60
+
61
+ const StatusReportSchema = z.object({
62
+ now: z.number(),
63
+ claude: z.object({
64
+ thresholds: z.object({ session: z.array(z.number()), weekly: z.number() }),
65
+ bars: ThresholdsSchema,
66
+ projectionMargin: z.number(),
67
+ accounts: z.array(ClaudeStatusAccountSchema),
68
+ }),
69
+ codex: z.object({
70
+ bars: ThresholdsSchema,
71
+ accounts: z.array(CodexStatusAccountSchema),
72
+ }),
73
+ });
74
+ export type StatusReport = z.infer<typeof StatusReportSchema>;
75
+
76
+ function currentWindow(w: UsageWindow, weekly: boolean, now: number): UsageWindow {
77
+ const passed = w.resetsAt != null && w.resetsAt <= now;
78
+ return {
79
+ usedPercentage: passed ? 0 : w.usedPercentage,
80
+ resetsAt: weekly ? nextWeeklyReset(w.resetsAt, now) : passed ? null : w.resetsAt,
26
81
  };
82
+ }
27
83
 
28
- if (idx.accounts.length === 0) {
29
- if (loadCodexAccounts().accounts.length > 0) {
30
- console.log(c.dim("no claude accounts (run `tokenmaxxing init` to pool claude too)"));
31
- console.log();
32
- await renderCodexSection({ cfg, now, row });
33
- return 0;
34
- }
35
- console.log(c.dim("no accounts yet, run `tokenmaxxing init` (or `tokenmaxxing init --codex`)"));
36
- return 0;
37
- }
84
+ function currentCodexWindow(w: CodexWindow, now: number): CodexWindow {
85
+ return { ...currentWindow(w, !isSessionWindow({ window: w }), now), windowSeconds: w.windowSeconds };
86
+ }
38
87
 
39
- console.error(c.dim(force ? "pinging every account (starts each 5h session timer) + sampling live usage..." : "sampling live usage..."));
40
- const outcomes = new Map<string, SampleOutcome>();
41
- await withLock(paths.lockFile, async () => {
42
- idx = loadAccounts();
43
- const live = loadUsage();
44
- const modelUsage = loadModelUsage();
45
- const liveOAuth = readOAuthAccount();
46
- const activeOrg = liveOAuth?.organizationUuid ?? null;
47
- const probeOne = async (a: Account) => {
88
+ async function collectClaude(input: { cfg: Config; force: boolean; now: number }): Promise<StatusReport["claude"]> {
89
+ const { cfg, force, now } = input;
90
+ let idx = loadAccounts();
91
+ const samples = new Map<string, { outcome: SampleOutcome; viaTee: boolean }>();
92
+ if (idx.accounts.length > 0) {
93
+ console.error(c.dim(force ? "pinging every account (starts each 5h session timer) + sampling live usage..." : "sampling live usage..."));
94
+ await withLock(paths.lockFile, async () => {
95
+ idx = loadAccounts();
96
+ const live = loadUsage();
97
+ const modelUsage = loadModelUsage();
98
+ const liveOAuth = readOAuthAccount();
99
+ const activeOrg = liveOAuth?.organizationUuid ?? null;
100
+ const probeOne = async (a: Account) => {
48
101
  const isActive = activeOrg != null && activeOrg === a.organizationUuid;
49
102
  if (isActive && liveOAuth?.organizationRateLimitTier != null) a.rateLimitTier = liveOAuth.organizationRateLimitTier;
50
103
  const fromStatusLine: FullUsage | null =
@@ -73,7 +126,7 @@ export async function cmdStatus(force = false, preRender?: () => void): Promise<
73
126
  ? await probeActiveUsage(a)
74
127
  : await probeParkedUsage(a);
75
128
  }
76
- outcomes.set(a.accountUuid, outcome);
129
+ samples.set(a.accountUuid, { outcome, viaTee });
77
130
  if (!outcome.ok) return;
78
131
  a.lastUsage = { fiveHour: outcome.usage.session, sevenDay: outcome.usage.weekAll };
79
132
  a.lastUsageAt = viaTee && live ? live.ts : Date.now();
@@ -81,72 +134,57 @@ export async function cmdStatus(force = false, preRender?: () => void): Promise<
81
134
  a.lastPerModel = outcome.usage.perModel;
82
135
  a.lastPerModelAt = viaTee && modelUsage ? (modelUsage.sampledAt ?? modelUsage.ts) : a.lastUsageAt;
83
136
  }
84
- };
85
- const activeAccount = idx.accounts.find((a) => activeOrg != null && activeOrg === a.organizationUuid) ?? null;
86
- if (activeAccount) await probeOne(activeAccount);
87
- try {
88
- await ensureLiveTokenFresh();
89
- } catch {
90
- }
91
- await Promise.all(idx.accounts.filter((a) => a !== activeAccount).map(probeOne));
92
- saveAccounts(idx);
93
- });
137
+ };
138
+ const activeAccount = idx.accounts.find((a) => activeOrg != null && activeOrg === a.organizationUuid) ?? null;
139
+ if (activeAccount) await probeOne(activeAccount);
140
+ try {
141
+ await ensureLiveTokenFresh();
142
+ } catch {
143
+ }
144
+ await Promise.all(idx.accounts.filter((a) => a !== activeAccount).map(probeOne));
145
+ saveAccounts(idx);
146
+ });
147
+ }
94
148
 
95
- preRender?.();
96
149
  const families = gatedFamilies(loadUsage()?.model ?? null, cfg.policy.switchModels);
97
150
  const bars = effectiveBars(cfg, { accounts: idx.accounts, now, switchFamilies: families });
98
- console.log(c.dim(`thresholds 5h ${cfg.thresholds.session.join("/")}% (at ${bars.session + cfg.policy.projectionMargin}%) weekly ${cfg.thresholds.weekly}% (${count({ n: idx.accounts.length, noun: "claude account" })})`));
99
- console.log();
100
-
101
- const displayAccounts = sortBy(idx.accounts, [(a) => (a.needsReauth ? 1 : 0), (a) => earliestReset(a, now)]);
102
- const displayActiveOrg = readOAuthAccount()?.organizationUuid ?? null;
103
- for (const a of displayAccounts) {
104
- const active = displayActiveOrg != null && a.organizationUuid === displayActiveOrg;
105
- const outcome = outcomes.get(a.accountUuid);
106
- const failed = outcome ? !outcome.ok : false;
107
- const usage = outcome?.ok ? outcome.usage : undefined;
151
+ const activeOrg = readOAuthAccount()?.organizationUuid ?? null;
152
+ const ordered = sortBy(idx.accounts, [(a) => (a.needsReauth ? 1 : 0), (a) => earliestReset(a, now)]);
153
+ const accounts = ordered.map((a): ClaudeStatusAccount => {
154
+ const sampled = samples.get(a.accountUuid) ?? { outcome: { ok: false, reason: "not sampled" }, viaTee: false };
155
+ const usage = sampled.outcome.ok ? sampled.outcome.usage : undefined;
108
156
  const aggregate = usage ? { fiveHour: usage.session, sevenDay: usage.weekAll } : a.lastUsage;
109
- const perModel = usage ? usage.perModel : a.lastPerModel;
110
-
111
- const marker = active ? c.green("●") : c.dim("○");
112
- const badges: string[] = [];
113
- if (active) badges.push(c.green("active"));
114
- if (a.needsReauth) badges.push(c.red("needs-reauth"));
115
- if (isExhausted(a, { now, thresholds: bars, currentAccountUuid: idx.activeAccountUuid, switchFamilies: families }))
116
- badges.push(c.yellow("exhausted"));
117
-
118
- const tier = claudeTierLabel(a);
119
- console.log(`${marker} ${c.bold(a.label || a.email)}${tier ? ` ${c.dim(tier)}` : ""}${badges.length ? ` ${badges.join(" ")}` : ""}`);
120
- if (aggregate) {
121
- row("5h", aggregate.fiveHour, false);
122
- row("week", aggregate.sevenDay, true);
123
- }
124
- if (perModel) for (const [name, w] of Object.entries(perModel)) row(name.toLowerCase(), w, true);
125
- if (failed && outcome && !outcome.ok) {
126
- const cached = aggregate || perModel ? `cached${a.lastUsageAt != null ? ` ${fmtAgo(a.lastUsageAt, now)}` : ""}, ` : "";
127
- console.log(` ${c.yellow(`${cached}live sample failed`)}: ${c.dim(outcome.reason)}`);
128
- }
129
- if (outcome?.pingError != null) {
130
- console.log(` ${c.yellow("ping failed (5h timer may not have started)")}: ${c.dim(outcome.pingError)}`);
131
- }
132
- if (force && outcome?.ok && outcome.pingError == null && aggregate && aggregate.fiveHour.resetsAt == null) {
133
- console.log(` ${c.dim("pinged - 5h timer started this run; the usage feed lags, re-run status shortly for the fresh window")}`);
134
- }
135
- console.log();
136
- }
137
-
138
- await renderCodexSection({ cfg, now, row });
139
- return 0;
157
+ const perModel = usage ? usage.perModel : (a.lastPerModel ?? {});
158
+ return {
159
+ label: a.label,
160
+ email: a.email,
161
+ accountUuid: a.accountUuid,
162
+ organizationUuid: a.organizationUuid,
163
+ tier: claudeTierLabel(a),
164
+ active: activeOrg != null && a.organizationUuid === activeOrg,
165
+ needsReauth: a.needsReauth === true,
166
+ exhausted: isExhausted(a, { now, thresholds: bars, currentAccountUuid: idx.activeAccountUuid, switchFamilies: families }),
167
+ usage: aggregate ? { fiveHour: currentWindow(aggregate.fiveHour, false, now), week: currentWindow(aggregate.sevenDay, true, now) } : null,
168
+ perModel: Object.fromEntries(Object.entries(perModel).map(([name, w]) => [name, currentWindow(w, true, now)])),
169
+ usageAt: a.lastUsageAt ?? null,
170
+ sample: sampled.outcome.ok ? { ok: true, source: sampled.viaTee ? "statusline" : "probe" } : { ok: false, reason: sampled.outcome.reason },
171
+ pingError: sampled.outcome.pingError ?? null,
172
+ pinged: force && sampled.outcome.ok && sampled.outcome.pingError == null && aggregate != null && aggregate.fiveHour.resetsAt == null,
173
+ };
174
+ });
175
+ return {
176
+ thresholds: { session: cfg.thresholds.session, weekly: cfg.thresholds.weekly },
177
+ bars,
178
+ projectionMargin: cfg.policy.projectionMargin,
179
+ accounts,
180
+ };
140
181
  }
141
182
 
142
- async function renderCodexSection(input: {
143
- cfg: Config;
144
- now: number;
145
- row: (name: string, w: UsageWindow, weekly: boolean) => void;
146
- }): Promise<void> {
147
- const { cfg, now, row } = input;
183
+ async function collectCodex(input: { cfg: Config; now: number }): Promise<StatusReport["codex"]> {
184
+ const { cfg, now } = input;
185
+ const bars = terminalBars(cfg);
148
186
  let index = loadCodexAccounts();
149
- if (index.accounts.length === 0) return;
187
+ if (index.accounts.length === 0) return { bars, accounts: [] };
150
188
 
151
189
  console.error(c.dim("sampling codex usage..."));
152
190
  const outcomes = new Map<string, CodexSampleOutcome>();
@@ -171,11 +209,7 @@ async function renderCodexSection(input: {
171
209
  saveCodexAccounts({ index });
172
210
  });
173
211
 
174
- console.log(c.dim(`codex (${count({ n: index.accounts.length, noun: "account" })})`));
175
- console.log();
176
- const windowLabel = (window: CodexWindow) =>
177
- isSessionWindow({ window }) ? `${Math.round((window.windowSeconds ?? 0) / 3600)}h` : "week";
178
- const displayAccounts = sortBy(index.accounts, [
212
+ const ordered = sortBy(index.accounts, [
179
213
  (a) => (a.needsReauth ? 1 : 0),
180
214
  (a) => {
181
215
  const windows = [...(a.lastUsage?.aggregate ?? []), ...Object.values(a.lastUsage?.perLimit ?? {}).flat()];
@@ -183,27 +217,122 @@ async function renderCodexSection(input: {
183
217
  return resets.length > 0 ? Math.min(...resets) : Number.POSITIVE_INFINITY;
184
218
  },
185
219
  ]);
186
- for (const account of displayAccounts) {
187
- const active = account.accountId === liveId;
188
- const marker = active ? c.green("●") : c.dim("○");
220
+ const accounts = ordered.map((account): CodexStatusAccount => {
221
+ const outcome = outcomes.get(account.accountId) ?? { ok: false, reason: "not sampled", deadGrant: false };
222
+ const usage = account.lastUsage;
223
+ return {
224
+ label: account.label,
225
+ email: account.email,
226
+ accountId: account.accountId,
227
+ planType: account.planType,
228
+ active: account.accountId === liveId,
229
+ needsReauth: account.needsReauth === true,
230
+ exhausted: isCodexExhausted({ account, thresholds: bars, now }),
231
+ usage: usage
232
+ ? {
233
+ aggregate: usage.aggregate.map((w) => currentCodexWindow(w, now)),
234
+ perLimit: Object.fromEntries(
235
+ Object.entries(usage.perLimit).map(([name, windows]) => [name, windows.map((w) => currentCodexWindow(w, now))]),
236
+ ),
237
+ }
238
+ : null,
239
+ usageAt: account.lastUsageAt ?? null,
240
+ sample: outcome.ok ? { ok: true, source: "probe" } : { ok: false, reason: outcome.reason },
241
+ };
242
+ });
243
+ return { bars, accounts };
244
+ }
245
+
246
+ function renderCodex(input: { codex: StatusReport["codex"]; now: number; row: (name: string, w: UsageWindow) => void }): void {
247
+ const { codex, now, row } = input;
248
+ if (codex.accounts.length === 0) return;
249
+ console.log(c.dim(`codex (${count({ n: codex.accounts.length, noun: "account" })})`));
250
+ console.log();
251
+ const windowLabel = (window: CodexWindow) =>
252
+ isSessionWindow({ window }) ? `${Math.round((window.windowSeconds ?? 0) / 3600)}h` : "week";
253
+ for (const account of codex.accounts) {
254
+ const marker = account.active ? c.green("●") : c.dim("○");
189
255
  const badges: string[] = [];
190
- if (active) badges.push(c.green("active"));
256
+ if (account.active) badges.push(c.green("active"));
191
257
  if (account.needsReauth) badges.push(c.red("needs-reauth"));
192
- if (isCodexExhausted({ account, thresholds: terminalBars(cfg), now })) badges.push(c.yellow("exhausted"));
258
+ if (account.exhausted) badges.push(c.yellow("exhausted"));
193
259
  console.log(`${marker} ${c.bold(account.label)}${account.planType ? ` ${c.dim(account.planType)}` : ""}${badges.length ? ` ${badges.join(" ")}` : ""}`);
194
-
195
- const usage = account.lastUsage;
196
- if (usage) {
197
- for (const window of usage.aggregate) row(windowLabel(window), window, !isSessionWindow({ window }));
198
- for (const [name, windows] of Object.entries(usage.perLimit)) {
199
- for (const window of windows) row(codexLimitLabel({ limitName: name }), window, !isSessionWindow({ window }));
260
+ if (account.usage) {
261
+ for (const window of account.usage.aggregate) row(windowLabel(window), window);
262
+ for (const [name, windows] of Object.entries(account.usage.perLimit)) {
263
+ for (const window of windows) row(codexLimitLabel({ limitName: name }), window);
200
264
  }
201
265
  }
202
- const outcome = outcomes.get(account.accountId);
203
- if (outcome && !outcome.ok) {
204
- const cached = usage ? `cached${account.lastUsageAt != null ? ` ${fmtAgo(account.lastUsageAt, now)}` : ""}, ` : "";
205
- console.log(` ${c.yellow(`${cached}live sample failed`)}: ${c.dim(outcome.reason)}`);
266
+ if (!account.sample.ok) {
267
+ const cached = account.usage ? `cached${account.usageAt != null ? ` ${fmtAgo(account.usageAt, now)}` : ""}, ` : "";
268
+ console.log(` ${c.yellow(`${cached}live sample failed`)}: ${c.dim(account.sample.reason)}`);
269
+ }
270
+ console.log();
271
+ }
272
+ }
273
+
274
+ function rowPrinter(now: number): (name: string, w: UsageWindow) => void {
275
+ return (name, w) => {
276
+ console.log(` ${name.padEnd(5)} ${bar(w.usedPercentage)} ${c.dim(fmtReset(w.resetsAt, now))}`);
277
+ };
278
+ }
279
+
280
+ function renderClaude(input: { claude: StatusReport["claude"]; codexPooled: boolean; now: number; row: (name: string, w: UsageWindow) => void }): void {
281
+ const { claude, codexPooled, now, row } = input;
282
+ if (claude.accounts.length === 0) {
283
+ if (!codexPooled) {
284
+ console.log(c.dim("no accounts yet, run `tokenmaxxing init` (or `tokenmaxxing init --codex`)"));
285
+ return;
286
+ }
287
+ console.log(c.dim("no claude accounts (run `tokenmaxxing init` to pool claude too)"));
288
+ console.log();
289
+ return;
290
+ }
291
+
292
+ console.log(c.dim(`thresholds 5h ${claude.thresholds.session.join("/")}% (at ${claude.bars.session + claude.projectionMargin}%) weekly ${claude.thresholds.weekly}% (${count({ n: claude.accounts.length, noun: "claude account" })})`));
293
+ console.log();
294
+ for (const a of claude.accounts) {
295
+ const marker = a.active ? c.green("●") : c.dim("○");
296
+ const badges: string[] = [];
297
+ if (a.active) badges.push(c.green("active"));
298
+ if (a.needsReauth) badges.push(c.red("needs-reauth"));
299
+ if (a.exhausted) badges.push(c.yellow("exhausted"));
300
+ console.log(`${marker} ${c.bold(a.label || a.email)}${a.tier ? ` ${c.dim(a.tier)}` : ""}${badges.length ? ` ${badges.join(" ")}` : ""}`);
301
+ if (a.usage) {
302
+ row("5h", a.usage.fiveHour);
303
+ row("week", a.usage.week);
304
+ }
305
+ for (const [name, w] of Object.entries(a.perModel)) row(name.toLowerCase(), w);
306
+ if (!a.sample.ok) {
307
+ const cached = a.usage || Object.keys(a.perModel).length > 0 ? `cached${a.usageAt != null ? ` ${fmtAgo(a.usageAt, now)}` : ""}, ` : "";
308
+ console.log(` ${c.yellow(`${cached}live sample failed`)}: ${c.dim(a.sample.reason)}`);
309
+ }
310
+ if (a.pingError != null) {
311
+ console.log(` ${c.yellow("ping failed (5h timer may not have started)")}: ${c.dim(a.pingError)}`);
312
+ }
313
+ if (a.pinged) {
314
+ console.log(` ${c.dim("pinged - 5h timer started this run; the usage feed lags, re-run status shortly for the fresh window")}`);
206
315
  }
207
316
  console.log();
208
317
  }
209
318
  }
319
+
320
+ export async function cmdStatus(opts: { force?: boolean; json?: boolean; preRender?: () => void } = {}): Promise<number> {
321
+ const { force = false, json = false } = opts;
322
+ const cfg = loadConfig();
323
+ const now = Date.now();
324
+ const row = rowPrinter(now);
325
+ const claude = await collectClaude({ cfg, force, now });
326
+ if (!json) {
327
+ opts.preRender?.();
328
+ renderClaude({ claude, codexPooled: loadCodexAccounts().accounts.length > 0, now, row });
329
+ }
330
+ const codex = await collectCodex({ cfg, now });
331
+ if (json) {
332
+ const report: StatusReport = { now, claude, codex };
333
+ emitJson({ ok: true, ...report });
334
+ return 0;
335
+ }
336
+ renderCodex({ codex, now, row });
337
+ return 0;
338
+ }
package/src/cli/switch.ts CHANGED
@@ -7,15 +7,24 @@ import { currentWins, effectiveBars, pickBest, pickEarliestReset, weeklyExpiry,
7
7
  import { InvalidGrantError } from "../lib/oauth.ts";
8
8
  import { gatedFamilies } from "../lib/usage.ts";
9
9
  import { findAccount } from "./rename.ts";
10
- import { c, fmtReset } from "./render.ts";
10
+ import { c, emitError, emitJson, fmtReset } from "./render.ts";
11
11
  import type { Account } from "../lib/types.ts";
12
12
 
13
- export async function cmdSwitch(selector?: string): Promise<number> {
14
- const idx0 = loadAccounts();
15
- if (idx0.accounts.length < 2) {
16
- console.error(c.yellow("need at least 2 accounts to switch - add one with `tokenmaxxing add`"));
13
+ export async function cmdSwitch(selector?: string, json = false): Promise<number> {
14
+ const deadGrants: string[] = [];
15
+ const withDeadGrants = (report: Record<string, unknown>) => (deadGrants.length > 0 ? { ...report, deadGrants } : report);
16
+ const emit = (text: string, report: Record<string, unknown>): void => {
17
+ if (json) emitJson({ ok: true, ...withDeadGrants(report) });
18
+ else console.log(text);
19
+ };
20
+ const fail = (message: string, opts: { paint?: (s: string) => string; extra?: Record<string, unknown> } = {}): number => {
21
+ emitError({ json, message, paint: opts.paint, extra: withDeadGrants(opts.extra ?? {}) });
17
22
  return 1;
18
- }
23
+ };
24
+ const deadGrantMessage = (a: Account) => `${a.label}'s refresh token is dead - run \`tokenmaxxing auth ${a.label}\``;
25
+
26
+ const idx0 = loadAccounts();
27
+ if (idx0.accounts.length < 2) return fail("need at least 2 accounts to switch - add one with `tokenmaxxing add`", { paint: c.yellow });
19
28
  const cfg = loadConfig();
20
29
  const now = Date.now();
21
30
 
@@ -26,23 +35,29 @@ export async function cmdSwitch(selector?: string): Promise<number> {
26
35
  const claimedOrg = liveClaim?.organizationUuid ?? null;
27
36
  const drifted = claimed != null && claimed !== idx.activeAccountUuid;
28
37
 
29
- const swapTo = async (target: Account): Promise<number> => {
38
+ const swapTo = async (target: Account, reason: string, extra: Record<string, unknown> = {}): Promise<number> => {
30
39
  try {
31
40
  await performSwap(target);
32
41
  } catch (e) {
33
- if (e instanceof InvalidGrantError) { console.error(c.red(`${target.label}'s refresh token is dead - run \`tokenmaxxing auth ${target.label}\``)); return 1; }
42
+ if (e instanceof InvalidGrantError) {
43
+ deadGrants.push(target.label);
44
+ return fail(deadGrantMessage(target));
45
+ }
34
46
  throw e;
35
47
  }
36
- console.log(`${c.green("↻")} switched to ${c.bold(target.label)}`);
48
+ emit(`${c.green("↻")} switched to ${c.bold(target.label)}`, { switched: true, account: target.label, reason, ...extra });
37
49
  return 0;
38
50
  };
39
51
 
40
52
  if (selector) {
41
53
  const target = findAccount(idx.accounts, selector);
42
- if (!target) { console.error(c.red(`no account matches "${selector}"`)); return 1; }
43
- if (target.accountUuid === idx.activeAccountUuid && !drifted) { console.log(`already on ${c.bold(target.label)}`); return 0; }
44
- if (target.needsReauth) { console.error(c.red(`${target.label} needs re-auth - run \`tokenmaxxing auth ${target.label}\``)); return 1; }
45
- return swapTo(target);
54
+ if (!target) return fail(`no account matches "${selector}"`);
55
+ if (target.accountUuid === idx.activeAccountUuid && !drifted) {
56
+ emit(`already on ${c.bold(target.label)}`, { switched: false, account: target.label, reason: "already-on" });
57
+ return 0;
58
+ }
59
+ if (target.needsReauth) return fail(`${target.label} needs re-auth - run \`tokenmaxxing auth ${target.label}\``);
60
+ return swapTo(target, "selected");
46
61
  }
47
62
 
48
63
  const switchFamilies = gatedFamilies(loadUsage()?.model ?? null, cfg.policy.switchModels);
@@ -60,10 +75,15 @@ export async function cmdSwitch(selector?: string): Promise<number> {
60
75
  cur.accounts.find((a) => a.accountUuid === cur.activeAccountUuid) ??
61
76
  null;
62
77
  if (active != null && currentWins(active, cur.accounts, everyone)) {
63
- if (drifted) return swapTo(active);
78
+ if (drifted) return swapTo(active, "drift-reconciled");
64
79
  const expiry = weeklyExpiry(active, now);
65
80
  const why = Number.isFinite(expiry) ? ` (weekly ${fmtReset(expiry, now)})` : "";
66
- console.log(`already on the best account: ${c.bold(active.label)}${why}`);
81
+ emit(`already on the best account: ${c.bold(active.label)}${why}`, {
82
+ switched: false,
83
+ account: active.label,
84
+ reason: "current-wins",
85
+ weeklyResetsAt: Number.isFinite(expiry) ? expiry : null,
86
+ });
67
87
  return 0;
68
88
  }
69
89
  const best = pickBest(cur.accounts, { ...everyone, currentAccountUuid: active?.accountUuid ?? null });
@@ -72,37 +92,53 @@ export async function cmdSwitch(selector?: string): Promise<number> {
72
92
  await performSwap(best);
73
93
  } catch (e) {
74
94
  if (e instanceof InvalidGrantError) {
75
- console.error(c.red(`${best.label}'s refresh token is dead - run \`tokenmaxxing auth ${best.label}\``));
95
+ deadGrants.push(best.label);
96
+ if (!json) console.error(c.red(deadGrantMessage(best)));
76
97
  continue;
77
98
  }
78
99
  throw e;
79
100
  }
80
- console.log(`${c.green("↻")} switched to ${c.bold(best.label)}`);
101
+ emit(`${c.green("↻")} switched to ${c.bold(best.label)}`, { switched: true, account: best.label, reason: "best" });
81
102
  return 0;
82
103
  }
83
104
 
84
105
  const fresh = loadAccounts();
85
106
  const earliest = pickEarliestReset(fresh.accounts, everyoneIn(fresh.accounts));
107
+ const reauth = fresh.accounts.filter((a) => a.needsReauth).map((a) => a.label);
86
108
  if (!earliest) {
87
- const reauth = fresh.accounts.filter((a) => a.needsReauth).map((a) => a.label);
88
- if (reauth.length > 0) { console.error(c.yellow(`no switchable account - reauth needed (run \`tokenmaxxing auth --all\`): ${reauth.join(", ")}`)); return 1; }
109
+ if (reauth.length > 0) {
110
+ return fail(`no switchable account - reauth needed (run \`tokenmaxxing auth --all\`): ${reauth.join(", ")}`, {
111
+ paint: c.yellow,
112
+ extra: { reauthNeeded: reauth },
113
+ });
114
+ }
89
115
  const freshActive = fresh.accounts.find((a) => a.accountUuid === fresh.activeAccountUuid) ?? null;
90
- if (drifted && freshActive) return swapTo(freshActive);
91
- console.log(c.yellow("all accounts at their limit with unknown reset times (unparsed reset clocks? see tokenmaxxing.log) - staying put"));
116
+ if (drifted && freshActive) return swapTo(freshActive, "drift-reconciled");
117
+ emit(c.yellow("all accounts at their limit with unknown reset times (unparsed reset clocks? see tokenmaxxing.log) - staying put"), {
118
+ switched: false,
119
+ account: freshActive?.label ?? null,
120
+ reason: "unknown-resets",
121
+ });
92
122
  return 0;
93
123
  }
94
- const reauth = fresh.accounts.filter((a) => a.needsReauth).map((a) => a.label);
95
124
  const reauthNote = reauth.length ? ` - re-auth needed: ${reauth.join(", ")}` : "";
125
+ const availableAt = earliest.availableAt > now ? earliest.availableAt : null;
96
126
  if (earliest.account.accountUuid === fresh.activeAccountUuid && !drifted) {
97
- const msg = earliest.availableAt <= now
127
+ const msg = availableAt == null
98
128
  ? `staying on ${c.bold(earliest.account.label)} - no usable switch target${reauthNote}`
99
- : `all accounts at limit - staying on ${c.bold(earliest.account.label)} (${fmtReset(earliest.availableAt, now)})${reauthNote}`;
100
- console.log(c.yellow(msg));
129
+ : `all accounts at limit - staying on ${c.bold(earliest.account.label)} (${fmtReset(availableAt, now)})${reauthNote}`;
130
+ emit(c.yellow(msg), {
131
+ switched: false,
132
+ account: earliest.account.label,
133
+ reason: availableAt == null ? "no-target" : "all-at-limit",
134
+ availableAt,
135
+ reauthNeeded: reauth,
136
+ });
101
137
  return 0;
102
138
  }
103
- const code = await swapTo(earliest.account);
104
- if (code === 0 && earliest.availableAt > now) {
105
- console.log(c.yellow(`all accounts at limit - ${c.bold(earliest.account.label)} recovers soonest (${fmtReset(earliest.availableAt, now)})${reauthNote}`));
139
+ const code = await swapTo(earliest.account, "earliest-reset", { availableAt, reauthNeeded: reauth });
140
+ if (code === 0 && availableAt != null && !json) {
141
+ console.log(c.yellow(`all accounts at limit - ${c.bold(earliest.account.label)} recovers soonest (${fmtReset(availableAt, now)})${reauthNote}`));
106
142
  }
107
143
  return code;
108
144
  });
package/src/cli/watch.ts CHANGED
@@ -1,7 +1,8 @@
1
1
  import { clamp, delay } from "es-toolkit";
2
2
  import { loadAccounts } from "../lib/state.ts";
3
+ import { loadCodexAccounts } from "../lib/codexstate.ts";
3
4
  import { cmdStatus } from "./status.ts";
4
- import { c } from "./render.ts";
5
+ import { c, emitError, emitJson } from "./render.ts";
5
6
 
6
7
  const DEFAULT_INTERVAL_S = 120;
7
8
  const MIN_INTERVAL_S = 30;
@@ -16,13 +17,13 @@ export function resolveWatchInterval(arg?: string): number | null {
16
17
 
17
18
  const CLEAR = "\x1b[H\x1b[2J\x1b[3J";
18
19
 
19
- export async function cmdWatch(intervalArg?: string): Promise<number> {
20
+ export async function cmdWatch(intervalArg?: string, json = false): Promise<number> {
20
21
  const intervalS = resolveWatchInterval(intervalArg);
21
22
  if (intervalS === null) {
22
- console.error(c.red(`watch interval must be a positive number of seconds, got: ${intervalArg}`));
23
+ emitError({ json, message: `watch interval must be a positive number of seconds, got: ${intervalArg}` });
23
24
  return 2;
24
25
  }
25
- if (loadAccounts().accounts.length === 0) return cmdStatus();
26
+ if (loadAccounts().accounts.length === 0 && loadCodexAccounts().accounts.length === 0) return cmdStatus({ json });
26
27
 
27
28
  const paintHeader = () => {
28
29
  process.stdout.write(process.stdout.isTTY ? CLEAR : "\n");
@@ -30,10 +31,15 @@ export async function cmdWatch(intervalArg?: string): Promise<number> {
30
31
  };
31
32
  while (true) {
32
33
  try {
33
- await cmdStatus(false, paintHeader);
34
+ await cmdStatus(json ? { json } : { preRender: paintHeader });
34
35
  } catch (e) {
35
- paintHeader();
36
- console.error(c.red(`status failed this tick: ${e instanceof Error ? e.message : String(e)}`));
36
+ const message = `status failed this tick: ${e instanceof Error ? e.message : String(e)}`;
37
+ if (json) {
38
+ emitJson({ ok: false, error: message });
39
+ } else {
40
+ paintHeader();
41
+ console.error(c.red(message));
42
+ }
37
43
  }
38
44
  await delay(intervalS * 1000);
39
45
  }
@@ -151,7 +151,7 @@ export function createTokenmaxxingMcpServer(): McpServer {
151
151
  inputSchema: {},
152
152
  },
153
153
  async () => {
154
- const cap = await captureCli(() => cmdStatus(false));
154
+ const cap = await captureCli(() => cmdStatus());
155
155
  return textResult({ text: formatCapture(cap), isError: cap.code !== 0 });
156
156
  },
157
157
  );
package/src/lib/state.ts CHANGED
@@ -18,11 +18,11 @@ import {
18
18
  } from "./types.ts";
19
19
 
20
20
  const DEFAULT_CONFIG: Config = {
21
- thresholds: { session: [50, 80, 95], weekly: 98 },
21
+ thresholds: { session: [90], weekly: 98 },
22
22
  hardThresholds: { session: 100, weekly: 100 },
23
23
  claudeBin: "",
24
24
  codexBin: "",
25
- policy: { projectionMargin: 0, greedySessionFloor: 50, switchModels: ["fable"], usagePollTtlMs: 90_000, maxWaitMs: 3_600_000 },
25
+ policy: { projectionMargin: 0, greedySessionFloor: 80, switchModels: ["fable"], usagePollTtlMs: 90_000, maxWaitMs: 3_600_000 },
26
26
  };
27
27
 
28
28
  const PercentSchema = z.number().min(0).max(100);