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.
- package/DESIGN.md +2 -4
- package/README.md +1 -1
- package/agent-plugin/plugin.json +1 -1
- package/package.json +1 -1
- package/src/cli/add.ts +1 -8
- package/src/cli/auth.ts +0 -23
- package/src/cli/check.ts +19 -13
- package/src/cli/codexadd.ts +0 -17
- package/src/cli/codexinit.ts +0 -40
- package/src/cli/codexrm.ts +0 -13
- package/src/cli/codexswitch.ts +0 -15
- package/src/cli/config.ts +0 -30
- package/src/cli/doctor.ts +1 -14
- package/src/cli/init.ts +1 -33
- package/src/cli/ls.ts +0 -2
- package/src/cli/onboard.ts +0 -37
- package/src/cli/rename.ts +0 -19
- package/src/cli/render.ts +0 -23
- package/src/cli/rm.ts +0 -19
- package/src/cli/status.ts +0 -80
- package/src/cli/switch.ts +1 -49
- package/src/cli/watch.ts +0 -17
- package/src/entries/codexstophook.ts +2 -73
- package/src/entries/codexsupervisor.ts +1 -67
- package/src/entries/mcp.ts +0 -11
- package/src/entries/sessionstart.ts +1 -8
- package/src/entries/statusline.ts +0 -66
- package/src/entries/stopfailurehook.ts +93 -0
- package/src/entries/stophook.ts +3 -34
- package/src/entries/subagentstatusline.ts +0 -19
- package/src/entries/supervisor.ts +32 -132
- package/src/lib/atomic.ts +0 -16
- package/src/lib/claudebin.ts +4 -55
- package/src/lib/claudejson.ts +0 -10
- package/src/lib/claudelock.ts +13 -35
- package/src/lib/codexauth.ts +0 -29
- package/src/lib/codexbin.ts +0 -10
- package/src/lib/codexdecide.ts +1 -112
- package/src/lib/codexoauth.ts +0 -16
- package/src/lib/codexpick.ts +0 -31
- package/src/lib/codexpresence.ts +0 -35
- package/src/lib/codexsample.ts +0 -23
- package/src/lib/codexstate.ts +0 -7
- package/src/lib/codexswap.ts +0 -32
- package/src/lib/codexusage.ts +0 -28
- package/src/lib/credstore.ts +0 -24
- package/src/lib/decide.ts +127 -180
- package/src/lib/http.ts +0 -9
- package/src/lib/install.ts +6 -127
- package/src/lib/keychain.ts +1 -39
- package/src/lib/lock.ts +0 -24
- package/src/lib/log.ts +0 -14
- package/src/lib/oauth.ts +1 -31
- package/src/lib/paths.ts +1 -48
- package/src/lib/picker.ts +1 -84
- package/src/lib/proc.ts +0 -17
- package/src/lib/sample.ts +0 -68
- package/src/lib/sessions.ts +0 -13
- package/src/lib/settings.ts +15 -42
- package/src/lib/state.ts +23 -77
- package/src/lib/swap.ts +3 -87
- package/src/lib/tty.ts +0 -4
- package/src/lib/types.ts +13 -140
- package/src/lib/usage.ts +108 -196
- package/src/lib/worktree.ts +0 -8
- package/src/main.ts +5 -40
- package/src/sdk.ts +0 -59
- package/agent-plugin/agents/tokenmaxxing-claude.md +0 -43
- package/agent-plugin/agents/tokenmaxxing-codex.md +0 -40
- package/agent-plugin/hooks/cursor-relay.json +0 -14
- package/agent-plugin/skills/relay-session/SKILL.md +0 -118
- package/agent-plugin/skills/relay-session/references/ipc.md +0 -23
- package/src/cli/relay.ts +0 -323
- package/src/entries/relaypermission.ts +0 -105
- package/src/lib/relay/config.ts +0 -84
- package/src/lib/relay/decide.ts +0 -75
- package/src/lib/relay/gc.ts +0 -80
- package/src/lib/relay/install.ts +0 -143
- package/src/lib/relay/markers.ts +0 -148
- package/src/lib/relay/modes.ts +0 -82
- package/src/lib/relay/protocol.ts +0 -61
- package/src/lib/relay/registry.ts +0 -175
- package/src/lib/relay/tmux.ts +0 -109
- package/src/lib/relay/turn.ts +0 -137
- 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
|
-
|
|
184
|
-
|
|
185
|
-
|
|
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
|
-
|
|
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
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
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
|
-
|
|
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
|
-
|
|
273
|
-
|
|
274
|
-
|
|
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
|
|
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;
|
|
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 {
|