tokenmaxxing 0.8.0 → 0.9.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/README.md CHANGED
@@ -39,6 +39,7 @@ claude # use claude as always
39
39
  | `tokenmaxxing add` | register an additional account (isolated login, harvested into the pool) |
40
40
  | `tokenmaxxing ls` | list pooled accounts |
41
41
  | `tokenmaxxing status` | accounts with 5h / weekly usage bars, active + exhausted-until-reset |
42
+ | `tokenmaxxing status --force` | additionally ping every account (one tiny haiku request each) so all 5h session timers start now, then sample fresh |
42
43
  | `tokenmaxxing doctor` | verify the supervisor + settings entries survived |
43
44
  | `tokenmaxxing rename <sel> <label>` · `rm <sel>` | manage the pool |
44
45
  | `tokenmaxxing uninstall` | remove supervisor + settings entries (accounts/credentials kept) |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tokenmaxxing",
3
- "version": "0.8.0",
3
+ "version": "0.9.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",
@@ -21,7 +21,10 @@
21
21
  "engines": {
22
22
  "bun": ">=1.2.6"
23
23
  },
24
- "os": ["darwin", "linux"],
24
+ "os": [
25
+ "darwin",
26
+ "linux"
27
+ ],
25
28
  "scripts": {
26
29
  "dev": "bun run src/main.ts",
27
30
  "test": "bun test",
package/src/cli/init.ts CHANGED
@@ -30,6 +30,20 @@ function reportTimer(out: InstallOutcome): void {
30
30
  else console.log(c.yellow(`⚠ check timer written but not activated - run: ${timerActivationHint()}`));
31
31
  }
32
32
 
33
+ /** The how-to-use epilogue both init paths end on: the whole point of the tool
34
+ * is that after init you just run `claude`, so say exactly that, and teach the
35
+ * `xx` shorthand every other command hangs off. Exported for the render test. */
36
+ export function printUsage(): void {
37
+ console.log();
38
+ console.log(` ${c.bold("how to use")} - ${c.cyan("xx")} is shorthand for ${c.cyan("tokenmaxxing")}:`);
39
+ console.log(` ${c.cyan("claude")} use claude as always; it switches accounts near quota automatically`);
40
+ console.log(` ${c.cyan("xx")} show the pool with usage bars (same as ${c.cyan("xx status")})`);
41
+ console.log(` ${c.cyan("xx status --force")} ping every account (one tiny haiku request each) so all 5h timers start now, then sample fresh`);
42
+ console.log(` ${c.cyan("xx add")} log in and pool another account`);
43
+ console.log(` ${c.cyan("xx switch")} hop to the best account right now (the automatic switching needs no command)`);
44
+ console.log(` ${c.cyan("xx help")} everything else`);
45
+ }
46
+
33
47
  export async function cmdInit(): Promise<number> {
34
48
  mkdirSync(paths.home, { recursive: true });
35
49
 
@@ -50,7 +64,8 @@ export async function cmdInit(): Promise<number> {
50
64
  console.log(`${c.green("✓")} re-installed supervisor + hooks (pool already has ${existingIdx.accounts.length} account${existingIdx.accounts.length === 1 ? "" : "s"} - not re-importing)`);
51
65
  reportTimer(out);
52
66
  if (!out.pathAhead) ensurePathAhead();
53
- console.log(` active: ${c.bold(active?.label ?? "unknown")} · run ${c.cyan("tokenmaxxing add")} for more, ${c.cyan("tokenmaxxing status")} to check`);
67
+ console.log(` active: ${c.bold(active?.label ?? "unknown")}`);
68
+ printUsage();
54
69
  return 0;
55
70
  }
56
71
 
@@ -127,6 +142,7 @@ export async function cmdInit(): Promise<number> {
127
142
  ensurePathAhead();
128
143
  }
129
144
  console.log();
130
- console.log(` pool ready (${idx.accounts.length} account${idx.accounts.length === 1 ? "" : "s"}) · add more with ${c.cyan("tokenmaxxing add")}`);
145
+ console.log(` pool ready (${idx.accounts.length} account${idx.accounts.length === 1 ? "" : "s"})`);
146
+ printUsage();
131
147
  return 0;
132
148
  }
package/src/cli/status.ts CHANGED
@@ -4,6 +4,13 @@
4
4
  // own busy token. A sample that fails falls back to the last-known values with a
5
5
  // visible "(cached)" note - never a silent stale number. Fresh figures are
6
6
  // persisted onto each account for the picker/switch logic.
7
+ //
8
+ // `--force` additionally PINGS every account (one minimal haiku request each)
9
+ // before sampling, so every account's 5h session window starts ticking NOW
10
+ // instead of lying dormant until first real use, and every bar is a live probe
11
+ // taken after the ping. The active account still prefers the tee only as a
12
+ // fallback: its own `/usage` fail-silents exactly when a live session is
13
+ // running it, and that session's tee is fresher than any cache.
7
14
 
8
15
  import { loadAccounts, loadConfig, loadUsage, loadModelUsage, saveAccounts } from "../lib/state.ts";
9
16
  import { readOAuthAccount } from "../lib/claudejson.ts";
@@ -15,7 +22,7 @@ import { bar, c, fmtAgo, fmtReset } from "./render.ts";
15
22
  import type { FullUsage } from "../lib/usage.ts";
16
23
  import type { UsageWindow } from "../lib/types.ts";
17
24
 
18
- export async function cmdStatus(): Promise<number> {
25
+ export async function cmdStatus(force = false): Promise<number> {
19
26
  let idx = loadAccounts();
20
27
  const cfg = loadConfig();
21
28
  const now = Date.now();
@@ -28,7 +35,7 @@ export async function cmdStatus(): Promise<number> {
28
35
  // Load, sample, and save entirely under the flock: parked refreshes must not
29
36
  // collide with an in-flight swap, and a save of an index loaded before a
30
37
  // concurrent swap would clobber the swap's activeAccountUuid.
31
- console.error(c.dim("sampling live usage..."));
38
+ console.error(c.dim(force ? "pinging every account (starts each 5h session timer) + sampling live usage..." : "sampling live usage..."));
32
39
  const outcomes = new Map<string, SampleOutcome>();
33
40
  await withLock(paths.lockFile, async () => {
34
41
  idx = loadAccounts();
@@ -49,18 +56,34 @@ export async function cmdStatus(): Promise<number> {
49
56
  perModel: modelUsage && modelUsage.org === a.organizationUuid ? modelUsage.perModel : {},
50
57
  }
51
58
  : null;
52
- const outcome: SampleOutcome = fromStatusLine
53
- ? { ok: true, usage: fromStatusLine }
54
- : isActive
55
- ? await probeActiveUsage(a)
56
- : await probeParkedUsage(a);
59
+ let viaTee = false;
60
+ let outcome: SampleOutcome;
61
+ if (force) {
62
+ // Force: ping + live probe for everyone. The tee predates the ping,
63
+ // so it serves only as the active account's fallback when its own
64
+ // `/usage` fail-silents (a running session's tee is still fresh).
65
+ outcome = isActive ? await probeActiveUsage(a, { ping: true }) : await probeParkedUsage(a, { ping: true });
66
+ if (!outcome.ok && fromStatusLine) {
67
+ const failed = outcome;
68
+ outcome = { ok: true, usage: fromStatusLine };
69
+ if (failed.pingError != null) outcome.pingError = failed.pingError;
70
+ viaTee = true;
71
+ }
72
+ } else {
73
+ viaTee = fromStatusLine != null;
74
+ outcome = fromStatusLine
75
+ ? { ok: true, usage: fromStatusLine }
76
+ : isActive
77
+ ? await probeActiveUsage(a)
78
+ : await probeParkedUsage(a);
79
+ }
57
80
  outcomes.set(a.accountUuid, outcome);
58
81
  if (!outcome.ok) return;
59
82
  a.lastUsage = { fiveHour: outcome.usage.session, sevenDay: outcome.usage.weekAll };
60
83
  if (Object.keys(outcome.usage.perModel).length > 0) a.lastPerModel = outcome.usage.perModel;
61
84
  // stamp when the figures were actually measured: the statusLine tee's
62
85
  // own write time for the push-fed active account, else the probe time.
63
- a.lastUsageAt = fromStatusLine && live ? live.ts : Date.now();
86
+ a.lastUsageAt = viaTee && live ? live.ts : Date.now();
64
87
  }),
65
88
  );
66
89
  saveAccounts(idx);
@@ -105,6 +128,16 @@ export async function cmdStatus(): Promise<number> {
105
128
  const cached = aggregate || perModel ? `cached${a.lastUsageAt != null ? ` ${fmtAgo(a.lastUsageAt, now)}` : ""} · ` : "";
106
129
  console.log(` ${c.yellow(`${cached}live sample failed`)}: ${c.dim(outcome.reason)}`);
107
130
  }
131
+ if (outcome?.pingError != null) {
132
+ console.log(` ${c.yellow("ping failed (5h timer may not have started)")}: ${c.dim(outcome.pingError)}`);
133
+ }
134
+ // A successful ping ALWAYS opens the 5h window (live-verified 2026-07-16),
135
+ // but the server's usage feed reflects it with a lag of up to a few
136
+ // minutes, so a probe taken seconds later can still show a dormant window.
137
+ // Say so rather than looking like the ping did nothing.
138
+ if (force && outcome?.ok && outcome.pingError == null && aggregate && aggregate.fiveHour.resetsAt == null) {
139
+ console.log(` ${c.dim("pinged - 5h timer started this run; the usage feed lags, re-run status shortly for the fresh window")}`);
140
+ }
108
141
  console.log();
109
142
  }
110
143
  return 0;
package/src/lib/sample.ts CHANGED
@@ -25,13 +25,15 @@ import { readItem, writeItem, deleteItem, liveTarget, parkedTarget, isolatedTarg
25
25
  import { credItemFor, paths } from "./paths.ts";
26
26
  import { withClaudeRefreshLock } from "./claudelock.ts";
27
27
  import { refreshCredential, isAccessTokenExpiring, fetchTokenOrg, InvalidGrantError } from "./oauth.ts";
28
- import { FullUsageSchema, probeUsage } from "./usage.ts";
28
+ import { FullUsageSchema, pingSession, probeUsage } from "./usage.ts";
29
29
  import { CredentialBlobSchema, type Account, type OAuthCreds, type RolesResponse } from "./types.ts";
30
30
 
31
- /** Result of a live sample: the fresh usage, or why it could not be taken. */
31
+ /** Result of a live sample: the fresh usage, or why it could not be taken.
32
+ * `pingError` is set only when a requested ping (status --force) failed - the
33
+ * account's 5h timer may not have started even if the sample itself succeeded. */
32
34
  export const SampleOutcomeSchema = z.discriminatedUnion("ok", [
33
- z.object({ ok: z.literal(true), usage: FullUsageSchema }),
34
- z.object({ ok: z.literal(false), reason: z.string() }),
35
+ z.object({ ok: z.literal(true), usage: FullUsageSchema, pingError: z.string().optional() }),
36
+ z.object({ ok: z.literal(false), reason: z.string(), pingError: z.string().optional() }),
35
37
  ]);
36
38
  export type SampleOutcome = z.infer<typeof SampleOutcomeSchema>;
37
39
 
@@ -51,8 +53,11 @@ async function identityMismatch(creds: OAuthCreds, account: Account): Promise<st
51
53
  * Live-sample `account`'s `/usage` in isolation. On a dead refresh token or a
52
54
  * mislabeled credential it sets `account.needsReauth` in place (the caller
53
55
  * persists accounts.json). Mutates only the passed object and keychain items.
56
+ * With `ping`, one minimal metered request runs first (through the same
57
+ * isolated credential) so the account's 5h session window starts now and the
58
+ * sample that follows reports the freshly opened window.
54
59
  */
55
- export async function probeParkedUsage(account: Account): Promise<SampleOutcome> {
60
+ export async function probeParkedUsage(account: Account, opts: { ping?: boolean } = {}): Promise<SampleOutcome> {
56
61
  const backup = parkedTarget(account.keychainItem);
57
62
  const parkedRaw = await readItem(backup);
58
63
  if (!parkedRaw) return { ok: false, reason: "no parked credential - re-add with `tokenmaxxing add`" };
@@ -94,8 +99,13 @@ export async function probeParkedUsage(account: Account): Promise<SampleOutcome>
94
99
  try {
95
100
  await writeItem(isoTarget, installed);
96
101
  writeFileSync(join(dir, ".claude.json"), JSON.stringify({ oauthAccount: account.oauthAccount, hasCompletedOnboarding: true }));
102
+ const pingError = opts.ping ? await pingSession(dir) : null;
97
103
  const usage = await probeUsage(dir);
98
- return usage ? { ok: true, usage } : { ok: false, reason: "`/usage` returned no limit data (see log)" };
104
+ const outcome: SampleOutcome = usage
105
+ ? { ok: true, usage }
106
+ : { ok: false, reason: "`/usage` returned no limit data (see log)" };
107
+ if (pingError != null) outcome.pingError = pingError;
108
+ return outcome;
99
109
  } finally {
100
110
  // capture-before-delete: never discard a rotation claude may have performed.
101
111
  const afterIso = await readItem(isoTarget);
@@ -109,9 +119,10 @@ export async function probeParkedUsage(account: Account): Promise<SampleOutcome>
109
119
  * Live-sample the ACTIVE account off the live login, verifying the live
110
120
  * credential belongs to it. `/usage` with no CLAUDE_CONFIG_DIR meters the live
111
121
  * keychain item. A drifted active label surfaces as an error, not another
112
- * account's bars.
122
+ * account's bars. With `ping`, one minimal metered request runs first (after
123
+ * the identity check - never spend quota on a drifted credential).
113
124
  */
114
- export async function probeActiveUsage(account: Account): Promise<SampleOutcome> {
125
+ export async function probeActiveUsage(account: Account, opts: { ping?: boolean } = {}): Promise<SampleOutcome> {
115
126
  const liveRaw = await readItem(liveTarget());
116
127
  if (!liveRaw) return { ok: false, reason: "no live credential - run `claude` and `/login`" };
117
128
  let creds: OAuthCreds;
@@ -139,6 +150,9 @@ export async function probeActiveUsage(account: Account): Promise<SampleOutcome>
139
150
  const mismatch = await identityMismatch(creds, account);
140
151
  if (mismatch) return { ok: false, reason: `live ${mismatch} - active label drifted; run \`tokenmaxxing switch\`` };
141
152
 
153
+ const pingError = opts.ping ? await pingSession() : null;
142
154
  const usage = await probeUsage();
143
- return usage ? { ok: true, usage } : { ok: false, reason: "`/usage` returned no limit data (see log)" };
155
+ const outcome: SampleOutcome = usage ? { ok: true, usage } : { ok: false, reason: "`/usage` returned no limit data (see log)" };
156
+ if (pingError != null) outcome.pingError = pingError;
157
+ return outcome;
144
158
  }
package/src/lib/usage.ts CHANGED
@@ -4,10 +4,13 @@
4
4
  // figures claude's own usage screen shows; we run it in a throwaway
5
5
  // CLAUDE_CONFIG_DIR to sample a parked account without disturbing the live login.
6
6
 
7
+ import { mkdirSync } from "node:fs";
8
+ import { join } from "node:path";
7
9
  import { delay } from "es-toolkit";
8
10
  import { z } from "zod";
9
11
  import { MAX_WRAP_DEPTH, WRAP_DEPTH_ENV, resolveRealClaude } from "./claudebin.ts";
10
12
  import { log } from "./log.ts";
13
+ import { paths } from "./paths.ts";
11
14
  import { RateLimitsStdinSchema, UsageWindowSchema, type ModelInfo, type UsageWindow, type UsageWindows } from "./types.ts";
12
15
 
13
16
  /** Normalize a resets_at value (epoch s, epoch ms, or ISO string) to epoch ms. */
@@ -215,41 +218,56 @@ const PROBE_KILL_MS = 60_000;
215
218
  /** How long after the child's death to keep waiting for pipe EOF. */
216
219
  const PIPE_GRACE_MS = 2_000;
217
220
 
221
+ const SpawnResultSchema = z.object({
222
+ exitCode: z.number().nullable(),
223
+ stdout: z.string(),
224
+ stderr: z.string(),
225
+ });
226
+ type SpawnResult = z.infer<typeof SpawnResultSchema>;
227
+
228
+ /** Spawn one bounded claude invocation (shared by the `/usage` probe and the
229
+ * `--force` ping). SIGKILL after PROBE_KILL_MS: claude traps SIGTERM, and a
230
+ * wedged child that survives the kill would keep p.exited pending and re-wedge
231
+ * the read race. Descendants inherit the output pipes, so EOF can lag the
232
+ * child's death or never arrive at all - a leaked grandchild holding the pipe
233
+ * wedged the 2026-07-12 probes forever, defeating the kill guard - so the
234
+ * reads are bounded by child-exit + grace instead of awaiting EOF
235
+ * unconditionally. Returns null when the pipes were withheld past the grace. */
236
+ async function spawnClaudeBounded(
237
+ cmd: string[],
238
+ env: Record<string, string>,
239
+ cwd?: string,
240
+ ): Promise<SpawnResult | null> {
241
+ const p = Bun.spawn(cmd, { env, cwd, stdout: "pipe", stderr: "pipe" });
242
+ const killer = setTimeout(() => p.kill("SIGKILL"), PROBE_KILL_MS);
243
+ try {
244
+ const reads = Promise.all([new Response(p.stdout).text(), new Response(p.stderr).text()]);
245
+ const settled = await Promise.race([
246
+ reads,
247
+ p.exited.then(() => delay(PIPE_GRACE_MS)).then(() => null),
248
+ ]);
249
+ if (settled === null) return null;
250
+ const [stdout, stderr] = settled;
251
+ await p.exited;
252
+ return { exitCode: p.exitCode, stdout, stderr };
253
+ } finally {
254
+ clearTimeout(killer);
255
+ }
256
+ }
257
+
218
258
  async function probeUsageOnce(env: Record<string, string>, now: number): Promise<FullUsage | null> {
219
259
  let out: string;
220
260
  try {
221
- const p = Bun.spawn([resolveRealClaude(), "-p", "/usage", "--output-format", "json"], {
222
- env,
223
- stdout: "pipe",
224
- stderr: "pipe",
225
- });
226
- // SIGKILL: claude traps SIGTERM, and a wedged probe child that survives the
227
- // kill would keep p.exited pending and re-wedge the read race below.
228
- const killer = setTimeout(() => p.kill("SIGKILL"), PROBE_KILL_MS);
229
- try {
230
- // Descendants inherit the output pipes, so EOF can lag the child's death
231
- // or never arrive at all - a leaked grandchild holding the pipe wedged
232
- // the 2026-07-12 probes forever, defeating the kill guard above. Bound
233
- // the reads by child-exit + grace instead of awaiting EOF unconditionally.
234
- const reads = Promise.all([new Response(p.stdout).text(), new Response(p.stderr).text()]);
235
- const settled = await Promise.race([
236
- reads,
237
- p.exited.then(() => delay(PIPE_GRACE_MS)).then(() => null),
238
- ]);
239
- if (settled === null) {
240
- log("usage.probe_failed", { err: "output pipes still open after child exit (leaked descendant)" });
241
- return null;
242
- }
243
- const [text, errText] = settled;
244
- await p.exited;
245
- if (p.exitCode !== 0) {
246
- log("usage.probe_failed", { exit: p.exitCode ?? "signal", stderr: errText.trim().slice(0, 200) });
247
- return null;
248
- }
249
- out = text;
250
- } finally {
251
- clearTimeout(killer);
261
+ const r = await spawnClaudeBounded([resolveRealClaude(), "-p", "/usage", "--output-format", "json"], env);
262
+ if (r === null) {
263
+ log("usage.probe_failed", { err: "output pipes still open after child exit (leaked descendant)" });
264
+ return null;
265
+ }
266
+ if (r.exitCode !== 0) {
267
+ log("usage.probe_failed", { exit: r.exitCode ?? "signal", stderr: r.stderr.trim().slice(0, 200) });
268
+ return null;
252
269
  }
270
+ out = r.stdout;
253
271
  } catch (e) {
254
272
  log("usage.probe_failed", { err: String((e as Error).message ?? e) });
255
273
  return null;
@@ -285,14 +303,20 @@ const PROBE_RETRY_DELAYS_MS = [2000, 5000];
285
303
  * keychain item. The empty-footer case (claude's own usage call throttled) is
286
304
  * transient, so retry with backoff. Returns null if it never yields data.
287
305
  */
288
- export async function probeUsage(configDir?: string, now = Date.now()): Promise<FullUsage | null> {
289
- // The probe spawns the real claude DIRECTLY - it never legitimately passes
290
- // through the wrapper again. Preset the depth to the cap so a poisoned pin
291
- // that leads back to the wrapper aborts on its first entry (the 2026-07-12
292
- // ~1800-process recursion started as exactly this probe).
306
+ /** The scrubbed env every probe/ping spawn uses. The child spawns the real
307
+ * claude DIRECTLY - it never legitimately passes through the wrapper again -
308
+ * so the depth is preset to the cap and a poisoned pin that leads back to the
309
+ * wrapper aborts on its first entry (the 2026-07-12 ~1800-process recursion
310
+ * started as exactly this probe). */
311
+ function probeEnv(configDir?: string): Record<string, string> {
293
312
  const env: Record<string, string> = { ...process.env, TOKENMAXXING_PROBE: "1", [WRAP_DEPTH_ENV]: String(MAX_WRAP_DEPTH) };
294
313
  for (const k of CRED_ENV_OVERRIDES) delete env[k];
295
314
  if (configDir) env.CLAUDE_CONFIG_DIR = configDir;
315
+ return env;
316
+ }
317
+
318
+ export async function probeUsage(configDir?: string, now = Date.now()): Promise<FullUsage | null> {
319
+ const env = probeEnv(configDir);
296
320
 
297
321
  for (let attempt = 0; ; attempt++) {
298
322
  const full = await probeUsageOnce(env, now);
@@ -304,3 +328,53 @@ export async function probeUsage(configDir?: string, now = Date.now()): Promise<
304
328
  await delay(PROBE_RETRY_DELAYS_MS[attempt]!);
305
329
  }
306
330
  }
331
+
332
+ // ---- `--force` ping --------------------------------------------------------
333
+
334
+ /** The ping is a REAL (but minimal) inference request: `/usage` is free and
335
+ * starts nothing, while any metered request opens the account's 5h session
336
+ * window at the current instant. haiku: the cheapest model (verified $1/$5 per
337
+ * MTok vs $3+ for every other current tier), and one with no per-model weekly
338
+ * cap (those exist only for Sonnet and Fable), so a ping never adds to a
339
+ * per-model cap the policy gates on; its dent in the aggregate 5h/7d windows
340
+ * is negligible. Hooks are disabled for the nested call
341
+ * (`--settings`, the only supported way; `--bare` would kill keychain reads)
342
+ * even though our own hooks already no-op on the probe env. */
343
+ const PING_ARGS = [
344
+ "-p", "Reply with exactly: ok",
345
+ "--model", "haiku",
346
+ "--settings", '{"disableAllHooks":true}',
347
+ "--output-format", "json",
348
+ ];
349
+
350
+ const PingResultSchema = z.looseObject({ is_error: z.boolean(), result: z.string().optional() });
351
+
352
+ /**
353
+ * Send one minimal metered request so the account's 5h session window starts
354
+ * NOW instead of lying dormant until first real use. Pass `configDir` to ping a
355
+ * parked account through its isolated dir (credential already installed); omit
356
+ * to ping the live login. Runs from an empty scratch cwd so no project context
357
+ * (CLAUDE.md, project settings) inflates the request. Returns null on success,
358
+ * else the failure reason.
359
+ */
360
+ export async function pingSession(configDir?: string): Promise<string | null> {
361
+ const cwd = join(paths.sampleDir, "ping-cwd");
362
+ const fail = (reason: string): string => {
363
+ log("usage.ping_failed", { dir: configDir ?? "live", reason: reason.slice(0, 200) });
364
+ return reason;
365
+ };
366
+ let r: SpawnResult | null;
367
+ try {
368
+ mkdirSync(cwd, { recursive: true });
369
+ r = await spawnClaudeBounded([resolveRealClaude(), ...PING_ARGS], probeEnv(configDir), cwd);
370
+ } catch (e) {
371
+ return fail(String((e as Error).message ?? e));
372
+ }
373
+ if (r === null) return fail("output pipes still open after child exit (leaked descendant)");
374
+ if (r.exitCode !== 0) return fail(`claude exited ${r.exitCode ?? "on signal"}: ${(r.stderr.trim() || r.stdout.trim()).slice(0, 160)}`);
375
+ const parsed = PingResultSchema.safeParse((() => { try { return JSON.parse(r.stdout); } catch { return null; } })());
376
+ if (!parsed.success) return fail(`unrecognized ping output: ${r.stdout.trim().slice(0, 120)}`);
377
+ if (parsed.data.is_error) return fail((parsed.data.result?.trim() || "request errored").slice(0, 160));
378
+ log("usage.ping_ok", { dir: configDir ?? "live" });
379
+ return null;
380
+ }
package/src/main.ts CHANGED
@@ -30,6 +30,7 @@ function printHelp(): void {
30
30
  ${c.cyan("tokenmaxxing add")} register an additional account (isolated login)
31
31
  ${c.cyan("tokenmaxxing ls")} list pooled accounts
32
32
  ${c.cyan("tokenmaxxing status")} accounts with 5h / weekly / per-model usage bars
33
+ ${c.cyan("tokenmaxxing status --force")} ping every account (one tiny haiku request each) so all 5h session timers start now, then sample fresh; ${c.cyan("xx --force")} works too
33
34
  ${c.cyan("tokenmaxxing doctor")} verify the install is intact
34
35
  ${c.cyan("tokenmaxxing rename")} <sel> <label>
35
36
  ${c.cyan("tokenmaxxing rm")} <sel>
@@ -57,12 +58,13 @@ async function main(): Promise<number> {
57
58
  case "__stop-hook": return runStopHook();
58
59
  case "__session-start": return runSessionStart();
59
60
  case undefined: return cmdStatus(); // bare `tokenmaxxing` / `xx` → status
61
+ case "--force": return cmdStatus(true); // bare `xx --force` → status --force
60
62
  case "switch": return cmdSwitch(args[1]);
61
63
  case "check": return cmdCheck();
62
64
  case "init": return cmdInit();
63
65
  case "add": return cmdAdd();
64
66
  case "ls": return cmdLs();
65
- case "status": return cmdStatus();
67
+ case "status": return cmdStatus(args.includes("--force"));
66
68
  case "doctor": return cmdDoctor();
67
69
  case "rm": return cmdRm(args[1]);
68
70
  case "rename": return cmdRename(args[1], args[2]);