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/paths.ts CHANGED
@@ -1,21 +1,15 @@
1
- // Central path resolution. EVERY externally-observable path is overridable via an
2
- // env var so the whole tool can run hermetically in tests without touching the
3
- // user's real ~/.config, ~/.claude, or login keychain.
4
-
5
1
  import { homedir } from "node:os";
6
2
  import { join } from "node:path";
7
3
  import { z } from "zod";
8
4
 
9
5
  const HOME = homedir();
10
6
 
11
- /** A set env override; empty or unset parses to undefined and the fallback applies. */
12
7
  const EnvOverrideSchema = z.string().min(1).optional().catch(undefined);
13
8
 
14
9
  function env(name: string, fallback: string): string {
15
10
  return EnvOverrideSchema.parse(process.env[name]) ?? fallback;
16
11
  }
17
12
 
18
- /** Root of all tokenmaxxing config + state. Default ~/.config/tokenmaxxing. */
19
13
  const TM_HOME = env("TOKENMAXXING_HOME", join(HOME, ".config", "tokenmaxxing"));
20
14
 
21
15
  export const paths = {
@@ -25,8 +19,8 @@ export const paths = {
25
19
  usageJson: join(TM_HOME, "usage.json"),
26
20
  modelUsageJson: join(TM_HOME, "model-usage.json"),
27
21
  lastSwapJson: join(TM_HOME, "lastswap.json"),
28
- /** the last depleted-wait decision, replayed to sibling hooks (self-expiring). */
29
22
  depletedJson: join(TM_HOME, "depleted.json"),
23
+ nextCheckJson: join(TM_HOME, "nextcheck.json"),
30
24
  respawnDir: join(TM_HOME, "respawn"),
31
25
  binDir: join(TM_HOME, "bin"),
32
26
  supervisorLink: join(TM_HOME, "bin", "claude"),
@@ -34,105 +28,64 @@ export const paths = {
34
28
  logFile: join(TM_HOME, "tokenmaxxing.log"),
35
29
  onboardDir: join(TM_HOME, "onboard"),
36
30
  sampleDir: join(TM_HOME, "sample"),
37
- /** linux only: parked credential .json files (0700 dir, 0600 files). */
38
31
  credsDir: join(TM_HOME, "creds"),
39
- /** Durable tmux relay companion: config + per-session state (not respawn/). */
40
- relayJson: join(TM_HOME, "relay.json"),
41
- relayDir: join(TM_HOME, "relay"),
42
32
 
43
- /** ~/.claude.json - holds the active `oauthAccount` identity object. */
44
33
  claudeJson: env("TOKENMAXXING_CLAUDE_JSON", join(HOME, ".claude.json")),
45
- /** ~/.claude/settings.json - user-owned; we merge four entries into it. */
46
34
  claudeSettings: env(
47
35
  "TOKENMAXXING_CLAUDE_SETTINGS",
48
36
  join(env("CLAUDE_CONFIG_DIR", join(HOME, ".claude")), "settings.json"),
49
37
  ),
50
- /** ~/.claude - claude's config dir (settings.json, projects/ transcripts;
51
- * credDir() falls back to it). */
52
38
  claudeDir: env("CLAUDE_CONFIG_DIR", join(HOME, ".claude")),
53
39
 
54
- /** where the periodic-check timer units live (launchd / systemd user). */
55
40
  launchdAgentsDir: env("TOKENMAXXING_LAUNCHD_DIR", join(HOME, "Library", "LaunchAgents")),
56
41
  systemdUserDir: env("TOKENMAXXING_SYSTEMD_USER_DIR", join(HOME, ".config", "systemd", "user")),
57
42
  } as const;
58
43
 
59
- /** Codex home: where the live auth.json lives. Test override first, then
60
- * codex's own CODEX_HOME env, then its default ~/.codex. */
61
44
  const CODEX_HOME = env("TOKENMAXXING_CODEX_HOME", env("CODEX_HOME", join(HOME, ".codex")));
62
45
 
63
46
  export const codexPaths = {
64
47
  home: CODEX_HOME,
65
- /** the live credential file (codex file-mode store; verified 0.144.4/5). */
66
48
  authJson: join(CODEX_HOME, "auth.json"),
67
- /** user-level hook declarations codex reads (verified against the binary + docs). */
68
49
  hooksJson: join(CODEX_HOME, "hooks.json"),
69
- /** tokenmaxxing's codex pool state, parallel to the claude files in TM_HOME. */
70
50
  accountsJson: join(TM_HOME, "codex-accounts.json"),
71
51
  lastSwapJson: join(TM_HOME, "codex-lastswap.json"),
72
52
  lockFile: join(TM_HOME, "codex-lock"),
73
- /** parked auth.json blobs: 0600 files on BOTH platforms (codex's own store is
74
- * a plaintext file, and parked blobs at ~6KB would risk the security(1)
75
- * write-size trap that once truncated a 4.3KB claude blob). */
76
53
  credsDir: join(TM_HOME, "codex-creds"),
77
54
  onboardDir: join(TM_HOME, "codex-onboard"),
78
55
  respawnDir: join(TM_HOME, "codex-respawn"),
79
- /** one file per RUNNING supervised codex session: {accountId, pid, ts}. A
80
- * running account's parked token must never be refreshed or targeted (its
81
- * live rotations supersede the parked copy, and reuse is punished). */
82
56
  presenceDir: join(TM_HOME, "codex-live"),
83
- /** cross-session reconcile signals, one file per supervisorId: a deciding
84
- * actor saw that supervisor's session running on a pooled NON-LIVE account
85
- * (healthy or not - owner decisions 2026-07-20) while the live seat is
86
- * usable; the session's OWN Stop hook promotes the signal into a respawn
87
- * marker at its next turn boundary. */
88
57
  reconcileDir: join(TM_HOME, "codex-reconcile"),
89
58
  } as const;
90
59
 
91
- /** Per-account parked codex credential file name: tokenmaxxing-codex-<id8>. */
92
60
  export function codexCredItemFor(accountId: string): string {
93
61
  return `tokenmaxxing-codex-${accountId.slice(0, 8)}`;
94
62
  }
95
63
 
96
- /** The macOS login-keychain generic-password the live `claude` reads. */
97
64
  export const keychain = {
98
65
  service: env("TOKENMAXXING_KEYCHAIN_SERVICE", "Claude Code-credentials"),
99
66
  account: env("TOKENMAXXING_KEYCHAIN_ACCOUNT", process.env.USER ?? "unknown"),
100
67
  } as const;
101
68
 
102
- /** Per-account parked credential item name: tokenmaxxing-cred-<accountUuid[:8]>. */
103
69
  export function credItemFor(accountUuid: string): string {
104
70
  return `tokenmaxxing-cred-${accountUuid.slice(0, 8)}`;
105
71
  }
106
72
 
107
- /**
108
- * The dir whose `.credentials.json` is claude's live credential on linux -
109
- * mirrors claude's own resolution (verified 2.1.205 `Wde()`): the
110
- * CLAUDE_SECURESTORAGE_CONFIG_DIR override is checked FIRST when defined
111
- * (defined-but-empty falls to ~/.claude, NFC-normalized), else the config dir.
112
- */
113
73
  export function credDir(): string {
114
74
  const secure = process.env.CLAUDE_SECURESTORAGE_CONFIG_DIR;
115
75
  if (secure !== undefined) return (secure || join(HOME, ".claude")).normalize("NFC");
116
76
  return paths.claudeDir;
117
77
  }
118
78
 
119
- /**
120
- * The keychain service claude uses when CLAUDE_CONFIG_DIR is set:
121
- * `Claude Code-credentials-<first 8 hex of sha256(NFC(raw dir string))>`.
122
- * Hash is over the RAW string, so the dir must be byte-stable.
123
- */
124
79
  export function namespacedCredService(configDirRaw: string): string {
125
80
  const h = new Bun.CryptoHasher("sha256");
126
81
  h.update(configDirRaw.normalize("NFC"));
127
82
  return `Claude Code-credentials-${h.digest("hex").slice(0, 8)}`;
128
83
  }
129
84
 
130
- /** Resolve the REAL claude binary (never our shim). Order: explicit env, config, PATH scan. */
131
85
  export function realClaudeBinFromEnv(): string | undefined {
132
86
  return EnvOverrideSchema.parse(process.env.TOKENMAXXING_CLAUDE_BIN);
133
87
  }
134
88
 
135
- /** Same override hook for the real codex binary (tests / relocation). */
136
89
  export function realCodexBinFromEnv(): string | undefined {
137
90
  return EnvOverrideSchema.parse(process.env.TOKENMAXXING_CODEX_BIN);
138
91
  }
package/src/lib/picker.ts CHANGED
@@ -1,25 +1,8 @@
1
- // Choose the account to switch TO. Greedy policy: among usable accounts (no
2
- // reauth, no window >= threshold that hasn't reset yet), take the one furthest
3
- // behind its own weekly pace - highest pacePressure, the burn rate its
4
- // remaining weekly quota demands to be consumed at before the fixed
5
- // per-account reset forfeits it (weekly allowance is use-it-or-lose-it).
6
- // This refines the older soonest-expiry policy in both directions: equal
7
- // remaining reduces to soonest expiry first, equal expiry to most remaining
8
- // first. Runs entirely off each account's cached windows (absolute UTC
9
- // epochs, so a stale snapshot still resolves to the correct upcoming reset),
10
- // which makes the pick deterministic and idempotent: re-running lands on the
11
- // same account.
12
-
13
1
  import { minBy, sortBy } from "es-toolkit";
14
2
  import { z } from "zod";
15
3
  import { familyTokens } from "./usage.ts";
16
4
  import { AccountSchema, ThresholdsSchema, type Account, type Config, type Thresholds, type UsageWindow } from "./types.ts";
17
5
 
18
- /** The effective per-window bars: configured thresholds minus the projection
19
- * margin. ONE bar per window for BOTH the trigger and candidate screening -
20
- * a screen laxer than the trigger would land swaps on accounts the trigger
21
- * immediately re-flags (cooldown-throttled ping-pong). Every PickCtx and the
22
- * trigger floors must be built from this. */
23
6
  export function effectiveBars(cfg: Config): Thresholds {
24
7
  return {
25
8
  session: cfg.thresholds.session - cfg.policy.projectionMargin,
@@ -27,16 +10,6 @@ export function effectiveBars(cfg: Config): Thresholds {
27
10
  };
28
11
  }
29
12
 
30
- /** LAYER 2 - the wall bars. The projection margin is subtracted the SAME way
31
- * effectiveBars does it, for two reasons: (1) it keeps the documented disable
32
- * contract honest - `hardThresholds == thresholds` then yields hardBars ==
33
- * effectiveBars, so Layer 2 has no band to act in and is truly off (without
34
- * the margin here a nonzero margin left a live band between the two, review
35
- * catch PR #47); (2) at the default margin 0 the wall is still the literal 100
36
- * (the server's own figure /rate-limit-options reads). Used only in the
37
- * all-Layer-1-exhausted fallback, where an account under its wall is still
38
- * worth squeezing. Config's refine (hardThresholds >= thresholds) guarantees
39
- * hardBars >= effectiveBars, so Layer 2 is never stricter than Layer 1. */
40
13
  export function hardBars(cfg: Config): Thresholds {
41
14
  return {
42
15
  session: cfg.hardThresholds.session - cfg.policy.projectionMargin,
@@ -47,16 +20,11 @@ export function hardBars(cfg: Config): Thresholds {
47
20
  const PickCtxSchema = z.object({
48
21
  now: z.number(),
49
22
  thresholds: ThresholdsSchema,
50
- /** account to exclude (hooks switch AWAY from it); null ranks everyone. */
51
23
  currentAccountUuid: z.string().nullable(),
52
- /** families whose per-model weekly cap counts toward exhaustion (from
53
- * gatedFamilies): a candidate with a burnt gated cap is no switch target -
54
- * landing on it would re-trigger the same gate and ping-pong the pool. */
55
24
  switchFamilies: z.array(z.string()),
56
25
  });
57
26
  export type PickCtx = z.infer<typeof PickCtxSchema>;
58
27
 
59
- /** The account's cached per-model windows that belong to a gated family. */
60
28
  function gatedPerModelWindows(a: Account, families: string[]): UsageWindow[] {
61
29
  return Object.entries(a.lastPerModel ?? {})
62
30
  .filter(([model]) => families.some((f) => familyTokens(model).includes(f)))
@@ -66,23 +34,12 @@ function gatedPerModelWindows(a: Account, families: string[]): UsageWindow[] {
66
34
  const WEEK_MS = 7 * 24 * 60 * 60 * 1000;
67
35
  const FIVE_HOURS_MS = 5 * 60 * 60 * 1000;
68
36
 
69
- /** Epoch until which `w` blocks the account; anything <= now means it does not
70
- * block. A window with no known reset (unparsed clock) is still bounded by
71
- * its own duration past the sample time - a 5h window sampled 6h ago has
72
- * certainly reset - so a benched account always recovers by itself. With no
73
- * sample time either, it blocks indefinitely: guessing "usable now" would
74
- * swap onto it with waitUntil=now and churn kill/respawn. */
75
37
  function blockedUntil(w: UsageWindow, windowMs: number, sampledAt: number | undefined, threshold: number): number {
76
38
  if (w.usedPercentage < threshold) return 0;
77
39
  if (w.resetsAt != null) return w.resetsAt;
78
40
  return sampledAt != null ? sampledAt + windowMs : Number.POSITIVE_INFINITY;
79
41
  }
80
42
 
81
- /** When each of the account's windows stops blocking: the two aggregates plus
82
- * the gated per-model caps, each against its own threshold (session swaps
83
- * earlier than the weekly windows, and a candidate is screened by the same
84
- * bar that would trigger a switch off it - landing below the trigger bar
85
- * would ping-pong). */
86
43
  function blockingUntil(a: Account, ctx: PickCtx): number[] {
87
44
  const u = a.lastUsage;
88
45
  return [
@@ -92,50 +49,29 @@ function blockingUntil(a: Account, ctx: PickCtx): number[] {
92
49
  blockedUntil(u.sevenDay, WEEK_MS, a.lastUsageAt, ctx.thresholds.weekly),
93
50
  ]
94
51
  : []),
95
- // per-model rows date by their OWN sample time when known: lastUsageAt
96
- // advances on every engaged evaluation while the rows may be days older,
97
- // which inflated the null-reset self-bound (closing-review catch).
98
52
  ...gatedPerModelWindows(a, ctx.switchFamilies).map((w) => blockedUntil(w, WEEK_MS, a.lastPerModelAt ?? a.lastUsageAt, ctx.thresholds.weekly)),
53
+ ...(a.enforcedUntil != null ? [a.enforcedUntil] : []),
99
54
  ];
100
55
  }
101
56
 
102
- /** An account is "exhausted" if a window is >= its threshold and hasn't reset yet. */
103
57
  export function isExhausted(a: Account, ctx: PickCtx): boolean {
104
58
  return blockingUntil(a, ctx).some((t) => t > ctx.now);
105
59
  }
106
60
 
107
- /** Next occurrence of a weekly reset. The weekly reset is a fixed per-account
108
- * anchor, so a cached (past) resetsAt extrapolates forward in 7-day steps -
109
- * an old snapshot still yields the correct upcoming reset. */
110
61
  export function nextWeeklyReset(resetsAt: number | null, now: number): number | null {
111
62
  if (resetsAt == null || resetsAt > now) return resetsAt;
112
63
  return resetsAt + (Math.floor((now - resetsAt) / WEEK_MS) + 1) * WEEK_MS;
113
64
  }
114
65
 
115
- /** Epoch ms when the account's weekly quota is next forfeited; an account with
116
- * no sampled reset sorts last. */
117
66
  export function weeklyExpiry(a: Account, now: number): number {
118
67
  return nextWeeklyReset(a.lastUsage?.sevenDay.resetsAt ?? null, now) ?? Number.POSITIVE_INFINITY;
119
68
  }
120
69
 
121
- /** Soonest upcoming reset among the account's cached windows: the 5h session
122
- * reset if still ahead, else the extrapolated weekly expiry; Infinity with no
123
- * known reset anchor (sorts last). Display order for `status` and the
124
- * statusLine pool (user decision 2026-07-18) - deliberately decoupled from
125
- * swapPreference, which keeps ranking actual swaps. */
126
70
  export function earliestReset(a: Account, now: number): number {
127
71
  const fiveHour = a.lastUsage?.fiveHour.resetsAt;
128
72
  return Math.min(fiveHour != null && fiveHour > now ? fiveHour : Number.POSITIVE_INFINITY, weeklyExpiry(a, now));
129
73
  }
130
74
 
131
- /** How far behind its own weekly pace the account is, measured forward: the
132
- * burn rate (percent per ms) its remaining weekly quota must be consumed at
133
- * to beat the reset that forfeits it. A backward-looking used/expected ratio
134
- * blows up right after a reset (expected ~0) and ignores how much quota is
135
- * at risk; the required forward rate has neither problem. A window past its
136
- * cached reset counts as empty (the account is fresh again); an account with
137
- * no sampled reset anchor has nothing to forfeit on any known clock and
138
- * ranks last (0). */
139
75
  export function pacePressure(a: Account, now: number): number {
140
76
  const cached = a.lastUsage?.sevenDay;
141
77
  const reset = nextWeeklyReset(cached?.resetsAt ?? null, now);
@@ -144,17 +80,9 @@ export function pacePressure(a: Account, now: number): number {
144
80
  return Math.max(0, 100 - used) / Math.max(1, reset - now);
145
81
  }
146
82
 
147
- /** The switch preference: furthest behind its own weekly pace first (highest
148
- * pacePressure), tiebreak soonest weekly expiry then lowest 7-day usage.
149
- * Ranks swaps only; display surfaces order by earliestReset instead. */
150
83
  const swapPreference = (now: number) => [
151
84
  (a: Account) => -pacePressure(a, now),
152
85
  (a: Account) => weeklyExpiry(a, now),
153
- // unmeasured ranks LAST in this tiebreak (101 > any real percentage):
154
- // `?? 0` made a never-sampled account look maximally safe and beat any
155
- // measured one, swapping a healthy measured seat onto a complete unknown -
156
- // the exact unmeasured-must-not-look-safe rule (closing-review catch);
157
- // pacePressure already ranks unmeasured last by the same principle.
158
86
  (a: Account) => a.lastUsage?.sevenDay.usedPercentage ?? 101,
159
87
  ];
160
88
 
@@ -165,11 +93,6 @@ export function pickBest(accounts: Account[], ctx: PickCtx): Account | null {
165
93
  return sortBy(usable, swapPreference(ctx.now))[0] ?? null;
166
94
  }
167
95
 
168
- /** Greedy idempotence, shared by bare `xx switch` and the hooks/timer path:
169
- * the active account keeps its seat while it is usable and no other usable
170
- * account ranks STRICTLY better on swapPreference - swapping between equals
171
- * buys nothing and would ping-pong. Rank with currentAccountUuid null so the
172
- * active account competes. */
173
96
  export function currentWins(active: Account | null, accounts: Account[], ctx: PickCtx): boolean {
174
97
  if (!active || active.needsReauth || isExhausted(active, ctx)) return false;
175
98
  const best = pickBest(accounts, { ...ctx, currentAccountUuid: null });
@@ -177,9 +100,6 @@ export function currentWins(active: Account | null, accounts: Account[], ctx: Pi
177
100
  return swapPreference(ctx.now).every((k) => k(active) === k(best));
178
101
  }
179
102
 
180
- /** When an account becomes usable again: the latest blocking bound among its
181
- * windows (all must clear), or `now` if nothing blocks. Consistent with
182
- * isExhausted by construction (same blockingUntil). */
183
103
  export function usableAt(a: Account, ctx: PickCtx): number {
184
104
  const blocking = blockingUntil(a, ctx).filter((t) => t > ctx.now);
185
105
  return blocking.length ? Math.max(...blocking) : ctx.now;
@@ -188,9 +108,6 @@ export function usableAt(a: Account, ctx: PickCtx): number {
188
108
  const EarliestResetSchema = z.object({ account: AccountSchema, availableAt: z.number() });
189
109
  export type EarliestReset = z.infer<typeof EarliestResetSchema>;
190
110
 
191
- /** For the all-depleted case: the account (not current, not reauth) that becomes
192
- * usable soonest. Accounts blocked with no known reset are unknowable, never
193
- * a wait target. */
194
111
  export function pickEarliestReset(accounts: Account[], ctx: PickCtx): EarliestReset | null {
195
112
  const mapped = accounts
196
113
  .filter((a) => a.accountUuid !== ctx.currentAccountUuid && !a.needsReauth)
package/src/lib/proc.ts CHANGED
@@ -1,19 +1,5 @@
1
- // Process identity beyond a bare PID: pids recycle, so anything that must act
2
- // on "the process I recorded earlier" (reaping an orphan, trusting a presence
3
- // file) pins pid + start time and treats a mismatch as a different process.
4
-
5
1
  import { z } from "zod";
6
2
 
7
- /** The ps lstart token for a pid, or null when no such process. pid + start
8
- * time is the standard process identity: equality with the token captured
9
- * at spawn proves this is still the SAME process, never a recycled pid.
10
- * LC_ALL=C pins the lstart rendering (cubic review catch): the capturing and
11
- * the comparing process can run under different locales (terminal vs
12
- * launchd), and a formatting mismatch would silently break the identity.
13
- * Tradeoff (flagged and accepted): lstart has one-second resolution, so a
14
- * pid recycled onto a process started within the SAME wall-clock second
15
- * would pass - landing on the exact pid AND second is vanishingly unlikely,
16
- * and finer start-time sources are per-platform native calls ps cannot give. */
17
3
  export function pidStartTime(pid: number): string | null {
18
4
  const res = Bun.spawnSync(["ps", "-p", String(pid), "-o", "lstart="], { env: { ...process.env, LC_ALL: "C" } });
19
5
  if (res.exitCode !== 0) return null;
@@ -21,9 +7,6 @@ export function pidStartTime(pid: number): string | null {
21
7
  return lstart === "" ? null : lstart;
22
8
  }
23
9
 
24
- /** Whether the pid names a LIVE process (signal-0 probe); EPERM still proves
25
- * existence. Distinguishes "pid is dead" from "ps could not answer", which a
26
- * null pidStartTime alone cannot. */
27
10
  export function pidExists(pid: number): boolean {
28
11
  try {
29
12
  process.kill(pid, 0);
package/src/lib/sample.ts CHANGED
@@ -1,23 +1,3 @@
1
- // Live-sample a PARKED account's `/usage` without disturbing the live login.
2
- // `/usage` is free (0 tokens), so `status` can show every account's real usage.
3
- // We install the account's parked credential into a throwaway CLAUDE_CONFIG_DIR
4
- // (the isolated credential claude reads for that dir), run `claude -p /usage`
5
- // against it, then tear the item down.
6
- //
7
- // Contamination guards (the incident that motivated these):
8
- // 1. probeUsage scrubs every ambient credential-override env var, so an
9
- // inherited CLAUDE_CODE_OAUTH_TOKEN can't hijack the isolated probe.
10
- // 2. before trusting a backup we verify (roles endpoint) that its token really
11
- // belongs to this account - a mislabeled backup (drifted harvest) surfaces
12
- // as an explicit error, never as another account's bars.
13
- //
14
- // Refresh tokens rotate single-use, so the one hazard is a rotation we fail to
15
- // capture. Two guards: refresh an expiring token OURSELVES up front, and
16
- // capture-before-delete the isolated item in case claude rotated it anyway.
17
- //
18
- // The caller MUST hold the tokenmaxxing flock so a parked refresh cannot collide
19
- // with an in-flight performSwap refreshing the same account.
20
-
21
1
  import { mkdirSync, rmSync, writeFileSync } from "node:fs";
22
2
  import { join } from "node:path";
23
3
  import { z } from "zod";
@@ -28,9 +8,6 @@ import { refreshCredential, isAccessTokenExpiring, fetchTokenOrg, InvalidGrantEr
28
8
  import { FullUsageSchema, pingSession, probeUsage } from "./usage.ts";
29
9
  import { CredentialBlobSchema, type Account, type OAuthCreds, type RolesResponse } from "./types.ts";
30
10
 
31
- /** Result of a live sample: the fresh usage, or why it could not be taken.
32
- * `pingError` is set only when a requested ping (status --force) failed - the
33
- * account's 5h timer may not have started even if the sample itself succeeded. */
34
11
  const SampleOutcomeSchema = z.discriminatedUnion("ok", [
35
12
  z.object({ ok: z.literal(true), usage: FullUsageSchema, pingError: z.string().optional() }),
36
13
  z.object({ ok: z.literal(false), reason: z.string(), pingError: z.string().optional() }),
@@ -44,10 +21,6 @@ const IdentityCheckSchema = z.discriminatedUnion("status", [
44
21
  ]);
45
22
  type IdentityCheck = z.infer<typeof IdentityCheckSchema>;
46
23
 
47
- /** Verify `creds` belongs to `account`. Only a definitive org DISAGREEMENT is
48
- * a mismatch; an unreachable roles endpoint is "unavailable" and must never
49
- * bench the account - a transient outage is not a dead credential, and
50
- * flagging on it once removed every parked account from switching. */
51
24
  async function checkIdentity(creds: OAuthCreds, account: Account): Promise<IdentityCheck> {
52
25
  let org: RolesResponse;
53
26
  try {
@@ -59,22 +32,11 @@ async function checkIdentity(creds: OAuthCreds, account: Account): Promise<Ident
59
32
  return { status: "mismatch", reason: `credential actually belongs to ${org.organization_name} (org ${org.organization_uuid.slice(0, 8)})` };
60
33
  }
61
34
 
62
- /** Stamp the blob's plan fields onto the account (caller persists). Runs only
63
- * after the identity check passed, so a drifted credential can never write
64
- * another account's tier. Absent blob fields keep the last-known values. */
65
35
  function refreshPlanFields(account: Account, creds: OAuthCreds): void {
66
36
  if (creds.subscriptionType != null) account.subscriptionType = creds.subscriptionType;
67
37
  if (creds.rateLimitTier != null) account.rateLimitTier = creds.rateLimitTier;
68
38
  }
69
39
 
70
- /**
71
- * Live-sample `account`'s `/usage` in isolation. On a dead refresh token or a
72
- * mislabeled credential it sets `account.needsReauth` in place (the caller
73
- * persists accounts.json). Mutates only the passed object and keychain items.
74
- * With `ping`, one minimal metered request runs first (through the same
75
- * isolated credential) so the account's 5h session window starts now and the
76
- * sample that follows reports the freshly opened window.
77
- */
78
40
  export async function probeParkedUsage(account: Account, opts: { ping?: boolean } = {}): Promise<SampleOutcome> {
79
41
  const backup = parkedTarget(account.keychainItem);
80
42
  const parkedRaw = await readItem(backup);
@@ -87,16 +49,6 @@ export async function probeParkedUsage(account: Account, opts: { ping?: boolean
87
49
  return { ok: false, reason: `parked credential unreadable (${(e instanceof Error ? e.message : String(e)).slice(0, 80)}) - run \`tokenmaxxing auth\`` };
88
50
  }
89
51
 
90
- // The parked copy must never be refreshed (or probed - the probe can rotate
91
- // it too) while its account secretly owns the LIVE login: after a crash
92
- // between performSwap's live install and the oauthAccount rewrite, status
93
- // still routes the live account here, and a parked-side rotation would
94
- // supersede the live item's single-use refresh token out from under the
95
- // running session - or, if claude rotated first, falsely flag the healthy
96
- // live account needsReauth (closing-review catch; mirrors the codex
97
- // sampler's present-account invariant). Verified against the live blob's
98
- // TRUE org, fail-closed like the rm guard: an unverifiable live owner
99
- // refuses the sample rather than risking the live grant.
100
52
  const liveRaw = await readItem(liveTarget());
101
53
  if (liveRaw != null) {
102
54
  let liveOrg: string;
@@ -111,9 +63,6 @@ export async function probeParkedUsage(account: Account, opts: { ping?: boolean
111
63
  }
112
64
  }
113
65
 
114
- // Hand claude a token with comfortable headroom so it won't run its own refresh
115
- // (which claude does within 300s of expiry - the same margin checked here).
116
- // Refresh + persist ourselves first.
117
66
  if (isAccessTokenExpiring(creds, 300_000)) {
118
67
  try {
119
68
  creds = await refreshCredential(creds);
@@ -154,7 +103,6 @@ export async function probeParkedUsage(account: Account, opts: { ping?: boolean
154
103
  if (pingError != null) outcome.pingError = pingError;
155
104
  return outcome;
156
105
  } finally {
157
- // capture-before-delete: never discard a rotation claude may have performed.
158
106
  const afterIso = await readItem(isoTarget);
159
107
  if (afterIso && afterIso !== installed) await writeItem(backup, claudeAiOauthOnly(afterIso));
160
108
  await deleteItem(isoTarget);
@@ -162,14 +110,6 @@ export async function probeParkedUsage(account: Account, opts: { ping?: boolean
162
110
  }
163
111
  }
164
112
 
165
- /** Refresh the LIVE access token when near expiry, under claude's own refresh
166
- * lock. Exported for `xx status`: every parked probe's fail-closed live-owner
167
- * check reads this token, so it must be fresh BEFORE those probes run - even
168
- * when the active account's own usage comes from the statusline tee and no
169
- * active probe happens (cubic review catch, PR #35: the tee short-circuit
170
- * skipped the refresh and every parked sample 401'd on the first post-idle
171
- * status). No live item, or an unparsable one, is a no-op here: the callers'
172
- * own guards surface those loudly. InvalidGrantError propagates. */
173
113
  export async function ensureLiveTokenFresh(): Promise<void> {
174
114
  const liveRaw = await readItem(liveTarget());
175
115
  if (!liveRaw) return;
@@ -191,15 +131,7 @@ export async function ensureLiveTokenFresh(): Promise<void> {
191
131
  });
192
132
  }
193
133
 
194
- /**
195
- * Live-sample the ACTIVE account off the live login, verifying the live
196
- * credential belongs to it. `/usage` with no CLAUDE_CONFIG_DIR meters the live
197
- * keychain item. A drifted active label surfaces as an error, not another
198
- * account's bars. With `ping`, one minimal metered request runs first (after
199
- * the identity check - never spend quota on a drifted credential).
200
- */
201
134
  export async function probeActiveUsage(account: Account, opts: { ping?: boolean } = {}): Promise<SampleOutcome> {
202
- // A running claude keeps the live token fresh; after long idle it may not have.
203
135
  try {
204
136
  await ensureLiveTokenFresh();
205
137
  } catch (e) {
@@ -1,8 +1,3 @@
1
- // Persist the flags a managed session was launched with, so any later relaunch
2
- // of that session id (a fresh supervisor invocation, or the depleted-pool
3
- // recovery in #20) re-applies them instead of dropping --dangerously-skip-
4
- // permissions / --model / etc.
5
-
6
1
  import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync } from "node:fs";
7
2
  import { join } from "node:path";
8
3
  import { z } from "zod";
@@ -11,10 +6,6 @@ import { writeFileAtomic } from "./atomic.ts";
11
6
 
12
7
  const SessionSchema = z.object({ flags: z.array(z.string()), cwd: z.string() });
13
8
 
14
- // Matches claude's default transcript retention (cleanupPeriodDays 30): a
15
- // transcript claude has already deleted cannot be resumed, so its flags file
16
- // is dead weight. saveSessionFlags rewrites the file on every (re)launch, so
17
- // an actively resumed session keeps its mtime fresh and is never pruned.
18
9
  const SESSION_RETENTION_MS = 30 * 24 * 3600 * 1000;
19
10
 
20
11
  function sessionFile(sid: string): string {
@@ -32,8 +23,6 @@ export function loadSessionFlags(sid: string): string[] | null {
32
23
  return SessionSchema.parse(JSON.parse(readFileSync(f, "utf8"))).flags;
33
24
  }
34
25
 
35
- /** Delete session files past the retention window (also reaps stale
36
- * writeFileAtomic temp siblings from a crashed writer). */
37
26
  export function pruneStaleSessions(now: number): void {
38
27
  const dir = join(paths.home, "sessions");
39
28
  if (!existsSync(dir)) return;
@@ -42,8 +31,6 @@ export function pruneStaleSessions(now: number): void {
42
31
  try {
43
32
  if (now - statSync(p).mtimeMs > SESSION_RETENTION_MS) rmSync(p, { force: true });
44
33
  } catch {
45
- // A concurrent writeFileAtomic renames its tmp sibling away between
46
- // readdir and stat; a vanished entry needs no pruning.
47
34
  }
48
35
  }
49
36
  }