tokenmaxxing 0.19.1 → 0.21.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 (59) hide show
  1. package/DESIGN.md +33 -24
  2. package/README.md +4 -4
  3. package/package.json +1 -1
  4. package/src/cli/add.ts +1 -0
  5. package/src/cli/auth.ts +25 -14
  6. package/src/cli/check.ts +3 -2
  7. package/src/cli/codexadd.ts +44 -40
  8. package/src/cli/codexinit.ts +59 -12
  9. package/src/cli/codexswitch.ts +15 -1
  10. package/src/cli/config.ts +10 -1
  11. package/src/cli/doctor.ts +3 -3
  12. package/src/cli/init.ts +54 -32
  13. package/src/cli/onboard.ts +62 -45
  14. package/src/cli/render.ts +0 -16
  15. package/src/cli/rm.ts +40 -2
  16. package/src/cli/serve.ts +629 -115
  17. package/src/cli/status.ts +69 -23
  18. package/src/cli/switch.ts +54 -19
  19. package/src/entries/codexstophook.ts +123 -4
  20. package/src/entries/codexsupervisor.ts +87 -13
  21. package/src/entries/sessionstart.ts +1 -1
  22. package/src/entries/statusline.ts +56 -20
  23. package/src/entries/stophook.ts +23 -9
  24. package/src/entries/supervisor.ts +134 -18
  25. package/src/lib/atomic.ts +28 -6
  26. package/src/lib/claudebin.ts +2 -2
  27. package/src/lib/claudejson.ts +5 -5
  28. package/src/lib/claudelock.ts +112 -37
  29. package/src/lib/codexauth.ts +10 -2
  30. package/src/lib/codexbin.ts +1 -1
  31. package/src/lib/codexdecide.ts +149 -19
  32. package/src/lib/codexpick.ts +17 -6
  33. package/src/lib/codexpresence.ts +59 -21
  34. package/src/lib/codexsample.ts +17 -8
  35. package/src/lib/codexswap.ts +10 -1
  36. package/src/lib/credstore.ts +6 -2
  37. package/src/lib/decide.ts +114 -42
  38. package/src/lib/install.ts +125 -17
  39. package/src/lib/keychain.ts +41 -15
  40. package/src/lib/lock.ts +57 -35
  41. package/src/lib/log.ts +36 -7
  42. package/src/lib/oauth.ts +18 -11
  43. package/src/lib/paths.ts +17 -11
  44. package/src/lib/picker.ts +11 -3
  45. package/src/lib/proc.ts +37 -0
  46. package/src/lib/sample.ts +91 -31
  47. package/src/lib/sessions.ts +23 -1
  48. package/src/lib/settings.ts +59 -18
  49. package/src/lib/slackbridge.ts +574 -81
  50. package/src/lib/slackstate.ts +159 -12
  51. package/src/lib/slackstream.ts +123 -20
  52. package/src/lib/state.ts +131 -35
  53. package/src/lib/swap.ts +109 -47
  54. package/src/lib/types.ts +79 -37
  55. package/src/lib/usage.ts +114 -16
  56. package/src/main.ts +61 -7
  57. package/src/serve-plugin/.claude-plugin/plugin.json +4 -0
  58. package/src/serve-plugin/skills/ask-the-user/SKILL.md +36 -0
  59. package/src/serve-plugin/skills/serve-session/SKILL.md +50 -0
package/src/lib/types.ts CHANGED
@@ -7,7 +7,7 @@ import { z } from "zod";
7
7
 
8
8
  /** OAuth object inside the keychain blob (`claudeAiOauth`). Loose: preserve any
9
9
  * extra fields claude may add so a harvest→install round-trip is lossless. */
10
- export const OAuthCredsSchema = z.looseObject({
10
+ const OAuthCredsSchema = z.looseObject({
11
11
  accessToken: z.string(),
12
12
  refreshToken: z.string(),
13
13
  expiresAt: z.number(),
@@ -22,7 +22,6 @@ export type OAuthCreds = z.infer<typeof OAuthCredsSchema>;
22
22
  * sibling state (e.g. per-MCP-server OAuth tokens), which we must preserve when
23
23
  * swapping - we only ever replace `claudeAiOauth`. */
24
24
  export const CredentialBlobSchema = z.looseObject({ claudeAiOauth: OAuthCredsSchema });
25
- export type CredentialBlob = z.infer<typeof CredentialBlobSchema>;
26
25
 
27
26
  /** The `oauthAccount` identity object in ~/.claude.json. Loose: preserve every
28
27
  * key so we can reinstall it verbatim on activation. Only the three ids are
@@ -48,13 +47,13 @@ export const UsageWindowSchema = z.object({
48
47
  });
49
48
  export type UsageWindow = z.infer<typeof UsageWindowSchema>;
50
49
 
51
- export const UsageWindowsSchema = z.object({
50
+ const UsageWindowsSchema = z.object({
52
51
  fiveHour: UsageWindowSchema,
53
52
  sevenDay: UsageWindowSchema,
54
53
  });
55
54
  export type UsageWindows = z.infer<typeof UsageWindowsSchema>;
56
55
 
57
- export const ModelInfoSchema = z.object({ id: z.string(), display: z.string() });
56
+ const ModelInfoSchema = z.object({ id: z.string(), display: z.string() });
58
57
  export type ModelInfo = z.infer<typeof ModelInfoSchema>;
59
58
 
60
59
  /** usage.json - written by the statusLine shim, read by the Stop hook. Carries
@@ -73,6 +72,12 @@ export const ModelUsageStateSchema = z.object({
73
72
  perModel: z.record(z.string(), UsageWindowSchema).default({}),
74
73
  org: z.string().nullable(),
75
74
  ts: z.number(),
75
+ /** when the rows were actually MEASURED. `ts` is the write time and drives
76
+ * the probe-TTL anti-storm, so a failed probe re-stamps it while carrying
77
+ * the OLD rows forward - dating those rows by ts rolled the null-reset
78
+ * self-bound forward on every failed probe (closing-review catch). Absent
79
+ * on records predating this field: readers fall back to ts. */
80
+ sampledAt: z.number().optional(),
76
81
  });
77
82
  export type ModelUsageState = z.infer<typeof ModelUsageStateSchema>;
78
83
 
@@ -87,9 +92,17 @@ export const AccountSchema = z.object({
87
92
  addedAt: z.string(),
88
93
  lastUsage: UsageWindowsSchema.optional(),
89
94
  lastPerModel: z.record(z.string(), UsageWindowSchema).optional(),
90
- /** epoch ms of the sample behind lastUsage/lastPerModel. Their resetsAt
91
- * values are absolute epochs (UTC-anchored), so even an old snapshot still
92
- * resolves to correct resets - display it as a dated cache, never discard. */
95
+ /** epoch ms of the sample behind lastPerModel SPECIFICALLY: the aggregate
96
+ * stamp (lastUsageAt) advances on every engaged evaluation while the
97
+ * per-model rows refresh only when a gated family is measured, so dating
98
+ * the rows by lastUsageAt inflated the null-reset self-bound by days
99
+ * (closing-review catch). Absent on records predating this field: readers
100
+ * fall back to lastUsageAt, the previous (over-)approximation. */
101
+ lastPerModelAt: z.number().optional(),
102
+ /** epoch ms of the sample behind the AGGREGATE lastUsage windows (per-model
103
+ * rows date by lastPerModelAt above). resetsAt values are absolute epochs
104
+ * (UTC-anchored), so even an old snapshot still resolves to correct
105
+ * resets - display it as a dated cache, never discard. */
93
106
  lastUsageAt: z.number().optional(),
94
107
  needsReauth: z.boolean().optional(),
95
108
  subscriptionType: z.string().optional(),
@@ -120,43 +133,61 @@ export type AccountsIndex = z.infer<typeof AccountsIndexSchema>;
120
133
  * trigger is the greedy pace-pressure convergence (policy.greedySessionFloor). */
121
134
  export const ThresholdsSchema = z.object({
122
135
  /** 5h session window. */
123
- session: z.number(),
136
+ session: z.number().min(0).max(100),
124
137
  /** 7-day aggregate AND per-model weekly caps. */
125
- weekly: z.number(),
138
+ weekly: z.number().min(0).max(100),
126
139
  });
127
140
  export type Thresholds = z.infer<typeof ThresholdsSchema>;
128
141
 
129
- export const ConfigSchema = z.object({
130
- thresholds: ThresholdsSchema,
131
- claudeBin: z.string(),
132
- /** the real codex binary (empty = resolve from PATH); pinned by `init --codex`. */
133
- codexBin: z.string(),
134
- policy: z.object({
135
- projectionMargin: z.number(),
136
- /** session-used % at which the greedy convergence engages: from here on,
137
- * every evaluation swaps to the usable account furthest behind its weekly
138
- * pace whenever that beats the current one (idempotent; current keeps its
139
- * seat on ties). Below the floor a fresh session rides its account. */
140
- greedySessionFloor: z.number(),
141
- /** models whose PER-MODEL weekly cap should trigger a switch (display names, lowercased). */
142
- switchModels: z.array(z.string()),
143
- /** how long a `/usage` per-model poll stays fresh before we re-poll (ms). */
144
- usagePollTtlMs: z.number(),
145
- /** when every account is depleted, auto-wait for a reset only if it is within this window (ms). */
146
- maxWaitMs: z.number(),
147
- }),
148
- });
142
+ export const ConfigSchema = z
143
+ .object({
144
+ thresholds: ThresholdsSchema,
145
+ claudeBin: z.string(),
146
+ /** the real codex binary (empty = resolve from PATH); pinned by `init --codex`. */
147
+ codexBin: z.string(),
148
+ policy: z.object({
149
+ /** percent margin subtracted from every bar (effectiveBars); above 100 the
150
+ * effective bars go negative and everything reads exhausted, so bounded. */
151
+ projectionMargin: z.number().min(0).max(100),
152
+ /** session-used % at which the greedy convergence engages: from here on,
153
+ * every evaluation swaps to the usable account furthest behind its weekly
154
+ * pace whenever that beats the current one (idempotent; current keeps its
155
+ * seat on ties). Below the floor a fresh session rides its account. */
156
+ greedySessionFloor: z.number().min(0).max(100),
157
+ /** models whose PER-MODEL weekly cap should trigger a switch (display names, lowercased). */
158
+ switchModels: z.array(z.string()),
159
+ /** how long a `/usage` per-model poll stays fresh before we re-poll (ms). */
160
+ usagePollTtlMs: z.number().int().positive(),
161
+ /** when every account is depleted, auto-wait for a reset only if it is within this window (ms). */
162
+ maxWaitMs: z.number().int().positive(),
163
+ }),
164
+ })
165
+ // Cross-field (review catch, PR #31): effectiveBars subtracts the margin
166
+ // from each threshold, and a bar at or below zero makes EVERY nonnegative
167
+ // usage percentage read as exhausted - the whole pool looks depleted and the
168
+ // switch path churns. Per-field bounds alone cannot see this.
169
+ .refine((cfg) => cfg.policy.projectionMargin < Math.min(cfg.thresholds.session, cfg.thresholds.weekly), {
170
+ message: "policy.projectionMargin must be strictly below both thresholds (effectiveBars would hit zero and every account would read as exhausted)",
171
+ });
149
172
  export type Config = z.infer<typeof ConfigSchema>;
150
173
 
151
- /** The hook -> supervisor respawn marker at respawn/<session-id>. Written only
152
- * for a depleted-pool wait (plain swaps adopt in place, no respawn). */
174
+ /** The hook -> supervisor respawn marker. Written only for a depleted-pool
175
+ * wait (plain swaps adopt in place, no respawn). The marker FILE is keyed by
176
+ * the supervisor's PINNED session id (the path it watches - stable for the
177
+ * process's whole life), while `sessionId` carries the CURRENT transcript to
178
+ * resume: after /clear claude mints a new session id, and keying the file by
179
+ * the stdin sid orphaned every marker while the anticipatory pre-park still
180
+ * fired (closing-review HIGH catch; the codex marker was immune by exactly
181
+ * this construction). */
153
182
  export const RespawnMarkerSchema = z.object({
154
183
  account: z.string(),
155
184
  ts: z.number(),
156
185
  /** the supervisor waits until this epoch ms before relaunching. */
157
186
  waitUntil: z.number(),
187
+ /** the transcript to `--resume`: the hook-stdin session id, which drifts
188
+ * from the pinned id after /clear. */
189
+ sessionId: z.string(),
158
190
  });
159
- export type RespawnMarker = z.infer<typeof RespawnMarkerSchema>;
160
191
 
161
192
  /** rate_limits + model as they appear in statusLine stdin (epoch-seconds resets). */
162
193
  export const RateLimitsStdinSchema = z.looseObject({
@@ -227,7 +258,6 @@ export const RefreshResponseSchema = z.looseObject({
227
258
  scope: z.string().optional(),
228
259
  token_type: z.string().optional(),
229
260
  });
230
- export type RefreshResponse = z.infer<typeof RefreshResponseSchema>;
231
261
 
232
262
  /** Success body of GET /api/oauth/claude_cli/roles - the org a token ACTUALLY
233
263
  * belongs to, independent of any stored label. */
@@ -242,13 +272,12 @@ export type RolesResponse = z.infer<typeof RolesResponseSchema>;
242
272
  /** `tokens` inside $CODEX_HOME/auth.json (verified against a live 0.144.4
243
273
  * auth.json). Loose: preserve unknown siblings so a harvest and reinstall
244
274
  * round-trip is lossless. */
245
- export const CodexTokensSchema = z.looseObject({
275
+ const CodexTokensSchema = z.looseObject({
246
276
  id_token: z.string(),
247
277
  access_token: z.string(),
248
278
  refresh_token: z.string(),
249
279
  account_id: z.string().optional(),
250
280
  });
251
- export type CodexTokens = z.infer<typeof CodexTokensSchema>;
252
281
 
253
282
  /** The whole auth.json. Loose: auth_mode, OPENAI_API_KEY (may be null), and
254
283
  * future siblings ride along verbatim. */
@@ -263,7 +292,7 @@ export type CodexAuthJson = z.infer<typeof CodexAuthJsonSchema>;
263
292
  * duration-driven (the weekly window is PRIMARY on plans whose 5h window was
264
293
  * removed in July 2026), so classification must go by windowSeconds, never
265
294
  * by primary/secondary position. */
266
- export const CodexWindowSchema = z.object({
295
+ const CodexWindowSchema = z.object({
267
296
  usedPercentage: z.number(),
268
297
  resetsAt: z.number().nullable(),
269
298
  windowSeconds: z.number().nullable(),
@@ -322,4 +351,17 @@ export const CodexRespawnMarkerSchema = z.object({
322
351
  sessionId: z.string().nullable(),
323
352
  ts: z.number(),
324
353
  });
325
- export type CodexRespawnMarker = z.infer<typeof CodexRespawnMarkerSchema>;
354
+
355
+ /** A cross-session reconcile signal (owner decisions 2026-07-20): the
356
+ * deciding actor saw the addressed supervisor's session running on this
357
+ * pooled NON-LIVE account - healthy or not (a non-live session cannot
358
+ * refresh cross-account and wedges at token expiry) - while the live seat
359
+ * is usable. The session's OWN Stop hook consumes it at its next turn
360
+ * boundary - the only safe respawn point - promoting it into a respawn
361
+ * marker with the session id its stdin alone carries. `accountId` doubles
362
+ * as the staleness guard: a session that already moved accounts drops the
363
+ * signal instead of respawning. */
364
+ export const CodexReconcileMarkerSchema = z.object({
365
+ accountId: z.string(),
366
+ ts: z.number(),
367
+ });
package/src/lib/usage.ts CHANGED
@@ -9,8 +9,11 @@ import { join } from "node:path";
9
9
  import { delay } from "es-toolkit";
10
10
  import { z } from "zod";
11
11
  import { MAX_WRAP_DEPTH, WRAP_DEPTH_ENV, resolveRealClaude } from "./claudebin.ts";
12
+ import { readOAuthAccount } from "./claudejson.ts";
13
+ import { withLock } from "./lock.ts";
12
14
  import { log } from "./log.ts";
13
15
  import { paths } from "./paths.ts";
16
+ import { loadUsage, writeUsage } from "./state.ts";
14
17
  import { RateLimitsStdinSchema, UsageWindowSchema, type ModelInfo, type UsageWindow, type UsageWindows } from "./types.ts";
15
18
 
16
19
  /** Normalize a resets_at value (epoch s, epoch ms, or ISO string) to epoch ms. */
@@ -29,17 +32,19 @@ export function normalizeResetsAt(v: unknown): number | null {
29
32
  return null;
30
33
  }
31
34
 
32
- const win = (w?: { used_percentage: number; resets_at?: number | null }): UsageWindow => ({
33
- usedPercentage: w?.used_percentage ?? 0,
34
- resetsAt: normalizeResetsAt(w?.resets_at),
35
+ const win = (w: { used_percentage: number; resets_at?: number | null }): UsageWindow => ({
36
+ usedPercentage: w.used_percentage,
37
+ resetsAt: normalizeResetsAt(w.resets_at),
35
38
  });
36
39
 
37
- /** Extract the two AGGREGATE windows from statusLine stdin. null if absent. */
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. */
38
43
  export function parseStatusLineStdin(obj: unknown): UsageWindows | null {
39
44
  const parsed = RateLimitsStdinSchema.safeParse(obj);
40
45
  if (!parsed.success) return null;
41
46
  const rl = parsed.data.rate_limits;
42
- if (!rl || (!rl.five_hour && !rl.seven_day)) return null;
47
+ if (!rl?.five_hour || !rl.seven_day) return null;
43
48
  return { fiveHour: win(rl.five_hour), sevenDay: win(rl.seven_day) };
44
49
  }
45
50
 
@@ -153,11 +158,105 @@ export function parseResetClock(clock: string, now = Date.now()): number | null
153
158
  return best;
154
159
  }
155
160
 
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
+ export function fmtResetShort(epochMs: number | null | undefined, now = Date.now()): string {
168
+ if (epochMs == null) return "";
169
+ const dsec = Math.round((epochMs - now) / 1000);
170
+ if (dsec <= 0) return "";
171
+ const d = Math.floor(dsec / 86400);
172
+ const h = Math.floor((dsec % 86400) / 3600);
173
+ const m = Math.floor((dsec % 3600) / 60);
174
+ if (d > 0) return `${d}d`;
175
+ if (h > 0) return `${h}h`;
176
+ return `${Math.max(m, 1)}m`;
177
+ }
178
+
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);
186
+ 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;
191
+ }
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
+ }
199
+
200
+ /**
201
+ * Persist a limit observed in a turn RESULT into usage.json so the next
202
+ * decision sees the depleted account immediately. A headless serve/SDK process
203
+ * has no statusLine tee and `loadFreshSnapshots` skips re-probing inside the
204
+ * poll TTL (and `/usage` is fail-silent against the just-limited active token
205
+ * anyway), so without this write a post-limit retry re-decides off the stale
206
+ * pre-limit snapshot and respawns the same depleted account. The session
207
+ * window is stamped 100% with the announced reset: whichever window actually
208
+ * tripped, the account is unusable until then, and the hard path swaps away.
209
+ * `org` is the identity captured AT THE SPAWN BOUNDARY of the turn that
210
+ * failed; the write happens only when that identity is known and still live,
211
+ * so a concurrent thread's mid-turn swap can never get its fresh account
212
+ * stamped depleted by this turn's failure (review catch, PR #18). Without a
213
+ * same-org prior snapshot there is nothing safe to write (a synthetic weekly
214
+ * value would flow into `account.lastUsage` and poison the picker's ranking;
215
+ * unmeasured must not look fresh), so the observation is dropped and the
216
+ * retry stays merely bounded.
217
+ *
218
+ * INTENTIONAL TRADEOFF (closing-review critic gap, 2026-07-20): the statusline
219
+ * tee (writeUsage) is deliberately UNLOCKED - the shim stays off flock/oauth -
220
+ * so it can interleave with this locked check-then-write. Last-writer-wins is
221
+ * semantically safe in both orderings: a tee landing after this stamp replaces
222
+ * it with a FRESHER live measurement (truth wins), and this stamp landing
223
+ * after a tee replaces pre-limit figures with the observed limit the server
224
+ * just enforced (also truth). Neither writer can write a stale fabrication
225
+ * over the other; locking the shim to close the interleave would buy nothing.
226
+ */
227
+ export async function recordObservedLimit(input: { text: string; now: number; org: string | null }): Promise<void> {
228
+ if (!input.org) return;
229
+ // the whole check-then-write runs under the swap flock: a concurrent
230
+ // performSwap clears the snapshots and flips the live org, and a stale
231
+ // depleted write must not land for the wrong org right after that
232
+ // (review catch, PR #18).
233
+ await withLock(paths.lockFile, () => {
234
+ const live = readOAuthAccount()?.organizationUuid ?? null;
235
+ if (live !== input.org) return;
236
+ const prior = loadUsage();
237
+ if (!prior || prior.org !== input.org) return;
238
+ const resetsAt = parseUsageLimitEpoch({ text: input.text });
239
+ // a weekly-phrased limit exhausts the WEEKLY window: stamping only the 5h
240
+ // window would let the picker re-seat this account in 5h while the weekly
241
+ // cap stays dead for days (review catch, PR #18). Unknown-reset blocked
242
+ // windows self-bound at the window's own duration either way.
243
+ const weekly = input.text.toLowerCase().includes("weekly");
244
+ writeUsage({
245
+ fiveHour: weekly ? prior.fiveHour : { usedPercentage: 100, resetsAt },
246
+ sevenDay: weekly ? { usedPercentage: 100, resetsAt } : prior.sevenDay,
247
+ org: input.org,
248
+ ts: input.now,
249
+ model: prior.model,
250
+ });
251
+ log("usage.observed_limit", { resetsAt, weekly });
252
+ });
253
+ }
254
+
156
255
  /**
157
256
  * Parse `claude -p '/usage'` .result text into all three limit kinds:
158
- * Current session: N% used · resets <clock> → session (5h)
159
- * Current week (all models): N% used · resets ... → weekAll (7d aggregate)
160
- * Current week (<Model>): N% used · resets ... → perModel[<Model>]
257
+ * Current session: N% used ... resets <clock> → session (5h)
258
+ * Current week (all models): N% used ... resets ... → weekAll (7d aggregate)
259
+ * Current week (<Model>): N% used ... resets ... → perModel[<Model>]
161
260
  */
162
261
  export function parseUsageTextFull(text: string, now = Date.now()): FullUsage | null {
163
262
  if (!text) return null;
@@ -179,12 +278,11 @@ export function parseUsageTextFull(text: string, now = Date.now()): FullUsage |
179
278
  else if (/^all models$/i.test(m[2]!.trim())) weekAll = window;
180
279
  else perModel[m[2]!.trim()] = window;
181
280
  }
182
- if (!session && !weekAll && Object.keys(perModel).length === 0) return null;
183
- return {
184
- session: session ?? { usedPercentage: 0, resetsAt: null },
185
- weekAll: weekAll ?? { usedPercentage: 0, resetsAt: null },
186
- perModel,
187
- };
281
+ // All-or-nothing on the aggregates: every captured /usage output shows both
282
+ // rows, so a missing one is format drift, and fabricating 0% for it would
283
+ // make an unmeasured window read as safe (the one thing it must never do).
284
+ if (!session || !weekAll) return null;
285
+ return { session, weekAll, perModel };
188
286
  }
189
287
 
190
288
  /** Aggregate windows only (session→5h, week-all→7d), for the cold-start fallback. */
@@ -269,7 +367,7 @@ async function probeUsageOnce(env: Record<string, string>, now: number): Promise
269
367
  }
270
368
  out = r.stdout;
271
369
  } catch (e) {
272
- log("usage.probe_failed", { err: String((e as Error).message ?? e) });
370
+ log("usage.probe_failed", { err: e instanceof Error ? e.message : String(e) });
273
371
  return null;
274
372
  }
275
373
 
@@ -368,7 +466,7 @@ export async function pingSession(configDir?: string): Promise<string | null> {
368
466
  mkdirSync(cwd, { recursive: true });
369
467
  r = await spawnClaudeBounded([resolveRealClaude(), ...PING_ARGS], probeEnv(configDir), cwd);
370
468
  } catch (e) {
371
- return fail(String((e as Error).message ?? e));
469
+ return fail(e instanceof Error ? e.message : String(e));
372
470
  }
373
471
  if (r === null) return fail("output pipes still open after child exit (leaked descendant)");
374
472
  if (r.exitCode !== 0) return fail(`claude exited ${r.exitCode ?? "on signal"}: ${(r.stderr.trim() || r.stdout.trim()).slice(0, 160)}`);
package/src/main.ts CHANGED
@@ -3,7 +3,9 @@
3
3
  // `claude` (or `__supervise`), routes hook/statusLine subcommands, and otherwise
4
4
  // dispatches the `tokenmaxxing` CLI.
5
5
 
6
+ import { existsSync } from "node:fs";
6
7
  import { basename } from "node:path";
8
+ import { paths } from "./lib/paths.ts";
7
9
  import { runSupervisor } from "./entries/supervisor.ts";
8
10
  import { runStatusline } from "./entries/statusline.ts";
9
11
  import { runSubagentStatusline } from "./entries/subagentstatusline.ts";
@@ -27,7 +29,7 @@ import { cmdSwitch } from "./cli/switch.ts";
27
29
  import { cmdCheck } from "./cli/check.ts";
28
30
  import { cmdConfig } from "./cli/config.ts";
29
31
  import { cmdServe } from "./cli/serve.ts";
30
- import { uninstallSupervisor } from "./lib/install.ts";
32
+ import { timerDeactivationHint, uninstallSupervisor } from "./lib/install.ts";
31
33
  import { c } from "./cli/render.ts";
32
34
 
33
35
  function printHelp(): void {
@@ -47,7 +49,7 @@ function printHelp(): void {
47
49
  ${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
48
50
  ${c.cyan("tokenmaxxing watch")} [seconds] live status: re-render every N seconds (default 120, never pings)
49
51
  ${c.cyan("tokenmaxxing config")} [get|set|unset|tidy] inspect and edit config.json (bare = effective config with sources)
50
- ${c.cyan("tokenmaxxing serve")} [setup|link|unlink|links] Slack bridge daemon: mention the bot in a linked channel to open a claude session per thread (worktree by default)
52
+ ${c.cyan("tokenmaxxing serve")} [setup|link|unlink|links] Slack bridge daemon: mention the bot in a linked channel to open a claude session per thread in the repo checkout
51
53
  ${c.cyan("tokenmaxxing doctor")} verify the install is intact
52
54
  ${c.cyan("tokenmaxxing rename")} [--codex] <sel> <label>
53
55
  ${c.cyan("tokenmaxxing rm")} <sel>
@@ -73,6 +75,28 @@ async function main(): Promise<number> {
73
75
  return runCodexSupervisor({ argv: sub === "__supervise-codex" ? args.slice(1) : args });
74
76
  }
75
77
 
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
+ 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
+ const nonEmpty = (v: string | undefined) => (v != null && v !== "" ? v : null);
93
+ const ambient = nonEmpty(process.env.CLAUDE_SECURESTORAGE_CONFIG_DIR) ?? nonEmpty(process.env.CLAUDE_CONFIG_DIR);
94
+ if (ambient != null) {
95
+ console.error(c.red(`CLAUDE_CONFIG_DIR / CLAUDE_SECURESTORAGE_CONFIG_DIR is set (${ambient}): claude uses a namespaced credential store there that tokenmaxxing does not manage - unset it (or run from a clean shell) and retry.`));
96
+ return 1;
97
+ }
98
+ }
99
+
76
100
  switch (sub) {
77
101
  case "__statusline": return runStatusline();
78
102
  case "__subagent-statusline": return runSubagentStatusline();
@@ -81,7 +105,13 @@ async function main(): Promise<number> {
81
105
  case "__codex-stop-hook": return runCodexStopHook();
82
106
  case undefined: return cmdStatus(); // bare `tokenmaxxing` / `xx` → status
83
107
  case "--force": return cmdStatus(true); // bare `xx --force` → status --force
84
- case "switch": return args[1] === "--codex" ? cmdCodexSwitch(args[2]) : cmdSwitch(args[1]);
108
+ // --codex accepted anywhere, like init/add/status: the old args[1]-only
109
+ // check made `xx switch <sel> --codex` silently run a real CLAUDE swap
110
+ // (one email can hold both pools' accounts - closing-review catch).
111
+ case "switch": {
112
+ const rest = args.slice(1).filter((a) => a !== "--codex");
113
+ return args.includes("--codex") ? cmdCodexSwitch(rest[0]) : cmdSwitch(rest[0]);
114
+ }
85
115
  case "check": return cmdCheck();
86
116
  case "config": return cmdConfig(args.slice(1));
87
117
  case "serve": return cmdServe(args.slice(1));
@@ -94,10 +124,23 @@ async function main(): Promise<number> {
94
124
  case "doctor": return cmdDoctor();
95
125
  case "rm": return cmdRm(args[1]);
96
126
  case "rename": return cmdRename(args.slice(1));
97
- case "uninstall":
98
- uninstallSupervisor();
99
- console.log("removed supervisor wrapper + settings entries (accounts/credentials kept)");
127
+ case "uninstall": {
128
+ const out = uninstallSupervisor();
129
+ // the headline lists only what verifiably happened - claiming the timer
130
+ // or PATH line gone while the outcome flags say otherwise would
131
+ // contradict the warnings below (bugbot review catch, PR #33).
132
+ const removed = [
133
+ "supervisor wrapper",
134
+ "settings entries",
135
+ ...(out.timerDeactivated ? ["check timer"] : []),
136
+ ...(out.pathLineRemoved ? ["rc PATH line"] : []),
137
+ ];
138
+ console.log(`removed ${removed.join(", ")}`);
139
+ if (!out.timerDeactivated) console.log(c.yellow(`⚠ the check job may still be loaded - run: ${timerDeactivationHint()}`));
140
+ if (!out.pathLineRemoved) console.log(c.dim("(no tokenmaxxing PATH line found in the shell rc)"));
141
+ console.log(`kept: accounts.json, config.json${existsSync(paths.slackJson) ? ", slack.json (Slack tokens)" : ""}, and every parked credential (claude - macOS: keychain items, Linux: creds/; codex: codex-creds/) - remove accounts with \`xx rm\` to delete their credentials`);
100
142
  return 0;
143
+ }
101
144
  case "help":
102
145
  case "-h":
103
146
  case "--help":
@@ -110,4 +153,15 @@ async function main(): Promise<number> {
110
153
  }
111
154
  }
112
155
 
113
- process.exit(await main());
156
+ // The CLI's error boundary: operational failures that deliberately THROW deep
157
+ // in the libs (a locked keychain failing readItem loudly, codexinit's
158
+ // changed-mid-init abort, corrupt state files) must reach the user as one
159
+ // clean red line with the recovery hint the throw site wrote - not a raw
160
+ // stack trace (bugbot review catch, PR #35). The __-entry subcommands keep
161
+ // their own never-throw contracts and normally never reach this.
162
+ try {
163
+ process.exit(await main());
164
+ } catch (e) {
165
+ console.error(c.red(e instanceof Error ? e.message : String(e)));
166
+ process.exit(1);
167
+ }
@@ -0,0 +1,4 @@
1
+ {
2
+ "name": "tokenmaxxing",
3
+ "description": "Skills for Claude Code sessions relayed through tokenmaxxing serve (the Slack bridge)"
4
+ }
@@ -0,0 +1,36 @@
1
+ ---
2
+ name: ask-the-user
3
+ description: Use when you need the requesting user's decision, approval, or missing information to proceed, when you are blocked, or when long-running work they asked about is done, in a Slack-relayed tokenmaxxing serve session. Explains how to ask so the user actually gets notified.
4
+ ---
5
+
6
+ # Asking the user from a serve session
7
+
8
+ This session is relayed into a Slack thread. There is no interactive prompt:
9
+ the AskUserQuestion tool is unavailable, and only what you write in your reply
10
+ reaches anyone. A plain reply does not notify the user; a mention does.
11
+
12
+ ## How to ask
13
+
14
+ 1. Write the question into your reply and tag the requester by including their
15
+ mention token literally in the text: `<@U...>`, using the requester id from
16
+ the "Slack relay context" note in this turn. Slack renders it as @name and
17
+ notifies them.
18
+ 2. State the fork in one short paragraph: what you were doing, the options,
19
+ which one you recommend and why. One question at a time.
20
+ 3. End the turn after asking. Do not pick a real fork's option unilaterally,
21
+ do not busy-wait, and do not keep working past the fork: the user's thread
22
+ reply arrives as your next turn and continues this same session.
23
+
24
+ ## When to ask (and tag)
25
+
26
+ - A real decision fork: destructive or irreversible actions, spending money or
27
+ metering account quota, a policy or design choice the user would want to
28
+ make, missing credentials or facts only they have.
29
+ - You are blocked and cannot proceed.
30
+ - You finished long-running work they asked to be told about.
31
+
32
+ ## When not to tag
33
+
34
+ - Routine replies and progress: the user reads the thread, and a mention that
35
+ is not actionable trains them to ignore the real ones.
36
+ - Never mention @here, @channel, or @everyone.
@@ -0,0 +1,50 @@
1
+ ---
2
+ name: serve-session
3
+ description: How a tokenmaxxing serve session runs - the shared repo checkout, session resume across turns, when to cut your own git worktree, and how to hand finished work back. Use when deciding where to work, commit, push, or open a PR, or before any operation that moves, deletes, or switches the working directory.
4
+ ---
5
+
6
+ # How a serve session runs
7
+
8
+ tokenmaxxing serve bridges a Slack thread to this Claude Code session. Every
9
+ thread message becomes one turn; your streamed output posts back into the
10
+ thread as Slack messages.
11
+
12
+ ## Working directory
13
+
14
+ - This session runs directly IN the linked repo checkout, and it is SHARED:
15
+ the repo's owner and other Slack threads work in the same checkout at the
16
+ same time. Read-only work (answering questions, reviewing code, reading
17
+ logs or history) happens here freely and in parallel. Uncommitted changes
18
+ you do not recognize belong to someone else - never reset, restore, or
19
+ stash over them; stage only your own hunks; on a collision, stop and ask
20
+ (see the ask-the-user skill).
21
+ - MUTATING work cuts its own worktree FIRST (`git worktree add`). If the
22
+ task will edit, create, move, or delete files - or run anything that
23
+ writes into the tree - do it from a fresh worktree by default: other
24
+ threads and the owner share this checkout, and two agents editing one tree
25
+ corrupt each other silently. Say in the thread that you cut one, do the
26
+ task's whole life there (follow-ups included), and remove it once the work
27
+ is merged. Skip the worktree only when the user explicitly asked for a
28
+ change in this checkout itself.
29
+ - Your session's cwd stays the SHARED CHECKOUT on every turn regardless (each
30
+ turn is a fresh process at the recorded cwd, and resume is keyed to it, so
31
+ the daemon can never move it). Reach your worktree by absolute path - or
32
+ `cd` inside each command - every turn; never rely on a previous turn's
33
+ `cd`, and re-read the thread for the worktree path when resuming a task.
34
+ - Session resume is keyed to the session's cwd. Never delete or move it, and
35
+ never switch the checkout's branch without asking: the thread (and other
36
+ people's work) would break.
37
+
38
+ ## Turns
39
+
40
+ - Each turn is a fresh claude process resumed by session id: the conversation
41
+ transcript carries over, but background processes and unsaved in-memory
42
+ state do not. Persist anything a later turn needs to files.
43
+ - Nothing can answer an interactive dialog mid-turn. To get the user's input,
44
+ follow the ask-the-user skill.
45
+
46
+ ## Handing work back
47
+
48
+ - Follow the repo's own shipping conventions (its AGENTS.md or CLAUDE.md);
49
+ when in doubt, branch and open a PR rather than committing to the default
50
+ branch. Push or merge only when the user asks.