tokenmaxxing 1.26.0 → 1.28.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/DESIGN.md CHANGED
@@ -50,7 +50,7 @@ The Stop hook's stdin has no usage data, but the **statusLine does** (`rate_limi
50
50
 
51
51
  ### 3.2 Detect + swap + signal (Stop hook, per turn)
52
52
  1. Read `usage.json` and exit while the live account is under both bars, as defined in the [switching policy](docs/content/docs/switching.mdx).
53
- 2. Take the pool lock, re-check state, select a candidate under the [switching policy](docs/content/docs/switching.mdx), and attempt bounded verification of stale usage before swapping the credential.
53
+ 2. Take the pool lock, re-check state, select a candidate under the [switching policy](docs/content/docs/switching.mdx) from the cached usage figures, and swap the credential.
54
54
  3. Done - the running session adopts the new credential on its own within a request or two. Only when the pool is depleted (the decision returned a `waitUntil`: pre-parked on the soonest-recovering account, or staying on the current one when it recovers first) does the hook write `respawn/<session_id>` (atomic temp+rename).
55
55
 
56
56
  ### 3.3 Depleted-pool pause (supervisor)
@@ -82,10 +82,7 @@ Each terminal ran the supervisor, so each has its own child `claude` and its own
82
82
  The [switching policy](docs/content/docs/switching.mdx) is one rule: under both bars, hold; at or over a bar, move to the usable account with the highest pace pressure; nothing usable, wait for the soonest reset.
83
83
 
84
84
  - Automatic and manual switching rank by pace pressure alone; organization membership is not an input.
85
- - Successful candidate probes persist their results and restart selection.
86
- - Verification attempts at most two candidates with a shared 12-second deadline per candidate for identity reads and CLI execution.
87
- - Expiring access tokens are not refreshed for verification, and the isolated CLI receives no refresh grant.
88
- - Failed or budget-limited verification uses the existing cached data, so stale-target handoffs remain possible.
85
+ - Selection reads the cached figures only. Each periodic check tick samples the parked account whose last `/usage` attempt is oldest, skipping any attempted within `policy.usagePollTtlMs`, so every parked account is attempted about once per tick per pooled account, or once per `policy.usagePollTtlMs` plus a tick when that is longer, and its cached figure is as fresh as its last successful attempt. No sample runs inside the post-swap cooldown. The pool lock covers only the reservation (its owner lookups and refresh under one 20-second deadline) and the result write, never the `/usage` child.
89
86
  - The depleted pause happens at the bar, not at 100; Codex has no pause and rides its account until the server refuses it, because a running sibling cannot adopt another account without restarting.
90
87
 
91
88
  The [profile](docs/content/docs/switching-profile.mdx) records observed cache rewrites across swaps and does not infer subscription quota savings.
package/README.md CHANGED
@@ -70,8 +70,7 @@ claude # use claude as always
70
70
  | `tokenmaxxing switch --codex [sel]` | switch the codex pool (takes effect on the next codex start) |
71
71
  | `tokenmaxxing ls` | list pooled accounts |
72
72
  | `tokenmaxxing status` | accounts with 5h / weekly usage bars, active + exhausted-until-reset |
73
- | `tokenmaxxing status --ping [--count N]` | additionally ping every account (one tiny haiku request each) so all 5h session timers start now, then sample fresh; `--count N` pings N randomly picked accounts instead, to stagger the resets |
74
- | `tokenmaxxing watch [seconds]` | live status: re-render every N seconds (default 120, floor 30; never pings) |
73
+ | `tokenmaxxing watch [seconds]` | live status: re-render every N seconds (default 120, floor 30) |
75
74
  | `tokenmaxxing config` | effective config with sources; `get`/`set`/`unset` dotted keys, `tidy` prunes unknown keys |
76
75
  | `tokenmaxxing doctor` | verify the supervisor + settings entries survived |
77
76
  | `tokenmaxxing rename [--codex] <sel> <label>` / `rm [--codex] <sel>` | manage the pool (`--codex` targets the codex pool: one email can hold both a claude and a codex account) |
@@ -89,8 +88,7 @@ The bars' headroom is deliberate: it's the budget to reach a clean turn boundary
89
88
 
90
89
  Selection ranks usable candidates by remaining weekly percentage divided by time to reset, highest first; organization membership is not an input, and manual `tokenmaxxing switch` uses the same ranking.
91
90
 
92
- - Candidate verification makes at most two bounded `/usage` attempts per evaluation and re-ranks successful samples before switching.
93
- - Failed verification leaves cached figures in force, so it cannot guarantee a fresh target.
91
+ - Every periodic check tick samples the parked account whose last `/usage` attempt is oldest, skipping any attempted within `policy.usagePollTtlMs`, so every parked account is attempted about once per tick per pooled account, or once per `usagePollTtlMs` plus a tick when that is longer; the swap reads the cached figures, which are as fresh as each account's last successful attempt. No sample runs inside the 45-second post-swap cooldown.
94
92
  - When no account is usable, the session pauses until the soonest reset if that lands within `policy.maxWaitMs`, and otherwise stays put.
95
93
 
96
94
  See [How switching decides](docs/content/docs/switching.mdx) for the policy and the [cache-cost profile](docs/content/docs/switching-profile.mdx) for measurements and their limits.
@@ -111,7 +109,7 @@ See [How switching decides](docs/content/docs/switching.mdx) for the policy and
111
109
  }
112
110
  ```
113
111
 
114
- `thresholds.session` and `thresholds.weekly` are the two bars, one number each (an array `thresholds.session`, the former ladder, fails config loading with a message naming the field); `projectionMargin` is a fixed safety margin subtracted from each threshold bar (effective bar = threshold - margin), so a large turn is less likely to blow past a bar between checks; `switchModels` names the models whose per-model cap triggers a switch; `usagePollTtlMs` is how long a `/usage` per-model poll stays fresh; `maxWaitMs` bounds the depleted-pool countdown - a soonest reset further out than this does not pause the session (no respawn marker is written and the session simply keeps hitting its limit until an account recovers); `checkIntervalMs` is the periodic check tick (default 60s), which `init` writes into the timer - re-run `tokenmaxxing init` after changing it so the timer unit picks up the new tick.
112
+ `thresholds.session` and `thresholds.weekly` are the two bars, one number each (an array `thresholds.session`, the former ladder, fails config loading with a message naming the field); `projectionMargin` is a fixed safety margin subtracted from each threshold bar (effective bar = threshold - margin), so a large turn is less likely to blow past a bar between checks; `switchModels` names the models whose per-model cap triggers a switch; `usagePollTtlMs` is how long a `/usage` attempt stays fresh, for the live per-model poll and for the parked account each check tick samples; `maxWaitMs` bounds the depleted-pool countdown - a soonest reset further out than this does not pause the session (no respawn marker is written and the session simply keeps hitting its limit until an account recovers); `checkIntervalMs` is the periodic check tick (default 60s), which `init` writes into the timer - re-run `tokenmaxxing init` after changing it so the timer unit picks up the new tick.
115
113
 
116
114
  State lives entirely in `~/.config/tokenmaxxing/`. Per-account credentials follow the platform's Claude Code store: the login keychain on macOS (`tokenmaxxing-cred-<uuid8>` items, never plaintext on disk), 0600 files under `~/.config/tokenmaxxing/creds/` on Linux (the same plaintext model claude itself uses for `~/.claude/.credentials.json`).
117
115
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tokenmaxxing",
3
- "version": "1.26.0",
3
+ "version": "1.28.0",
4
4
  "description": "Automatic Claude Code account switching: pool multiple accounts and hot-swap when quota fills, resuming your session on the fresh account.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/cli/check.ts CHANGED
@@ -1,11 +1,15 @@
1
1
  import { evaluateAndMaybeSwap } from "../lib/decide.ts";
2
+ import { sampleOldestParked } from "../lib/sample.ts";
3
+ import { loadConfig } from "../lib/state.ts";
2
4
  import { log } from "../lib/log.ts";
3
5
  import { c, emitError, emitJson, fmtReset } from "./render.ts";
4
6
 
5
7
  export async function cmdCheck(json = false): Promise<number> {
8
+ const now = Date.now();
6
9
  let d;
7
10
  try {
8
- d = await evaluateAndMaybeSwap(Date.now());
11
+ d = await evaluateAndMaybeSwap(now);
12
+ await sampleOldestParked(loadConfig());
9
13
  } catch (e) {
10
14
  const detail = e instanceof Error ? e.message : String(e);
11
15
  log("check.error", { err: detail });
package/src/cli/init.ts CHANGED
@@ -37,7 +37,6 @@ export function printUsage(): void {
37
37
  console.log(` ${c.bold("how to use")} - ${c.cyan("xx")} is shorthand for ${c.cyan("tokenmaxxing")}:`);
38
38
  console.log(` ${c.cyan("claude")} use claude as always; it switches accounts near quota automatically`);
39
39
  console.log(` ${c.cyan("xx")} show the pool with usage bars (same as ${c.cyan("xx status")})`);
40
- console.log(` ${c.cyan("xx status --ping")} ping every account (one tiny haiku request each) so all 5h timers start now, then sample fresh; ${c.cyan("--count N")} pings N random accounts instead`);
41
40
  console.log(` ${c.cyan("xx add")} log in and pool another account`);
42
41
  console.log(` ${c.cyan("xx switch")} hop to the best account right now (the automatic switching needs no command)`);
43
42
  console.log(` ${c.cyan("xx help")} everything else`);
package/src/cli/rm.ts CHANGED
@@ -44,9 +44,11 @@ export async function cmdRm(selector?: string, json = false): Promise<number> {
44
44
  }
45
45
  }
46
46
  await deleteItem(parkedTarget(a.keychainItem));
47
- const sampleDir = join(paths.sampleDir, credItemFor(a.accountUuid));
48
- await deleteItem(isolatedTarget(sampleDir));
49
- rmSync(sampleDir, { recursive: true, force: true });
47
+ const item = credItemFor(a.accountUuid);
48
+ for (const sampleDir of [join(paths.sampleDir, item), join(paths.sampleDir, `${item}-tick`)]) {
49
+ await deleteItem(isolatedTarget(sampleDir));
50
+ rmSync(sampleDir, { recursive: true, force: true });
51
+ }
50
52
  idx.accounts = idx.accounts.filter((x) => x.accountUuid !== a.accountUuid);
51
53
  saveAccounts(idx);
52
54
  if (json) emitJson({ ok: true, pool: "claude", removed: a.label, remaining: idx.accounts.length });
package/src/cli/status.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { chunk, sampleSize, sortBy } from "es-toolkit";
1
+ import { chunk, sortBy } from "es-toolkit";
2
2
  import { z } from "zod";
3
3
  import { loadAccounts, loadConfig, loadUsage, loadUsageSnapshot, loadModelUsage, saveAccounts } from "../lib/state.ts";
4
4
  import { readOAuthAccount } from "../lib/claudejson.ts";
@@ -32,9 +32,6 @@ const ClaudeStatusAccountSchema = z.object({
32
32
  perModel: z.record(z.string(), UsageWindowSchema),
33
33
  usageAt: z.number().nullable(),
34
34
  sample: SampleReportSchema,
35
- pingError: z.string().nullable(),
36
- pingRejected: z.boolean(),
37
- pinged: z.boolean(),
38
35
  });
39
36
  type ClaudeStatusAccount = z.infer<typeof ClaudeStatusAccountSchema>;
40
37
 
@@ -86,31 +83,14 @@ function currentCodexWindow(w: CodexWindow, now: number): CodexWindow {
86
83
  return { ...currentWindow(w, !isSessionWindow({ window: w }), now), windowSeconds: w.windowSeconds };
87
84
  }
88
85
 
89
- function pickForPing(accounts: Account[], pingCount: number | undefined): Account[] {
90
- if (pingCount == null) return accounts;
91
- const usable = accounts.filter((a) => a.needsReauth !== true);
92
- return sampleSize(usable, Math.min(pingCount, usable.length));
93
- }
94
-
95
- function progressNote(input: { ping: boolean; pingCount: number | undefined; picked: Account[]; total: number }): string {
96
- const { ping, pingCount, picked, total } = input;
97
- if (!ping) return "sampling live usage...";
98
- if (pingCount == null) return "pinging every account (starts each 5h session timer) + sampling live usage...";
99
- if (picked.length === 0) return "no account to ping (every account needs reauth), sampling live usage...";
100
- return `pinging ${picked.length} of ${count({ n: total, noun: "account" })} (${picked.map((a) => a.label || a.email).join(", ")}) so their 5h session timers start now + sampling live usage...`;
101
- }
102
-
103
- async function collectClaude(input: { cfg: Config; ping: boolean; pingCount: number | undefined; now: number }): Promise<StatusReport["claude"]> {
104
- const { cfg, ping, pingCount, now } = input;
86
+ async function collectClaude(input: { cfg: Config; now: number }): Promise<StatusReport["claude"]> {
87
+ const { cfg, now } = input;
105
88
  let idx = loadAccounts();
106
89
  const samples = new Map<string, { outcome: SampleOutcome; viaTee: boolean }>();
107
- let pings = new Set<string>();
108
90
  if (idx.accounts.length > 0) {
109
91
  await withLock(paths.lockFile, async () => {
110
92
  idx = loadAccounts();
111
- const picked = ping ? pickForPing(idx.accounts, pingCount) : [];
112
- pings = new Set(picked.map((a) => a.accountUuid));
113
- console.error(c.dim(progressNote({ ping, pingCount, picked, total: idx.accounts.length })));
93
+ console.error(c.dim("sampling live usage..."));
114
94
  const tee = loadUsageSnapshot();
115
95
  const live = tee?.state ?? null;
116
96
  const teeAt = tee?.at ?? null;
@@ -129,27 +109,12 @@ async function collectClaude(input: { cfg: Config; ping: boolean; pingCount: num
129
109
  perModel: modelUsage && modelUsage.account === a.accountUuid ? modelUsage.perModel : {},
130
110
  }
131
111
  : null;
132
- let viaTee = false;
133
- let outcome: SampleOutcome;
134
- if (pings.has(a.accountUuid)) {
135
- outcome = isActive ? await probeActiveUsage(a, { ping: true }) : await probeParkedUsage(a, { ping: true });
136
- if (!outcome.ok && fromStatusLine && outcome.reason.includes("no limit data")) {
137
- const failed = outcome;
138
- outcome = { ok: true, usage: fromStatusLine };
139
- if (failed.pingError != null) {
140
- outcome.pingError = failed.pingError;
141
- outcome.pingRejected = failed.pingRejected;
142
- }
143
- viaTee = true;
144
- }
145
- } else {
146
- viaTee = fromStatusLine != null;
147
- outcome = fromStatusLine
148
- ? { ok: true, usage: fromStatusLine }
149
- : isActive
150
- ? await probeActiveUsage(a)
151
- : await probeParkedUsage(a);
152
- }
112
+ const viaTee = fromStatusLine != null;
113
+ const outcome: SampleOutcome = fromStatusLine
114
+ ? { ok: true, usage: fromStatusLine }
115
+ : isActive
116
+ ? await probeActiveUsage(a)
117
+ : await probeParkedUsage(a);
153
118
  samples.set(a.accountUuid, { outcome, viaTee });
154
119
  if (!outcome.ok) return;
155
120
  a.lastUsage = { fiveHour: outcome.usage.session, sevenDay: outcome.usage.weekAll };
@@ -192,9 +157,6 @@ async function collectClaude(input: { cfg: Config; ping: boolean; pingCount: num
192
157
  perModel: Object.fromEntries(Object.entries(perModel).map(([name, w]) => [name, currentWindow(w, true, now)])),
193
158
  usageAt: a.lastUsageAt ?? null,
194
159
  sample: sampled.outcome.ok ? { ok: true, source: sampled.viaTee ? "statusline" : "probe" } : { ok: false, reason: sampled.outcome.reason },
195
- pingError: sampled.outcome.pingError ?? null,
196
- pingRejected: sampled.outcome.pingRejected === true,
197
- pinged: pings.has(a.accountUuid) && sampled.outcome.ok && sampled.outcome.pingError == null && aggregate != null && aggregate.fiveHour.resetsAt == null,
198
160
  };
199
161
  });
200
162
  return {
@@ -383,18 +345,6 @@ function claudeCard(a: ClaudeStatusAccount, now: number, staleAfterMs: number):
383
345
  if (!a.sample.ok) {
384
346
  notes.push(...sampleFailedNotes({ cached: a.usage != null || Object.keys(a.perModel).length > 0, usageAt: a.usageAt, reason: a.sample.reason, now }));
385
347
  }
386
- if (a.pingError != null && a.pingRejected) {
387
- const at = a.pingError.indexOf(": ");
388
- const head = at >= 0 ? a.pingError.slice(0, at) : a.pingError;
389
- const tail = at >= 0 ? a.pingError.slice(at + 2) : "";
390
- notes.push({ paint: c.yellow, text: `ping ${head}${tail ? ":" : ""}` });
391
- if (tail) notes.push({ paint: c.dim, text: tail });
392
- } else if (a.pingError != null) {
393
- notes.push({ paint: c.yellow, text: "ping failed (5h timer may not have started):" }, { paint: c.dim, text: a.pingError });
394
- }
395
- if (a.pinged) {
396
- notes.push({ paint: c.dim, text: "pinged - 5h timer started this run; the usage feed lags, re-run status shortly for the fresh window" });
397
- }
398
348
  return { lines, notes };
399
349
  }
400
350
 
@@ -415,11 +365,11 @@ function renderClaude(input: { claude: StatusReport["claude"]; codexPooled: bool
415
365
  renderGrid(claude.accounts.map((a) => claudeCard(a, now, staleAfterMs)));
416
366
  }
417
367
 
418
- export async function cmdStatus(opts: { ping?: boolean; pingCount?: number; json?: boolean; preRender?: () => void } = {}): Promise<number> {
419
- const { ping = false, pingCount, json = false } = opts;
368
+ export async function cmdStatus(opts: { json?: boolean; preRender?: () => void } = {}): Promise<number> {
369
+ const { json = false } = opts;
420
370
  const cfg = loadConfig();
421
371
  const now = Date.now();
422
- const claude = await collectClaude({ cfg, ping, pingCount, now });
372
+ const claude = await collectClaude({ cfg, now });
423
373
  if (!json) {
424
374
  opts.preRender?.();
425
375
  const at = Date.now();
package/src/lib/decide.ts CHANGED
@@ -4,9 +4,8 @@ import { withLock } from "./lock.ts";
4
4
  import { paths } from "./paths.ts";
5
5
  import { POST_SWAP_COOLDOWN_MS, loadAccounts, loadConfig, loadDepletedWait, loadLastSwapAt, loadUsage, loadUsageSnapshot, loadModelUsage, saveAccounts, saveDepletedWait, saveModelUsage, writeUsage } from "./state.ts";
6
6
  import { readOAuthAccount } from "./claudejson.ts";
7
- import { isSkippableSwapError, performSwap, type VerifyVerdict } from "./swap.ts";
8
- import { probeParkedUsage } from "./sample.ts";
9
- import { isExhausted, nextWeeklyReset, pickBest, pickEarliestReset, thresholdBars, usableAt, type PickCtx } from "./picker.ts";
7
+ import { isSkippableSwapError, performSwap } from "./swap.ts";
8
+ import { nextWeeklyReset, pickBest, pickEarliestReset, thresholdBars, usableAt } from "./picker.ts";
10
9
  import { familyTokens, gatedFamilies, probeUsage, type EnforcedClass } from "./usage.ts";
11
10
  import { log } from "./log.ts";
12
11
  import { AccountSchema, ModelUsageStateSchema, UsageStateSchema, type Account, type Config, type EnforcedLimit, type ModelUsageState, type Thresholds, type UsageState, type UsageWindow } from "./types.ts";
@@ -21,8 +20,6 @@ export type SwapDecision = z.infer<typeof SwapDecisionSchema>;
21
20
 
22
21
  const WEEK_MS = 7 * 24 * 60 * 60 * 1000;
23
22
  const FIVE_HOURS_MS = 5 * 60 * 60 * 1000;
24
- const VERIFY_PROBE_BUDGET = 2;
25
- const VERIFY_PROBE_KILL_MS = 12_000;
26
23
 
27
24
  function liveUsed(input: { window: UsageWindow; windowMs: number; sampledAt: number; now: number }): number {
28
25
  const { window: w, windowMs, sampledAt, now } = input;
@@ -186,53 +183,12 @@ export async function evaluateAndMaybeSwap(now = Date.now(), anticipatory = fals
186
183
  rejected.add(candidate.accountUuid);
187
184
  log("decide.candidate_rejected", { account: candidate.accountUuid.slice(0, 8), error: e instanceof Error ? e.message : String(e) });
188
185
  };
189
- let probeBudget = VERIFY_PROBE_BUDGET;
190
- const verified = async (candidate: Account, ctx: PickCtx): Promise<VerifyVerdict> => {
191
- if (candidate.lastUsageAt != null && now - candidate.lastUsageAt <= cfg.policy.usagePollTtlMs) return "go";
192
- const short = candidate.accountUuid.slice(0, 8);
193
- const idx2 = loadAccounts();
194
- const stored = idx2.accounts.find((a) => a.accountUuid === candidate.accountUuid);
195
- if (!stored) return "go";
196
- if (probeBudget <= 0) {
197
- log("decide.verify_budget_spent", { account: short });
198
- return "go";
199
- }
200
- probeBudget -= 1;
201
- const sample = await probeParkedUsage(stored, { retries: 0, refreshParked: false, killMs: VERIFY_PROBE_KILL_MS, signal: AbortSignal.timeout(VERIFY_PROBE_KILL_MS) });
202
- if (sample.ok) {
203
- const at = Date.now();
204
- stored.lastUsage = { fiveHour: sample.usage.session, sevenDay: sample.usage.weekAll };
205
- stored.lastUsageAt = at;
206
- if (Object.keys(sample.usage.perModel).length > 0) {
207
- stored.lastPerModel = sample.usage.perModel;
208
- stored.lastPerModelAt = at;
209
- }
210
- }
211
- if (sample.ok || stored.needsReauth === true) saveAccounts(idx2);
212
- if (stored.needsReauth === true) {
213
- rejected.add(candidate.accountUuid);
214
- log("decide.candidate_rejected", { account: short, error: "needs reauth" });
215
- return "skip";
216
- }
217
- if (!sample.ok) {
218
- log("decide.candidate_unverified", { account: short });
219
- return "go";
220
- }
221
- log(isExhausted(stored, ctx) ? "decide.candidate_walled" : "decide.candidate_verified", {
222
- account: short,
223
- session: sample.usage.session.usedPercentage,
224
- weekly: sample.usage.weekAll.usedPercentage,
225
- });
226
- return "rerank";
227
- };
228
-
229
186
  while (true) {
230
187
  const cur = loadAccounts();
231
188
  const seat = seatOf(cur);
232
189
  const ctx = { now, thresholds: bars, currentAccountUuid: seat?.accountUuid ?? null, switchFamilies };
233
190
  const best = pickBest(usable(cur.accounts), ctx);
234
191
  if (!best) break;
235
- if ((await verified(best, ctx)) !== "go") continue;
236
192
  try {
237
193
  await performSwap(best);
238
194
  } catch (e) {
package/src/lib/oauth.ts CHANGED
@@ -40,7 +40,7 @@ export function isDeadCredential(creds: OAuthCreds): boolean {
40
40
  return creds.refreshToken === "" || creds.accessToken === "";
41
41
  }
42
42
 
43
- export async function refreshCredential(creds: OAuthCreds, now = Date.now()): Promise<OAuthCreds> {
43
+ export async function refreshCredential(creds: OAuthCreds, now = Date.now(), signal?: AbortSignal): Promise<OAuthCreds> {
44
44
  if (isDeadCredential(creds)) throw new InvalidGrantError("credential was cleared after a failed refresh");
45
45
  const scope = (creds.scopes?.length ? creds.scopes : DEFAULT_SCOPES).join(" ");
46
46
  const body = {
@@ -55,6 +55,7 @@ export async function refreshCredential(creds: OAuthCreds, now = Date.now()): Pr
55
55
  res = await http.post(TOKEN_URL, {
56
56
  headers: { "Content-Type": "application/json" },
57
57
  body: JSON.stringify(body),
58
+ signal,
58
59
  });
59
60
  } catch (e) {
60
61
  throw new Error(`token endpoint unreachable: ${e instanceof Error ? e.message : String(e)}`);
package/src/lib/sample.ts CHANGED
@@ -1,19 +1,22 @@
1
1
  import { mkdirSync, rmSync, writeFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
+ import { minBy } from "es-toolkit";
3
4
  import { z } from "zod";
4
5
  import { readItem, writeItem, deleteItem, liveTarget, parkedTarget, isolatedTarget, claudeAiOauthOnly, mergeIntoLive } from "./credstore.ts";
5
6
  import { credItemFor, paths } from "./paths.ts";
6
7
  import { withClaudeRefreshLock } from "./claudelock.ts";
8
+ import { withLock } from "./lock.ts";
9
+ import { readOAuthAccount } from "./claudejson.ts";
7
10
  import { refreshCredential, isAccessTokenExpiring, isDeadCredential, fetchTokenIdentity, describeIdentity, IdentityUnavailableError, InvalidGrantError } from "./oauth.ts";
8
- import { FullUsageSchema, pingSession, probeUsage } from "./usage.ts";
9
- import { loadAccounts } from "./state.ts";
11
+ import { FullUsageSchema, probeUsage } from "./usage.ts";
12
+ import { POST_SWAP_COOLDOWN_MS, loadAccounts, loadLastSwapAt, saveAccounts } from "./state.ts";
10
13
  import { keepRotatedPair } from "./swap.ts";
11
14
  import { log } from "./log.ts";
12
- import { CredentialBlobSchema, TokenIdentitySchema, type Account, type OAuthCreds, type TokenIdentity } from "./types.ts";
15
+ import { CredentialBlobSchema, TokenIdentitySchema, type Account, type Config, type OAuthCreds, type TokenIdentity } from "./types.ts";
13
16
 
14
17
  const SampleOutcomeSchema = z.discriminatedUnion("ok", [
15
- z.object({ ok: z.literal(true), usage: FullUsageSchema, pingError: z.string().optional(), pingRejected: z.boolean().optional() }),
16
- z.object({ ok: z.literal(false), reason: z.string(), pingError: z.string().optional(), pingRejected: z.boolean().optional() }),
18
+ z.object({ ok: z.literal(true), usage: FullUsageSchema }),
19
+ z.object({ ok: z.literal(false), reason: z.string() }),
17
20
  ]);
18
21
  export type SampleOutcome = z.infer<typeof SampleOutcomeSchema>;
19
22
 
@@ -44,10 +47,12 @@ function refreshPlanFields(account: Account, creds: OAuthCreds): void {
44
47
  if (creds.rateLimitTier != null) account.rateLimitTier = creds.rateLimitTier;
45
48
  }
46
49
 
47
- export async function probeParkedUsage(
48
- account: Account,
49
- opts: { ping?: boolean; retries?: number; refreshParked?: boolean; killMs?: number; signal?: AbortSignal } = {},
50
- ): Promise<SampleOutcome> {
50
+ const ProbeFailureSchema = z.object({ ok: z.literal(false), reason: z.string() });
51
+ type ProbeFailure = z.infer<typeof ProbeFailureSchema>;
52
+ const PreparedProbeSchema = z.discriminatedUnion("ok", [z.object({ ok: z.literal(true), dir: z.string(), installed: z.string() }), ProbeFailureSchema]);
53
+ type PreparedProbe = z.infer<typeof PreparedProbeSchema>;
54
+
55
+ async function prepareParkedProbe(account: Account, dir: string, signal?: AbortSignal): Promise<PreparedProbe> {
51
56
  const backup = parkedTarget(account.keychainItem);
52
57
  const parkedRaw = await readItem(backup);
53
58
  if (!parkedRaw) return { ok: false, reason: "no parked credential - run `tokenmaxxing auth`" };
@@ -62,13 +67,11 @@ export async function probeParkedUsage(
62
67
  account.needsReauth = true;
63
68
  return { ok: false, reason: "parked credential was cleared after a failed refresh - re-auth with `tokenmaxxing auth`" };
64
69
  }
65
- if (opts.refreshParked === false && isAccessTokenExpiring(creds, 300_000)) {
66
- return { ok: false, reason: "parked access token is near expiry - verification requires an unexpired token" };
67
- }
68
70
 
69
71
  const liveRaw = await readItem(liveTarget());
70
72
  let liveAccount: string | null = null;
71
73
  let liveToken: string | null = null;
74
+ let liveRefresh: string | null = null;
72
75
  if (liveRaw != null) {
73
76
  let liveCreds: OAuthCreds;
74
77
  try {
@@ -78,8 +81,9 @@ export async function probeParkedUsage(
78
81
  }
79
82
  if (!isDeadCredential(liveCreds)) {
80
83
  liveToken = liveCreds.accessToken;
84
+ liveRefresh = liveCreds.refreshToken;
81
85
  try {
82
- liveAccount = (await fetchTokenIdentity(liveCreds.accessToken, opts.signal)).accountUuid;
86
+ liveAccount = (await fetchTokenIdentity(liveCreds.accessToken, signal)).accountUuid;
83
87
  } catch (e) {
84
88
  return { ok: false, reason: `cannot verify the live credential's owner (${(e instanceof Error ? e.message : String(e)).slice(0, 80)}) - refusing to sample a possibly-live account` };
85
89
  }
@@ -98,10 +102,7 @@ export async function probeParkedUsage(
98
102
  };
99
103
 
100
104
  if (isAccessTokenExpiring(creds, 300_000)) {
101
- if (opts.refreshParked === false) {
102
- return { ok: false, reason: "parked access token is near expiry - verification requires an unexpired token" };
103
- }
104
- const owner = await checkIdentity(creds, account);
105
+ const owner = await checkIdentity(creds, account, signal);
105
106
  if (owner.status === "mismatch") {
106
107
  const kept = await relocate(owner.owner);
107
108
  account.needsReauth = true;
@@ -110,9 +111,9 @@ export async function probeParkedUsage(
110
111
  if (owner.status === "unavailable" && !owner.stale) {
111
112
  return { ok: false, reason: `${owner.reason} - refusing to refresh a parked credential whose owner cannot be verified` };
112
113
  }
113
- const rotate = async (): Promise<{ fresh: OAuthCreds } | { failed: SampleOutcome }> => {
114
+ const rotate = async (): Promise<{ fresh: OAuthCreds } | { failed: ProbeFailure }> => {
114
115
  try {
115
- return { fresh: await refreshCredential(creds) };
116
+ return { fresh: await refreshCredential(creds, Date.now(), signal) };
116
117
  } catch (e) {
117
118
  if (e instanceof InvalidGrantError) {
118
119
  account.needsReauth = true;
@@ -121,16 +122,24 @@ export async function probeParkedUsage(
121
122
  return { failed: { ok: false, reason: `token refresh failed: ${e instanceof Error ? e.message : String(e)}` } };
122
123
  }
123
124
  };
124
- let result: { fresh: OAuthCreds } | { failed: SampleOutcome };
125
+ let result: { fresh: OAuthCreds } | { failed: ProbeFailure };
125
126
  if (owner.status === "match") {
126
127
  result = await rotate();
127
128
  } else {
129
+ if (liveRefresh != null && creds.refreshToken === liveRefresh) {
130
+ return { ok: false, reason: "this slot holds a copy of the LIVE login's grant - refusing to rotate it from a parked slot; run `tokenmaxxing switch` to reconcile" };
131
+ }
128
132
  try {
133
+ signal?.throwIfAborted();
129
134
  result = await withClaudeRefreshLock(async (lock) => {
130
135
  const rotated = await rotate();
131
136
  if ("failed" in rotated) return rotated;
132
- const verified = await checkIdentity(rotated.fresh, account);
133
- if (verified.status !== "mismatch") return rotated;
137
+ const verified = await checkIdentity(rotated.fresh, account, signal);
138
+ if (verified.status === "match") return rotated;
139
+ if (verified.status === "unavailable") {
140
+ await writeItem(backup, JSON.stringify({ claudeAiOauth: rotated.fresh }));
141
+ return { failed: { ok: false, reason: `${verified.reason} - the rotated pair stays in this slot unverified until the next pass` } };
142
+ }
134
143
  const trueOwner = loadAccounts().accounts.find((a) => a.accountUuid === verified.owner.accountUuid) ?? null;
135
144
  const kept = await keepRotatedPair({ fresh: rotated.fresh, owner: trueOwner, fallback: account, liveOwnerUuid: liveAccount, expectedLiveToken: liveToken, lock });
136
145
  account.needsReauth = true;
@@ -145,7 +154,7 @@ export async function probeParkedUsage(
145
154
  await writeItem(backup, JSON.stringify({ claudeAiOauth: creds }));
146
155
  }
147
156
 
148
- const identity = await checkIdentity(creds, account, opts.signal);
157
+ const identity = await checkIdentity(creds, account, signal);
149
158
  if (identity.status === "mismatch") {
150
159
  const kept = await relocate(identity.owner);
151
160
  account.needsReauth = true;
@@ -156,35 +165,100 @@ export async function probeParkedUsage(
156
165
  }
157
166
  refreshPlanFields(account, creds);
158
167
 
159
- const dir = join(paths.sampleDir, credItemFor(account.accountUuid));
160
168
  rmSync(dir, { recursive: true, force: true });
161
169
  mkdirSync(dir, { recursive: true });
162
- const isoTarget = isolatedTarget(dir);
163
- const installed = JSON.stringify({ claudeAiOauth: opts.refreshParked === false ? { ...creds, refreshToken: "" } : creds });
164
-
170
+ const installed = JSON.stringify({ claudeAiOauth: creds });
165
171
  try {
166
- await writeItem(isoTarget, installed);
172
+ await writeItem(isolatedTarget(dir), installed);
167
173
  writeFileSync(join(dir, ".claude.json"), JSON.stringify({ oauthAccount: account.oauthAccount, hasCompletedOnboarding: true }));
168
- const ping = opts.ping ? await pingSession(dir) : null;
169
- const usage = await probeUsage(dir, Date.now(), opts);
170
- const outcome: SampleOutcome = usage
171
- ? { ok: true, usage }
172
- : { ok: false, reason: "`/usage` returned no limit data (see log)" };
173
- if (ping != null) {
174
- outcome.pingError = ping.reason;
175
- outcome.pingRejected = ping.rejected;
174
+ } catch (e) {
175
+ await finishParkedProbe(account, { dir, installed });
176
+ throw e;
177
+ }
178
+ return { ok: true, dir, installed };
179
+ }
180
+
181
+ async function runParkedProbe(dir: string, opts: { retries?: number }): Promise<SampleOutcome> {
182
+ const usage = await probeUsage(dir, Date.now(), { retries: opts.retries });
183
+ return usage ? { ok: true, usage } : { ok: false, reason: "`/usage` returned no limit data (see log)" };
184
+ }
185
+
186
+ async function finishParkedProbe(account: Account, prepared: { dir: string; installed: string }): Promise<void> {
187
+ const isoTarget = isolatedTarget(prepared.dir);
188
+ const backup = parkedTarget(account.keychainItem);
189
+ try {
190
+ const afterIso = await readItem(isoTarget);
191
+ if (afterIso && afterIso !== prepared.installed) {
192
+ const parkedNow = await readItem(backup);
193
+ const installedToken = CredentialBlobSchema.parse(JSON.parse(prepared.installed)).claudeAiOauth.accessToken;
194
+ const parkedToken = parkedNow == null ? null : CredentialBlobSchema.parse(JSON.parse(parkedNow)).claudeAiOauth.accessToken;
195
+ if (parkedToken === installedToken) await writeItem(backup, claudeAiOauthOnly(afterIso));
196
+ else log("sample.harvest_skipped", { account: account.accountUuid.slice(0, 8) });
176
197
  }
177
- return outcome;
178
198
  } finally {
179
- try {
180
- if (opts.refreshParked !== false) {
181
- const afterIso = await readItem(isoTarget);
182
- if (afterIso && afterIso !== installed) await writeItem(backup, claudeAiOauthOnly(afterIso));
183
- }
184
- } finally {
185
- await deleteItem(isoTarget);
186
- rmSync(dir, { recursive: true, force: true });
199
+ await deleteItem(isoTarget);
200
+ rmSync(prepared.dir, { recursive: true, force: true });
201
+ }
202
+ }
203
+
204
+ export async function probeParkedUsage(account: Account): Promise<SampleOutcome> {
205
+ const prepared = await prepareParkedProbe(account, join(paths.sampleDir, credItemFor(account.accountUuid)));
206
+ if (!prepared.ok) return prepared;
207
+ try {
208
+ return await runParkedProbe(prepared.dir, {});
209
+ } finally {
210
+ await finishParkedProbe(account, prepared);
211
+ }
212
+ }
213
+
214
+ const PREPARE_DEADLINE_MS = 20_000;
215
+
216
+ export async function sampleOldestParked(cfg: Config): Promise<void> {
217
+ const reserved = await withLock(paths.lockFile, async () => {
218
+ const now = Date.now();
219
+ const lastSwapAt = loadLastSwapAt();
220
+ if (lastSwapAt != null && now - lastSwapAt < POST_SWAP_COOLDOWN_MS) return null;
221
+ const idx = loadAccounts();
222
+ const live = readOAuthAccount()?.accountUuid ?? null;
223
+ const sampledAt = (a: Account) => Math.max(a.lastUsageAt ?? 0, a.lastProbeAt ?? 0);
224
+ const stale = idx.accounts.filter((a) => a.accountUuid !== live && a.needsReauth !== true && now - sampledAt(a) > cfg.policy.usagePollTtlMs);
225
+ const target = minBy(stale, sampledAt);
226
+ if (!target) return null;
227
+ target.lastProbeAt = now;
228
+ const prepared = await prepareParkedProbe(target, join(paths.sampleDir, `${credItemFor(target.accountUuid)}-tick`), AbortSignal.timeout(PREPARE_DEADLINE_MS));
229
+ saveAccounts(idx);
230
+ if (!prepared.ok) {
231
+ log("sample.parked_failed", { account: target.accountUuid.slice(0, 8), reason: prepared.reason.slice(0, 200) });
232
+ return null;
187
233
  }
234
+ return { account: target, prepared };
235
+ });
236
+ if (!reserved) return;
237
+ const { account, prepared } = reserved;
238
+ const startedAt = Date.now();
239
+ let outcome: SampleOutcome | null = null;
240
+ try {
241
+ outcome = await runParkedProbe(prepared.dir, { retries: 0 });
242
+ } finally {
243
+ await withLock(paths.lockFile, async () => {
244
+ await finishParkedProbe(account, prepared);
245
+ if (outcome == null) return;
246
+ const idx = loadAccounts();
247
+ const stored = idx.accounts.find((a) => a.accountUuid === account.accountUuid);
248
+ if (stored && outcome.ok && (stored.lastUsageAt == null || startedAt > stored.lastUsageAt)) {
249
+ stored.lastUsage = { fiveHour: outcome.usage.session, sevenDay: outcome.usage.weekAll };
250
+ stored.lastUsageAt = startedAt;
251
+ if (Object.keys(outcome.usage.perModel).length > 0) {
252
+ stored.lastPerModel = outcome.usage.perModel;
253
+ stored.lastPerModelAt = startedAt;
254
+ }
255
+ saveAccounts(idx);
256
+ }
257
+ log(outcome.ok ? "sample.parked_ok" : "sample.parked_failed", {
258
+ account: account.accountUuid.slice(0, 8),
259
+ ...(outcome.ok ? {} : { reason: outcome.reason.slice(0, 200) }),
260
+ });
261
+ });
188
262
  }
189
263
  }
190
264
 
@@ -211,7 +285,7 @@ export async function ensureLiveTokenFresh(): Promise<void> {
211
285
  });
212
286
  }
213
287
 
214
- export async function probeActiveUsage(account: Account, opts: { ping?: boolean } = {}): Promise<SampleOutcome> {
288
+ export async function probeActiveUsage(account: Account): Promise<SampleOutcome> {
215
289
  try {
216
290
  await ensureLiveTokenFresh();
217
291
  } catch (e) {
@@ -232,12 +306,6 @@ export async function probeActiveUsage(account: Account, opts: { ping?: boolean
232
306
  if (identity.status === "unavailable") return { ok: false, reason: identity.reason };
233
307
  refreshPlanFields(account, creds);
234
308
 
235
- const ping = opts.ping ? await pingSession() : null;
236
309
  const usage = await probeUsage();
237
- const outcome: SampleOutcome = usage ? { ok: true, usage } : { ok: false, reason: "`/usage` returned no limit data (see log)" };
238
- if (ping != null) {
239
- outcome.pingError = ping.reason;
240
- outcome.pingRejected = ping.rejected;
241
- }
242
- return outcome;
310
+ return usage ? { ok: true, usage } : { ok: false, reason: "`/usage` returned no limit data (see log)" };
243
311
  }
package/src/lib/swap.ts CHANGED
@@ -1,4 +1,3 @@
1
- import { z } from "zod";
2
1
  import { clearDepletedWait, clearUsageSnapshots, loadAccounts, saveAccounts, saveLastSwapAt } from "./state.ts";
3
2
  import { readItem, writeItem, liveTarget, parkedTarget, claudeAiOauthOnly, mergeIntoLive } from "./credstore.ts";
4
3
  import { refreshCredential, isAccessTokenExpiring, isDeadCredential, fetchTokenIdentity, describeIdentity, IdentityUnavailableError, InvalidGrantError, RefreshRejectedError } from "./oauth.ts";
@@ -11,9 +10,6 @@ function parseBlob(raw: string) {
11
10
  return CredentialBlobSchema.parse(JSON.parse(raw));
12
11
  }
13
12
 
14
- const VerifyVerdictSchema = z.enum(["go", "rerank", "skip"]);
15
- export type VerifyVerdict = z.infer<typeof VerifyVerdictSchema>;
16
-
17
13
  export async function performSwap(target: Account): Promise<void> {
18
14
  const idx = loadAccounts();
19
15
 
package/src/lib/types.ts CHANGED
@@ -68,6 +68,7 @@ export const AccountSchema = z.object({
68
68
  lastPerModel: z.record(z.string(), UsageWindowSchema).optional(),
69
69
  lastPerModelAt: z.number().optional(),
70
70
  lastUsageAt: z.number().optional(),
71
+ lastProbeAt: z.number().optional(),
71
72
  enforcedUntil: z.number().optional(),
72
73
  needsReauth: z.boolean().optional(),
73
74
  subscriptionType: z.string().optional(),
package/src/lib/usage.ts CHANGED
@@ -1,10 +1,8 @@
1
- import { closeSync, existsSync, fstatSync, mkdirSync, openSync, readSync } from "node:fs";
2
- import { join } from "node:path";
1
+ import { closeSync, fstatSync, openSync, readSync } from "node:fs";
3
2
  import { delay } from "es-toolkit";
4
3
  import { z } from "zod";
5
4
  import { MAX_WRAP_DEPTH, WRAP_DEPTH_ENV, resolveRealClaude } from "./claudebin.ts";
6
5
  import { log } from "./log.ts";
7
- import { paths } from "./paths.ts";
8
6
  import { RateLimitsStdinSchema, UsageWindowSchema, type ModelInfo, type UsageWindow, type UsageWindows } from "./types.ts";
9
7
 
10
8
  export function normalizeResetsAt(v: unknown): number | null {
@@ -282,18 +280,9 @@ const SpawnResultSchema = z.object({
282
280
  });
283
281
  type SpawnResult = z.infer<typeof SpawnResultSchema>;
284
282
 
285
- async function spawnClaudeBounded(
286
- cmd: string[],
287
- env: Record<string, string>,
288
- cwd?: string,
289
- killMs = PROBE_KILL_MS,
290
- signal?: AbortSignal,
291
- ): Promise<SpawnResult | null> {
292
- signal?.throwIfAborted();
293
- const p = Bun.spawn(cmd, { env, cwd, stdout: "pipe", stderr: "pipe" });
294
- const abort = () => p.kill("SIGKILL");
295
- signal?.addEventListener("abort", abort, { once: true });
296
- const killer = setTimeout(() => p.kill("SIGKILL"), killMs);
283
+ async function spawnClaudeBounded(cmd: string[], env: Record<string, string>): Promise<SpawnResult | null> {
284
+ const p = Bun.spawn(cmd, { env, stdout: "pipe", stderr: "pipe" });
285
+ const killer = setTimeout(() => p.kill("SIGKILL"), PROBE_KILL_MS);
297
286
  try {
298
287
  const reads = Promise.all([new Response(p.stdout).text(), new Response(p.stderr).text()]);
299
288
  const settled = await Promise.race([
@@ -306,14 +295,13 @@ async function spawnClaudeBounded(
306
295
  return { exitCode: p.exitCode, stdout, stderr };
307
296
  } finally {
308
297
  clearTimeout(killer);
309
- signal?.removeEventListener("abort", abort);
310
298
  }
311
299
  }
312
300
 
313
- async function probeUsageOnce(env: Record<string, string>, now: number, killMs: number, signal?: AbortSignal): Promise<FullUsage | null> {
301
+ async function probeUsageOnce(env: Record<string, string>, now: number): Promise<FullUsage | null> {
314
302
  let out: string;
315
303
  try {
316
- const r = await spawnClaudeBounded([resolveRealClaude(), "-p", "/usage", "--output-format", "json"], env, undefined, killMs, signal);
304
+ const r = await spawnClaudeBounded([resolveRealClaude(), "-p", "/usage", "--output-format", "json"], env);
317
305
  if (r === null) {
318
306
  log("usage.probe_failed", { err: "output pipes still open after child exit (leaked descendant)" });
319
307
  return null;
@@ -351,81 +339,16 @@ function probeEnv(configDir?: string): Record<string, string> {
351
339
  return env;
352
340
  }
353
341
 
354
- export async function probeUsage(configDir?: string, now = Date.now(), opts: { retries?: number; killMs?: number; signal?: AbortSignal } = {}): Promise<FullUsage | null> {
342
+ export async function probeUsage(configDir?: string, now = Date.now(), opts: { retries?: number } = {}): Promise<FullUsage | null> {
355
343
  const env = probeEnv(configDir);
356
344
 
357
345
  for (let attempt = 0; ; attempt++) {
358
- const full = await probeUsageOnce(env, now, opts.killMs ?? PROBE_KILL_MS, opts.signal);
346
+ const full = await probeUsageOnce(env, now);
359
347
  if (full) return full;
360
- if (attempt >= (opts.retries ?? PROBE_RETRY_DELAYS_MS.length) || opts.signal?.aborted) {
348
+ if (attempt >= (opts.retries ?? PROBE_RETRY_DELAYS_MS.length)) {
361
349
  log("usage.probe_gave_up", { attempts: attempt + 1 });
362
350
  return null;
363
351
  }
364
352
  await delay(PROBE_RETRY_DELAYS_MS[Math.min(attempt, PROBE_RETRY_DELAYS_MS.length - 1)]!);
365
353
  }
366
354
  }
367
-
368
- const PING_ARGS = [
369
- "-p", "Reply with exactly: ok",
370
- "--model", "haiku",
371
- "--settings", '{"disableAllHooks":true}',
372
- "--output-format", "json",
373
- ];
374
-
375
- const PingResultSchema = z.looseObject({ is_error: z.boolean().optional(), result: z.string().optional(), session_id: z.string().optional() });
376
-
377
- export const PingFailureSchema = z.object({ reason: z.string(), rejected: z.boolean() });
378
- export type PingFailure = z.infer<typeof PingFailureSchema>;
379
-
380
- export function transcriptSlug(cwd: string): string {
381
- return cwd.replace(/[^a-zA-Z0-9]/g, "-");
382
- }
383
-
384
- export function pingTranscriptPath(input: { configDir: string; cwd: string; sessionId: string }): string {
385
- return join(input.configDir, "projects", transcriptSlug(input.cwd), `${input.sessionId}.jsonl`);
386
- }
387
-
388
- const LIMIT_LABELS: Record<string, string> = { five_hour: "5h", seven_day: "weekly" };
389
-
390
- export function pingRejection(rows: TranscriptRow[]): string | null {
391
- const row = rows.findLast((r) => r.isApiErrorMessage === true);
392
- if (!row || row.error !== "rate_limit" || row.apiErrorIsTransient === true) return null;
393
- const type = row.quotaLimits?.rateLimitType ?? "";
394
- if (type === "" && parseErrorBody(row.errorDetails)?.error?.type !== "rate_limit_error") return null;
395
- const label = LIMIT_LABELS[type] ?? (type !== "" ? type.replace(/_/g, " ") : "usage");
396
- const text = transcriptRowText(row);
397
- return `${label} limit${text ? `: ${text}` : ""}`;
398
- }
399
-
400
- export async function pingSession(configDir?: string): Promise<PingFailure | null> {
401
- const cwd = join(paths.sampleDir, "ping-cwd");
402
- const fail = (reason: string, rejected = false): PingFailure => {
403
- log("usage.ping_failed", { dir: configDir ?? "live", rejected, reason: reason.slice(0, 200) });
404
- return { reason, rejected };
405
- };
406
- let r: SpawnResult | null;
407
- try {
408
- mkdirSync(cwd, { recursive: true });
409
- r = await spawnClaudeBounded([resolveRealClaude(), ...PING_ARGS], probeEnv(configDir), cwd);
410
- } catch (e) {
411
- return fail(e instanceof Error ? e.message : String(e));
412
- }
413
- if (r === null) return fail("output pipes still open after child exit (leaked descendant)");
414
- const parsed = PingResultSchema.safeParse((() => { try { return JSON.parse(r.stdout); } catch { return null; } })());
415
- const result = parsed.success ? parsed.data : null;
416
- if (r.exitCode === 0 && result?.is_error === false) {
417
- log("usage.ping_ok", { dir: configDir ?? "live" });
418
- return null;
419
- }
420
- if (result?.session_id) {
421
- const transcript = pingTranscriptPath({ configDir: configDir ?? paths.claudeDir, cwd, sessionId: result.session_id });
422
- const rejection = existsSync(transcript) ? pingRejection(readTranscriptTail(transcript)) : null;
423
- if (rejection) return fail(`rejected at the ${rejection}`.slice(0, 200), true);
424
- }
425
- if (r.exitCode !== 0) {
426
- const detail = result?.result?.trim() || r.stderr.trim() || r.stdout.trim();
427
- return fail(`claude exited ${r.exitCode ?? "on signal"}: ${detail.slice(0, 160)}`);
428
- }
429
- if (result?.is_error === true) return fail((result.result?.trim() || "request errored").slice(0, 160));
430
- return fail(`unrecognized ping output: ${r.stdout.trim().slice(0, 120)}`);
431
- }
package/src/main.ts CHANGED
@@ -45,8 +45,7 @@ function printHelp(): void {
45
45
  ${c.cyan("tokenmaxxing switch --codex")} [sel] switch the codex pool (takes effect on next codex start)
46
46
  ${c.cyan("tokenmaxxing ls")} list pooled accounts
47
47
  ${c.cyan("tokenmaxxing status")} accounts with 5h / weekly / per-model usage bars
48
- ${c.cyan("tokenmaxxing status --ping")} [--count N] ping every account (one tiny haiku request each) so all 5h session timers start now, then sample fresh; ${c.cyan("--count N")} pings N randomly picked accounts instead, to stagger the resets; ${c.cyan("xx --ping")} works too
49
- ${c.cyan("tokenmaxxing watch")} [seconds] live status: re-render every N seconds (default 120, never pings)
48
+ ${c.cyan("tokenmaxxing watch")} [seconds] live status: re-render every N seconds (default 120)
50
49
  ${c.cyan("tokenmaxxing config")} [get|set|unset|tidy] inspect and edit config.json (bare = effective config with sources)
51
50
  ${c.cyan("tokenmaxxing doctor")} verify the install is intact
52
51
  ${c.cyan("tokenmaxxing rename")} [--codex] <sel> <label>
@@ -60,28 +59,6 @@ function printHelp(): void {
60
59
 
61
60
  let jsonMode = false;
62
61
 
63
- function statusFlags(rest: string[]): { ping: boolean; pingCount: number | undefined } | { error: string } {
64
- let ping = false;
65
- let pingCount: number | undefined;
66
- for (let i = 0; i < rest.length; i++) {
67
- const arg = rest[i]!;
68
- if (arg === "--ping") {
69
- ping = true;
70
- continue;
71
- }
72
- if (arg === "--count") {
73
- if (pingCount != null) return { error: "--count may only be given once" };
74
- const raw = rest[++i];
75
- pingCount = Number(raw);
76
- if (!Number.isInteger(pingCount) || pingCount < 1) return { error: `--count needs a positive whole number of accounts, got: ${raw ?? "nothing"}` };
77
- continue;
78
- }
79
- return { error: `unknown status option: ${arg} (status takes --ping and --count N)` };
80
- }
81
- if (pingCount != null && !ping) return { error: "--count only applies together with --ping" };
82
- return { ping, pingCount };
83
- }
84
-
85
62
  async function main(): Promise<number> {
86
63
  if (process.platform !== "darwin" && process.platform !== "linux") {
87
64
  console.error(`tokenmaxxing supports macOS and Linux only (this is ${process.platform})`);
@@ -126,15 +103,13 @@ async function main(): Promise<number> {
126
103
  case "__session-start": return runSessionStart();
127
104
  case "__codex-stop-hook": return runCodexStopHook();
128
105
  case undefined:
129
- case "status":
130
- case "--ping":
131
- case "--count": {
132
- const flags = statusFlags(sub === "status" ? args.slice(1) : args);
133
- if ("error" in flags) {
134
- emitError({ json, message: flags.error });
106
+ case "status": {
107
+ const extra = args[1];
108
+ if (extra != null) {
109
+ emitError({ json, message: `unknown status option: ${extra} (status takes no options)` });
135
110
  return 2;
136
111
  }
137
- return cmdStatus({ ...flags, json });
112
+ return cmdStatus({ json });
138
113
  }
139
114
  case "switch": {
140
115
  const rest = args.slice(1).filter((a) => a !== "--codex");