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/decide.ts CHANGED
@@ -1,48 +1,20 @@
1
- // Shared switch decision used by the Stop/SessionStart hooks and the periodic
2
- // `check` timer. Cheap pre-check off the lock; the authoritative re-check + swap
3
- // under the flock.
4
- //
5
- // The decision ENGAGES (user policy 2026-07-16) once the active account's 5h
6
- // session window reaches policy.greedySessionFloor, or once any screening bar
7
- // is crossed. Engaged-but-under-every-bar runs the same greedy pace-pressure
8
- // convergence as bare `xx switch` (swap only onto a STRICTLY better usable
9
- // account, current keeps its seat on ties, never a depleted pre-park); over a
10
- // bar keeps the original hard semantics (swap or depleted-wait).
11
- //
12
- // The windows feeding that, all metered against the CURRENTLY-active org:
13
- // 1. AGGREGATE windows (session=five_hour, week-all=seven_day). A rendering
14
- // statusLine tees them fresh every turn; when nothing renders (headless
15
- // boxes, idle TUIs) they come from `claude -p '/usage'`, re-probed once the
16
- // snapshot ages past the poll TTL so they can never freeze at a stale value.
17
- // 2. PER-MODEL weekly cap (e.g. "week (Fable)") - when the active model is
18
- // capacity-constrained (config policy.switchModels), or for EVERY configured
19
- // family when the model is unknown (nothing rendered within the TTL, so the
20
- // session that stamped the last model may be gone).
21
- //
22
- // The org guard is load-bearing: right after a respawn, usage.json still reflects
23
- // the OLD account, so org != activeOrg → we correctly do nothing until fresh usage
24
- // for the new account arrives.
25
-
26
1
  import { maxBy } from "es-toolkit";
27
2
  import { z } from "zod";
28
3
  import { withLock } from "./lock.ts";
29
4
  import { paths } from "./paths.ts";
30
- import { loadAccounts, loadConfig, loadDepletedWait, loadLastSwapAt, loadUsage, loadModelUsage, saveAccounts, saveDepletedWait, saveModelUsage, usageTeeAt, writeUsage } from "./state.ts";
5
+ import { MAX_CHECK_DELAY_MS, loadAccounts, loadConfig, loadDepletedWait, loadLastSwapAt, loadUsage, loadModelUsage, saveAccounts, saveDepletedWait, saveModelUsage, usageTeeAt, writeUsage } from "./state.ts";
31
6
  import { readOAuthAccount } from "./claudejson.ts";
32
7
  import { chooseAndSwap, performSwap } from "./swap.ts";
33
- import { currentWins, effectiveBars, hardBars, isExhausted, pickBest, pickEarliestReset, usableAt } from "./picker.ts";
8
+ import { currentWins, effectiveBars, hardBars, isExhausted, nextWeeklyReset, pickBest, pickEarliestReset, usableAt } from "./picker.ts";
34
9
  import { InvalidGrantError } from "./oauth.ts";
35
- import { familyTokens, gatedFamilies, probeUsage } from "./usage.ts";
10
+ import { familyTokens, gatedFamilies, probeUsage, type EnforcedClass } from "./usage.ts";
36
11
  import { log } from "./log.ts";
37
- import { AccountSchema, ModelUsageStateSchema, UsageStateSchema, type Account, type Config, type ModelUsageState, type UsageState, type UsageWindow } from "./types.ts";
12
+ import { AccountSchema, ModelUsageStateSchema, UsageStateSchema, type Account, type Config, type EnforcedLimit, type ModelUsageState, type UsageState, type UsageWindow } from "./types.ts";
38
13
 
39
14
  const SwapDecisionSchema = z.object({
40
15
  swapped: z.boolean(),
41
16
  account: AccountSchema.nullable(),
42
17
  reason: z.string(),
43
- /** set when every account is depleted and the soonest recovery is known:
44
- * epoch ms that account recovers. The wait target on depleted-wait;
45
- * informational on a bare all-depleted (nothing here waits). */
46
18
  waitUntil: z.number().optional(),
47
19
  });
48
20
  export type SwapDecision = z.infer<typeof SwapDecisionSchema>;
@@ -50,14 +22,6 @@ export type SwapDecision = z.infer<typeof SwapDecisionSchema>;
50
22
  const WEEK_MS = 7 * 24 * 60 * 60 * 1000;
51
23
  const FIVE_HOURS_MS = 5 * 60 * 60 * 1000;
52
24
 
53
- /** A window's usable-against percentage NOW: one whose cached reset has passed
54
- * is empty again, never a switch reason. A NULL-reset window (reset clock
55
- * failed to parse) self-bounds at sampledAt + the window's own duration,
56
- * mirroring the picker's blockedUntil and the codex liveUsed: without the
57
- * bound the trigger side kept reading a long-stale over-bar row as live while
58
- * the screening side had already released it - the two halves of one decision
59
- * disagreed, forcing hard-path swaps (or waitUntil=now respawn churn) off a
60
- * healthy account (adversarial-review catch). */
61
25
  function liveUsed(input: { window: UsageWindow; windowMs: number; sampledAt: number; now: number }): number {
62
26
  const { window: w, windowMs, sampledAt, now } = input;
63
27
  if (w.resetsAt != null) return w.resetsAt <= now ? 0 : w.usedPercentage;
@@ -65,9 +29,6 @@ function liveUsed(input: { window: UsageWindow; windowMs: number; sampledAt: num
65
29
  return w.usedPercentage;
66
30
  }
67
31
 
68
- /** The family's weekly cap among the `/usage` rows; when several rows match the
69
- * family, the most-used LIVE one wins (switching early beats metering a depleted
70
- * cap, but a row whose reset passed must not mask a still-burning sibling). */
71
32
  function capForFamily(mu: ModelUsageState, family: string, now: number): UsageWindow | undefined {
72
33
  const rows = Object.entries(mu.perModel)
73
34
  .filter(([k]) => familyTokens(k).includes(family))
@@ -75,10 +36,6 @@ function capForFamily(mu: ModelUsageState, family: string, now: number): UsageWi
75
36
  return maxBy(rows, (w) => liveUsed({ window: w, windowMs: WEEK_MS, sampledAt: mu.sampledAt ?? mu.ts, now }));
76
37
  }
77
38
 
78
- /** True if the active account is over its floor on ANY screening bar: the 5h
79
- * session against thresholds.session, the 7-day aggregate and gated per-model
80
- * caps against thresholds.weekly. Over a bar means the hard path (swap away
81
- * or depleted-wait); the greedy path may fire well before this. */
82
39
  function isOver(u: UsageState | null, mu: ModelUsageState | null, org: string | null, cfg: Config, now: number): boolean {
83
40
  if (!u || !org || u.org !== org) return false;
84
41
  const bars = effectiveBars(cfg);
@@ -95,14 +52,10 @@ function isOver(u: UsageState | null, mu: ModelUsageState | null, org: string |
95
52
  return false;
96
53
  }
97
54
 
98
- /** Does a per-model cap gate the decision for this usage snapshot? */
99
55
  function needsPerModel(u: UsageState | null, cfg: Config): boolean {
100
56
  return u != null && gatedFamilies(u.model, cfg.policy.switchModels).length > 0;
101
57
  }
102
58
 
103
- /** Whether the decision engages at all: half a session window buys the swap
104
- * (greedySessionFloor), and a crossed screening bar always does. Below both,
105
- * a fresh session rides its account - no churn. */
106
59
  function isEngaged(u: UsageState | null, mu: ModelUsageState | null, org: string | null, cfg: Config, now: number): boolean {
107
60
  if (!u || !org || u.org !== org) return false;
108
61
  return liveUsed({ window: u.fiveHour, windowMs: FIVE_HOURS_MS, sampledAt: u.ts, now }) >= cfg.policy.greedySessionFloor || isOver(u, mu, org, cfg, now);
@@ -114,26 +67,12 @@ const SnapshotsSchema = z.object({
114
67
  });
115
68
  type Snapshots = z.infer<typeof SnapshotsSchema>;
116
69
 
117
- /** usage.json is trusted while the tee proved itself alive (mtime, NOT the
118
- * embedded ts - write-on-change lets ts age under an alive feed) within the
119
- * TTL for the live org. */
120
70
  function usageFresh(u: UsageState | null, org: string | null, ttl: number, now: number): boolean {
121
71
  if (u == null || u.org !== org) return false;
122
72
  const teeAt = usageTeeAt();
123
73
  return teeAt != null && now - teeAt <= ttl;
124
74
  }
125
75
 
126
- /**
127
- * Load the two usage snapshots, re-probing `/usage` (free, 0 tokens) when they
128
- * are absent, org-drifted, or older than the poll TTL. ONE probe carries all
129
- * three limit kinds, so a success refreshes BOTH files; anything less leaves a
130
- * headless box (no rendering statusLine to tee) evaluating frozen or
131
- * org-mismatched values forever - the 2026-07-12 ARM-box blindness. The
132
- * refreshed usage carries model: null (whatever session stamped the old model
133
- * may be gone), which gates every configured family. model-usage.json's ts also
134
- * stamps FAILED attempts, so a busy live token cannot cause a probe storm
135
- * (2026-07-10): the next hook waits out the TTL instead of re-probing.
136
- */
137
76
  async function loadFreshSnapshots(cfg: Config, org: string | null, now: number): Promise<Snapshots> {
138
77
  let u = loadUsage();
139
78
  let mu = loadModelUsage();
@@ -142,14 +81,8 @@ async function loadFreshSnapshots(cfg: Config, org: string | null, now: number):
142
81
  if (org && !probeAttempted && (!usageFresh(u, org, ttl, now) || needsPerModel(u, cfg))) {
143
82
  const full = await probeUsage();
144
83
  const ts = Date.now();
145
- // A swap can complete while the probe runs (no lock is held here). Its
146
- // result would then be stamped under the pre-swap org over the files the
147
- // swap just cleared - discard it; the locked re-check below rejects this
148
- // evaluation anyway and the next one re-probes the new org.
149
84
  if (readOAuthAccount()?.organizationUuid === org) {
150
85
  if (full) {
151
- // A probe takes seconds; a rendering session's tee may have landed
152
- // while it ran. The tee is fresher AND model-aware, so it wins.
153
86
  const teed = loadUsage();
154
87
  if (usageFresh(teed, org, ttl, ts)) {
155
88
  u = teed;
@@ -159,11 +92,12 @@ async function loadFreshSnapshots(cfg: Config, org: string | null, now: number):
159
92
  }
160
93
  mu = { perModel: full.perModel, org, ts, sampledAt: ts };
161
94
  saveModelUsage(mu);
95
+ const expected = gatedFamilies(u?.model ?? null, cfg.policy.switchModels);
96
+ const rows = Object.keys(full.perModel);
97
+ if (expected.length > 0 && !expected.some((f) => rows.some((k) => familyTokens(k).includes(f)))) {
98
+ log("usage.no_permodel_row", { families: expected.join(","), rows: rows.join(",") });
99
+ }
162
100
  } else {
163
- // the anti-storm stamp: ts=now suppresses re-probing, but the carried
164
- // rows keep their ORIGINAL sample time - dating them by ts rolled the
165
- // null-reset self-bound forward on every failed probe (closing-review
166
- // catch).
167
101
  mu = { perModel: mu?.org === org ? (mu?.perModel ?? {}) : {}, org, ts, sampledAt: mu?.org === org ? (mu?.sampledAt ?? mu?.ts) : undefined };
168
102
  saveModelUsage(mu);
169
103
  }
@@ -172,38 +106,22 @@ async function loadFreshSnapshots(cfg: Config, org: string | null, now: number):
172
106
  return { u, mu };
173
107
  }
174
108
 
175
- /** How long after a swap the auto paths hold still. The statusLine tee is
176
- * suppressed this long (sessions adopt the swap in <=30s), so a decision made
177
- * sooner runs model-blind on data the swap itself invalidated - that is how a
178
- * model-aware swap got immediately undone into an A<->B respawn loop. Manual
179
- * `switch` is unaffected. */
180
109
  export const POST_SWAP_COOLDOWN_MS = 45_000;
181
110
 
182
- /**
183
- * `anticipatory` allows the depleted path to swap onto an account that is still
184
- * blocked but recovers soonest. Only a caller that can PAUSE until the reset
185
- * (the supervised Stop hook, which writes a respawn marker the supervisor
186
- * honors with a countdown) should pass true: from the check timer or an
187
- * unsupervised hook, pre-parking silently yanks a live session onto a
188
- * known-over-limit account, and buys nothing - the normal pick path adopts the
189
- * recovering account the moment its reset passes.
190
- */
191
- export async function evaluateAndMaybeSwap(now = Date.now(), anticipatory = false): Promise<SwapDecision> {
111
+ export async function evaluateAndMaybeSwap(now = Date.now(), anticipatory = false, enforced: EnforcedLimit | null = null): Promise<SwapDecision> {
112
+ const activeOrg = readOAuthAccount()?.organizationUuid ?? null;
113
+ const enforced0 = enforced && enforced.org === activeOrg ? enforced : null;
114
+
192
115
  const lastSwapAt = loadLastSwapAt();
193
- if (lastSwapAt != null && now - lastSwapAt < POST_SWAP_COOLDOWN_MS) {
116
+ if (!enforced0 && lastSwapAt != null && now - lastSwapAt < POST_SWAP_COOLDOWN_MS) {
194
117
  return depletedReplay(now) ?? { swapped: false, account: null, reason: "post-swap-cooldown" };
195
118
  }
196
119
 
197
120
  const cfg = loadConfig();
198
- const activeOrg = readOAuthAccount()?.organizationUuid ?? null;
199
121
 
200
122
  const { u: usage, mu } = await loadFreshSnapshots(cfg, activeOrg, now);
201
123
 
202
- // cheap pre-check off the lock - the common case exits here. When there is
203
- // NO measurement for the live org (a pre-park just cleared the snapshots),
204
- // a recorded depleted-wait still replays; a fresh measurement that reads
205
- // under-threshold never does - measured-healthy must win over a stale wait.
206
- if (!isEngaged(usage, mu, activeOrg, cfg, now)) {
124
+ if (!enforced0 && !isEngaged(usage, mu, activeOrg, cfg, now)) {
207
125
  const measured = usage != null && activeOrg != null && usage.org === activeOrg;
208
126
  if (!measured) {
209
127
  const replay = depletedReplay(now);
@@ -215,74 +133,43 @@ export async function evaluateAndMaybeSwap(now = Date.now(), anticipatory = fals
215
133
  return withLock(paths.lockFile, async () => {
216
134
  const idx = loadAccounts();
217
135
  const org2 = readOAuthAccount()?.organizationUuid ?? null;
136
+ const enforced2 = enforced0 && enforced0.org === org2 ? enforced0 : null;
218
137
  const u2 = loadUsage() ?? usage;
219
- const mu2 = needsPerModel(u2, cfg) ? loadModelUsage() ?? mu : null;
220
-
221
- // A live login whose org is KNOWN but outside the pool: do nothing - the
222
- // codex org-guard analog. performSwap would refuse any swap over it (an
223
- // unpooled credential's only copy must never be overwritten), and the
224
- // seat fallback below must not stand in a stale pooled label for it: the
225
- // depleted path could then park a supervised session against the LABELED
226
- // account's reset while the running login is someone else entirely
227
- // (pullfrog review catch, PR #33).
138
+ const mu2 = needsPerModel(u2, cfg) || enforced2?.family ? loadModelUsage() ?? mu : null;
139
+
228
140
  if (org2 != null && !idx.accounts.some((a) => a.organizationUuid === org2)) {
229
141
  return { swapped: false, account: null, reason: "live-credential-not-in-pool" };
230
142
  }
231
143
 
232
- // record the active account's aggregate usage so the picker + `status` see it.
233
- // Resolved by the LIVE org the guard just verified, never the
234
- // activeAccountUuid label: after a manual /login the label drifts (the
235
- // surviving drift source, see cli/switch.ts), and a label-keyed write
236
- // would stamp the live account's windows onto whichever account the label
237
- // still names (closing-review catch, mirrors the codex live-identity rule).
238
- if (u2 && org2 && u2.org === org2) {
239
- const active = idx.accounts.find((a) => a.organizationUuid === org2);
240
- if (active) {
144
+ const active = org2 ? idx.accounts.find((a) => a.organizationUuid === org2) : undefined;
145
+ if (active) {
146
+ let sampled = false;
147
+ if (u2 && u2.org === org2) {
241
148
  active.lastUsage = { fiveHour: u2.fiveHour, sevenDay: u2.sevenDay };
242
149
  active.lastUsageAt = u2.ts;
243
- // Snapshot per-model caps too, so they still show after we switch away.
244
- // An empty map is a failed probe's anti-storm stamp, not a measurement -
245
- // it must not erase the burnt-cap snapshot the picker screens on.
246
- if (mu2 && mu2.org === org2 && Object.keys(mu2.perModel).length > 0) {
247
- active.lastPerModel = mu2.perModel;
248
- // the rows' TRUE sample time, not the write time: an anti-storm
249
- // stamp re-writes ts while carrying old rows (closing-review catch).
250
- active.lastPerModelAt = mu2.sampledAt ?? mu2.ts;
251
- }
252
- saveAccounts(idx);
150
+ sampled = true;
253
151
  }
152
+ if (mu2 && mu2.org === org2 && Object.keys(mu2.perModel).length > 0) {
153
+ active.lastPerModel = mu2.perModel;
154
+ active.lastPerModelAt = mu2.sampledAt ?? mu2.ts;
155
+ sampled = true;
156
+ }
157
+ if (sampled) saveAccounts(idx);
254
158
  }
255
159
 
256
- if (!isEngaged(u2, mu2, org2, cfg, now)) {
160
+ if (!enforced2 && !isEngaged(u2, mu2, org2, cfg, now)) {
257
161
  return depletedReplay(now) ?? { swapped: false, account: null, reason: "raced-already-swapped" };
258
162
  }
259
163
 
260
- // The SEAT every path below evaluates and excludes: the live org's pooled
261
- // account when resolvable, the stored label only as fallback - the same
262
- // identity rule as the usage stamp above and depletedReplay. Trusting the
263
- // label here let the greedy convergence judge a stale account as "the
264
- // seat" after a manual /login, ranking against the wrong cached windows
265
- // and even offering the LIVE account as a swap target (bugbot review
266
- // catch, PR #33).
267
164
  const seatOf = (idx2: { activeAccountUuid: string | null; accounts: Account[] }): Account | null =>
268
165
  idx2.accounts.find((a) => a.organizationUuid === org2) ??
269
166
  idx2.accounts.find((a) => a.accountUuid === idx2.activeAccountUuid) ??
270
167
  null;
271
168
 
272
- // Candidates are screened by the same families that drove this decision, so
273
- // the pool cannot ping-pong onto an account the gate would immediately flag.
274
- const switchFamilies = gatedFamilies(u2?.model ?? null, cfg.policy.switchModels);
275
-
276
- // Greedy path: engaged but under every screening bar. Converge like bare
277
- // `xx switch` - swap only onto a strictly better usable account - and stay
278
- // put otherwise: with a usable current account, a depleted pre-park or wait
279
- // would trade a working session for nothing. NOT chooseAndSwap: its dead-
280
- // token fallback lands on the next usable candidate unconditionally, but
281
- // here the fallback must ALSO strictly beat the current account, or a dead
282
- // refresh token on the winner would bounce a healthy session onto a worse
283
- // account and back. performSwap marks needs-reauth before throwing, so each
284
- // reload re-ranks without the dead account and the loop must terminate.
285
- if (!isOver(u2, mu2, org2, cfg, now)) {
169
+ const gated = gatedFamilies(u2?.model ?? null, cfg.policy.switchModels);
170
+ const switchFamilies = enforced2?.family && !gated.includes(enforced2.family) ? [...gated, enforced2.family] : gated;
171
+
172
+ if (!enforced2 && !isOver(u2, mu2, org2, cfg, now)) {
286
173
  const ctxAll = { now, thresholds: effectiveBars(cfg), currentAccountUuid: null, switchFamilies };
287
174
  while (true) {
288
175
  const cur = loadAccounts();
@@ -306,21 +193,9 @@ export async function evaluateAndMaybeSwap(now = Date.now(), anticipatory = fals
306
193
  const landed = await chooseAndSwap({ now, thresholds: effectiveBars(cfg), switchFamilies, currentAccountUuid: seatOf(loadAccounts())?.accountUuid ?? null });
307
194
  if (landed) return { swapped: true, account: landed, reason: "swapped" };
308
195
 
309
- // ── LAYER 2 (the wall). Every account is exhausted at the Layer 1
310
- // screening bars, so Layer 1 alone would park the pool right here with
311
- // quota still unspent on every account. Before parking, pump the last drops
312
- // against the hard wall bars (default the server's own 100% limit, the same
313
- // figure /rate-limit-options reads): hold the seat while it is still under
314
- // its wall, else move onto the best still-under-wall account (chooseAndSwap
315
- // keeps the usual pace-pressure ranking - squeeze the account whose weekly
316
- // quota is most about to be forfeited first). Only when EVERY account has
317
- // truly walled do we fall through to the depleted-wait park below. The wall
318
- // reading is the statusLine's authoritative rate_limits tee (the same data
319
- // /rate-limit-options renders); a single-turn overshoot is caught one
320
- // boundary later by the check timer or the next Stop hook.
321
196
  const hardCtx = { now, thresholds: hardBars(cfg), currentAccountUuid: null, switchFamilies };
322
197
  const seat = seatOf(loadAccounts());
323
- if (seat && !seat.needsReauth && !isExhausted(seat, hardCtx)) {
198
+ if (!enforced2 && seat && !seat.needsReauth && !isExhausted(seat, hardCtx)) {
324
199
  log("decide.last_drop_hold", { account: seat.accountUuid.slice(0, 8) });
325
200
  return { swapped: false, account: null, reason: "last-drop-hold" };
326
201
  }
@@ -330,20 +205,12 @@ export async function evaluateAndMaybeSwap(now = Date.now(), anticipatory = fals
330
205
  return { swapped: true, account: squeezed, reason: "last-drop-swap" };
331
206
  }
332
207
 
333
- // Every account is walled. Wait for whichever drops below its wall soonest
334
- // (including the current one), if that reset is within the auto-wait window.
335
- // Recovery is measured against the WALL, not the screening bars: an account
336
- // whose session window resets below 100 is squeezable again even while its
337
- // weekly window still sits above the Layer 1 bar, so waiting on the Layer 1
338
- // reset would over-park. A dead grant on the chosen pre-park target must not
339
- // abort the wait: performSwap persists needs-reauth before throwing, so each
340
- // retry re-ranks without the dead account and the loop terminates (mirrors
341
- // the greedy loop above).
342
208
  while (true) {
343
209
  const fresh = loadAccounts();
344
210
  const current = seatOf(fresh);
345
211
  const ctx = { now, thresholds: hardBars(cfg), currentAccountUuid: current?.accountUuid ?? null, switchFamilies };
346
- const currentAt = current ? usableAt(current, ctx) : Number.POSITIVE_INFINITY;
212
+ const enforcedUntil = enforced2 && current && current.organizationUuid === enforced2.org ? (enforced2.resetsAt ?? now + enforced2.windowMs) : 0;
213
+ const currentAt = current ? Math.max(usableAt(current, ctx), enforcedUntil) : Number.POSITIVE_INFINITY;
347
214
  const other = pickEarliestReset(fresh.accounts, ctx);
348
215
 
349
216
  let target: Account | null = null;
@@ -366,12 +233,10 @@ export async function evaluateAndMaybeSwap(now = Date.now(), anticipatory = fals
366
233
  try {
367
234
  await performSwap(target);
368
235
  } catch (e) {
369
- if (e instanceof InvalidGrantError) continue; // dead grant - re-rank without it
236
+ if (e instanceof InvalidGrantError) continue;
370
237
  throw e;
371
238
  }
372
239
  }
373
- // Persist the wait so sibling hooks arriving through the cooldown / raced
374
- // / cleared-snapshot exits replay it and write their OWN respawn markers.
375
240
  saveDepletedWait({ waitUntil, accountUuid: target.accountUuid, ts: now });
376
241
  log("decide.depleted_wait", { account: target.accountUuid.slice(0, 8), waitUntil });
377
242
  return { swapped: !isCurrent, account: target, reason: "depleted-wait", waitUntil };
@@ -379,13 +244,6 @@ export async function evaluateAndMaybeSwap(now = Date.now(), anticipatory = fals
379
244
  });
380
245
  }
381
246
 
382
- /** The recorded depleted-wait, iff still standing: unexpired and still naming
383
- * the LIVE seat. The check reads claude's own oauthAccount, not the
384
- * accounts.json label: a tokenmaxxing swap rewrites oauthAccount inside its
385
- * critical section and a manual /login rewrites it too, while the label lags
386
- * a manual /login and would replay a wait for an account no longer live
387
- * (review catch, PR #31). A real swap elsewhere, a manual /login, or the
388
- * reset passing all kill the record. */
389
247
  function depletedReplay(now: number): SwapDecision | null {
390
248
  const rec = loadDepletedWait();
391
249
  if (!rec || rec.waitUntil <= now) return null;
@@ -394,3 +252,92 @@ function depletedReplay(now: number): SwapDecision | null {
394
252
  if (account.organizationUuid !== (readOAuthAccount()?.organizationUuid ?? null)) return null;
395
253
  return { swapped: false, account, reason: "depleted-wait", waitUntil: rec.waitUntil };
396
254
  }
255
+
256
+ export function enforcedWindowMs(limit: EnforcedClass): number {
257
+ return limit.kind === "session" ? FIVE_HOURS_MS : WEEK_MS;
258
+ }
259
+
260
+ export function postSwapProof(input: { swapAt: number | null; launchedAt: number | null; errorAt: number | null; now: number }): boolean {
261
+ const { swapAt, launchedAt, errorAt, now } = input;
262
+ if (swapAt == null) return true;
263
+ if (launchedAt != null && launchedAt > swapAt) return true;
264
+ return (errorAt ?? now) - swapAt >= POST_SWAP_COOLDOWN_MS;
265
+ }
266
+
267
+ const StampSchema = z.object({ outcome: z.enum(["stamped", "org-moved", "no-carrier"]), resetsAt: z.number().nullable() });
268
+ export type Stamp = z.infer<typeof StampSchema>;
269
+
270
+ export async function recordEnforcedLimit(input: { limit: EnforcedClass; org: string; now: number }): Promise<Stamp> {
271
+ const { limit, org, now } = input;
272
+ return withLock(paths.lockFile, () => {
273
+ if ((readOAuthAccount()?.organizationUuid ?? null) !== org) return { outcome: "org-moved", resetsAt: limit.resetsAt };
274
+ const prior = loadUsage();
275
+ const priorSame = prior && prior.org === org ? prior : null;
276
+ const idx = loadAccounts();
277
+ const account = idx.accounts.find((a) => a.organizationUuid === org);
278
+ const mu = loadModelUsage();
279
+ const muSame = mu && mu.org === org ? mu : null;
280
+ const carriedRows = muSame?.perModel ?? {};
281
+ const sampledAt = muSame?.sampledAt ?? muSame?.ts;
282
+ if (limit.kind === "model") {
283
+ const rowsFor = (rows: Record<string, UsageWindow>) => Object.entries(rows).filter(([k]) => familyTokens(k).includes(limit.family)).map(([, w]) => w);
284
+ const knownReset = [...rowsFor(carriedRows), ...rowsFor(account?.lastPerModel ?? {})].map((w) => w.resetsAt).find((r): r is number => r != null) ?? null;
285
+ const weeklyReset = priorSame?.sevenDay.resetsAt ?? account?.lastUsage?.sevenDay.resetsAt ?? null;
286
+ const resetsAt = limit.resetsAt ?? nextWeeklyReset(knownReset ?? weeklyReset, now);
287
+ saveModelUsage({
288
+ perModel: { ...carriedRows, [limit.family]: { usedPercentage: 100, resetsAt } },
289
+ org,
290
+ ts: now,
291
+ sampledAt: resetsAt == null ? now : sampledAt ?? now,
292
+ });
293
+ log("usage.enforced_limit", { kind: limit.kind, family: limit.family, resetsAt });
294
+ return { outcome: "stamped", resetsAt };
295
+ }
296
+ saveModelUsage({ perModel: carriedRows, org, ts: now, sampledAt });
297
+ if (account && limit.resetsAt != null) {
298
+ account.enforcedUntil = limit.resetsAt;
299
+ saveAccounts(idx);
300
+ }
301
+ const carrier = priorSame ?? (account?.lastUsage ? { ...account.lastUsage, model: null } : null);
302
+ if (!carrier) return { outcome: "no-carrier", resetsAt: limit.resetsAt };
303
+ const window: UsageWindow = { usedPercentage: 100, resetsAt: limit.resetsAt };
304
+ writeUsage({
305
+ fiveHour: limit.kind === "session" ? window : carrier.fiveHour,
306
+ sevenDay: limit.kind === "weekly" ? window : carrier.sevenDay,
307
+ org,
308
+ ts: now,
309
+ model: carrier.model,
310
+ });
311
+ log("usage.enforced_limit", { kind: limit.kind, resetsAt: limit.resetsAt });
312
+ return { outcome: "stamped", resetsAt: limit.resetsAt };
313
+ });
314
+ }
315
+
316
+ export const CHECK_DELAY_FLOOR_MS = 60_000;
317
+ const CHECK_DELAY_UNKNOWN_MS = 180_000;
318
+
319
+ export function checkDelayMs(input: { cfg: Config; org: string | null; now: number; decision: SwapDecision }): number {
320
+ const { cfg, org, now, decision } = input;
321
+ if (decision.waitUntil !== undefined) return Math.min(MAX_CHECK_DELAY_MS, Math.max(CHECK_DELAY_FLOOR_MS, decision.waitUntil - now));
322
+ const swapAt = loadLastSwapAt();
323
+ if (swapAt != null && now - swapAt < POST_SWAP_COOLDOWN_MS) return swapAt + POST_SWAP_COOLDOWN_MS - now;
324
+ const u = loadUsage();
325
+ if (!org || !u || !usageFresh(u, org, cfg.policy.usagePollTtlMs, now)) return CHECK_DELAY_UNKNOWN_MS;
326
+ const bars = effectiveBars(cfg);
327
+ const heads = [
328
+ bars.session - liveUsed({ window: u.fiveHour, windowMs: FIVE_HOURS_MS, sampledAt: u.ts, now }),
329
+ bars.weekly - liveUsed({ window: u.sevenDay, windowMs: WEEK_MS, sampledAt: u.ts, now }),
330
+ ];
331
+ const mu = loadModelUsage();
332
+ const muSame = mu && mu.org === org ? mu : null;
333
+ let capMissing = false;
334
+ const capFresh = muSame != null && now - (muSame.sampledAt ?? muSame.ts) <= cfg.policy.usagePollTtlMs;
335
+ for (const family of gatedFamilies(u.model, cfg.policy.switchModels)) {
336
+ const cap = muSame && capFresh ? capForFamily(muSame, family, now) : undefined;
337
+ if (cap && muSame) heads.push(bars.weekly - liveUsed({ window: cap, windowMs: WEEK_MS, sampledAt: muSame.sampledAt ?? muSame.ts, now }));
338
+ else capMissing = true;
339
+ }
340
+ const headroom = Math.min(...heads);
341
+ const banded = headroom >= 40 ? MAX_CHECK_DELAY_MS : headroom >= 20 ? 180_000 : headroom >= 8 ? 120_000 : CHECK_DELAY_FLOOR_MS;
342
+ return capMissing ? Math.min(banded, 120_000) : banded;
343
+ }
package/src/lib/http.ts CHANGED
@@ -1,14 +1,6 @@
1
- // Shared HTTP client. Sampling every account at once, or swapping right at a
2
- // limit, can trip an endpoint's burst throttle, so GETs retry a bounded number
3
- // of times, honoring the server's Retry-After (capped). throwHttpErrors is off
4
- // so callers read the body and shape their own fail-fast error; retry still runs.
5
-
6
1
  import ky from "ky";
7
2
  import { z } from "zod";
8
3
 
9
- /** The error-shape fields OAuth/usage endpoints legitimately explain themselves
10
- * with. Error bodies are NEVER surfaced raw: a token endpoint's failure body
11
- * can echo request material, so anything outside this allowlist is dropped. */
12
4
  const ErrorBodySchema = z.looseObject({
13
5
  error: z.string().optional(),
14
6
  error_description: z.string().optional(),
@@ -16,7 +8,6 @@ const ErrorBodySchema = z.looseObject({
16
8
  message: z.string().optional(),
17
9
  });
18
10
 
19
- /** Allowlisted, token-safe rendering of an HTTP error body. */
20
11
  export function safeErrorDetail(input: { text: string }): string {
21
12
  const parsed = ErrorBodySchema.safeParse((() => {
22
13
  try {