tokenmaxxing 1.8.0 → 1.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.
Files changed (85) hide show
  1. package/DESIGN.md +2 -4
  2. package/README.md +1 -1
  3. package/agent-plugin/plugin.json +1 -1
  4. package/package.json +1 -1
  5. package/src/cli/add.ts +1 -8
  6. package/src/cli/auth.ts +0 -23
  7. package/src/cli/check.ts +19 -13
  8. package/src/cli/codexadd.ts +0 -17
  9. package/src/cli/codexinit.ts +0 -40
  10. package/src/cli/codexrm.ts +0 -13
  11. package/src/cli/codexswitch.ts +0 -15
  12. package/src/cli/config.ts +0 -30
  13. package/src/cli/doctor.ts +1 -14
  14. package/src/cli/init.ts +1 -33
  15. package/src/cli/ls.ts +0 -2
  16. package/src/cli/onboard.ts +0 -37
  17. package/src/cli/rename.ts +0 -19
  18. package/src/cli/render.ts +0 -23
  19. package/src/cli/rm.ts +0 -19
  20. package/src/cli/status.ts +0 -80
  21. package/src/cli/switch.ts +1 -49
  22. package/src/cli/watch.ts +0 -17
  23. package/src/entries/codexstophook.ts +2 -73
  24. package/src/entries/codexsupervisor.ts +1 -67
  25. package/src/entries/mcp.ts +0 -11
  26. package/src/entries/sessionstart.ts +1 -8
  27. package/src/entries/statusline.ts +0 -66
  28. package/src/entries/stopfailurehook.ts +93 -0
  29. package/src/entries/stophook.ts +3 -34
  30. package/src/entries/subagentstatusline.ts +0 -19
  31. package/src/entries/supervisor.ts +32 -132
  32. package/src/lib/atomic.ts +0 -16
  33. package/src/lib/claudebin.ts +4 -55
  34. package/src/lib/claudejson.ts +0 -10
  35. package/src/lib/claudelock.ts +13 -35
  36. package/src/lib/codexauth.ts +0 -29
  37. package/src/lib/codexbin.ts +0 -10
  38. package/src/lib/codexdecide.ts +1 -112
  39. package/src/lib/codexoauth.ts +0 -16
  40. package/src/lib/codexpick.ts +0 -31
  41. package/src/lib/codexpresence.ts +0 -35
  42. package/src/lib/codexsample.ts +0 -23
  43. package/src/lib/codexstate.ts +0 -7
  44. package/src/lib/codexswap.ts +0 -32
  45. package/src/lib/codexusage.ts +0 -28
  46. package/src/lib/credstore.ts +0 -24
  47. package/src/lib/decide.ts +127 -180
  48. package/src/lib/http.ts +0 -9
  49. package/src/lib/install.ts +6 -127
  50. package/src/lib/keychain.ts +1 -39
  51. package/src/lib/lock.ts +0 -24
  52. package/src/lib/log.ts +0 -14
  53. package/src/lib/oauth.ts +1 -31
  54. package/src/lib/paths.ts +1 -48
  55. package/src/lib/picker.ts +1 -84
  56. package/src/lib/proc.ts +0 -17
  57. package/src/lib/sample.ts +0 -68
  58. package/src/lib/sessions.ts +0 -13
  59. package/src/lib/settings.ts +15 -42
  60. package/src/lib/state.ts +23 -77
  61. package/src/lib/swap.ts +3 -87
  62. package/src/lib/tty.ts +0 -4
  63. package/src/lib/types.ts +13 -140
  64. package/src/lib/usage.ts +108 -196
  65. package/src/lib/worktree.ts +0 -8
  66. package/src/main.ts +5 -40
  67. package/src/sdk.ts +0 -59
  68. package/agent-plugin/agents/tokenmaxxing-claude.md +0 -43
  69. package/agent-plugin/agents/tokenmaxxing-codex.md +0 -40
  70. package/agent-plugin/hooks/cursor-relay.json +0 -14
  71. package/agent-plugin/skills/relay-session/SKILL.md +0 -118
  72. package/agent-plugin/skills/relay-session/references/ipc.md +0 -23
  73. package/src/cli/relay.ts +0 -323
  74. package/src/entries/relaypermission.ts +0 -105
  75. package/src/lib/relay/config.ts +0 -84
  76. package/src/lib/relay/decide.ts +0 -75
  77. package/src/lib/relay/gc.ts +0 -80
  78. package/src/lib/relay/install.ts +0 -143
  79. package/src/lib/relay/markers.ts +0 -148
  80. package/src/lib/relay/modes.ts +0 -82
  81. package/src/lib/relay/protocol.ts +0 -61
  82. package/src/lib/relay/registry.ts +0 -175
  83. package/src/lib/relay/tmux.ts +0 -109
  84. package/src/lib/relay/turn.ts +0 -137
  85. package/src/lib/relay/worker.ts +0 -141
package/src/lib/usage.ts CHANGED
@@ -1,22 +1,12 @@
1
- // Usage data from two sources: statusLine stdin (pushed every turn, aggregate
2
- // windows only, epoch resets) and `claude -p '/usage'` (free, 0 tokens, all
3
- // three limit kinds). `/usage` is a client-side command that renders the same
4
- // figures claude's own usage screen shows; we run it in a throwaway
5
- // CLAUDE_CONFIG_DIR to sample a parked account without disturbing the live login.
6
-
7
- import { mkdirSync } from "node:fs";
1
+ import { closeSync, fstatSync, mkdirSync, openSync, readSync } from "node:fs";
8
2
  import { join } from "node:path";
9
3
  import { delay } from "es-toolkit";
10
4
  import { z } from "zod";
11
5
  import { MAX_WRAP_DEPTH, WRAP_DEPTH_ENV, resolveRealClaude } from "./claudebin.ts";
12
- import { readOAuthAccount } from "./claudejson.ts";
13
- import { withLock } from "./lock.ts";
14
6
  import { log } from "./log.ts";
15
7
  import { paths } from "./paths.ts";
16
- import { loadUsage, writeUsage } from "./state.ts";
17
8
  import { RateLimitsStdinSchema, UsageWindowSchema, type ModelInfo, type UsageWindow, type UsageWindows } from "./types.ts";
18
9
 
19
- /** Normalize a resets_at value (epoch s, epoch ms, or ISO string) to epoch ms. */
20
10
  export function normalizeResetsAt(v: unknown): number | null {
21
11
  const num = z.number().finite().safeParse(v);
22
12
  if (num.success) {
@@ -37,9 +27,6 @@ const win = (w: { used_percentage: number; resets_at?: number | null }): UsageWi
37
27
  resetsAt: normalizeResetsAt(w.resets_at),
38
28
  });
39
29
 
40
- /** Extract the two AGGREGATE windows from statusLine stdin. null if absent.
41
- * All-or-nothing on the aggregates (mirrors the /usage parser): fabricating
42
- * 0% for a missing window would make unmeasured read as safe. */
43
30
  export function parseStatusLineStdin(obj: unknown): UsageWindows | null {
44
31
  const parsed = RateLimitsStdinSchema.safeParse(obj);
45
32
  if (!parsed.success) return null;
@@ -48,7 +35,6 @@ export function parseStatusLineStdin(obj: unknown): UsageWindows | null {
48
35
  return { fiveHour: win(rl.five_hour), sevenDay: win(rl.seven_day) };
49
36
  }
50
37
 
51
- /** Extract the active model from statusLine stdin. null if absent. */
52
38
  export function parseStatusLineModel(obj: unknown): ModelInfo | null {
53
39
  const parsed = RateLimitsStdinSchema.safeParse(obj);
54
40
  if (!parsed.success) return null;
@@ -57,31 +43,16 @@ export function parseStatusLineModel(obj: unknown): ModelInfo | null {
57
43
  return { id: m?.id ?? m?.display_name ?? "", display: m?.display_name ?? m?.id ?? "" };
58
44
  }
59
45
 
60
- /** Lowercased word tokens of a model id or display string: "claude-opus-4-8" /
61
- * "Opus 4.8" -> ["claude","opus","4","8"] / ["opus","4","8"]. Model naming
62
- * drifts per release ("Fable" became "Fable 5" in 2.1.206, and id grammar has
63
- * historically flipped between family-first and version-first), so gates match
64
- * a family token anywhere instead of an exact string - an exact-string gate
65
- * silently disabled the per-model check in the 2026-07-09/10 incidents. */
66
46
  export function familyTokens(s: string): string[] {
67
47
  return s.trim().toLowerCase().split(/[\s.-]+/).filter((t) => t.length > 0);
68
48
  }
69
49
 
70
- /** The switchModels family the active model belongs to, from its id OR display
71
- * tokens; null when the model is not capacity-constrained. */
72
50
  export function matchedFamily(model: ModelInfo | null, families: string[]): string | null {
73
51
  if (!model) return null;
74
52
  const tokens = new Set([...familyTokens(model.id), ...familyTokens(model.display)]);
75
53
  return families.find((f) => tokens.has(f)) ?? null;
76
54
  }
77
55
 
78
- /** Families whose per-model weekly cap gates a switch, given the active model:
79
- * the model's own family when it is capacity-constrained, none when it is a
80
- * known-unconstrained model, and EVERY configured family when the model is
81
- * unknown. The unknown case matters on headless boxes: a swap clears the
82
- * snapshots and only an actively-rendering statusLine restores the model, so
83
- * the periodic check ran model-blind for hours while the active account sat at
84
- * its Fable cap (the 2026-07-12 ARM-box incident). */
85
56
  export function gatedFamilies(model: ModelInfo | null, families: string[]): string[] {
86
57
  if (!model) return families;
87
58
  const family = matchedFamily(model, families);
@@ -95,13 +66,10 @@ export const FullUsageSchema = z.object({
95
66
  });
96
67
  export type FullUsage = z.infer<typeof FullUsageSchema>;
97
68
 
98
- // ---- `/usage` text parsing -----------------------------------------------
99
-
100
69
  const MONTHS: Record<string, number> = {
101
70
  jan: 0, feb: 1, mar: 2, apr: 3, may: 4, jun: 5, jul: 6, aug: 7, sep: 8, oct: 9, nov: 10, dec: 11,
102
71
  };
103
72
 
104
- /** The tz offset (wall-clock minus UTC, in ms) in `tz` at instant `utcMs`. */
105
73
  function tzOffsetMs(utcMs: number, tz: string): number {
106
74
  const parts = new Intl.DateTimeFormat("en-US", {
107
75
  timeZone: tz,
@@ -115,7 +83,6 @@ function tzOffsetMs(utcMs: number, tz: string): number {
115
83
  return asUTC - utcMs;
116
84
  }
117
85
 
118
- /** Interpret a wall-clock time in `tz` as an epoch (DST-correct via one refine). */
119
86
  function zonedWallToEpoch(y: number, mon: number, day: number, hour: number, min: number, tz: string): number {
120
87
  const guess = Date.UTC(y, mon, day, hour, min);
121
88
  const off1 = tzOffsetMs(guess, tz);
@@ -123,16 +90,6 @@ function zonedWallToEpoch(y: number, mon: number, day: number, hour: number, min
123
90
  return guess - off2;
124
91
  }
125
92
 
126
- /**
127
- * Parse a `/usage` reset clock like `Jul 11 at 12pm (Asia/Seoul)` or
128
- * `Jul 10, 3:30pm (Asia/Seoul)` to epoch ms. The day-time glue is not stable
129
- * across claude installs (observed on 2.1.206: ` at ` on macOS, `, ` on Linux);
130
- * exactly those two glues are accepted, same-line only, so a third drift shows
131
- * up as an unparsed clock (and the usage.reset_clock_unparsed log) instead of a
132
- * guessed instant. The text carries no year, so we pick the year whose resulting
133
- * instant is nearest `now` (resets are always days away, so the correct year
134
- * wins by ~360 days). Returns null if unparseable.
135
- */
136
93
  export function parseResetClock(clock: string, now = Date.now()): number | null {
137
94
  const m = clock.match(/\b([A-Za-z]{3,9})\s+(\d{1,2})(?:[^\S\n]+at[^\S\n]+|,[^\S\n]*)(\d{1,2})(?::(\d{2}))?\s*([ap])m\s*\(([^)]+)\)/i);
138
95
  if (!m) return null;
@@ -151,19 +108,13 @@ export function parseResetClock(clock: string, now = Date.now()): number | null
151
108
  try {
152
109
  epoch = zonedWallToEpoch(y, mon, day, hour, min, tz);
153
110
  } catch {
154
- return null; // invalid tz
111
+ return null;
155
112
  }
156
113
  if (best === null || Math.abs(epoch - now) < Math.abs(best - now)) best = epoch;
157
114
  }
158
115
  return best;
159
116
  }
160
117
 
161
- /** Compact time-until-reset: the largest unit only ("6d", "2h", "45m"),
162
- * floored to "1m" so a live window never reads as zero, and "" once the reset
163
- * has passed (the window is simply empty again). Non-empty output always ends
164
- * in a unit letter, so the statusLine's digit-leading used-percent glued
165
- * after it stays parseable. Lives here (not cli/render.ts) so headless lib
166
- * consumers stay off the terminal-rendering module. */
167
118
  export function fmtResetShort(epochMs: number | null | undefined, now = Date.now()): string {
168
119
  if (epochMs == null) return "";
169
120
  const dsec = Math.round((epochMs - now) / 1000);
@@ -176,99 +127,120 @@ export function fmtResetShort(epochMs: number | null | undefined, now = Date.now
176
127
  return `${Math.max(m, 1)}m`;
177
128
  }
178
129
 
179
- /** The reset epoch a limit result announces ("Claude AI usage limit
180
- * reached|<epoch>", 10-digit seconds or 13-digit ms). Structural scan, no
181
- * regex: everything after the pipe up to the first non-digit. Null when the
182
- * text is a phrase-only limit with no epoch. */
183
- export function parseUsageLimitEpoch(input: { text: string }): number | null {
184
- const marker = "usage limit reached|";
185
- const at = input.text.toLowerCase().indexOf(marker);
130
+ const TranscriptBlockSchema = z.looseObject({ type: z.string().optional(), text: z.string().optional() });
131
+ export const TranscriptRowSchema = z.looseObject({
132
+ type: z.string().optional(),
133
+ timestamp: z.string().optional(),
134
+ isApiErrorMessage: z.boolean().optional(),
135
+ apiErrorIsTransient: z.boolean().optional(),
136
+ error: z.string().optional(),
137
+ errorDetails: z.string().optional(),
138
+ quotaLimits: z.looseObject({ rateLimitType: z.string().optional(), resetsAt: z.number().optional() }).optional(),
139
+ message: z.looseObject({ content: z.unknown().optional() }).optional(),
140
+ });
141
+ export type TranscriptRow = z.infer<typeof TranscriptRowSchema>;
142
+
143
+ const TRANSCRIPT_TAIL_BYTES = 256 * 1024;
144
+
145
+ export function readTranscriptTail(path: string, maxBytes = TRANSCRIPT_TAIL_BYTES): TranscriptRow[] {
146
+ let text: string;
147
+ try {
148
+ const fd = openSync(path, "r");
149
+ try {
150
+ const size = fstatSync(fd).size;
151
+ const start = Math.max(0, size - maxBytes);
152
+ const buf = Buffer.alloc(size - start);
153
+ readSync(fd, buf, 0, buf.length, start);
154
+ text = buf.toString("utf8");
155
+ if (start > 0) text = text.slice(text.indexOf("\n") + 1);
156
+ } finally {
157
+ closeSync(fd);
158
+ }
159
+ } catch {
160
+ return [];
161
+ }
162
+ const rows: TranscriptRow[] = [];
163
+ for (const line of text.split("\n")) {
164
+ if (line.trim() === "") continue;
165
+ try {
166
+ const parsed = TranscriptRowSchema.safeParse(JSON.parse(line));
167
+ if (parsed.success) rows.push(parsed.data);
168
+ } catch {}
169
+ }
170
+ return rows;
171
+ }
172
+
173
+ export function transcriptRowText(row: TranscriptRow): string {
174
+ const blocks = z.array(TranscriptBlockSchema).safeParse(row.message?.content);
175
+ if (!blocks.success) return "";
176
+ return blocks.data.filter((b) => b.type === "text").map((b) => b.text ?? "").join("\n").trim();
177
+ }
178
+
179
+ const ROW_RECENCY_MS = 60_000;
180
+
181
+ const EnforcedRowSchema = z.object({
182
+ row: TranscriptRowSchema,
183
+ errorAt: z.number().nullable(),
184
+ });
185
+ export type EnforcedRow = z.infer<typeof EnforcedRowSchema>;
186
+
187
+ export function findEnforcedRow(input: { rows: TranscriptRow[]; lastAssistantMessage: string | undefined; now: number }): EnforcedRow | null {
188
+ const { rows, lastAssistantMessage, now } = input;
189
+ for (let i = rows.length - 1; i >= 0; i--) {
190
+ const row = rows[i]!;
191
+ if (row.isApiErrorMessage !== true || row.error !== "rate_limit") continue;
192
+ const ts = row.timestamp ? Date.parse(row.timestamp) : Number.NaN;
193
+ const byContent = lastAssistantMessage != null && lastAssistantMessage !== "" && transcriptRowText(row) === lastAssistantMessage;
194
+ const byRecency = Number.isFinite(ts) && Math.abs(now - ts) <= ROW_RECENCY_MS;
195
+ if (!byContent && !byRecency) continue;
196
+ return { row, errorAt: Number.isFinite(ts) ? ts : null };
197
+ }
198
+ return null;
199
+ }
200
+
201
+ export const EnforcedClassSchema = z.discriminatedUnion("kind", [
202
+ z.object({ kind: z.literal("session"), resetsAt: z.number().nullable() }),
203
+ z.object({ kind: z.literal("weekly"), resetsAt: z.number().nullable() }),
204
+ z.object({ kind: z.literal("model"), family: z.string(), resetsAt: z.number().nullable() }),
205
+ ]);
206
+ export type EnforcedClass = z.infer<typeof EnforcedClassSchema>;
207
+
208
+ const ErrorBodySchema = z.looseObject({
209
+ error: z.looseObject({ type: z.string().optional(), details: z.looseObject({ error_code: z.string().optional() }).optional() }).optional(),
210
+ });
211
+
212
+ const CREDITS_GATED_FAMILIES = ["fable"];
213
+
214
+ export function parseErrorBody(errorDetails: string | undefined): z.infer<typeof ErrorBodySchema> | null {
215
+ if (!errorDetails) return null;
216
+ const at = errorDetails.indexOf("{");
186
217
  if (at < 0) return null;
187
- let digits = "";
188
- for (const ch of input.text.slice(at + marker.length)) {
189
- if (ch < "0" || ch > "9") break;
190
- digits += ch;
218
+ try {
219
+ const body = ErrorBodySchema.safeParse(JSON.parse(errorDetails.slice(at)));
220
+ return body.success ? body.data : null;
221
+ } catch {
222
+ return null;
191
223
  }
192
- // exactly the two real encodings: 10-digit seconds or 13-digit ms. An 11-
193
- // or 12-digit run is malformed and must stay an unknown reset, not become
194
- // a far-future one via the seconds branch.
195
- if (digits.length !== 10 && digits.length !== 13) return null;
196
- const n = Number(digits);
197
- return digits.length === 13 ? n : n * 1000;
198
224
  }
199
225
 
200
- /**
201
- * Persist a limit observed in a turn RESULT into usage.json so the next
202
- * decision sees the depleted account immediately. Callers without a statusLine
203
- * tee (headless Agent SDK integrations) should invoke this on errored limit
204
- * results: `loadFreshSnapshots` skips re-probing inside the poll TTL and
205
- * `/usage` is fail-silent against the just-limited active token, so without
206
- * this write a post-limit retry re-decides off the stale pre-limit snapshot
207
- * and respawns the same depleted account. The session
208
- * window is stamped 100% with the announced reset: whichever window actually
209
- * tripped, the account is unusable until then, and the hard path swaps away.
210
- * `org` is the identity captured AT THE SPAWN BOUNDARY of the turn that
211
- * failed; the write happens only when that identity is known and still live,
212
- * so a concurrent thread's mid-turn swap can never get its fresh account
213
- * stamped depleted by this turn's failure (review catch, PR #18). Without a
214
- * same-org prior snapshot there is nothing safe to write (a synthetic weekly
215
- * value would flow into `account.lastUsage` and poison the picker's ranking;
216
- * unmeasured must not look fresh), so the observation is dropped and the
217
- * retry stays merely bounded.
218
- *
219
- * INTENTIONAL TRADEOFF (closing-review critic gap, 2026-07-20): the statusline
220
- * tee (writeUsage) is deliberately UNLOCKED - the shim stays off flock/oauth -
221
- * so it can interleave with this locked check-then-write. Last-writer-wins is
222
- * semantically safe in both orderings: a tee landing after this stamp replaces
223
- * it with a FRESHER live measurement (truth wins), and this stamp landing
224
- * after a tee replaces pre-limit figures with the observed limit the server
225
- * just enforced (also truth). Neither writer can write a stale fabrication
226
- * over the other; locking the shim to close the interleave would buy nothing.
227
- */
228
- export async function recordObservedLimit(input: { text: string; now: number; org: string | null }): Promise<void> {
229
- if (!input.org) return;
230
- // the whole check-then-write runs under the swap flock: a concurrent
231
- // performSwap clears the snapshots and flips the live org, and a stale
232
- // depleted write must not land for the wrong org right after that
233
- // (review catch, PR #18).
234
- await withLock(paths.lockFile, () => {
235
- const live = readOAuthAccount()?.organizationUuid ?? null;
236
- if (live !== input.org) return;
237
- const prior = loadUsage();
238
- if (!prior || prior.org !== input.org) return;
239
- const resetsAt = parseUsageLimitEpoch({ text: input.text });
240
- // a weekly-phrased limit exhausts the WEEKLY window: stamping only the 5h
241
- // window would let the picker re-seat this account in 5h while the weekly
242
- // cap stays dead for days (review catch, PR #18). Unknown-reset blocked
243
- // windows self-bound at the window's own duration either way.
244
- const weekly = input.text.toLowerCase().includes("weekly");
245
- writeUsage({
246
- fiveHour: weekly ? prior.fiveHour : { usedPercentage: 100, resetsAt },
247
- sevenDay: weekly ? { usedPercentage: 100, resetsAt } : prior.sevenDay,
248
- org: input.org,
249
- ts: input.now,
250
- model: prior.model,
251
- });
252
- log("usage.observed_limit", { resetsAt, weekly });
253
- });
226
+ export function classifyEnforcedLimit(row: TranscriptRow, switchModels: string[]): EnforcedClass | null {
227
+ const q = row.quotaLimits;
228
+ if (q) {
229
+ const resetsAt = q.resetsAt != null ? normalizeResetsAt(q.resetsAt) : null;
230
+ const type = q.rateLimitType ?? "";
231
+ if (type === "five_hour") return { kind: "session", resetsAt };
232
+ if (type === "seven_day") return { kind: "weekly", resetsAt };
233
+ const family = switchModels.find((f) => type.includes(f));
234
+ return family ? { kind: "model", family, resetsAt } : null;
235
+ }
236
+ if (row.apiErrorIsTransient === true) return null;
237
+ if (parseErrorBody(row.errorDetails)?.error?.type !== "rate_limit_error") return null;
238
+ const family = switchModels.find((f) => CREDITS_GATED_FAMILIES.includes(f));
239
+ return family ? { kind: "model", family, resetsAt: null } : null;
254
240
  }
255
241
 
256
- /**
257
- * Parse `claude -p '/usage'` .result text into all three limit kinds:
258
- * Current session: N% used ... resets <clock> → session (5h)
259
- * Current week (all models): N% used ... resets ... → weekAll (7d aggregate)
260
- * Current week (<Model>): N% used ... resets ... → perModel[<Model>]
261
- */
262
242
  export function parseUsageTextFull(text: string, now = Date.now()): FullUsage | null {
263
243
  if (!text) return null;
264
- // The reset clock is required in full (month day[, | at ]h[:mm]am/pm (tz))
265
- // inside its optional group, so the lazy bridge is forced to find it when
266
- // present yet the group cleanly skips a line that has no clock. The bridge
267
- // refuses to cross another "Current" so a dateless clock ("resets Jul 8.")
268
- // can never steal the NEXT entry's clock or swallow that entry. The day-time
269
- // glue is not stable across installs (observed on 2.1.206: ` at ` on macOS,
270
- // `, ` on Linux); exactly those two are accepted, same-line only, so a third
271
- // drift surfaces in the usage.reset_clock_unparsed log instead of misparsing.
272
244
  const re = /current (session|week \(([^)]+)\)):\s*(\d+)\s*%(?:(?:(?!current)[^\n])*?\bresets\s+([A-Z][a-z]{2,8}\s+\d{1,2}(?:[^\S\n]+at[^\S\n]+|,[^\S\n]*)\d{1,2}(?::\d{2})?\s*[ap]m\s*\([^)]+\)))?/gi;
273
245
  let session: UsageWindow | null = null;
274
246
  let weekAll: UsageWindow | null = null;
@@ -279,23 +251,16 @@ export function parseUsageTextFull(text: string, now = Date.now()): FullUsage |
279
251
  else if (/^all models$/i.test(m[2]!.trim())) weekAll = window;
280
252
  else perModel[m[2]!.trim()] = window;
281
253
  }
282
- // All-or-nothing on the aggregates: every captured /usage output shows both
283
- // rows, so a missing one is format drift, and fabricating 0% for it would
284
- // make an unmeasured window read as safe (the one thing it must never do).
285
254
  if (!session || !weekAll) return null;
286
255
  return { session, weekAll, perModel };
287
256
  }
288
257
 
289
- /** Aggregate windows only (session→5h, week-all→7d), for the cold-start fallback. */
290
258
  export function parseUsageText(text: string, now = Date.now()): UsageWindows | null {
291
259
  const f = parseUsageTextFull(text, now);
292
260
  if (!f) return null;
293
261
  return { fiveHour: f.session, sevenDay: f.weekAll };
294
262
  }
295
263
 
296
- /** Env-var identity/credential overrides the claude binary honors BEFORE its
297
- * keychain lookup (verified 2.1.205). A probe MUST scrub every one of these or
298
- * an ambient value silently meters the wrong account. */
299
264
  export const CRED_ENV_OVERRIDES = [
300
265
  "ANTHROPIC_API_KEY",
301
266
  "ANTHROPIC_AUTH_TOKEN",
@@ -307,14 +272,7 @@ export const CRED_ENV_OVERRIDES = [
307
272
  "CLAUDE_CODE_USE_VERTEX",
308
273
  ] as const;
309
274
 
310
- /** One `claude -p '/usage'` invocation → parsed usage, or null if it produced no
311
- * limit lines (claude prints only a local-stats footer when its own usage fetch
312
- * errors/throttles) or failed to run. */
313
- /** A probe child that wedges (auth prompt, dead endpoint) must never block the
314
- * Stop/SessionStart hooks or the status flock forever; a healthy `/usage`
315
- * answers in seconds. */
316
275
  const PROBE_KILL_MS = 60_000;
317
- /** How long after the child's death to keep waiting for pipe EOF. */
318
276
  const PIPE_GRACE_MS = 2_000;
319
277
 
320
278
  const SpawnResultSchema = z.object({
@@ -324,14 +282,6 @@ const SpawnResultSchema = z.object({
324
282
  });
325
283
  type SpawnResult = z.infer<typeof SpawnResultSchema>;
326
284
 
327
- /** Spawn one bounded claude invocation (shared by the `/usage` probe and the
328
- * `--force` ping). SIGKILL after PROBE_KILL_MS: claude traps SIGTERM, and a
329
- * wedged child that survives the kill would keep p.exited pending and re-wedge
330
- * the read race. Descendants inherit the output pipes, so EOF can lag the
331
- * child's death or never arrive at all - a leaked grandchild holding the pipe
332
- * wedged the 2026-07-12 probes forever, defeating the kill guard - so the
333
- * reads are bounded by child-exit + grace instead of awaiting EOF
334
- * unconditionally. Returns null when the pipes were withheld past the grace. */
335
285
  async function spawnClaudeBounded(
336
286
  cmd: string[],
337
287
  env: Record<string, string>,
@@ -380,33 +330,14 @@ async function probeUsageOnce(env: Record<string, string>, now: number): Promise
380
330
  } else {
381
331
  const clockLine = text.split("\n").find((l) => /^current /i.test(l) && /\bresets\b/i.test(l));
382
332
  if (clockLine && [full.session, full.weekAll, ...Object.values(full.perModel)].every((w) => w.resetsAt === null)) {
383
- // Percentages parsed but every reset clock was dropped: the clock format
384
- // drifted again. Log the line so the next drift is visible in the log.
385
333
  log("usage.reset_clock_unparsed", { sample: clockLine.slice(0, 120) });
386
334
  }
387
335
  }
388
336
  return full;
389
337
  }
390
338
 
391
- /** Escalating retry delays for the empty-footer case (usage endpoint throttled
392
- * or the sampled token busy). Capped at ~7s of sleep: probeUsage runs inside
393
- * Stop/SessionStart hooks and under the status flock, and the periodic `check`
394
- * timer re-runs every 3 minutes anyway, owning the long-tail retry. */
395
339
  const PROBE_RETRY_DELAYS_MS = [2000, 5000];
396
340
 
397
- /**
398
- * Run `claude -p '/usage'` (free, 0 tokens) and parse all three limit kinds.
399
- * Pass `configDir` to sample a specific account (its CLAUDE_CONFIG_DIR); omit to
400
- * sample the live account. All ambient credential overrides are scrubbed so the
401
- * probe meters exactly the OAuth credential in the (possibly namespaced)
402
- * keychain item. The empty-footer case (claude's own usage call throttled) is
403
- * transient, so retry with backoff. Returns null if it never yields data.
404
- */
405
- /** The scrubbed env every probe/ping spawn uses. The child spawns the real
406
- * claude DIRECTLY - it never legitimately passes through the wrapper again -
407
- * so the depth is preset to the cap and a poisoned pin that leads back to the
408
- * wrapper aborts on its first entry (the 2026-07-12 ~1800-process recursion
409
- * started as exactly this probe). */
410
341
  function probeEnv(configDir?: string): Record<string, string> {
411
342
  const env: Record<string, string> = { ...process.env, TOKENMAXXING_PROBE: "1", [WRAP_DEPTH_ENV]: String(MAX_WRAP_DEPTH) };
412
343
  for (const k of CRED_ENV_OVERRIDES) delete env[k];
@@ -428,17 +359,6 @@ export async function probeUsage(configDir?: string, now = Date.now()): Promise<
428
359
  }
429
360
  }
430
361
 
431
- // ---- `--force` ping --------------------------------------------------------
432
-
433
- /** The ping is a REAL (but minimal) inference request: `/usage` is free and
434
- * starts nothing, while any metered request opens the account's 5h session
435
- * window at the current instant. haiku: the cheapest model (verified $1/$5 per
436
- * MTok vs $3+ for every other current tier), and one with no per-model weekly
437
- * cap (those exist only for Sonnet and Fable), so a ping never adds to a
438
- * per-model cap the policy gates on; its dent in the aggregate 5h/7d windows
439
- * is negligible. Hooks are disabled for the nested call
440
- * (`--settings`, the only supported way; `--bare` would kill keychain reads)
441
- * even though our own hooks already no-op on the probe env. */
442
362
  const PING_ARGS = [
443
363
  "-p", "Reply with exactly: ok",
444
364
  "--model", "haiku",
@@ -448,14 +368,6 @@ const PING_ARGS = [
448
368
 
449
369
  const PingResultSchema = z.looseObject({ is_error: z.boolean(), result: z.string().optional() });
450
370
 
451
- /**
452
- * Send one minimal metered request so the account's 5h session window starts
453
- * NOW instead of lying dormant until first real use. Pass `configDir` to ping a
454
- * parked account through its isolated dir (credential already installed); omit
455
- * to ping the live login. Runs from an empty scratch cwd so no project context
456
- * (CLAUDE.md, project settings) inflates the request. Returns null on success,
457
- * else the failure reason.
458
- */
459
371
  export async function pingSession(configDir?: string): Promise<string | null> {
460
372
  const cwd = join(paths.sampleDir, "ping-cwd");
461
373
  const fail = (reason: string): string => {
@@ -1,14 +1,6 @@
1
- // Linked-worktree detection for the statusLine. A linked worktree's root holds
2
- // a .git FILE (a gitdir pointer) where the main checkout has a .git directory.
3
- // Read the filesystem directly - never a git subprocess.
4
-
5
1
  import { statSync } from "node:fs";
6
2
  import { basename, dirname, join } from "node:path";
7
3
 
8
- /** The worktree root's basename when `dir` is inside a LINKED git worktree;
9
- * null in a main checkout or outside any repository. Any fs error while
10
- * walking (EACCES, ENOTDIR, stale mounts) is also null: the status line must
11
- * never break over an unreadable path. */
12
4
  export function worktreeName(dir: string): string | null {
13
5
  try {
14
6
  for (let cur = dir; ; cur = dirname(cur)) {
package/src/main.ts CHANGED
@@ -1,13 +1,11 @@
1
1
  #!/usr/bin/env bun
2
- // Single multi-call binary. Behaves as the `claude` supervisor when invoked as
3
- // `claude` (or `__supervise`), routes hook/statusLine subcommands, and otherwise
4
- // dispatches the `tokenmaxxing` CLI.
5
2
 
6
3
  import { basename } from "node:path";
7
4
  import { runSupervisor } from "./entries/supervisor.ts";
8
5
  import { runStatusline } from "./entries/statusline.ts";
9
6
  import { runSubagentStatusline } from "./entries/subagentstatusline.ts";
10
7
  import { runStopHook } from "./entries/stophook.ts";
8
+ import { runStopFailureHook } from "./entries/stopfailurehook.ts";
11
9
  import { runSessionStart } from "./entries/sessionstart.ts";
12
10
  import { cmdInit } from "./cli/init.ts";
13
11
  import { cmdAdd } from "./cli/add.ts";
@@ -27,8 +25,6 @@ import { cmdRename } from "./cli/rename.ts";
27
25
  import { cmdSwitch } from "./cli/switch.ts";
28
26
  import { cmdCheck } from "./cli/check.ts";
29
27
  import { cmdConfig } from "./cli/config.ts";
30
- import { cmdRelay } from "./cli/relay.ts";
31
- import { runRelayPermissionHook } from "./entries/relaypermission.ts";
32
28
  import { timerDeactivationHint, uninstallSupervisor } from "./lib/install.ts";
33
29
  import { c } from "./cli/render.ts";
34
30
 
@@ -49,7 +45,6 @@ function printHelp(): void {
49
45
  ${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
50
46
  ${c.cyan("tokenmaxxing watch")} [seconds] live status: re-render every N seconds (default 120, never pings)
51
47
  ${c.cyan("tokenmaxxing config")} [get|set|unset|tidy] inspect and edit config.json (bare = effective config with sources)
52
- ${c.cyan("tokenmaxxing relay")} … durable tmux relay for host agents (turn/decide/status/…); see ${c.cyan("relay --help")}
53
48
  ${c.cyan("tokenmaxxing doctor")} verify the install is intact
54
49
  ${c.cyan("tokenmaxxing rename")} [--codex] <sel> <label>
55
50
  ${c.cyan("tokenmaxxing rm")} [--codex] <sel>
@@ -67,7 +62,6 @@ async function main(): Promise<number> {
67
62
  const argv0 = basename(process.argv0 || process.argv[0] || "");
68
63
  const sub = args[0];
69
64
 
70
- // supervisor mode: invoked as `claude`, or explicit `__supervise`
71
65
  if (argv0 === "claude" || sub === "__supervise") {
72
66
  return runSupervisor(sub === "__supervise" ? args.slice(1) : args);
73
67
  }
@@ -75,20 +69,7 @@ async function main(): Promise<number> {
75
69
  return runCodexSupervisor({ argv: sub === "__supervise-codex" ? args.slice(1) : args });
76
70
  }
77
71
 
78
- // CLI commands refuse an ambient claude store override (closing-review
79
- // catch): with CLAUDE_CONFIG_DIR set, claude reads a hash-namespaced
80
- // keychain item and a relocated .claude.json while this tool's identity,
81
- // swap, and sampling machinery target the default store - `xx init` would
82
- // silently import whatever stale login lives in the default location. The
83
- // SDK's pooledSpawnEnv fails fast on exactly this; the CLI now matches.
84
- // Scoped to COMMANDS only: the __-entries (hooks/statusline) and the
85
- // supervisor arms above must never break a session claude itself launched
86
- // with that env - sessions run under an ambient override are outside the
87
- // managed envelope, like claude's bg-daemon bypass.
88
72
  if (!(sub != null && sub.startsWith("__")) && !process.env.TOKENMAXXING_PROBE) {
89
- // first NONEMPTY value, secure-storage first (claude's own precedence):
90
- // `??` alone let an empty CLAUDE_CONFIG_DIR mask a set SECURESTORAGE
91
- // override (cubic review catch, PR #35).
92
73
  const nonEmpty = (v: string | undefined) => (v != null && v !== "" ? v : null);
93
74
  const ambient = nonEmpty(process.env.CLAUDE_SECURESTORAGE_CONFIG_DIR) ?? nonEmpty(process.env.CLAUDE_CONFIG_DIR);
94
75
  if (ambient != null) {
@@ -101,20 +82,16 @@ async function main(): Promise<number> {
101
82
  case "__statusline": return runStatusline();
102
83
  case "__subagent-statusline": return runSubagentStatusline();
103
84
  case "__stop-hook": return runStopHook();
85
+ case "__stop-failure-hook": return runStopFailureHook();
104
86
  case "__session-start": return runSessionStart();
105
87
  case "__codex-stop-hook": return runCodexStopHook();
106
- case "__relay-permission-hook": return runRelayPermissionHook();
107
- case undefined: return cmdStatus(); // bare `tokenmaxxing` / `xx` → status
108
- case "--force": return cmdStatus(true); // bare `xx --force` → status --force
109
- // --codex accepted anywhere, like init/add/status: the old args[1]-only
110
- // check made `xx switch <sel> --codex` silently run a real CLAUDE swap
111
- // (one email can hold both pools' accounts - closing-review catch).
112
- case "relay": return cmdRelay(args.slice(1));
88
+ case undefined: return cmdStatus();
89
+ case "--force": return cmdStatus(true);
113
90
  case "switch": {
114
91
  const rest = args.slice(1).filter((a) => a !== "--codex");
115
92
  return args.includes("--codex") ? cmdCodexSwitch(rest[0]) : cmdSwitch(rest[0]);
116
93
  }
117
- case "check": return cmdCheck();
94
+ case "check": return cmdCheck(args.slice(1));
118
95
  case "config": return cmdConfig(args.slice(1));
119
96
  case "init": return args.includes("--codex") ? cmdCodexInit() : cmdInit();
120
97
  case "add": return args.includes("--codex") ? cmdCodexAdd() : cmdAdd();
@@ -123,9 +100,6 @@ async function main(): Promise<number> {
123
100
  case "status": return cmdStatus(args.includes("--force"));
124
101
  case "watch": return cmdWatch(args[1]);
125
102
  case "doctor": return cmdDoctor();
126
- // --codex accepted anywhere, like switch/rename: the two pools are
127
- // separate namespaces and codex accounts were otherwise unremovable
128
- // (adversarial-review catch).
129
103
  case "rm": {
130
104
  const rest = args.slice(1).filter((a) => a !== "--codex");
131
105
  return args.includes("--codex") ? cmdCodexRm(rest[0]) : cmdRm(rest[0]);
@@ -133,9 +107,6 @@ async function main(): Promise<number> {
133
107
  case "rename": return cmdRename(args.slice(1));
134
108
  case "uninstall": {
135
109
  const out = uninstallSupervisor();
136
- // the headline lists only what verifiably happened - claiming the timer
137
- // or PATH line gone while the outcome flags say otherwise would
138
- // contradict the warnings below (bugbot review catch, PR #33).
139
110
  const removed = [
140
111
  "supervisor wrapper",
141
112
  "settings entries",
@@ -160,12 +131,6 @@ async function main(): Promise<number> {
160
131
  }
161
132
  }
162
133
 
163
- // The CLI's error boundary: operational failures that deliberately THROW deep
164
- // in the libs (a locked keychain failing readItem loudly, codexinit's
165
- // changed-mid-init abort, corrupt state files) must reach the user as one
166
- // clean red line with the recovery hint the throw site wrote - not a raw
167
- // stack trace (bugbot review catch, PR #35). The __-entry subcommands keep
168
- // their own never-throw contracts and normally never reach this.
169
134
  try {
170
135
  process.exit(await main());
171
136
  } catch (e) {