tokenmaxxing 1.27.0 → 1.28.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 -5
- package/README.md +2 -3
- package/package.json +1 -1
- package/src/cli/check.ts +5 -1
- package/src/cli/rm.ts +5 -3
- package/src/lib/decide.ts +2 -46
- package/src/lib/oauth.ts +2 -1
- package/src/lib/sample.ts +117 -35
- package/src/lib/swap.ts +0 -4
- package/src/lib/types.ts +1 -0
- package/src/lib/usage.ts +7 -16
package/DESIGN.md
CHANGED
|
@@ -50,7 +50,7 @@ The Stop hook's stdin has no usage data, but the **statusLine does** (`rate_limi
|
|
|
50
50
|
|
|
51
51
|
### 3.2 Detect + swap + signal (Stop hook, per turn)
|
|
52
52
|
1. Read `usage.json` and exit while the live account is under both bars, as 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)
|
|
53
|
+
2. Take the pool lock, re-check state, select a candidate under the [switching policy](docs/content/docs/switching.mdx) from the cached usage figures, and swap 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)
|
|
@@ -82,10 +82,7 @@ Each terminal ran the supervisor, so each has its own child `claude` and its own
|
|
|
82
82
|
The [switching policy](docs/content/docs/switching.mdx) is one rule: under both bars, hold; at or over a bar, move to the usable account with the highest pace pressure; nothing usable, wait for the soonest reset.
|
|
83
83
|
|
|
84
84
|
- Automatic and manual switching rank by pace pressure alone; organization membership is not an input.
|
|
85
|
-
-
|
|
86
|
-
- Verification attempts at most two candidates with a shared 12-second deadline per candidate for identity reads and CLI execution.
|
|
87
|
-
- Expiring access tokens are not refreshed for verification, and the isolated CLI receives no refresh grant.
|
|
88
|
-
- Failed or budget-limited verification uses the existing cached data, so stale-target handoffs remain possible.
|
|
85
|
+
- Selection reads the cached figures only. Each periodic check tick samples the parked account whose last `/usage` attempt is oldest, skipping any attempted within `policy.usagePollTtlMs`, so every parked account is attempted about once per tick per pooled account, or once per `policy.usagePollTtlMs` plus a tick when that is longer, and its cached figure is as fresh as its last successful attempt. No sample runs inside the post-swap cooldown. The pool lock covers only the reservation (its owner lookups and refresh under one 20-second deadline) and the result write, never the `/usage` child.
|
|
89
86
|
- The depleted pause happens at the bar, not at 100; Codex has no pause and rides its account until the server refuses it, because a running sibling cannot adopt another account without restarting.
|
|
90
87
|
|
|
91
88
|
The [profile](docs/content/docs/switching-profile.mdx) records observed cache rewrites across swaps and does not infer subscription quota savings.
|
package/README.md
CHANGED
|
@@ -88,8 +88,7 @@ The bars' headroom is deliberate: it's the budget to reach a clean turn boundary
|
|
|
88
88
|
|
|
89
89
|
Selection ranks usable candidates by remaining weekly percentage divided by time to reset, highest first; organization membership is not an input, and manual `tokenmaxxing switch` uses the same ranking.
|
|
90
90
|
|
|
91
|
-
-
|
|
92
|
-
- Failed verification leaves cached figures in force, so it cannot guarantee a fresh target.
|
|
91
|
+
- Every periodic check tick samples the parked account whose last `/usage` attempt is oldest, skipping any attempted within `policy.usagePollTtlMs`, so every parked account is attempted about once per tick per pooled account, or once per `usagePollTtlMs` plus a tick when that is longer; the swap reads the cached figures, which are as fresh as each account's last successful attempt. No sample runs inside the 45-second post-swap cooldown.
|
|
93
92
|
- When no account is usable, the session pauses until the soonest reset if that lands within `policy.maxWaitMs`, and otherwise stays put.
|
|
94
93
|
|
|
95
94
|
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.
|
|
@@ -110,7 +109,7 @@ See [How switching decides](docs/content/docs/switching.mdx) for the policy and
|
|
|
110
109
|
}
|
|
111
110
|
```
|
|
112
111
|
|
|
113
|
-
`thresholds.session` and `thresholds.weekly` are the two bars, one number each (an array `thresholds.session`, the former ladder, fails config loading with a message naming the field); `projectionMargin` is a fixed safety margin subtracted from each threshold bar (effective bar = threshold - margin), so a large turn is less likely to blow past a bar between checks; `switchModels` names the models whose per-model cap triggers a switch; `usagePollTtlMs` is how long a `/usage` per-model poll
|
|
112
|
+
`thresholds.session` and `thresholds.weekly` are the two bars, one number each (an array `thresholds.session`, the former ladder, fails config loading with a message naming the field); `projectionMargin` is a fixed safety margin subtracted from each threshold bar (effective bar = threshold - margin), so a large turn is less likely to blow past a bar between checks; `switchModels` names the models whose per-model cap triggers a switch; `usagePollTtlMs` is how long a `/usage` attempt stays fresh, for the live per-model poll and for the parked account each check tick samples; `maxWaitMs` bounds the depleted-pool countdown - a soonest reset further out than this does not pause the session (no respawn marker is written and the session simply keeps hitting its limit until an account recovers); `checkIntervalMs` is the periodic check tick (default 60s), which `init` writes into the timer - re-run `tokenmaxxing init` after changing it so the timer unit picks up the new tick.
|
|
114
113
|
|
|
115
114
|
State lives entirely in `~/.config/tokenmaxxing/`. Per-account credentials follow the platform's Claude Code store: the login keychain on macOS (`tokenmaxxing-cred-<uuid8>` items, never plaintext on disk), 0600 files under `~/.config/tokenmaxxing/creds/` on Linux (the same plaintext model claude itself uses for `~/.claude/.credentials.json`).
|
|
116
115
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tokenmaxxing",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.28.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/check.ts
CHANGED
|
@@ -1,11 +1,15 @@
|
|
|
1
1
|
import { evaluateAndMaybeSwap } from "../lib/decide.ts";
|
|
2
|
+
import { sampleOldestParked } from "../lib/sample.ts";
|
|
3
|
+
import { loadConfig } from "../lib/state.ts";
|
|
2
4
|
import { log } from "../lib/log.ts";
|
|
3
5
|
import { c, emitError, emitJson, fmtReset } from "./render.ts";
|
|
4
6
|
|
|
5
7
|
export async function cmdCheck(json = false): Promise<number> {
|
|
8
|
+
const now = Date.now();
|
|
6
9
|
let d;
|
|
7
10
|
try {
|
|
8
|
-
d = await evaluateAndMaybeSwap(
|
|
11
|
+
d = await evaluateAndMaybeSwap(now);
|
|
12
|
+
await sampleOldestParked(loadConfig());
|
|
9
13
|
} catch (e) {
|
|
10
14
|
const detail = e instanceof Error ? e.message : String(e);
|
|
11
15
|
log("check.error", { err: detail });
|
package/src/cli/rm.ts
CHANGED
|
@@ -44,9 +44,11 @@ export async function cmdRm(selector?: string, json = false): Promise<number> {
|
|
|
44
44
|
}
|
|
45
45
|
}
|
|
46
46
|
await deleteItem(parkedTarget(a.keychainItem));
|
|
47
|
-
const
|
|
48
|
-
|
|
49
|
-
|
|
47
|
+
const item = credItemFor(a.accountUuid);
|
|
48
|
+
for (const sampleDir of [join(paths.sampleDir, item), join(paths.sampleDir, `${item}-tick`)]) {
|
|
49
|
+
await deleteItem(isolatedTarget(sampleDir));
|
|
50
|
+
rmSync(sampleDir, { recursive: true, force: true });
|
|
51
|
+
}
|
|
50
52
|
idx.accounts = idx.accounts.filter((x) => x.accountUuid !== a.accountUuid);
|
|
51
53
|
saveAccounts(idx);
|
|
52
54
|
if (json) emitJson({ ok: true, pool: "claude", removed: a.label, remaining: idx.accounts.length });
|
package/src/lib/decide.ts
CHANGED
|
@@ -4,9 +4,8 @@ import { withLock } from "./lock.ts";
|
|
|
4
4
|
import { paths } from "./paths.ts";
|
|
5
5
|
import { POST_SWAP_COOLDOWN_MS, loadAccounts, loadConfig, loadDepletedWait, loadLastSwapAt, loadUsage, loadUsageSnapshot, loadModelUsage, saveAccounts, saveDepletedWait, saveModelUsage, writeUsage } from "./state.ts";
|
|
6
6
|
import { readOAuthAccount } from "./claudejson.ts";
|
|
7
|
-
import { isSkippableSwapError, performSwap
|
|
8
|
-
import {
|
|
9
|
-
import { isExhausted, nextWeeklyReset, pickBest, pickEarliestReset, thresholdBars, usableAt, type PickCtx } from "./picker.ts";
|
|
7
|
+
import { isSkippableSwapError, performSwap } from "./swap.ts";
|
|
8
|
+
import { nextWeeklyReset, pickBest, pickEarliestReset, thresholdBars, usableAt } from "./picker.ts";
|
|
10
9
|
import { familyTokens, gatedFamilies, probeUsage, type EnforcedClass } from "./usage.ts";
|
|
11
10
|
import { log } from "./log.ts";
|
|
12
11
|
import { AccountSchema, ModelUsageStateSchema, UsageStateSchema, type Account, type Config, type EnforcedLimit, type ModelUsageState, type Thresholds, type UsageState, type UsageWindow } from "./types.ts";
|
|
@@ -21,8 +20,6 @@ export type SwapDecision = z.infer<typeof SwapDecisionSchema>;
|
|
|
21
20
|
|
|
22
21
|
const WEEK_MS = 7 * 24 * 60 * 60 * 1000;
|
|
23
22
|
const FIVE_HOURS_MS = 5 * 60 * 60 * 1000;
|
|
24
|
-
const VERIFY_PROBE_BUDGET = 2;
|
|
25
|
-
const VERIFY_PROBE_KILL_MS = 12_000;
|
|
26
23
|
|
|
27
24
|
function liveUsed(input: { window: UsageWindow; windowMs: number; sampledAt: number; now: number }): number {
|
|
28
25
|
const { window: w, windowMs, sampledAt, now } = input;
|
|
@@ -186,53 +183,12 @@ export async function evaluateAndMaybeSwap(now = Date.now(), anticipatory = fals
|
|
|
186
183
|
rejected.add(candidate.accountUuid);
|
|
187
184
|
log("decide.candidate_rejected", { account: candidate.accountUuid.slice(0, 8), error: e instanceof Error ? e.message : String(e) });
|
|
188
185
|
};
|
|
189
|
-
let probeBudget = VERIFY_PROBE_BUDGET;
|
|
190
|
-
const verified = async (candidate: Account, ctx: PickCtx): Promise<VerifyVerdict> => {
|
|
191
|
-
if (candidate.lastUsageAt != null && now - candidate.lastUsageAt <= cfg.policy.usagePollTtlMs) return "go";
|
|
192
|
-
const short = candidate.accountUuid.slice(0, 8);
|
|
193
|
-
const idx2 = loadAccounts();
|
|
194
|
-
const stored = idx2.accounts.find((a) => a.accountUuid === candidate.accountUuid);
|
|
195
|
-
if (!stored) return "go";
|
|
196
|
-
if (probeBudget <= 0) {
|
|
197
|
-
log("decide.verify_budget_spent", { account: short });
|
|
198
|
-
return "go";
|
|
199
|
-
}
|
|
200
|
-
probeBudget -= 1;
|
|
201
|
-
const sample = await probeParkedUsage(stored, { retries: 0, refreshParked: false, killMs: VERIFY_PROBE_KILL_MS, signal: AbortSignal.timeout(VERIFY_PROBE_KILL_MS) });
|
|
202
|
-
if (sample.ok) {
|
|
203
|
-
const at = Date.now();
|
|
204
|
-
stored.lastUsage = { fiveHour: sample.usage.session, sevenDay: sample.usage.weekAll };
|
|
205
|
-
stored.lastUsageAt = at;
|
|
206
|
-
if (Object.keys(sample.usage.perModel).length > 0) {
|
|
207
|
-
stored.lastPerModel = sample.usage.perModel;
|
|
208
|
-
stored.lastPerModelAt = at;
|
|
209
|
-
}
|
|
210
|
-
}
|
|
211
|
-
if (sample.ok || stored.needsReauth === true) saveAccounts(idx2);
|
|
212
|
-
if (stored.needsReauth === true) {
|
|
213
|
-
rejected.add(candidate.accountUuid);
|
|
214
|
-
log("decide.candidate_rejected", { account: short, error: "needs reauth" });
|
|
215
|
-
return "skip";
|
|
216
|
-
}
|
|
217
|
-
if (!sample.ok) {
|
|
218
|
-
log("decide.candidate_unverified", { account: short });
|
|
219
|
-
return "go";
|
|
220
|
-
}
|
|
221
|
-
log(isExhausted(stored, ctx) ? "decide.candidate_walled" : "decide.candidate_verified", {
|
|
222
|
-
account: short,
|
|
223
|
-
session: sample.usage.session.usedPercentage,
|
|
224
|
-
weekly: sample.usage.weekAll.usedPercentage,
|
|
225
|
-
});
|
|
226
|
-
return "rerank";
|
|
227
|
-
};
|
|
228
|
-
|
|
229
186
|
while (true) {
|
|
230
187
|
const cur = loadAccounts();
|
|
231
188
|
const seat = seatOf(cur);
|
|
232
189
|
const ctx = { now, thresholds: bars, currentAccountUuid: seat?.accountUuid ?? null, switchFamilies };
|
|
233
190
|
const best = pickBest(usable(cur.accounts), ctx);
|
|
234
191
|
if (!best) break;
|
|
235
|
-
if ((await verified(best, ctx)) !== "go") continue;
|
|
236
192
|
try {
|
|
237
193
|
await performSwap(best);
|
|
238
194
|
} catch (e) {
|
package/src/lib/oauth.ts
CHANGED
|
@@ -40,7 +40,7 @@ export function isDeadCredential(creds: OAuthCreds): boolean {
|
|
|
40
40
|
return creds.refreshToken === "" || creds.accessToken === "";
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
-
export async function refreshCredential(creds: OAuthCreds, now = Date.now()): Promise<OAuthCreds> {
|
|
43
|
+
export async function refreshCredential(creds: OAuthCreds, now = Date.now(), signal?: AbortSignal): Promise<OAuthCreds> {
|
|
44
44
|
if (isDeadCredential(creds)) throw new InvalidGrantError("credential was cleared after a failed refresh");
|
|
45
45
|
const scope = (creds.scopes?.length ? creds.scopes : DEFAULT_SCOPES).join(" ");
|
|
46
46
|
const body = {
|
|
@@ -55,6 +55,7 @@ export async function refreshCredential(creds: OAuthCreds, now = Date.now()): Pr
|
|
|
55
55
|
res = await http.post(TOKEN_URL, {
|
|
56
56
|
headers: { "Content-Type": "application/json" },
|
|
57
57
|
body: JSON.stringify(body),
|
|
58
|
+
signal,
|
|
58
59
|
});
|
|
59
60
|
} catch (e) {
|
|
60
61
|
throw new Error(`token endpoint unreachable: ${e instanceof Error ? e.message : String(e)}`);
|
package/src/lib/sample.ts
CHANGED
|
@@ -1,15 +1,18 @@
|
|
|
1
1
|
import { mkdirSync, rmSync, writeFileSync } from "node:fs";
|
|
2
2
|
import { join } from "node:path";
|
|
3
|
+
import { minBy } from "es-toolkit";
|
|
3
4
|
import { z } from "zod";
|
|
4
5
|
import { readItem, writeItem, deleteItem, liveTarget, parkedTarget, isolatedTarget, claudeAiOauthOnly, mergeIntoLive } from "./credstore.ts";
|
|
5
6
|
import { credItemFor, paths } from "./paths.ts";
|
|
6
7
|
import { withClaudeRefreshLock } from "./claudelock.ts";
|
|
8
|
+
import { withLock } from "./lock.ts";
|
|
9
|
+
import { readOAuthAccount } from "./claudejson.ts";
|
|
7
10
|
import { refreshCredential, isAccessTokenExpiring, isDeadCredential, fetchTokenIdentity, describeIdentity, IdentityUnavailableError, InvalidGrantError } from "./oauth.ts";
|
|
8
11
|
import { FullUsageSchema, probeUsage } from "./usage.ts";
|
|
9
|
-
import { loadAccounts } from "./state.ts";
|
|
12
|
+
import { POST_SWAP_COOLDOWN_MS, loadAccounts, loadLastSwapAt, saveAccounts } from "./state.ts";
|
|
10
13
|
import { keepRotatedPair } from "./swap.ts";
|
|
11
14
|
import { log } from "./log.ts";
|
|
12
|
-
import { CredentialBlobSchema, TokenIdentitySchema, type Account, type OAuthCreds, type TokenIdentity } from "./types.ts";
|
|
15
|
+
import { CredentialBlobSchema, TokenIdentitySchema, type Account, type Config, type OAuthCreds, type TokenIdentity } from "./types.ts";
|
|
13
16
|
|
|
14
17
|
const SampleOutcomeSchema = z.discriminatedUnion("ok", [
|
|
15
18
|
z.object({ ok: z.literal(true), usage: FullUsageSchema }),
|
|
@@ -44,10 +47,12 @@ function refreshPlanFields(account: Account, creds: OAuthCreds): void {
|
|
|
44
47
|
if (creds.rateLimitTier != null) account.rateLimitTier = creds.rateLimitTier;
|
|
45
48
|
}
|
|
46
49
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
50
|
+
const ProbeFailureSchema = z.object({ ok: z.literal(false), reason: z.string() });
|
|
51
|
+
type ProbeFailure = z.infer<typeof ProbeFailureSchema>;
|
|
52
|
+
const PreparedProbeSchema = z.discriminatedUnion("ok", [z.object({ ok: z.literal(true), dir: z.string(), installed: z.string() }), ProbeFailureSchema]);
|
|
53
|
+
type PreparedProbe = z.infer<typeof PreparedProbeSchema>;
|
|
54
|
+
|
|
55
|
+
async function prepareParkedProbe(account: Account, dir: string, signal?: AbortSignal): Promise<PreparedProbe> {
|
|
51
56
|
const backup = parkedTarget(account.keychainItem);
|
|
52
57
|
const parkedRaw = await readItem(backup);
|
|
53
58
|
if (!parkedRaw) return { ok: false, reason: "no parked credential - run `tokenmaxxing auth`" };
|
|
@@ -62,13 +67,11 @@ export async function probeParkedUsage(
|
|
|
62
67
|
account.needsReauth = true;
|
|
63
68
|
return { ok: false, reason: "parked credential was cleared after a failed refresh - re-auth with `tokenmaxxing auth`" };
|
|
64
69
|
}
|
|
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
|
-
}
|
|
68
70
|
|
|
69
71
|
const liveRaw = await readItem(liveTarget());
|
|
70
72
|
let liveAccount: string | null = null;
|
|
71
73
|
let liveToken: string | null = null;
|
|
74
|
+
let liveRefresh: string | null = null;
|
|
72
75
|
if (liveRaw != null) {
|
|
73
76
|
let liveCreds: OAuthCreds;
|
|
74
77
|
try {
|
|
@@ -78,8 +81,9 @@ export async function probeParkedUsage(
|
|
|
78
81
|
}
|
|
79
82
|
if (!isDeadCredential(liveCreds)) {
|
|
80
83
|
liveToken = liveCreds.accessToken;
|
|
84
|
+
liveRefresh = liveCreds.refreshToken;
|
|
81
85
|
try {
|
|
82
|
-
liveAccount = (await fetchTokenIdentity(liveCreds.accessToken,
|
|
86
|
+
liveAccount = (await fetchTokenIdentity(liveCreds.accessToken, signal)).accountUuid;
|
|
83
87
|
} catch (e) {
|
|
84
88
|
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` };
|
|
85
89
|
}
|
|
@@ -98,10 +102,7 @@ export async function probeParkedUsage(
|
|
|
98
102
|
};
|
|
99
103
|
|
|
100
104
|
if (isAccessTokenExpiring(creds, 300_000)) {
|
|
101
|
-
|
|
102
|
-
return { ok: false, reason: "parked access token is near expiry - verification requires an unexpired token" };
|
|
103
|
-
}
|
|
104
|
-
const owner = await checkIdentity(creds, account);
|
|
105
|
+
const owner = await checkIdentity(creds, account, signal);
|
|
105
106
|
if (owner.status === "mismatch") {
|
|
106
107
|
const kept = await relocate(owner.owner);
|
|
107
108
|
account.needsReauth = true;
|
|
@@ -110,9 +111,9 @@ export async function probeParkedUsage(
|
|
|
110
111
|
if (owner.status === "unavailable" && !owner.stale) {
|
|
111
112
|
return { ok: false, reason: `${owner.reason} - refusing to refresh a parked credential whose owner cannot be verified` };
|
|
112
113
|
}
|
|
113
|
-
const rotate = async (): Promise<{ fresh: OAuthCreds } | { failed:
|
|
114
|
+
const rotate = async (): Promise<{ fresh: OAuthCreds } | { failed: ProbeFailure }> => {
|
|
114
115
|
try {
|
|
115
|
-
return { fresh: await refreshCredential(creds) };
|
|
116
|
+
return { fresh: await refreshCredential(creds, Date.now(), signal) };
|
|
116
117
|
} catch (e) {
|
|
117
118
|
if (e instanceof InvalidGrantError) {
|
|
118
119
|
account.needsReauth = true;
|
|
@@ -121,16 +122,24 @@ export async function probeParkedUsage(
|
|
|
121
122
|
return { failed: { ok: false, reason: `token refresh failed: ${e instanceof Error ? e.message : String(e)}` } };
|
|
122
123
|
}
|
|
123
124
|
};
|
|
124
|
-
let result: { fresh: OAuthCreds } | { failed:
|
|
125
|
+
let result: { fresh: OAuthCreds } | { failed: ProbeFailure };
|
|
125
126
|
if (owner.status === "match") {
|
|
126
127
|
result = await rotate();
|
|
127
128
|
} else {
|
|
129
|
+
if (liveRefresh != null && creds.refreshToken === liveRefresh) {
|
|
130
|
+
return { ok: false, reason: "this slot holds a copy of the LIVE login's grant - refusing to rotate it from a parked slot; run `tokenmaxxing switch` to reconcile" };
|
|
131
|
+
}
|
|
128
132
|
try {
|
|
133
|
+
signal?.throwIfAborted();
|
|
129
134
|
result = await withClaudeRefreshLock(async (lock) => {
|
|
130
135
|
const rotated = await rotate();
|
|
131
136
|
if ("failed" in rotated) return rotated;
|
|
132
|
-
const verified = await checkIdentity(rotated.fresh, account);
|
|
133
|
-
if (verified.status
|
|
137
|
+
const verified = await checkIdentity(rotated.fresh, account, signal);
|
|
138
|
+
if (verified.status === "match") return rotated;
|
|
139
|
+
if (verified.status === "unavailable") {
|
|
140
|
+
await writeItem(backup, JSON.stringify({ claudeAiOauth: rotated.fresh }));
|
|
141
|
+
return { failed: { ok: false, reason: `${verified.reason} - the rotated pair stays in this slot unverified until the next pass` } };
|
|
142
|
+
}
|
|
134
143
|
const trueOwner = loadAccounts().accounts.find((a) => a.accountUuid === verified.owner.accountUuid) ?? null;
|
|
135
144
|
const kept = await keepRotatedPair({ fresh: rotated.fresh, owner: trueOwner, fallback: account, liveOwnerUuid: liveAccount, expectedLiveToken: liveToken, lock });
|
|
136
145
|
account.needsReauth = true;
|
|
@@ -145,7 +154,7 @@ export async function probeParkedUsage(
|
|
|
145
154
|
await writeItem(backup, JSON.stringify({ claudeAiOauth: creds }));
|
|
146
155
|
}
|
|
147
156
|
|
|
148
|
-
const identity = await checkIdentity(creds, account,
|
|
157
|
+
const identity = await checkIdentity(creds, account, signal);
|
|
149
158
|
if (identity.status === "mismatch") {
|
|
150
159
|
const kept = await relocate(identity.owner);
|
|
151
160
|
account.needsReauth = true;
|
|
@@ -156,27 +165,100 @@ export async function probeParkedUsage(
|
|
|
156
165
|
}
|
|
157
166
|
refreshPlanFields(account, creds);
|
|
158
167
|
|
|
159
|
-
const dir = join(paths.sampleDir, credItemFor(account.accountUuid));
|
|
160
168
|
rmSync(dir, { recursive: true, force: true });
|
|
161
169
|
mkdirSync(dir, { recursive: true });
|
|
162
|
-
const
|
|
163
|
-
const installed = JSON.stringify({ claudeAiOauth: opts.refreshParked === false ? { ...creds, refreshToken: "" } : creds });
|
|
164
|
-
|
|
170
|
+
const installed = JSON.stringify({ claudeAiOauth: creds });
|
|
165
171
|
try {
|
|
166
|
-
await writeItem(
|
|
172
|
+
await writeItem(isolatedTarget(dir), installed);
|
|
167
173
|
writeFileSync(join(dir, ".claude.json"), JSON.stringify({ oauthAccount: account.oauthAccount, hasCompletedOnboarding: true }));
|
|
168
|
-
|
|
169
|
-
|
|
174
|
+
} catch (e) {
|
|
175
|
+
await finishParkedProbe(account, { dir, installed });
|
|
176
|
+
throw e;
|
|
177
|
+
}
|
|
178
|
+
return { ok: true, dir, installed };
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
async function runParkedProbe(dir: string, opts: { retries?: number }): Promise<SampleOutcome> {
|
|
182
|
+
const usage = await probeUsage(dir, Date.now(), { retries: opts.retries });
|
|
183
|
+
return usage ? { ok: true, usage } : { ok: false, reason: "`/usage` returned no limit data (see log)" };
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
async function finishParkedProbe(account: Account, prepared: { dir: string; installed: string }): Promise<void> {
|
|
187
|
+
const isoTarget = isolatedTarget(prepared.dir);
|
|
188
|
+
const backup = parkedTarget(account.keychainItem);
|
|
189
|
+
try {
|
|
190
|
+
const afterIso = await readItem(isoTarget);
|
|
191
|
+
if (afterIso && afterIso !== prepared.installed) {
|
|
192
|
+
const parkedNow = await readItem(backup);
|
|
193
|
+
const installedToken = CredentialBlobSchema.parse(JSON.parse(prepared.installed)).claudeAiOauth.accessToken;
|
|
194
|
+
const parkedToken = parkedNow == null ? null : CredentialBlobSchema.parse(JSON.parse(parkedNow)).claudeAiOauth.accessToken;
|
|
195
|
+
if (parkedToken === installedToken) await writeItem(backup, claudeAiOauthOnly(afterIso));
|
|
196
|
+
else log("sample.harvest_skipped", { account: account.accountUuid.slice(0, 8) });
|
|
197
|
+
}
|
|
170
198
|
} finally {
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
199
|
+
await deleteItem(isoTarget);
|
|
200
|
+
rmSync(prepared.dir, { recursive: true, force: true });
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
export async function probeParkedUsage(account: Account): Promise<SampleOutcome> {
|
|
205
|
+
const prepared = await prepareParkedProbe(account, join(paths.sampleDir, credItemFor(account.accountUuid)));
|
|
206
|
+
if (!prepared.ok) return prepared;
|
|
207
|
+
try {
|
|
208
|
+
return await runParkedProbe(prepared.dir, {});
|
|
209
|
+
} finally {
|
|
210
|
+
await finishParkedProbe(account, prepared);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const PREPARE_DEADLINE_MS = 20_000;
|
|
215
|
+
|
|
216
|
+
export async function sampleOldestParked(cfg: Config): Promise<void> {
|
|
217
|
+
const reserved = await withLock(paths.lockFile, async () => {
|
|
218
|
+
const now = Date.now();
|
|
219
|
+
const lastSwapAt = loadLastSwapAt();
|
|
220
|
+
if (lastSwapAt != null && now - lastSwapAt < POST_SWAP_COOLDOWN_MS) return null;
|
|
221
|
+
const idx = loadAccounts();
|
|
222
|
+
const live = readOAuthAccount()?.accountUuid ?? null;
|
|
223
|
+
const sampledAt = (a: Account) => Math.max(a.lastUsageAt ?? 0, a.lastProbeAt ?? 0);
|
|
224
|
+
const stale = idx.accounts.filter((a) => a.accountUuid !== live && a.needsReauth !== true && now - sampledAt(a) > cfg.policy.usagePollTtlMs);
|
|
225
|
+
const target = minBy(stale, sampledAt);
|
|
226
|
+
if (!target) return null;
|
|
227
|
+
target.lastProbeAt = now;
|
|
228
|
+
const prepared = await prepareParkedProbe(target, join(paths.sampleDir, `${credItemFor(target.accountUuid)}-tick`), AbortSignal.timeout(PREPARE_DEADLINE_MS));
|
|
229
|
+
saveAccounts(idx);
|
|
230
|
+
if (!prepared.ok) {
|
|
231
|
+
log("sample.parked_failed", { account: target.accountUuid.slice(0, 8), reason: prepared.reason.slice(0, 200) });
|
|
232
|
+
return null;
|
|
179
233
|
}
|
|
234
|
+
return { account: target, prepared };
|
|
235
|
+
});
|
|
236
|
+
if (!reserved) return;
|
|
237
|
+
const { account, prepared } = reserved;
|
|
238
|
+
const startedAt = Date.now();
|
|
239
|
+
let outcome: SampleOutcome | null = null;
|
|
240
|
+
try {
|
|
241
|
+
outcome = await runParkedProbe(prepared.dir, { retries: 0 });
|
|
242
|
+
} finally {
|
|
243
|
+
await withLock(paths.lockFile, async () => {
|
|
244
|
+
await finishParkedProbe(account, prepared);
|
|
245
|
+
if (outcome == null) return;
|
|
246
|
+
const idx = loadAccounts();
|
|
247
|
+
const stored = idx.accounts.find((a) => a.accountUuid === account.accountUuid);
|
|
248
|
+
if (stored && outcome.ok && (stored.lastUsageAt == null || startedAt > stored.lastUsageAt)) {
|
|
249
|
+
stored.lastUsage = { fiveHour: outcome.usage.session, sevenDay: outcome.usage.weekAll };
|
|
250
|
+
stored.lastUsageAt = startedAt;
|
|
251
|
+
if (Object.keys(outcome.usage.perModel).length > 0) {
|
|
252
|
+
stored.lastPerModel = outcome.usage.perModel;
|
|
253
|
+
stored.lastPerModelAt = startedAt;
|
|
254
|
+
}
|
|
255
|
+
saveAccounts(idx);
|
|
256
|
+
}
|
|
257
|
+
log(outcome.ok ? "sample.parked_ok" : "sample.parked_failed", {
|
|
258
|
+
account: account.accountUuid.slice(0, 8),
|
|
259
|
+
...(outcome.ok ? {} : { reason: outcome.reason.slice(0, 200) }),
|
|
260
|
+
});
|
|
261
|
+
});
|
|
180
262
|
}
|
|
181
263
|
}
|
|
182
264
|
|
package/src/lib/swap.ts
CHANGED
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { z } from "zod";
|
|
2
1
|
import { clearDepletedWait, clearUsageSnapshots, loadAccounts, saveAccounts, saveLastSwapAt } from "./state.ts";
|
|
3
2
|
import { readItem, writeItem, liveTarget, parkedTarget, claudeAiOauthOnly, mergeIntoLive } from "./credstore.ts";
|
|
4
3
|
import { refreshCredential, isAccessTokenExpiring, isDeadCredential, fetchTokenIdentity, describeIdentity, IdentityUnavailableError, InvalidGrantError, RefreshRejectedError } from "./oauth.ts";
|
|
@@ -11,9 +10,6 @@ function parseBlob(raw: string) {
|
|
|
11
10
|
return CredentialBlobSchema.parse(JSON.parse(raw));
|
|
12
11
|
}
|
|
13
12
|
|
|
14
|
-
const VerifyVerdictSchema = z.enum(["go", "rerank", "skip"]);
|
|
15
|
-
export type VerifyVerdict = z.infer<typeof VerifyVerdictSchema>;
|
|
16
|
-
|
|
17
13
|
export async function performSwap(target: Account): Promise<void> {
|
|
18
14
|
const idx = loadAccounts();
|
|
19
15
|
|
package/src/lib/types.ts
CHANGED
|
@@ -68,6 +68,7 @@ export const AccountSchema = z.object({
|
|
|
68
68
|
lastPerModel: z.record(z.string(), UsageWindowSchema).optional(),
|
|
69
69
|
lastPerModelAt: z.number().optional(),
|
|
70
70
|
lastUsageAt: z.number().optional(),
|
|
71
|
+
lastProbeAt: z.number().optional(),
|
|
71
72
|
enforcedUntil: z.number().optional(),
|
|
72
73
|
needsReauth: z.boolean().optional(),
|
|
73
74
|
subscriptionType: z.string().optional(),
|
package/src/lib/usage.ts
CHANGED
|
@@ -280,17 +280,9 @@ const SpawnResultSchema = z.object({
|
|
|
280
280
|
});
|
|
281
281
|
type SpawnResult = z.infer<typeof SpawnResultSchema>;
|
|
282
282
|
|
|
283
|
-
async function spawnClaudeBounded(
|
|
284
|
-
cmd: string[],
|
|
285
|
-
env: Record<string, string>,
|
|
286
|
-
killMs = PROBE_KILL_MS,
|
|
287
|
-
signal?: AbortSignal,
|
|
288
|
-
): Promise<SpawnResult | null> {
|
|
289
|
-
signal?.throwIfAborted();
|
|
283
|
+
async function spawnClaudeBounded(cmd: string[], env: Record<string, string>): Promise<SpawnResult | null> {
|
|
290
284
|
const p = Bun.spawn(cmd, { env, stdout: "pipe", stderr: "pipe" });
|
|
291
|
-
const
|
|
292
|
-
signal?.addEventListener("abort", abort, { once: true });
|
|
293
|
-
const killer = setTimeout(() => p.kill("SIGKILL"), killMs);
|
|
285
|
+
const killer = setTimeout(() => p.kill("SIGKILL"), PROBE_KILL_MS);
|
|
294
286
|
try {
|
|
295
287
|
const reads = Promise.all([new Response(p.stdout).text(), new Response(p.stderr).text()]);
|
|
296
288
|
const settled = await Promise.race([
|
|
@@ -303,14 +295,13 @@ async function spawnClaudeBounded(
|
|
|
303
295
|
return { exitCode: p.exitCode, stdout, stderr };
|
|
304
296
|
} finally {
|
|
305
297
|
clearTimeout(killer);
|
|
306
|
-
signal?.removeEventListener("abort", abort);
|
|
307
298
|
}
|
|
308
299
|
}
|
|
309
300
|
|
|
310
|
-
async function probeUsageOnce(env: Record<string, string>, now: number
|
|
301
|
+
async function probeUsageOnce(env: Record<string, string>, now: number): Promise<FullUsage | null> {
|
|
311
302
|
let out: string;
|
|
312
303
|
try {
|
|
313
|
-
const r = await spawnClaudeBounded([resolveRealClaude(), "-p", "/usage", "--output-format", "json"], env
|
|
304
|
+
const r = await spawnClaudeBounded([resolveRealClaude(), "-p", "/usage", "--output-format", "json"], env);
|
|
314
305
|
if (r === null) {
|
|
315
306
|
log("usage.probe_failed", { err: "output pipes still open after child exit (leaked descendant)" });
|
|
316
307
|
return null;
|
|
@@ -348,13 +339,13 @@ function probeEnv(configDir?: string): Record<string, string> {
|
|
|
348
339
|
return env;
|
|
349
340
|
}
|
|
350
341
|
|
|
351
|
-
export async function probeUsage(configDir?: string, now = Date.now(), opts: { retries?: number
|
|
342
|
+
export async function probeUsage(configDir?: string, now = Date.now(), opts: { retries?: number } = {}): Promise<FullUsage | null> {
|
|
352
343
|
const env = probeEnv(configDir);
|
|
353
344
|
|
|
354
345
|
for (let attempt = 0; ; attempt++) {
|
|
355
|
-
const full = await probeUsageOnce(env, now
|
|
346
|
+
const full = await probeUsageOnce(env, now);
|
|
356
347
|
if (full) return full;
|
|
357
|
-
if (attempt >= (opts.retries ?? PROBE_RETRY_DELAYS_MS.length)
|
|
348
|
+
if (attempt >= (opts.retries ?? PROBE_RETRY_DELAYS_MS.length)) {
|
|
358
349
|
log("usage.probe_gave_up", { attempts: attempt + 1 });
|
|
359
350
|
return null;
|
|
360
351
|
}
|