tokenmaxxing 1.19.0 → 1.20.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 +17 -4
- package/README.md +8 -1
- package/agent-plugin/plugin.json +1 -1
- package/agent-plugin/skills/safe-contribution/SKILL.md +1 -0
- package/agent-plugin/skills/switching-policy/SKILL.md +7 -2
- package/agent-plugin/skills/switching-policy/references/policy.md +4 -2
- package/package.json +1 -1
- package/src/cli/switch.ts +5 -1
- package/src/lib/decide.ts +55 -7
- package/src/lib/oauth.ts +2 -1
- package/src/lib/picker.ts +25 -5
- package/src/lib/sample.ts +25 -11
- package/src/lib/swap.ts +17 -1
- package/src/lib/usage.ts +13 -7
package/DESIGN.md
CHANGED
|
@@ -49,8 +49,8 @@ The switching path runs no long-lived daemon. The statusline pushes usage. Hooks
|
|
|
49
49
|
The Stop hook's stdin has no usage data, but the **statusLine does** (`rate_limits.{five_hour,seven_day}.{used_percentage,resets_at}`, after every turn, 300ms debounce, zero cost). tokenmaxxing's statusLine tees that to `usage.json` (write-on-change, O(ms)) and renders its own native line (it replaced the earlier pass-through delegation on 2026-07-11: install takes the statusLine slot outright, so a pre-existing custom statusline command is overwritten). Cold-start fallback if `usage.json` is absent: `TOKENMAXXING_PROBE=1 claude -p '/usage'`, with `[ -n "$TOKENMAXXING_PROBE" ] && exit 0` as the hook's first line to stop the nested process recursing (hooks fire in `-p` too). The probe scrubs every ambient credential override claude reads before the keychain (`CLAUDE_CODE_OAUTH_TOKEN`, `CLAUDE_SECURESTORAGE_CONFIG_DIR`, etc.) so it can only meter the credential in the keychain item, and retries the transient empty-footer case (claude prints local stats with no percentages when its own usage fetch throttles).
|
|
50
50
|
|
|
51
51
|
### 3.2 Detect + swap + signal (Stop hook, per turn)
|
|
52
|
-
1. Read `usage.json
|
|
53
|
-
2.
|
|
52
|
+
1. Read `usage.json` and exit below the engagement floor unless a screening bar is crossed, then follow the greedy or hard path defined in the [switching policy](docs/content/docs/switching.mdx).
|
|
53
|
+
2. Take the pool lock, re-check state, select a candidate under the [switching policy](docs/content/docs/switching.mdx), and attempt bounded verification of stale usage before swapping the credential.
|
|
54
54
|
3. Done - the running session adopts the new credential on its own within a request or two. Only when the pool is depleted (the decision returned a `waitUntil`: pre-parked on the soonest-recovering account, or staying on the current one when it recovers first) does the hook write `respawn/<session_id>` (atomic temp+rename).
|
|
55
55
|
|
|
56
56
|
### 3.3 Depleted-pool pause (supervisor)
|
|
@@ -79,9 +79,22 @@ Each terminal ran the supervisor, so each has its own child `claude` and its own
|
|
|
79
79
|
---
|
|
80
80
|
|
|
81
81
|
## 5. Rotation policy
|
|
82
|
-
The
|
|
82
|
+
The [switching policy](docs/content/docs/switching.mdx) defines engagement, screening bars, organization preference, incumbent hysteresis and the wall squeeze.
|
|
83
83
|
|
|
84
|
-
|
|
84
|
+
- Automatic lateral moves stay within the incumbent organization, and a crossed bar can trigger a cross-organization handoff.
|
|
85
|
+
- Organization preference requires measured headroom in every applicable window and is an optimization heuristic, not a cache-hit guarantee.
|
|
86
|
+
- Manual switching retains pure pace-pressure ranking.
|
|
87
|
+
- Successful candidate probes persist their results and restart selection, preserving candidates that fail a screening bar but still clear the wall.
|
|
88
|
+
- Verification attempts at most two candidates with a shared 12-second deadline per candidate for identity reads and CLI execution.
|
|
89
|
+
- Expiring access tokens are not refreshed for verification, and the isolated CLI receives no refresh grant.
|
|
90
|
+
- Failed or budget-limited verification uses the existing cached data, so stale-target handoffs remain possible.
|
|
91
|
+
|
|
92
|
+
The [profile](docs/content/docs/switching-profile.mdx) distinguishes observed cache rewrites from pricing-weighted estimates and does not infer subscription quota savings.
|
|
93
|
+
|
|
94
|
+
The [wall policy](docs/content/docs/switching.mdx#the-wall) defines Layer 2 recovery and its conditional headroom preference, with pace-only selection under default bars.
|
|
95
|
+
|
|
96
|
+
- Layer 2 is Claude-only because its running sessions can adopt a credential change.
|
|
97
|
+
- Codex keeps its current account until the wall because a running sibling cannot adopt another account without restarting.
|
|
85
98
|
|
|
86
99
|
**Model-aware trigger.** Claude subscriptions also enforce **per-model weekly caps** - currently only for Sonnet and Fable (there is no Opus-only quota), and Fable's tighter limit binds *before* the aggregate (e.g. 80% week-Fable at only 50% week-all-models). This cap isn't in statusLine stdin, so when the active model is in `policy.switchModels` we read it from `claude -p '/usage'` (free, 0 tokens, TTL-cached) and add `week(<activeModel>) >= threshold` to the trigger. A Fable session switches on the Fable cap; a Sonnet session rides the aggregate. Both layers apply the per-model gate: a burnt Fable cap screens an account out of a Layer 1 switch, and a Fable cap at the wall screens it out of the Layer 2 squeeze too.
|
|
87
100
|
|
package/README.md
CHANGED
|
@@ -87,7 +87,14 @@ Switching engages (configurable) once the active account's 5-hour session window
|
|
|
87
87
|
|
|
88
88
|
The bars' headroom is deliberate: it's the budget to reach a clean turn boundary (plus up to one turn of adoption lag on macOS) before the wall. The session ladder tops out lower (90) because a 5-hour reset is cheap to sit out, and any lower rungs you add keep the pool draining level by level; weekly quota is use-it-or-lose-it, so it drains closer to the wall (98). The greedy engagement floor (80) sits below both: weekly allowance is forfeited at each account's fixed reset, so once most of a session window is spent, quota is best burned on whichever account has the most at risk.
|
|
89
89
|
|
|
90
|
-
|
|
90
|
+
Automatic selection prefers same-organization candidates with measured session and weekly headroom, then uses remaining weekly percentage divided by time to reset.
|
|
91
|
+
|
|
92
|
+
- Lateral moves stay within the current organization, while a crossed bar or enforced limit may trigger a cross-organization handoff.
|
|
93
|
+
- Candidate verification makes at most two bounded `/usage` attempts per evaluation and re-ranks successful samples before switching.
|
|
94
|
+
- Failed verification leaves cached figures in force, so it cannot guarantee a fresh target.
|
|
95
|
+
- Manual `tokenmaxxing switch` retains pure pace-pressure selection.
|
|
96
|
+
|
|
97
|
+
See [How switching decides](docs/content/docs/switching.mdx) for the policy and the [cache-cost profile](docs/content/docs/switching-profile.mdx) for measurements and their limits.
|
|
91
98
|
|
|
92
99
|
## Configuration
|
|
93
100
|
|
package/agent-plugin/plugin.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
|
|
3
3
|
"name": "tokenmaxxing",
|
|
4
|
-
"version": "1.
|
|
4
|
+
"version": "1.20.0",
|
|
5
5
|
"description": "Pool Claude Code and Codex logins, switch on pace pressure, and expose safe ops tools to agent clients.",
|
|
6
6
|
"author": {
|
|
7
7
|
"name": "anaclumos",
|
|
@@ -14,6 +14,7 @@ description: Safely change and ship tokenmaxxing (verification without a test su
|
|
|
14
14
|
## Ship
|
|
15
15
|
|
|
16
16
|
- Work on a branch; bump `package.json` and `agent-plugin/plugin.json` to the same version in the same PR; open PR; wait the full review window; handle every review; merge; `gh release create v<version>`; verify npm publish.
|
|
17
|
+
- A PR that changes no packed file skips the bump and the release, and merging it is shipping it. Check membership with `npm pack --dry-run`, not the `files` array: the tarball is `src`, `agent-plugin`, `README.md`, `DESIGN.md`, `LICENSE`, and `package.json`, the last two always packed though `files` never names them. So a `docs/`, `.memory/`, or AGENTS.md change is exempt, while a LICENSE or metadata change is not. Everything else, the full review window included, still applies.
|
|
17
18
|
- Never push directly to main. npm trusted publishing is bound to workflow filename `ci.yml`.
|
|
18
19
|
|
|
19
20
|
## Machine gotchas
|
|
@@ -8,8 +8,13 @@ description: Explain and apply tokenmaxxing switch policy (greedy vs hard path,
|
|
|
8
8
|
## Vocabulary
|
|
9
9
|
|
|
10
10
|
- **Engaged**: session used >= `policy.greedySessionFloor` (default 80) or any screening bar crossed.
|
|
11
|
-
- **GREEDY path
|
|
12
|
-
- **HARD path
|
|
11
|
+
- **GREEDY path** permits lateral moves only within the incumbent organization and retains `greedySwapMargin` hysteresis.
|
|
12
|
+
- **HARD path** starts at a crossed screening bar and may cross organizations, with Layer 2 as its fallback.
|
|
13
|
+
- **Organization preference** requires measured headroom in session, aggregate weekly and gated model windows under the [policy](references/policy.md).
|
|
14
|
+
- **Verification** attempts at most two stale candidates per evaluation, with a 12-second deadline covering identity reads and the CLI for each candidate.
|
|
15
|
+
- **Probe results** are persisted and re-ranked, while screening exhaustion remains eligible for the wall squeeze and reset selection.
|
|
16
|
+
- **Unavailable verification** leaves cached figures in force, and verification never spends a parked refresh grant.
|
|
17
|
+
- **Manual switch** retains pure pace ranking with no organization preference or incumbent margin.
|
|
13
18
|
- **Pace pressure**: remaining weekly percent / time to weekly reset (highest first). Not most-remaining.
|
|
14
19
|
- **Effective bars**: `effectiveBars(cfg, pool)` = the active rung of the 5h ladder (`thresholds.session`, default `[90]`, a single rung: the lowest rung some pooled account, the current one included, still clears) and the weekly bar, each minus `policy.projectionMargin`. Trigger and screening must share these bars or swaps ping-pong. Codex reads `terminalBars(cfg)`, the top rung only. The check cadence is capped one band per rung climbed, and every band is a multiple of the tick `policy.checkIntervalMs` (default 60000).
|
|
15
20
|
- **Banked reset**: opt-in via `policy.preferToUseBankedReset` (a provider list, default `[]`), hard path only. Claude: the seat holds between the session rung and the wall while a reset is believed available and the weekly windows have room for one more measured session window (`sessionWindowWeeklyCost`; unmeasured swaps), claims the `/limit-reset` server call at the wall, and keeps the seat on `reset` (the failed turn is retriggered in place). Codex: rides to the wall while a reset credit exists, consumes one, no restart. The greedy path never resets.
|
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
- `docs/content/docs/switching.mdx`
|
|
4
4
|
- `src/lib/decide.ts`, `src/lib/picker.ts`
|
|
5
5
|
- `src/lib/bankedreset.ts`, `src/lib/codexreset.ts` (banked limit resets)
|
|
6
|
-
- `.
|
|
6
|
+
- `.cursor/rules/switch-verification.md`
|
|
7
7
|
|
|
8
|
-
|
|
8
|
+
Automatic organization preference requires session usage below `greedySessionFloor` and more than `max(1, session bar - greedySessionFloor)` points below every applicable bar.
|
|
9
|
+
|
|
10
|
+
The exact policy, defaults, verification limits and manual-switch behavior live in `docs/content/docs/switching.mdx`.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tokenmaxxing",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.20.0",
|
|
4
4
|
"description": "Automatic Claude Code account switching: pool multiple accounts and hot-swap when quota fills, resuming your session on the fresh account.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
package/src/cli/switch.ts
CHANGED
|
@@ -7,6 +7,7 @@ import { currentWins, effectiveBars, pickBest, pickEarliestReset, weeklyExpiry,
|
|
|
7
7
|
import { InvalidGrantError } from "../lib/oauth.ts";
|
|
8
8
|
import { gatedFamilies } from "../lib/usage.ts";
|
|
9
9
|
import { findAccount } from "./rename.ts";
|
|
10
|
+
import { log } from "../lib/log.ts";
|
|
10
11
|
import { c, emitError, emitJson, fmtReset } from "./render.ts";
|
|
11
12
|
import type { Account } from "../lib/types.ts";
|
|
12
13
|
|
|
@@ -44,6 +45,7 @@ export async function cmdSwitch(selector?: string, json = false): Promise<number
|
|
|
44
45
|
}
|
|
45
46
|
throw e;
|
|
46
47
|
}
|
|
48
|
+
log("switch.manual", { account: target.accountUuid.slice(0, 8), reason });
|
|
47
49
|
emit(`${c.green("↻")} switched to ${c.bold(target.label)}`, { switched: true, account: target.label, reason, ...extra });
|
|
48
50
|
return 0;
|
|
49
51
|
};
|
|
@@ -69,12 +71,12 @@ export async function cmdSwitch(selector?: string, json = false): Promise<number
|
|
|
69
71
|
const rejected = new Set<string>();
|
|
70
72
|
while (true) {
|
|
71
73
|
const cur = loadAccounts();
|
|
72
|
-
const everyone = everyoneIn(cur.accounts);
|
|
73
74
|
const pool = cur.accounts.filter((a) => !rejected.has(a.accountUuid));
|
|
74
75
|
const active =
|
|
75
76
|
(claimed != null ? cur.accounts.find((a) => a.accountUuid === claimed) : null) ??
|
|
76
77
|
cur.accounts.find((a) => a.accountUuid === cur.activeAccountUuid) ??
|
|
77
78
|
null;
|
|
79
|
+
const everyone = everyoneIn(cur.accounts);
|
|
78
80
|
if (active != null && currentWins(active, pool, everyone)) {
|
|
79
81
|
if (drifted) return swapTo(active, "drift-reconciled");
|
|
80
82
|
const expiry = weeklyExpiry(active, now);
|
|
@@ -104,6 +106,7 @@ export async function cmdSwitch(selector?: string, json = false): Promise<number
|
|
|
104
106
|
}
|
|
105
107
|
throw e;
|
|
106
108
|
}
|
|
109
|
+
log("switch.manual", { account: best.accountUuid.slice(0, 8), reason: "best" });
|
|
107
110
|
emit(`${c.green("↻")} switched to ${c.bold(best.label)}`, { switched: true, account: best.label, reason: "best" });
|
|
108
111
|
return 0;
|
|
109
112
|
}
|
|
@@ -159,6 +162,7 @@ export async function cmdSwitch(selector?: string, json = false): Promise<number
|
|
|
159
162
|
}
|
|
160
163
|
throw e;
|
|
161
164
|
}
|
|
165
|
+
log("switch.manual", { account: earliest.account.accountUuid.slice(0, 8), reason: "earliest-reset" });
|
|
162
166
|
emit(`${c.green("↻")} switched to ${c.bold(earliest.account.label)}`, { switched: true, account: earliest.account.label, reason: "earliest-reset", availableAt, reauthNeeded: reauth });
|
|
163
167
|
if (availableAt != null && !json) {
|
|
164
168
|
console.log(c.yellow(`all accounts at limit - ${c.bold(earliest.account.label)} recovers soonest (${fmtReset(availableAt, now)})${reauthNote}`));
|
package/src/lib/decide.ts
CHANGED
|
@@ -5,8 +5,9 @@ import { paths } from "./paths.ts";
|
|
|
5
5
|
import { MAX_CHECK_DELAY_TICKS, POST_SWAP_COOLDOWN_MS, maxCheckDelayMs, loadAccounts, loadConfig, loadDepletedWait, loadLastSwapAt, loadUsage, loadUsageSnapshot, loadModelUsage, measureFamilyWindow, saveAccounts, saveDepletedWait, saveModelUsage, writeUsage } from "./state.ts";
|
|
6
6
|
import { readOAuthAccount } from "./claudejson.ts";
|
|
7
7
|
import { bankedResetBelievedAvailable, claimBankedReset } from "./bankedreset.ts";
|
|
8
|
-
import { chooseAndSwap, isSkippableSwapError, performSwap } from "./swap.ts";
|
|
9
|
-
import {
|
|
8
|
+
import { chooseAndSwap, isSkippableSwapError, performSwap, type VerifyVerdict } from "./swap.ts";
|
|
9
|
+
import { probeParkedUsage } from "./sample.ts";
|
|
10
|
+
import { currentWins, effectiveBars, hardBars, isExhausted, nextWeeklyReset, pickBest, pickEarliestReset, sessionLadder, usableAt, type PickCtx } from "./picker.ts";
|
|
10
11
|
import { familyTokens, gatedFamilies, probeUsage, type EnforcedClass } from "./usage.ts";
|
|
11
12
|
import { log } from "./log.ts";
|
|
12
13
|
import { AccountSchema, ModelUsageStateSchema, UsageStateSchema, type Account, type Config, type EnforcedLimit, type ModelUsageState, type Thresholds, type UsageState, type UsageWindow } from "./types.ts";
|
|
@@ -22,6 +23,8 @@ export type SwapDecision = z.infer<typeof SwapDecisionSchema>;
|
|
|
22
23
|
|
|
23
24
|
const WEEK_MS = 7 * 24 * 60 * 60 * 1000;
|
|
24
25
|
const FIVE_HOURS_MS = 5 * 60 * 60 * 1000;
|
|
26
|
+
const VERIFY_PROBE_BUDGET = 2;
|
|
27
|
+
const VERIFY_PROBE_KILL_MS = 12_000;
|
|
25
28
|
|
|
26
29
|
function liveUsed(input: { window: UsageWindow; windowMs: number; sampledAt: number; now: number }): number {
|
|
27
30
|
const { window: w, windowMs, sampledAt, now } = input;
|
|
@@ -253,18 +256,60 @@ export async function evaluateAndMaybeSwap(now = Date.now(), anticipatory = fals
|
|
|
253
256
|
rejected.add(candidate.accountUuid);
|
|
254
257
|
log("decide.candidate_rejected", { account: candidate.accountUuid.slice(0, 8), error: e instanceof Error ? e.message : String(e) });
|
|
255
258
|
};
|
|
259
|
+
let probeBudget = VERIFY_PROBE_BUDGET;
|
|
260
|
+
const verified = async (candidate: Account, ctx: PickCtx): Promise<VerifyVerdict> => {
|
|
261
|
+
if (candidate.lastUsageAt != null && now - candidate.lastUsageAt <= cfg.policy.usagePollTtlMs) return "go";
|
|
262
|
+
const short = candidate.accountUuid.slice(0, 8);
|
|
263
|
+
const idx2 = loadAccounts();
|
|
264
|
+
const stored = idx2.accounts.find((a) => a.accountUuid === candidate.accountUuid);
|
|
265
|
+
if (!stored) return "go";
|
|
266
|
+
if (probeBudget <= 0) {
|
|
267
|
+
log("decide.verify_budget_spent", { account: short });
|
|
268
|
+
return "go";
|
|
269
|
+
}
|
|
270
|
+
probeBudget -= 1;
|
|
271
|
+
const sample = await probeParkedUsage(stored, { retries: 0, refreshParked: false, killMs: VERIFY_PROBE_KILL_MS, signal: AbortSignal.timeout(VERIFY_PROBE_KILL_MS) });
|
|
272
|
+
if (sample.ok) {
|
|
273
|
+
const at = Date.now();
|
|
274
|
+
stored.lastUsage = { fiveHour: sample.usage.session, sevenDay: sample.usage.weekAll };
|
|
275
|
+
stored.lastUsageAt = at;
|
|
276
|
+
if (Object.keys(sample.usage.perModel).length > 0) {
|
|
277
|
+
stored.lastPerModel = sample.usage.perModel;
|
|
278
|
+
stored.lastPerModelAt = at;
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
if (sample.ok || stored.needsReauth === true) saveAccounts(idx2);
|
|
282
|
+
if (stored.needsReauth === true) {
|
|
283
|
+
rejected.add(candidate.accountUuid);
|
|
284
|
+
log("decide.candidate_rejected", { account: short, error: "needs reauth" });
|
|
285
|
+
return "skip";
|
|
286
|
+
}
|
|
287
|
+
if (!sample.ok) {
|
|
288
|
+
log("decide.candidate_unverified", { account: short });
|
|
289
|
+
return "go";
|
|
290
|
+
}
|
|
291
|
+
log(isExhausted(stored, ctx) ? "decide.candidate_walled" : "decide.candidate_verified", {
|
|
292
|
+
account: short,
|
|
293
|
+
session: sample.usage.session.usedPercentage,
|
|
294
|
+
weekly: sample.usage.weekAll.usedPercentage,
|
|
295
|
+
});
|
|
296
|
+
return "rerank";
|
|
297
|
+
};
|
|
256
298
|
|
|
257
299
|
const greedy = async (holdMargin: number): Promise<SwapDecision> => {
|
|
258
300
|
while (true) {
|
|
259
301
|
const cur = loadAccounts();
|
|
260
302
|
const active = seatOf(cur);
|
|
261
|
-
const
|
|
262
|
-
const
|
|
303
|
+
const ctxAll = { now, thresholds: barsOf(cur.accounts), currentAccountUuid: null, currentOrganizationUuid: active?.organizationUuid ?? null, orgAffinityFloor: cfg.policy.greedySessionFloor, switchFamilies, holdMargin };
|
|
304
|
+
const seatOrg = active != null && !active.needsReauth && !isExhausted(active, ctxAll) ? active.organizationUuid : null;
|
|
305
|
+
const pool = usable(cur.accounts).filter((a) => seatOrg == null || a.organizationUuid === seatOrg);
|
|
263
306
|
if (currentWins(active, pool, ctxAll)) {
|
|
264
307
|
return { swapped: false, account: null, reason: "current-best" };
|
|
265
308
|
}
|
|
266
|
-
const
|
|
309
|
+
const ctx = { ...ctxAll, currentAccountUuid: active?.accountUuid ?? null };
|
|
310
|
+
const best = pickBest(pool, ctx);
|
|
267
311
|
if (!best) return { swapped: false, account: null, reason: "no-usable-target" };
|
|
312
|
+
if ((await verified(best, ctx)) !== "go") continue;
|
|
268
313
|
try {
|
|
269
314
|
await performSwap(best);
|
|
270
315
|
} catch (e) {
|
|
@@ -297,14 +342,17 @@ export async function evaluateAndMaybeSwap(now = Date.now(), anticipatory = fals
|
|
|
297
342
|
if (claim === "reset") return { swapped: false, account: seat, reason: "banked-reset", reset: true };
|
|
298
343
|
}
|
|
299
344
|
}
|
|
300
|
-
const
|
|
345
|
+
const ctx = { now, thresholds, currentAccountUuid: seat?.accountUuid ?? null, currentOrganizationUuid: seat?.organizationUuid ?? null, orgAffinityFloor: cfg.policy.greedySessionFloor, switchFamilies };
|
|
346
|
+
const best = pickBest(pool, ctx);
|
|
301
347
|
if (!best) break;
|
|
348
|
+
if ((await verified(best, ctx)) !== "go") continue;
|
|
302
349
|
try {
|
|
303
350
|
await performSwap(best);
|
|
304
351
|
} catch (e) {
|
|
305
352
|
skipOrThrow(e, best);
|
|
306
353
|
continue;
|
|
307
354
|
}
|
|
355
|
+
log("decide.hard_swap", { account: best.accountUuid.slice(0, 8), enforced: enforced2 != null });
|
|
308
356
|
return { swapped: true, account: best, reason: "swapped" };
|
|
309
357
|
}
|
|
310
358
|
|
|
@@ -314,7 +362,7 @@ export async function evaluateAndMaybeSwap(now = Date.now(), anticipatory = fals
|
|
|
314
362
|
log("decide.last_drop_hold", { account: seat.accountUuid.slice(0, 8) });
|
|
315
363
|
return { swapped: false, account: null, reason: "last-drop-hold" };
|
|
316
364
|
}
|
|
317
|
-
const squeezed = await chooseAndSwap({ ...hardCtx, currentAccountUuid: seat?.accountUuid ?? null }, rejected);
|
|
365
|
+
const squeezed = await chooseAndSwap({ ...hardCtx, currentAccountUuid: seat?.accountUuid ?? null, currentOrganizationUuid: seat?.organizationUuid ?? null, orgAffinityFloor: cfg.policy.greedySessionFloor }, rejected, verified);
|
|
318
366
|
if (squeezed) {
|
|
319
367
|
log("decide.last_drop_swap", { account: squeezed.accountUuid.slice(0, 8) });
|
|
320
368
|
return { swapped: true, account: squeezed, reason: "last-drop-swap" };
|
package/src/lib/oauth.ts
CHANGED
|
@@ -94,11 +94,12 @@ export function isAccessTokenExpiring(creds: OAuthCreds, skewMs = 120_000, now =
|
|
|
94
94
|
return !creds.expiresAt || creds.expiresAt - now <= skewMs;
|
|
95
95
|
}
|
|
96
96
|
|
|
97
|
-
export async function fetchTokenIdentity(accessToken: string): Promise<TokenIdentity> {
|
|
97
|
+
export async function fetchTokenIdentity(accessToken: string, signal?: AbortSignal): Promise<TokenIdentity> {
|
|
98
98
|
let res: Response;
|
|
99
99
|
try {
|
|
100
100
|
res = await http.get(PROFILE_URL, {
|
|
101
101
|
headers: { Authorization: `Bearer ${accessToken}`, "Content-Type": "application/json" },
|
|
102
|
+
signal,
|
|
102
103
|
});
|
|
103
104
|
} catch (e) {
|
|
104
105
|
throw new IdentityUnavailableError(null, e instanceof Error ? e.message : String(e));
|
package/src/lib/picker.ts
CHANGED
|
@@ -34,6 +34,8 @@ const PickCtxSchema = z.object({
|
|
|
34
34
|
now: z.number(),
|
|
35
35
|
thresholds: ThresholdsSchema,
|
|
36
36
|
currentAccountUuid: z.string().nullable(),
|
|
37
|
+
currentOrganizationUuid: z.string().nullable().optional(),
|
|
38
|
+
orgAffinityFloor: z.number().optional(),
|
|
37
39
|
switchFamilies: z.array(z.string()),
|
|
38
40
|
holdMargin: z.number().min(0).optional(),
|
|
39
41
|
});
|
|
@@ -94,9 +96,27 @@ export function pacePressure(a: Account, now: number): number {
|
|
|
94
96
|
return Math.max(0, 100 - used) / Math.max(1, reset - now);
|
|
95
97
|
}
|
|
96
98
|
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
(
|
|
99
|
+
function sessionUsed(a: Account, now: number): number {
|
|
100
|
+
const w = a.lastUsage?.fiveHour;
|
|
101
|
+
if (w == null) return 101;
|
|
102
|
+
if (w.resetsAt != null) return w.resetsAt <= now ? 0 : w.usedPercentage;
|
|
103
|
+
if (a.lastUsageAt != null && now >= a.lastUsageAt + FIVE_HOURS_MS) return 0;
|
|
104
|
+
return w.usedPercentage;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function orgTier(a: Account, ctx: PickCtx): number {
|
|
108
|
+
if (ctx.currentOrganizationUuid == null || a.organizationUuid !== ctx.currentOrganizationUuid) return 1;
|
|
109
|
+
if (ctx.orgAffinityFloor != null && sessionUsed(a, ctx.now) >= ctx.orgAffinityFloor) return 1;
|
|
110
|
+
if (!a.lastUsage || ctx.switchFamilies.some((family) => gatedPerModelWindows(a, [family]).length === 0)) return 1;
|
|
111
|
+
const headroom = Math.max(1, ctx.thresholds.session - (ctx.orgAffinityFloor ?? ctx.thresholds.session));
|
|
112
|
+
if (isExhausted(a, { ...ctx, thresholds: { session: ctx.thresholds.session - headroom, weekly: ctx.thresholds.weekly - headroom } })) return 1;
|
|
113
|
+
return 0;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const swapPreference = (ctx: PickCtx) => [
|
|
117
|
+
(a: Account) => orgTier(a, ctx),
|
|
118
|
+
(a: Account) => -pacePressure(a, ctx.now),
|
|
119
|
+
(a: Account) => weeklyExpiry(a, ctx.now),
|
|
100
120
|
(a: Account) => a.lastUsage?.sevenDay.usedPercentage ?? 101,
|
|
101
121
|
];
|
|
102
122
|
|
|
@@ -104,7 +124,7 @@ export function pickBest(accounts: Account[], ctx: PickCtx): Account | null {
|
|
|
104
124
|
const usable = accounts.filter(
|
|
105
125
|
(a) => a.accountUuid !== ctx.currentAccountUuid && !a.needsReauth && !isExhausted(a, ctx),
|
|
106
126
|
);
|
|
107
|
-
return sortBy(usable, swapPreference(ctx
|
|
127
|
+
return sortBy(usable, swapPreference(ctx))[0] ?? null;
|
|
108
128
|
}
|
|
109
129
|
|
|
110
130
|
export function currentWins(active: Account | null, accounts: Account[], ctx: PickCtx): boolean {
|
|
@@ -114,7 +134,7 @@ export function currentWins(active: Account | null, accounts: Account[], ctx: Pi
|
|
|
114
134
|
const margin = ctx.holdMargin ?? 0;
|
|
115
135
|
const bestPace = pacePressure(best, ctx.now);
|
|
116
136
|
if (margin > 0 && bestPace > 0 && bestPace <= pacePressure(active, ctx.now) * (1 + margin)) return true;
|
|
117
|
-
return swapPreference(ctx
|
|
137
|
+
return swapPreference(ctx).every((k) => k(active) === k(best));
|
|
118
138
|
}
|
|
119
139
|
|
|
120
140
|
export function usableAt(a: Account, ctx: PickCtx): number {
|
package/src/lib/sample.ts
CHANGED
|
@@ -24,10 +24,10 @@ const IdentityCheckSchema = z.discriminatedUnion("status", [
|
|
|
24
24
|
]);
|
|
25
25
|
type IdentityCheck = z.infer<typeof IdentityCheckSchema>;
|
|
26
26
|
|
|
27
|
-
async function checkIdentity(creds: OAuthCreds, account: Account): Promise<IdentityCheck> {
|
|
27
|
+
async function checkIdentity(creds: OAuthCreds, account: Account, signal?: AbortSignal): Promise<IdentityCheck> {
|
|
28
28
|
let identity: TokenIdentity;
|
|
29
29
|
try {
|
|
30
|
-
identity = await fetchTokenIdentity(creds.accessToken);
|
|
30
|
+
identity = await fetchTokenIdentity(creds.accessToken, signal);
|
|
31
31
|
} catch (e) {
|
|
32
32
|
return {
|
|
33
33
|
status: "unavailable",
|
|
@@ -44,7 +44,10 @@ function refreshPlanFields(account: Account, creds: OAuthCreds): void {
|
|
|
44
44
|
if (creds.rateLimitTier != null) account.rateLimitTier = creds.rateLimitTier;
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
-
export async function probeParkedUsage(
|
|
47
|
+
export async function probeParkedUsage(
|
|
48
|
+
account: Account,
|
|
49
|
+
opts: { ping?: boolean; retries?: number; refreshParked?: boolean; killMs?: number; signal?: AbortSignal } = {},
|
|
50
|
+
): Promise<SampleOutcome> {
|
|
48
51
|
const backup = parkedTarget(account.keychainItem);
|
|
49
52
|
const parkedRaw = await readItem(backup);
|
|
50
53
|
if (!parkedRaw) return { ok: false, reason: "no parked credential - run `tokenmaxxing auth`" };
|
|
@@ -59,6 +62,9 @@ export async function probeParkedUsage(account: Account, opts: { ping?: boolean
|
|
|
59
62
|
account.needsReauth = true;
|
|
60
63
|
return { ok: false, reason: "parked credential was cleared after a failed refresh - re-auth with `tokenmaxxing auth`" };
|
|
61
64
|
}
|
|
65
|
+
if (opts.refreshParked === false && isAccessTokenExpiring(creds, 300_000)) {
|
|
66
|
+
return { ok: false, reason: "parked access token is near expiry - verification requires an unexpired token" };
|
|
67
|
+
}
|
|
62
68
|
|
|
63
69
|
const liveRaw = await readItem(liveTarget());
|
|
64
70
|
let liveAccount: string | null = null;
|
|
@@ -73,7 +79,7 @@ export async function probeParkedUsage(account: Account, opts: { ping?: boolean
|
|
|
73
79
|
if (!isDeadCredential(liveCreds)) {
|
|
74
80
|
liveToken = liveCreds.accessToken;
|
|
75
81
|
try {
|
|
76
|
-
liveAccount = (await fetchTokenIdentity(liveCreds.accessToken)).accountUuid;
|
|
82
|
+
liveAccount = (await fetchTokenIdentity(liveCreds.accessToken, opts.signal)).accountUuid;
|
|
77
83
|
} catch (e) {
|
|
78
84
|
return { ok: false, reason: `cannot verify the live credential's owner (${(e instanceof Error ? e.message : String(e)).slice(0, 80)}) - refusing to sample a possibly-live account` };
|
|
79
85
|
}
|
|
@@ -92,6 +98,9 @@ export async function probeParkedUsage(account: Account, opts: { ping?: boolean
|
|
|
92
98
|
};
|
|
93
99
|
|
|
94
100
|
if (isAccessTokenExpiring(creds, 300_000)) {
|
|
101
|
+
if (opts.refreshParked === false) {
|
|
102
|
+
return { ok: false, reason: "parked access token is near expiry - verification requires an unexpired token" };
|
|
103
|
+
}
|
|
95
104
|
const owner = await checkIdentity(creds, account);
|
|
96
105
|
if (owner.status === "mismatch") {
|
|
97
106
|
const kept = await relocate(owner.owner);
|
|
@@ -136,7 +145,7 @@ export async function probeParkedUsage(account: Account, opts: { ping?: boolean
|
|
|
136
145
|
await writeItem(backup, JSON.stringify({ claudeAiOauth: creds }));
|
|
137
146
|
}
|
|
138
147
|
|
|
139
|
-
const identity = await checkIdentity(creds, account);
|
|
148
|
+
const identity = await checkIdentity(creds, account, opts.signal);
|
|
140
149
|
if (identity.status === "mismatch") {
|
|
141
150
|
const kept = await relocate(identity.owner);
|
|
142
151
|
account.needsReauth = true;
|
|
@@ -151,13 +160,13 @@ export async function probeParkedUsage(account: Account, opts: { ping?: boolean
|
|
|
151
160
|
rmSync(dir, { recursive: true, force: true });
|
|
152
161
|
mkdirSync(dir, { recursive: true });
|
|
153
162
|
const isoTarget = isolatedTarget(dir);
|
|
154
|
-
const installed = JSON.stringify({ claudeAiOauth: creds });
|
|
163
|
+
const installed = JSON.stringify({ claudeAiOauth: opts.refreshParked === false ? { ...creds, refreshToken: "" } : creds });
|
|
155
164
|
|
|
156
165
|
try {
|
|
157
166
|
await writeItem(isoTarget, installed);
|
|
158
167
|
writeFileSync(join(dir, ".claude.json"), JSON.stringify({ oauthAccount: account.oauthAccount, hasCompletedOnboarding: true }));
|
|
159
168
|
const ping = opts.ping ? await pingSession(dir) : null;
|
|
160
|
-
const usage = await probeUsage(dir);
|
|
169
|
+
const usage = await probeUsage(dir, Date.now(), opts);
|
|
161
170
|
const outcome: SampleOutcome = usage
|
|
162
171
|
? { ok: true, usage }
|
|
163
172
|
: { ok: false, reason: "`/usage` returned no limit data (see log)" };
|
|
@@ -167,10 +176,15 @@ export async function probeParkedUsage(account: Account, opts: { ping?: boolean
|
|
|
167
176
|
}
|
|
168
177
|
return outcome;
|
|
169
178
|
} finally {
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
179
|
+
try {
|
|
180
|
+
if (opts.refreshParked !== false) {
|
|
181
|
+
const afterIso = await readItem(isoTarget);
|
|
182
|
+
if (afterIso && afterIso !== installed) await writeItem(backup, claudeAiOauthOnly(afterIso));
|
|
183
|
+
}
|
|
184
|
+
} finally {
|
|
185
|
+
await deleteItem(isoTarget);
|
|
186
|
+
rmSync(dir, { recursive: true, force: true });
|
|
187
|
+
}
|
|
174
188
|
}
|
|
175
189
|
}
|
|
176
190
|
|
package/src/lib/swap.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
1
2
|
import { clearDepletedWait, clearNextCheck, clearUsageSnapshots, loadAccounts, saveAccounts, saveLastSwapAt } from "./state.ts";
|
|
2
3
|
import { readItem, writeItem, liveTarget, parkedTarget, claudeAiOauthOnly, mergeIntoLive } from "./credstore.ts";
|
|
3
4
|
import { refreshCredential, isAccessTokenExpiring, isDeadCredential, fetchTokenIdentity, describeIdentity, IdentityUnavailableError, InvalidGrantError, RefreshRejectedError } from "./oauth.ts";
|
|
@@ -11,6 +12,9 @@ function parseBlob(raw: string) {
|
|
|
11
12
|
return CredentialBlobSchema.parse(JSON.parse(raw));
|
|
12
13
|
}
|
|
13
14
|
|
|
15
|
+
const VerifyVerdictSchema = z.enum(["go", "rerank", "skip"]);
|
|
16
|
+
export type VerifyVerdict = z.infer<typeof VerifyVerdictSchema>;
|
|
17
|
+
|
|
14
18
|
export async function performSwap(target: Account): Promise<void> {
|
|
15
19
|
const idx = loadAccounts();
|
|
16
20
|
|
|
@@ -196,13 +200,25 @@ export function isSkippableSwapError(e: unknown): boolean {
|
|
|
196
200
|
return e instanceof InvalidGrantError || e instanceof RefreshRejectedError || e instanceof IdentityUnavailableError;
|
|
197
201
|
}
|
|
198
202
|
|
|
199
|
-
export async function chooseAndSwap(
|
|
203
|
+
export async function chooseAndSwap(
|
|
204
|
+
ctx: PickCtx,
|
|
205
|
+
exclude: ReadonlySet<string> = new Set(),
|
|
206
|
+
verify?: (candidate: Account, ctx: PickCtx) => Promise<VerifyVerdict>,
|
|
207
|
+
): Promise<Account | null> {
|
|
200
208
|
const tried = new Set<string>(exclude);
|
|
201
209
|
while (true) {
|
|
202
210
|
const idx = loadAccounts();
|
|
203
211
|
const candidates = idx.accounts.filter((a) => !tried.has(a.accountUuid));
|
|
204
212
|
const best = pickBest(candidates, ctx);
|
|
205
213
|
if (!best) return null;
|
|
214
|
+
if (verify) {
|
|
215
|
+
const verdict = await verify(best, ctx);
|
|
216
|
+
if (verdict === "rerank") continue;
|
|
217
|
+
if (verdict === "skip") {
|
|
218
|
+
tried.add(best.accountUuid);
|
|
219
|
+
continue;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
206
222
|
tried.add(best.accountUuid);
|
|
207
223
|
try {
|
|
208
224
|
await performSwap(best);
|
package/src/lib/usage.ts
CHANGED
|
@@ -286,9 +286,14 @@ async function spawnClaudeBounded(
|
|
|
286
286
|
cmd: string[],
|
|
287
287
|
env: Record<string, string>,
|
|
288
288
|
cwd?: string,
|
|
289
|
+
killMs = PROBE_KILL_MS,
|
|
290
|
+
signal?: AbortSignal,
|
|
289
291
|
): Promise<SpawnResult | null> {
|
|
292
|
+
signal?.throwIfAborted();
|
|
290
293
|
const p = Bun.spawn(cmd, { env, cwd, stdout: "pipe", stderr: "pipe" });
|
|
291
|
-
const
|
|
294
|
+
const abort = () => p.kill("SIGKILL");
|
|
295
|
+
signal?.addEventListener("abort", abort, { once: true });
|
|
296
|
+
const killer = setTimeout(() => p.kill("SIGKILL"), killMs);
|
|
292
297
|
try {
|
|
293
298
|
const reads = Promise.all([new Response(p.stdout).text(), new Response(p.stderr).text()]);
|
|
294
299
|
const settled = await Promise.race([
|
|
@@ -301,13 +306,14 @@ async function spawnClaudeBounded(
|
|
|
301
306
|
return { exitCode: p.exitCode, stdout, stderr };
|
|
302
307
|
} finally {
|
|
303
308
|
clearTimeout(killer);
|
|
309
|
+
signal?.removeEventListener("abort", abort);
|
|
304
310
|
}
|
|
305
311
|
}
|
|
306
312
|
|
|
307
|
-
async function probeUsageOnce(env: Record<string, string>, now: number): Promise<FullUsage | null> {
|
|
313
|
+
async function probeUsageOnce(env: Record<string, string>, now: number, killMs: number, signal?: AbortSignal): Promise<FullUsage | null> {
|
|
308
314
|
let out: string;
|
|
309
315
|
try {
|
|
310
|
-
const r = await spawnClaudeBounded([resolveRealClaude(), "-p", "/usage", "--output-format", "json"], env);
|
|
316
|
+
const r = await spawnClaudeBounded([resolveRealClaude(), "-p", "/usage", "--output-format", "json"], env, undefined, killMs, signal);
|
|
311
317
|
if (r === null) {
|
|
312
318
|
log("usage.probe_failed", { err: "output pipes still open after child exit (leaked descendant)" });
|
|
313
319
|
return null;
|
|
@@ -345,17 +351,17 @@ function probeEnv(configDir?: string): Record<string, string> {
|
|
|
345
351
|
return env;
|
|
346
352
|
}
|
|
347
353
|
|
|
348
|
-
export async function probeUsage(configDir?: string, now = Date.now()): Promise<FullUsage | null> {
|
|
354
|
+
export async function probeUsage(configDir?: string, now = Date.now(), opts: { retries?: number; killMs?: number; signal?: AbortSignal } = {}): Promise<FullUsage | null> {
|
|
349
355
|
const env = probeEnv(configDir);
|
|
350
356
|
|
|
351
357
|
for (let attempt = 0; ; attempt++) {
|
|
352
|
-
const full = await probeUsageOnce(env, now);
|
|
358
|
+
const full = await probeUsageOnce(env, now, opts.killMs ?? PROBE_KILL_MS, opts.signal);
|
|
353
359
|
if (full) return full;
|
|
354
|
-
if (attempt >= PROBE_RETRY_DELAYS_MS.length) {
|
|
360
|
+
if (attempt >= (opts.retries ?? PROBE_RETRY_DELAYS_MS.length) || opts.signal?.aborted) {
|
|
355
361
|
log("usage.probe_gave_up", { attempts: attempt + 1 });
|
|
356
362
|
return null;
|
|
357
363
|
}
|
|
358
|
-
await delay(PROBE_RETRY_DELAYS_MS[attempt]!);
|
|
364
|
+
await delay(PROBE_RETRY_DELAYS_MS[Math.min(attempt, PROBE_RETRY_DELAYS_MS.length - 1)]!);
|
|
359
365
|
}
|
|
360
366
|
}
|
|
361
367
|
|