tokenmaxxing 1.13.0 → 1.13.1

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.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
3
3
  "name": "tokenmaxxing",
4
- "version": "1.13.0",
4
+ "version": "1.13.1",
5
5
  "description": "Pool Claude Code and Codex logins, switch on pace pressure, and expose safe ops tools to agent clients.",
6
6
  "author": {
7
7
  "name": "anaclumos",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tokenmaxxing",
3
- "version": "1.13.0",
3
+ "version": "1.13.1",
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/status.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { sortBy } from "es-toolkit";
2
2
  import { z } from "zod";
3
- import { loadAccounts, loadConfig, loadUsage, loadModelUsage, saveAccounts } from "../lib/state.ts";
3
+ import { loadAccounts, loadConfig, loadUsage, loadUsageSnapshot, loadModelUsage, saveAccounts } from "../lib/state.ts";
4
4
  import { readOAuthAccount } from "../lib/claudejson.ts";
5
5
  import { ensureLiveTokenFresh, probeActiveUsage, probeParkedUsage, type SampleOutcome } from "../lib/sample.ts";
6
6
  import { withLock } from "../lib/lock.ts";
@@ -33,6 +33,7 @@ const ClaudeStatusAccountSchema = z.object({
33
33
  usageAt: z.number().nullable(),
34
34
  sample: SampleReportSchema,
35
35
  pingError: z.string().nullable(),
36
+ pingRejected: z.boolean(),
36
37
  pinged: z.boolean(),
37
38
  });
38
39
  type ClaudeStatusAccount = z.infer<typeof ClaudeStatusAccountSchema>;
@@ -93,15 +94,18 @@ async function collectClaude(input: { cfg: Config; force: boolean; now: number }
93
94
  console.error(c.dim(force ? "pinging every account (starts each 5h session timer) + sampling live usage..." : "sampling live usage..."));
94
95
  await withLock(paths.lockFile, async () => {
95
96
  idx = loadAccounts();
96
- const live = loadUsage();
97
+ const tee = loadUsageSnapshot();
98
+ const live = tee?.state ?? null;
99
+ const teeAt = tee?.at ?? null;
97
100
  const modelUsage = loadModelUsage();
98
101
  const liveOAuth = readOAuthAccount();
99
102
  const activeOrg = liveOAuth?.organizationUuid ?? null;
100
103
  const probeOne = async (a: Account) => {
101
104
  const isActive = activeOrg != null && activeOrg === a.organizationUuid;
102
105
  if (isActive && liveOAuth?.organizationRateLimitTier != null) a.rateLimitTier = liveOAuth.organizationRateLimitTier;
106
+ const teeCurrent = teeAt != null && (a.lastUsageAt == null || teeAt >= a.lastUsageAt);
103
107
  const fromStatusLine: FullUsage | null =
104
- isActive && live && live.org === a.organizationUuid
108
+ isActive && live && teeCurrent && live.org === a.organizationUuid
105
109
  ? {
106
110
  session: live.fiveHour,
107
111
  weekAll: live.sevenDay,
@@ -115,7 +119,10 @@ async function collectClaude(input: { cfg: Config; force: boolean; now: number }
115
119
  if (!outcome.ok && fromStatusLine && outcome.reason.includes("no limit data")) {
116
120
  const failed = outcome;
117
121
  outcome = { ok: true, usage: fromStatusLine };
118
- if (failed.pingError != null) outcome.pingError = failed.pingError;
122
+ if (failed.pingError != null) {
123
+ outcome.pingError = failed.pingError;
124
+ outcome.pingRejected = failed.pingRejected;
125
+ }
119
126
  viaTee = true;
120
127
  }
121
128
  } else {
@@ -129,7 +136,7 @@ async function collectClaude(input: { cfg: Config; force: boolean; now: number }
129
136
  samples.set(a.accountUuid, { outcome, viaTee });
130
137
  if (!outcome.ok) return;
131
138
  a.lastUsage = { fiveHour: outcome.usage.session, sevenDay: outcome.usage.weekAll };
132
- a.lastUsageAt = viaTee && live ? live.ts : Date.now();
139
+ a.lastUsageAt = viaTee && teeAt != null ? teeAt : Date.now();
133
140
  if (Object.keys(outcome.usage.perModel).length > 0) {
134
141
  a.lastPerModel = outcome.usage.perModel;
135
142
  a.lastPerModelAt = viaTee && modelUsage ? (modelUsage.sampledAt ?? modelUsage.ts) : a.lastUsageAt;
@@ -169,6 +176,7 @@ async function collectClaude(input: { cfg: Config; force: boolean; now: number }
169
176
  usageAt: a.lastUsageAt ?? null,
170
177
  sample: sampled.outcome.ok ? { ok: true, source: sampled.viaTee ? "statusline" : "probe" } : { ok: false, reason: sampled.outcome.reason },
171
178
  pingError: sampled.outcome.pingError ?? null,
179
+ pingRejected: sampled.outcome.pingRejected === true,
172
180
  pinged: force && sampled.outcome.ok && sampled.outcome.pingError == null && aggregate != null && aggregate.fiveHour.resetsAt == null,
173
181
  };
174
182
  });
@@ -277,8 +285,14 @@ function rowPrinter(now: number): (name: string, w: UsageWindow) => void {
277
285
  };
278
286
  }
279
287
 
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;
288
+ function renderClaude(input: {
289
+ claude: StatusReport["claude"];
290
+ codexPooled: boolean;
291
+ now: number;
292
+ staleAfterMs: number;
293
+ row: (name: string, w: UsageWindow) => void;
294
+ }): void {
295
+ const { claude, codexPooled, now, staleAfterMs, row } = input;
282
296
  if (claude.accounts.length === 0) {
283
297
  if (!codexPooled) {
284
298
  console.log(c.dim("no accounts yet, run `tokenmaxxing init` (or `tokenmaxxing init --codex`)"));
@@ -303,11 +317,21 @@ function renderClaude(input: { claude: StatusReport["claude"]; codexPooled: bool
303
317
  row("week", a.usage.week);
304
318
  }
305
319
  for (const [name, w] of Object.entries(a.perModel)) row(name.toLowerCase(), w);
320
+ if (a.sample.ok && a.sample.source === "statusline") {
321
+ const stale = a.usageAt == null || now - a.usageAt > staleAfterMs;
322
+ const age = a.usageAt != null ? fmtAgo(a.usageAt, now) : "age unknown";
323
+ console.log(` ${(stale ? c.yellow : c.dim)(`statusline tee ${age}${stale ? " (stale)" : ""}`)}`);
324
+ }
306
325
  if (!a.sample.ok) {
307
326
  const cached = a.usage || Object.keys(a.perModel).length > 0 ? `cached${a.usageAt != null ? ` ${fmtAgo(a.usageAt, now)}` : ""}, ` : "";
308
327
  console.log(` ${c.yellow(`${cached}live sample failed`)}: ${c.dim(a.sample.reason)}`);
309
328
  }
310
- if (a.pingError != null) {
329
+ if (a.pingError != null && a.pingRejected) {
330
+ const at = a.pingError.indexOf(": ");
331
+ const head = at >= 0 ? a.pingError.slice(0, at) : a.pingError;
332
+ const tail = at >= 0 ? a.pingError.slice(at + 2) : "";
333
+ console.log(` ${c.yellow(`ping ${head}`)}${tail ? `: ${c.dim(tail)}` : ""}`);
334
+ } else if (a.pingError != null) {
311
335
  console.log(` ${c.yellow("ping failed (5h timer may not have started)")}: ${c.dim(a.pingError)}`);
312
336
  }
313
337
  if (a.pinged) {
@@ -321,11 +345,11 @@ export async function cmdStatus(opts: { force?: boolean; json?: boolean; preRend
321
345
  const { force = false, json = false } = opts;
322
346
  const cfg = loadConfig();
323
347
  const now = Date.now();
324
- const row = rowPrinter(now);
325
348
  const claude = await collectClaude({ cfg, force, now });
326
349
  if (!json) {
327
350
  opts.preRender?.();
328
- renderClaude({ claude, codexPooled: loadCodexAccounts().accounts.length > 0, now, row });
351
+ const at = Date.now();
352
+ renderClaude({ claude, codexPooled: loadCodexAccounts().accounts.length > 0, now: at, staleAfterMs: cfg.policy.usagePollTtlMs, row: rowPrinter(at) });
329
353
  }
330
354
  const codex = await collectCodex({ cfg, now });
331
355
  if (json) {
@@ -333,6 +357,7 @@ export async function cmdStatus(opts: { force?: boolean; json?: boolean; preRend
333
357
  emitJson({ ok: true, ...report });
334
358
  return 0;
335
359
  }
336
- renderCodex({ codex, now, row });
360
+ const at = Date.now();
361
+ renderCodex({ codex, now: at, row: rowPrinter(at) });
337
362
  return 0;
338
363
  }
package/src/lib/decide.ts CHANGED
@@ -2,7 +2,7 @@ import { maxBy } from "es-toolkit";
2
2
  import { z } from "zod";
3
3
  import { withLock } from "./lock.ts";
4
4
  import { paths } from "./paths.ts";
5
- import { MAX_CHECK_DELAY_MS, loadAccounts, loadConfig, loadDepletedWait, loadLastSwapAt, loadUsage, loadModelUsage, saveAccounts, saveDepletedWait, saveModelUsage, usageTeeAt, writeUsage } from "./state.ts";
5
+ import { MAX_CHECK_DELAY_MS, loadAccounts, loadConfig, loadDepletedWait, loadLastSwapAt, loadUsage, loadUsageSnapshot, loadModelUsage, saveAccounts, saveDepletedWait, saveModelUsage, writeUsage } from "./state.ts";
6
6
  import { readOAuthAccount } from "./claudejson.ts";
7
7
  import { chooseAndSwap, performSwap } from "./swap.ts";
8
8
  import { currentWins, effectiveBars, hardBars, isExhausted, nextWeeklyReset, pickBest, pickEarliestReset, sessionLadder, usableAt } from "./picker.ts";
@@ -72,31 +72,43 @@ function isEngaged(u: UsageState | null, mu: ModelUsageState | null, org: string
72
72
  const SnapshotsSchema = z.object({
73
73
  u: UsageStateSchema.nullable(),
74
74
  mu: ModelUsageStateSchema.nullable(),
75
+ uAt: z.number().nullable(),
75
76
  });
76
77
  type Snapshots = z.infer<typeof SnapshotsSchema>;
77
78
 
78
- function usageFresh(u: UsageState | null, org: string | null, ttl: number, now: number): boolean {
79
- if (u == null || u.org !== org) return false;
80
- const teeAt = usageTeeAt();
81
- return teeAt != null && now - teeAt <= ttl;
79
+ function usageFresh(u: UsageState | null, uAt: number | null, org: string | null, ttl: number, now: number): boolean {
80
+ return u != null && u.org === org && uAt != null && now - uAt <= ttl;
81
+ }
82
+
83
+ function freshest(u: UsageState | null, uAt: number | null, account: Account | undefined): UsageState | null {
84
+ if (!u || uAt == null) return u;
85
+ const stored = account?.lastUsage;
86
+ if (stored && account.lastUsageAt != null && u.org === account.organizationUuid && uAt < account.lastUsageAt) {
87
+ return { ...u, fiveHour: stored.fiveHour, sevenDay: stored.sevenDay, ts: account.lastUsageAt };
88
+ }
89
+ return { ...u, ts: uAt };
82
90
  }
83
91
 
84
92
  async function loadFreshSnapshots(cfg: Config, org: string | null, now: number): Promise<Snapshots> {
85
- let u = loadUsage();
93
+ const snap = loadUsageSnapshot();
94
+ let u = snap?.state ?? null;
95
+ let uAt = snap?.at ?? null;
86
96
  let mu = loadModelUsage();
87
97
  const ttl = cfg.policy.usagePollTtlMs;
88
98
  const probeAttempted = mu != null && mu.org === org && now - mu.ts <= ttl;
89
- if (org && !probeAttempted && (!usageFresh(u, org, ttl, now) || needsPerModel(u, cfg))) {
99
+ if (org && !probeAttempted && (!usageFresh(u, uAt, org, ttl, now) || needsPerModel(u, cfg))) {
90
100
  const full = await probeUsage();
91
101
  const ts = Date.now();
92
102
  if (readOAuthAccount()?.organizationUuid === org) {
93
103
  if (full) {
94
- const teed = loadUsage();
95
- if (usageFresh(teed, org, ttl, ts)) {
96
- u = teed;
104
+ const teed = loadUsageSnapshot();
105
+ if (teed && usageFresh(teed.state, teed.at, org, ttl, ts)) {
106
+ u = teed.state;
107
+ uAt = teed.at;
97
108
  } else {
98
109
  u = { fiveHour: full.session, sevenDay: full.weekAll, org, ts, model: null };
99
110
  writeUsage(u);
111
+ uAt = ts;
100
112
  }
101
113
  mu = { perModel: full.perModel, org, ts, sampledAt: ts };
102
114
  saveModelUsage(mu);
@@ -111,7 +123,7 @@ async function loadFreshSnapshots(cfg: Config, org: string | null, now: number):
111
123
  }
112
124
  }
113
125
  }
114
- return { u, mu };
126
+ return { u, mu, uAt };
115
127
  }
116
128
 
117
129
  export const POST_SWAP_COOLDOWN_MS = 45_000;
@@ -127,9 +139,11 @@ export async function evaluateAndMaybeSwap(now = Date.now(), anticipatory = fals
127
139
 
128
140
  const cfg = loadConfig();
129
141
 
130
- const { u: usage, mu } = await loadFreshSnapshots(cfg, activeOrg, now);
142
+ const { u: teeUsage, mu, uAt } = await loadFreshSnapshots(cfg, activeOrg, now);
143
+ const pool = loadAccounts();
144
+ const usage = freshest(teeUsage, uAt, pool.accounts.find((a) => a.organizationUuid === activeOrg));
131
145
  const bars0 = effectiveBars(cfg, {
132
- accounts: overlayLive(loadAccounts().accounts, usage, mu, activeOrg),
146
+ accounts: overlayLive(pool.accounts, usage, mu, activeOrg),
133
147
  now,
134
148
  switchFamilies: gatedFamilies(usage?.model ?? null, cfg.policy.switchModels),
135
149
  });
@@ -147,19 +161,20 @@ export async function evaluateAndMaybeSwap(now = Date.now(), anticipatory = fals
147
161
  const idx = loadAccounts();
148
162
  const org2 = readOAuthAccount()?.organizationUuid ?? null;
149
163
  const enforced2 = enforced0 && enforced0.org === org2 ? enforced0 : null;
150
- const u2 = loadUsage() ?? usage;
164
+ const tee = loadUsageSnapshot();
165
+ const active = org2 ? idx.accounts.find((a) => a.organizationUuid === org2) : undefined;
166
+ const u2 = tee ? freshest(tee.state, tee.at, active) : usage;
151
167
  const mu2 = needsPerModel(u2, cfg) || enforced2?.family ? loadModelUsage() ?? mu : null;
152
168
 
153
- if (org2 != null && !idx.accounts.some((a) => a.organizationUuid === org2)) {
169
+ if (org2 != null && !active) {
154
170
  return { swapped: false, account: null, reason: "live-credential-not-in-pool" };
155
171
  }
156
172
 
157
- const active = org2 ? idx.accounts.find((a) => a.organizationUuid === org2) : undefined;
158
173
  if (active) {
159
174
  let sampled = false;
160
- if (u2 && u2.org === org2) {
161
- active.lastUsage = { fiveHour: u2.fiveHour, sevenDay: u2.sevenDay };
162
- active.lastUsageAt = u2.ts;
175
+ if (tee && tee.state.org === org2 && (active.lastUsageAt == null || tee.at >= active.lastUsageAt)) {
176
+ active.lastUsage = { fiveHour: tee.state.fiveHour, sevenDay: tee.state.sevenDay };
177
+ active.lastUsageAt = tee.at;
163
178
  sampled = true;
164
179
  }
165
180
  if (mu2 && mu2.org === org2 && Object.keys(mu2.perModel).length > 0) {
@@ -351,12 +366,14 @@ export function checkDelayMs(input: { cfg: Config; org: string | null; now: numb
351
366
  if (decision.waitUntil !== undefined) return Math.min(MAX_CHECK_DELAY_MS, Math.max(CHECK_DELAY_FLOOR_MS, decision.waitUntil - now));
352
367
  const swapAt = loadLastSwapAt();
353
368
  if (swapAt != null && now - swapAt < POST_SWAP_COOLDOWN_MS) return swapAt + POST_SWAP_COOLDOWN_MS - now;
354
- const u = loadUsage();
355
- if (!org || !u || !usageFresh(u, org, cfg.policy.usagePollTtlMs, now)) return CHECK_DELAY_UNKNOWN_MS;
369
+ const snap = loadUsageSnapshot();
370
+ if (!org || !snap || !usageFresh(snap.state, snap.at, org, cfg.policy.usagePollTtlMs, now)) return CHECK_DELAY_UNKNOWN_MS;
371
+ const accounts = loadAccounts().accounts;
372
+ const u = freshest(snap.state, snap.at, accounts.find((a) => a.organizationUuid === org)) ?? { ...snap.state, ts: snap.at };
356
373
  const mu = loadModelUsage();
357
374
  const muSame = mu && mu.org === org ? mu : null;
358
375
  const families = gatedFamilies(u.model, cfg.policy.switchModels);
359
- const bars = effectiveBars(cfg, { accounts: overlayLive(loadAccounts().accounts, u, muSame, org), now, switchFamilies: families });
376
+ const bars = effectiveBars(cfg, { accounts: overlayLive(accounts, u, muSame, org), now, switchFamilies: families });
360
377
  const heads = [
361
378
  bars.session - liveUsed({ window: u.fiveHour, windowMs: FIVE_HOURS_MS, sampledAt: u.ts, now }),
362
379
  bars.weekly - liveUsed({ window: u.sevenDay, windowMs: WEEK_MS, sampledAt: u.ts, now }),
package/src/lib/sample.ts CHANGED
@@ -9,8 +9,8 @@ import { FullUsageSchema, pingSession, probeUsage } from "./usage.ts";
9
9
  import { CredentialBlobSchema, type Account, type OAuthCreds, type RolesResponse } from "./types.ts";
10
10
 
11
11
  const SampleOutcomeSchema = z.discriminatedUnion("ok", [
12
- z.object({ ok: z.literal(true), usage: FullUsageSchema, pingError: z.string().optional() }),
13
- z.object({ ok: z.literal(false), reason: z.string(), pingError: z.string().optional() }),
12
+ z.object({ ok: z.literal(true), usage: FullUsageSchema, pingError: z.string().optional(), pingRejected: z.boolean().optional() }),
13
+ z.object({ ok: z.literal(false), reason: z.string(), pingError: z.string().optional(), pingRejected: z.boolean().optional() }),
14
14
  ]);
15
15
  export type SampleOutcome = z.infer<typeof SampleOutcomeSchema>;
16
16
 
@@ -95,12 +95,15 @@ export async function probeParkedUsage(account: Account, opts: { ping?: boolean
95
95
  try {
96
96
  await writeItem(isoTarget, installed);
97
97
  writeFileSync(join(dir, ".claude.json"), JSON.stringify({ oauthAccount: account.oauthAccount, hasCompletedOnboarding: true }));
98
- const pingError = opts.ping ? await pingSession(dir) : null;
98
+ const ping = opts.ping ? await pingSession(dir) : null;
99
99
  const usage = await probeUsage(dir);
100
100
  const outcome: SampleOutcome = usage
101
101
  ? { ok: true, usage }
102
102
  : { ok: false, reason: "`/usage` returned no limit data (see log)" };
103
- if (pingError != null) outcome.pingError = pingError;
103
+ if (ping != null) {
104
+ outcome.pingError = ping.reason;
105
+ outcome.pingRejected = ping.rejected;
106
+ }
104
107
  return outcome;
105
108
  } finally {
106
109
  const afterIso = await readItem(isoTarget);
@@ -152,9 +155,12 @@ export async function probeActiveUsage(account: Account, opts: { ping?: boolean
152
155
  if (identity.status === "unavailable") return { ok: false, reason: identity.reason };
153
156
  refreshPlanFields(account, creds);
154
157
 
155
- const pingError = opts.ping ? await pingSession() : null;
158
+ const ping = opts.ping ? await pingSession() : null;
156
159
  const usage = await probeUsage();
157
160
  const outcome: SampleOutcome = usage ? { ok: true, usage } : { ok: false, reason: "`/usage` returned no limit data (see log)" };
158
- if (pingError != null) outcome.pingError = pingError;
161
+ if (ping != null) {
162
+ outcome.pingError = ping.reason;
163
+ outcome.pingRejected = ping.rejected;
164
+ }
159
165
  return outcome;
160
166
  }
package/src/lib/state.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { existsSync, readFileSync, rmSync, statSync, utimesSync } from "node:fs";
1
+ import { closeSync, existsSync, fstatSync, openSync, readFileSync, rmSync, utimesSync } from "node:fs";
2
2
  import { isEqual } from "es-toolkit";
3
3
  import { z } from "zod";
4
4
  import { paths, realClaudeBinFromEnv, realCodexBinFromEnv } from "./paths.ts";
@@ -135,16 +135,28 @@ export function saveAccounts(idx: AccountsIndex): void {
135
135
  writeFileAtomic(paths.accountsJson, JSON.stringify(AccountsIndexSchema.parse(idx), null, 2) + "\n");
136
136
  }
137
137
 
138
- export function loadUsage(): UsageState | null {
139
- if (!existsSync(paths.usageJson)) return null;
138
+ export function loadUsageSnapshot(): { state: UsageState; at: number } | null {
139
+ let fd: number;
140
140
  try {
141
- const parsed = UsageStateSchema.safeParse(JSON.parse(readFileSync(paths.usageJson, "utf8")));
142
- return parsed.success ? parsed.data : null;
141
+ fd = openSync(paths.usageJson, "r");
142
+ } catch {
143
+ return null;
144
+ }
145
+ try {
146
+ const at = fstatSync(fd).mtimeMs;
147
+ const parsed = UsageStateSchema.safeParse(JSON.parse(readFileSync(fd, "utf8")));
148
+ return parsed.success ? { state: parsed.data, at } : null;
143
149
  } catch {
144
150
  return null;
151
+ } finally {
152
+ closeSync(fd);
145
153
  }
146
154
  }
147
155
 
156
+ export function loadUsage(): UsageState | null {
157
+ return loadUsageSnapshot()?.state ?? null;
158
+ }
159
+
148
160
  export function clearUsageSnapshots(): void {
149
161
  rmSync(paths.usageJson, { force: true });
150
162
  rmSync(paths.modelUsageJson, { force: true });
@@ -226,14 +238,6 @@ export function writeUsage(next: UsageState): boolean {
226
238
  return true;
227
239
  }
228
240
 
229
- export function usageTeeAt(): number | null {
230
- try {
231
- return statSync(paths.usageJson).mtimeMs;
232
- } catch {
233
- return null;
234
- }
235
- }
236
-
237
241
  export function loadModelUsage(): ModelUsageState | null {
238
242
  if (!existsSync(paths.modelUsageJson)) return null;
239
243
  try {
package/src/lib/usage.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { closeSync, fstatSync, mkdirSync, openSync, readSync } from "node:fs";
1
+ import { closeSync, existsSync, fstatSync, mkdirSync, openSync, readSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { delay } from "es-toolkit";
4
4
  import { z } from "zod";
@@ -366,13 +366,36 @@ const PING_ARGS = [
366
366
  "--output-format", "json",
367
367
  ];
368
368
 
369
- const PingResultSchema = z.looseObject({ is_error: z.boolean(), result: z.string().optional() });
369
+ const PingResultSchema = z.looseObject({ is_error: z.boolean().optional(), result: z.string().optional(), session_id: z.string().optional() });
370
370
 
371
- export async function pingSession(configDir?: string): Promise<string | null> {
371
+ export const PingFailureSchema = z.object({ reason: z.string(), rejected: z.boolean() });
372
+ export type PingFailure = z.infer<typeof PingFailureSchema>;
373
+
374
+ export function transcriptSlug(cwd: string): string {
375
+ return cwd.replace(/[^a-zA-Z0-9]/g, "-");
376
+ }
377
+
378
+ export function pingTranscriptPath(input: { configDir: string; cwd: string; sessionId: string }): string {
379
+ return join(input.configDir, "projects", transcriptSlug(input.cwd), `${input.sessionId}.jsonl`);
380
+ }
381
+
382
+ const LIMIT_LABELS: Record<string, string> = { five_hour: "5h", seven_day: "weekly" };
383
+
384
+ export function pingRejection(rows: TranscriptRow[]): string | null {
385
+ const row = rows.findLast((r) => r.isApiErrorMessage === true);
386
+ if (!row || row.error !== "rate_limit" || row.apiErrorIsTransient === true) return null;
387
+ const type = row.quotaLimits?.rateLimitType ?? "";
388
+ if (type === "" && parseErrorBody(row.errorDetails)?.error?.type !== "rate_limit_error") return null;
389
+ const label = LIMIT_LABELS[type] ?? (type !== "" ? type.replace(/_/g, " ") : "usage");
390
+ const text = transcriptRowText(row);
391
+ return `${label} limit${text ? `: ${text}` : ""}`;
392
+ }
393
+
394
+ export async function pingSession(configDir?: string): Promise<PingFailure | null> {
372
395
  const cwd = join(paths.sampleDir, "ping-cwd");
373
- const fail = (reason: string): string => {
374
- log("usage.ping_failed", { dir: configDir ?? "live", reason: reason.slice(0, 200) });
375
- return reason;
396
+ const fail = (reason: string, rejected = false): PingFailure => {
397
+ log("usage.ping_failed", { dir: configDir ?? "live", rejected, reason: reason.slice(0, 200) });
398
+ return { reason, rejected };
376
399
  };
377
400
  let r: SpawnResult | null;
378
401
  try {
@@ -382,10 +405,21 @@ export async function pingSession(configDir?: string): Promise<string | null> {
382
405
  return fail(e instanceof Error ? e.message : String(e));
383
406
  }
384
407
  if (r === null) return fail("output pipes still open after child exit (leaked descendant)");
385
- if (r.exitCode !== 0) return fail(`claude exited ${r.exitCode ?? "on signal"}: ${(r.stderr.trim() || r.stdout.trim()).slice(0, 160)}`);
386
408
  const parsed = PingResultSchema.safeParse((() => { try { return JSON.parse(r.stdout); } catch { return null; } })());
387
- if (!parsed.success) return fail(`unrecognized ping output: ${r.stdout.trim().slice(0, 120)}`);
388
- if (parsed.data.is_error) return fail((parsed.data.result?.trim() || "request errored").slice(0, 160));
389
- log("usage.ping_ok", { dir: configDir ?? "live" });
390
- return null;
409
+ const result = parsed.success ? parsed.data : null;
410
+ if (r.exitCode === 0 && result?.is_error === false) {
411
+ log("usage.ping_ok", { dir: configDir ?? "live" });
412
+ return null;
413
+ }
414
+ if (result?.session_id) {
415
+ const transcript = pingTranscriptPath({ configDir: configDir ?? paths.claudeDir, cwd, sessionId: result.session_id });
416
+ const rejection = existsSync(transcript) ? pingRejection(readTranscriptTail(transcript)) : null;
417
+ if (rejection) return fail(`rejected at the ${rejection}`.slice(0, 200), true);
418
+ }
419
+ if (r.exitCode !== 0) {
420
+ const detail = result?.result?.trim() || r.stderr.trim() || r.stdout.trim();
421
+ return fail(`claude exited ${r.exitCode ?? "on signal"}: ${detail.slice(0, 160)}`);
422
+ }
423
+ if (result?.is_error === true) return fail((result.result?.trim() || "request errored").slice(0, 160));
424
+ return fail(`unrecognized ping output: ${r.stdout.trim().slice(0, 120)}`);
391
425
  }