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 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), and attempt bounded verification of stale usage before swapping the credential.
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
- - Successful candidate probes persist their results and restart selection.
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
- - Candidate verification makes at most two bounded `/usage` attempts per evaluation and re-ranks successful samples before switching.
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 stays fresh; `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.
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.27.0",
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(Date.now());
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 sampleDir = join(paths.sampleDir, credItemFor(a.accountUuid));
48
- await deleteItem(isolatedTarget(sampleDir));
49
- rmSync(sampleDir, { recursive: true, force: true });
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, type VerifyVerdict } from "./swap.ts";
8
- import { probeParkedUsage } from "./sample.ts";
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
- export async function probeParkedUsage(
48
- account: Account,
49
- opts: { retries?: number; refreshParked?: boolean; killMs?: number; signal?: AbortSignal } = {},
50
- ): Promise<SampleOutcome> {
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, opts.signal)).accountUuid;
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
- if (opts.refreshParked === false) {
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: SampleOutcome }> => {
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: SampleOutcome };
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 !== "mismatch") return rotated;
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, opts.signal);
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 isoTarget = isolatedTarget(dir);
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(isoTarget, installed);
172
+ await writeItem(isolatedTarget(dir), installed);
167
173
  writeFileSync(join(dir, ".claude.json"), JSON.stringify({ oauthAccount: account.oauthAccount, hasCompletedOnboarding: true }));
168
- const usage = await probeUsage(dir, Date.now(), opts);
169
- return usage ? { ok: true, usage } : { ok: false, reason: "`/usage` returned no limit data (see log)" };
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
- try {
172
- if (opts.refreshParked !== false) {
173
- const afterIso = await readItem(isoTarget);
174
- if (afterIso && afterIso !== installed) await writeItem(backup, claudeAiOauthOnly(afterIso));
175
- }
176
- } finally {
177
- await deleteItem(isoTarget);
178
- rmSync(dir, { recursive: true, force: true });
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 abort = () => p.kill("SIGKILL");
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, killMs: number, signal?: AbortSignal): Promise<FullUsage | null> {
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, killMs, signal);
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; killMs?: number; signal?: AbortSignal } = {}): Promise<FullUsage | null> {
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, opts.killMs ?? PROBE_KILL_MS, opts.signal);
346
+ const full = await probeUsageOnce(env, now);
356
347
  if (full) return full;
357
- if (attempt >= (opts.retries ?? PROBE_RETRY_DELAYS_MS.length) || opts.signal?.aborted) {
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
  }