tokenmaxxing 1.26.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 +3 -5
- package/package.json +1 -1
- package/src/cli/check.ts +5 -1
- package/src/cli/init.ts +0 -1
- package/src/cli/rm.ts +5 -3
- package/src/cli/status.ts +13 -63
- package/src/lib/decide.ts +2 -46
- package/src/lib/oauth.ts +2 -1
- package/src/lib/sample.ts +121 -53
- package/src/lib/swap.ts +0 -4
- package/src/lib/types.ts +1 -0
- package/src/lib/usage.ts +9 -86
- package/src/main.ts +6 -31
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
|
@@ -70,8 +70,7 @@ claude # use claude as always
|
|
|
70
70
|
| `tokenmaxxing switch --codex [sel]` | switch the codex pool (takes effect on the next codex start) |
|
|
71
71
|
| `tokenmaxxing ls` | list pooled accounts |
|
|
72
72
|
| `tokenmaxxing status` | accounts with 5h / weekly usage bars, active + exhausted-until-reset |
|
|
73
|
-
| `tokenmaxxing
|
|
74
|
-
| `tokenmaxxing watch [seconds]` | live status: re-render every N seconds (default 120, floor 30; never pings) |
|
|
73
|
+
| `tokenmaxxing watch [seconds]` | live status: re-render every N seconds (default 120, floor 30) |
|
|
75
74
|
| `tokenmaxxing config` | effective config with sources; `get`/`set`/`unset` dotted keys, `tidy` prunes unknown keys |
|
|
76
75
|
| `tokenmaxxing doctor` | verify the supervisor + settings entries survived |
|
|
77
76
|
| `tokenmaxxing rename [--codex] <sel> <label>` / `rm [--codex] <sel>` | manage the pool (`--codex` targets the codex pool: one email can hold both a claude and a codex account) |
|
|
@@ -89,8 +88,7 @@ The bars' headroom is deliberate: it's the budget to reach a clean turn boundary
|
|
|
89
88
|
|
|
90
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.
|
|
91
90
|
|
|
92
|
-
-
|
|
93
|
-
- 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.
|
|
94
92
|
- When no account is usable, the session pauses until the soonest reset if that lands within `policy.maxWaitMs`, and otherwise stays put.
|
|
95
93
|
|
|
96
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.
|
|
@@ -111,7 +109,7 @@ See [How switching decides](docs/content/docs/switching.mdx) for the policy and
|
|
|
111
109
|
}
|
|
112
110
|
```
|
|
113
111
|
|
|
114
|
-
`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.
|
|
115
113
|
|
|
116
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`).
|
|
117
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/init.ts
CHANGED
|
@@ -37,7 +37,6 @@ export function printUsage(): void {
|
|
|
37
37
|
console.log(` ${c.bold("how to use")} - ${c.cyan("xx")} is shorthand for ${c.cyan("tokenmaxxing")}:`);
|
|
38
38
|
console.log(` ${c.cyan("claude")} use claude as always; it switches accounts near quota automatically`);
|
|
39
39
|
console.log(` ${c.cyan("xx")} show the pool with usage bars (same as ${c.cyan("xx status")})`);
|
|
40
|
-
console.log(` ${c.cyan("xx status --ping")} ping every account (one tiny haiku request each) so all 5h timers start now, then sample fresh; ${c.cyan("--count N")} pings N random accounts instead`);
|
|
41
40
|
console.log(` ${c.cyan("xx add")} log in and pool another account`);
|
|
42
41
|
console.log(` ${c.cyan("xx switch")} hop to the best account right now (the automatic switching needs no command)`);
|
|
43
42
|
console.log(` ${c.cyan("xx help")} everything else`);
|
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/cli/status.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { chunk,
|
|
1
|
+
import { chunk, sortBy } from "es-toolkit";
|
|
2
2
|
import { z } from "zod";
|
|
3
3
|
import { loadAccounts, loadConfig, loadUsage, loadUsageSnapshot, loadModelUsage, saveAccounts } from "../lib/state.ts";
|
|
4
4
|
import { readOAuthAccount } from "../lib/claudejson.ts";
|
|
@@ -32,9 +32,6 @@ const ClaudeStatusAccountSchema = z.object({
|
|
|
32
32
|
perModel: z.record(z.string(), UsageWindowSchema),
|
|
33
33
|
usageAt: z.number().nullable(),
|
|
34
34
|
sample: SampleReportSchema,
|
|
35
|
-
pingError: z.string().nullable(),
|
|
36
|
-
pingRejected: z.boolean(),
|
|
37
|
-
pinged: z.boolean(),
|
|
38
35
|
});
|
|
39
36
|
type ClaudeStatusAccount = z.infer<typeof ClaudeStatusAccountSchema>;
|
|
40
37
|
|
|
@@ -86,31 +83,14 @@ function currentCodexWindow(w: CodexWindow, now: number): CodexWindow {
|
|
|
86
83
|
return { ...currentWindow(w, !isSessionWindow({ window: w }), now), windowSeconds: w.windowSeconds };
|
|
87
84
|
}
|
|
88
85
|
|
|
89
|
-
function
|
|
90
|
-
|
|
91
|
-
const usable = accounts.filter((a) => a.needsReauth !== true);
|
|
92
|
-
return sampleSize(usable, Math.min(pingCount, usable.length));
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
function progressNote(input: { ping: boolean; pingCount: number | undefined; picked: Account[]; total: number }): string {
|
|
96
|
-
const { ping, pingCount, picked, total } = input;
|
|
97
|
-
if (!ping) return "sampling live usage...";
|
|
98
|
-
if (pingCount == null) return "pinging every account (starts each 5h session timer) + sampling live usage...";
|
|
99
|
-
if (picked.length === 0) return "no account to ping (every account needs reauth), sampling live usage...";
|
|
100
|
-
return `pinging ${picked.length} of ${count({ n: total, noun: "account" })} (${picked.map((a) => a.label || a.email).join(", ")}) so their 5h session timers start now + sampling live usage...`;
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
async function collectClaude(input: { cfg: Config; ping: boolean; pingCount: number | undefined; now: number }): Promise<StatusReport["claude"]> {
|
|
104
|
-
const { cfg, ping, pingCount, now } = input;
|
|
86
|
+
async function collectClaude(input: { cfg: Config; now: number }): Promise<StatusReport["claude"]> {
|
|
87
|
+
const { cfg, now } = input;
|
|
105
88
|
let idx = loadAccounts();
|
|
106
89
|
const samples = new Map<string, { outcome: SampleOutcome; viaTee: boolean }>();
|
|
107
|
-
let pings = new Set<string>();
|
|
108
90
|
if (idx.accounts.length > 0) {
|
|
109
91
|
await withLock(paths.lockFile, async () => {
|
|
110
92
|
idx = loadAccounts();
|
|
111
|
-
|
|
112
|
-
pings = new Set(picked.map((a) => a.accountUuid));
|
|
113
|
-
console.error(c.dim(progressNote({ ping, pingCount, picked, total: idx.accounts.length })));
|
|
93
|
+
console.error(c.dim("sampling live usage..."));
|
|
114
94
|
const tee = loadUsageSnapshot();
|
|
115
95
|
const live = tee?.state ?? null;
|
|
116
96
|
const teeAt = tee?.at ?? null;
|
|
@@ -129,27 +109,12 @@ async function collectClaude(input: { cfg: Config; ping: boolean; pingCount: num
|
|
|
129
109
|
perModel: modelUsage && modelUsage.account === a.accountUuid ? modelUsage.perModel : {},
|
|
130
110
|
}
|
|
131
111
|
: null;
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
outcome = { ok: true, usage: fromStatusLine };
|
|
139
|
-
if (failed.pingError != null) {
|
|
140
|
-
outcome.pingError = failed.pingError;
|
|
141
|
-
outcome.pingRejected = failed.pingRejected;
|
|
142
|
-
}
|
|
143
|
-
viaTee = true;
|
|
144
|
-
}
|
|
145
|
-
} else {
|
|
146
|
-
viaTee = fromStatusLine != null;
|
|
147
|
-
outcome = fromStatusLine
|
|
148
|
-
? { ok: true, usage: fromStatusLine }
|
|
149
|
-
: isActive
|
|
150
|
-
? await probeActiveUsage(a)
|
|
151
|
-
: await probeParkedUsage(a);
|
|
152
|
-
}
|
|
112
|
+
const viaTee = fromStatusLine != null;
|
|
113
|
+
const outcome: SampleOutcome = fromStatusLine
|
|
114
|
+
? { ok: true, usage: fromStatusLine }
|
|
115
|
+
: isActive
|
|
116
|
+
? await probeActiveUsage(a)
|
|
117
|
+
: await probeParkedUsage(a);
|
|
153
118
|
samples.set(a.accountUuid, { outcome, viaTee });
|
|
154
119
|
if (!outcome.ok) return;
|
|
155
120
|
a.lastUsage = { fiveHour: outcome.usage.session, sevenDay: outcome.usage.weekAll };
|
|
@@ -192,9 +157,6 @@ async function collectClaude(input: { cfg: Config; ping: boolean; pingCount: num
|
|
|
192
157
|
perModel: Object.fromEntries(Object.entries(perModel).map(([name, w]) => [name, currentWindow(w, true, now)])),
|
|
193
158
|
usageAt: a.lastUsageAt ?? null,
|
|
194
159
|
sample: sampled.outcome.ok ? { ok: true, source: sampled.viaTee ? "statusline" : "probe" } : { ok: false, reason: sampled.outcome.reason },
|
|
195
|
-
pingError: sampled.outcome.pingError ?? null,
|
|
196
|
-
pingRejected: sampled.outcome.pingRejected === true,
|
|
197
|
-
pinged: pings.has(a.accountUuid) && sampled.outcome.ok && sampled.outcome.pingError == null && aggregate != null && aggregate.fiveHour.resetsAt == null,
|
|
198
160
|
};
|
|
199
161
|
});
|
|
200
162
|
return {
|
|
@@ -383,18 +345,6 @@ function claudeCard(a: ClaudeStatusAccount, now: number, staleAfterMs: number):
|
|
|
383
345
|
if (!a.sample.ok) {
|
|
384
346
|
notes.push(...sampleFailedNotes({ cached: a.usage != null || Object.keys(a.perModel).length > 0, usageAt: a.usageAt, reason: a.sample.reason, now }));
|
|
385
347
|
}
|
|
386
|
-
if (a.pingError != null && a.pingRejected) {
|
|
387
|
-
const at = a.pingError.indexOf(": ");
|
|
388
|
-
const head = at >= 0 ? a.pingError.slice(0, at) : a.pingError;
|
|
389
|
-
const tail = at >= 0 ? a.pingError.slice(at + 2) : "";
|
|
390
|
-
notes.push({ paint: c.yellow, text: `ping ${head}${tail ? ":" : ""}` });
|
|
391
|
-
if (tail) notes.push({ paint: c.dim, text: tail });
|
|
392
|
-
} else if (a.pingError != null) {
|
|
393
|
-
notes.push({ paint: c.yellow, text: "ping failed (5h timer may not have started):" }, { paint: c.dim, text: a.pingError });
|
|
394
|
-
}
|
|
395
|
-
if (a.pinged) {
|
|
396
|
-
notes.push({ paint: c.dim, text: "pinged - 5h timer started this run; the usage feed lags, re-run status shortly for the fresh window" });
|
|
397
|
-
}
|
|
398
348
|
return { lines, notes };
|
|
399
349
|
}
|
|
400
350
|
|
|
@@ -415,11 +365,11 @@ function renderClaude(input: { claude: StatusReport["claude"]; codexPooled: bool
|
|
|
415
365
|
renderGrid(claude.accounts.map((a) => claudeCard(a, now, staleAfterMs)));
|
|
416
366
|
}
|
|
417
367
|
|
|
418
|
-
export async function cmdStatus(opts: {
|
|
419
|
-
const {
|
|
368
|
+
export async function cmdStatus(opts: { json?: boolean; preRender?: () => void } = {}): Promise<number> {
|
|
369
|
+
const { json = false } = opts;
|
|
420
370
|
const cfg = loadConfig();
|
|
421
371
|
const now = Date.now();
|
|
422
|
-
const claude = await collectClaude({ cfg,
|
|
372
|
+
const claude = await collectClaude({ cfg, now });
|
|
423
373
|
if (!json) {
|
|
424
374
|
opts.preRender?.();
|
|
425
375
|
const at = Date.now();
|
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,19 +1,22 @@
|
|
|
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
|
-
import { FullUsageSchema,
|
|
9
|
-
import { loadAccounts } from "./state.ts";
|
|
11
|
+
import { FullUsageSchema, probeUsage } from "./usage.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
|
-
z.object({ ok: z.literal(true), usage: FullUsageSchema
|
|
16
|
-
z.object({ ok: z.literal(false), reason: z.string()
|
|
18
|
+
z.object({ ok: z.literal(true), usage: FullUsageSchema }),
|
|
19
|
+
z.object({ ok: z.literal(false), reason: z.string() }),
|
|
17
20
|
]);
|
|
18
21
|
export type SampleOutcome = z.infer<typeof SampleOutcomeSchema>;
|
|
19
22
|
|
|
@@ -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,35 +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
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
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) });
|
|
176
197
|
}
|
|
177
|
-
return outcome;
|
|
178
198
|
} finally {
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
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;
|
|
187
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
|
+
});
|
|
188
262
|
}
|
|
189
263
|
}
|
|
190
264
|
|
|
@@ -211,7 +285,7 @@ export async function ensureLiveTokenFresh(): Promise<void> {
|
|
|
211
285
|
});
|
|
212
286
|
}
|
|
213
287
|
|
|
214
|
-
export async function probeActiveUsage(account: Account
|
|
288
|
+
export async function probeActiveUsage(account: Account): Promise<SampleOutcome> {
|
|
215
289
|
try {
|
|
216
290
|
await ensureLiveTokenFresh();
|
|
217
291
|
} catch (e) {
|
|
@@ -232,12 +306,6 @@ export async function probeActiveUsage(account: Account, opts: { ping?: boolean
|
|
|
232
306
|
if (identity.status === "unavailable") return { ok: false, reason: identity.reason };
|
|
233
307
|
refreshPlanFields(account, creds);
|
|
234
308
|
|
|
235
|
-
const ping = opts.ping ? await pingSession() : null;
|
|
236
309
|
const usage = await probeUsage();
|
|
237
|
-
|
|
238
|
-
if (ping != null) {
|
|
239
|
-
outcome.pingError = ping.reason;
|
|
240
|
-
outcome.pingRejected = ping.rejected;
|
|
241
|
-
}
|
|
242
|
-
return outcome;
|
|
310
|
+
return usage ? { ok: true, usage } : { ok: false, reason: "`/usage` returned no limit data (see log)" };
|
|
243
311
|
}
|
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
|
@@ -1,10 +1,8 @@
|
|
|
1
|
-
import { closeSync,
|
|
2
|
-
import { join } from "node:path";
|
|
1
|
+
import { closeSync, fstatSync, openSync, readSync } from "node:fs";
|
|
3
2
|
import { delay } from "es-toolkit";
|
|
4
3
|
import { z } from "zod";
|
|
5
4
|
import { MAX_WRAP_DEPTH, WRAP_DEPTH_ENV, resolveRealClaude } from "./claudebin.ts";
|
|
6
5
|
import { log } from "./log.ts";
|
|
7
|
-
import { paths } from "./paths.ts";
|
|
8
6
|
import { RateLimitsStdinSchema, UsageWindowSchema, type ModelInfo, type UsageWindow, type UsageWindows } from "./types.ts";
|
|
9
7
|
|
|
10
8
|
export function normalizeResetsAt(v: unknown): number | null {
|
|
@@ -282,18 +280,9 @@ const SpawnResultSchema = z.object({
|
|
|
282
280
|
});
|
|
283
281
|
type SpawnResult = z.infer<typeof SpawnResultSchema>;
|
|
284
282
|
|
|
285
|
-
async function spawnClaudeBounded(
|
|
286
|
-
cmd:
|
|
287
|
-
|
|
288
|
-
cwd?: string,
|
|
289
|
-
killMs = PROBE_KILL_MS,
|
|
290
|
-
signal?: AbortSignal,
|
|
291
|
-
): Promise<SpawnResult | null> {
|
|
292
|
-
signal?.throwIfAborted();
|
|
293
|
-
const p = Bun.spawn(cmd, { env, cwd, stdout: "pipe", stderr: "pipe" });
|
|
294
|
-
const abort = () => p.kill("SIGKILL");
|
|
295
|
-
signal?.addEventListener("abort", abort, { once: true });
|
|
296
|
-
const killer = setTimeout(() => p.kill("SIGKILL"), killMs);
|
|
283
|
+
async function spawnClaudeBounded(cmd: string[], env: Record<string, string>): Promise<SpawnResult | null> {
|
|
284
|
+
const p = Bun.spawn(cmd, { env, stdout: "pipe", stderr: "pipe" });
|
|
285
|
+
const killer = setTimeout(() => p.kill("SIGKILL"), PROBE_KILL_MS);
|
|
297
286
|
try {
|
|
298
287
|
const reads = Promise.all([new Response(p.stdout).text(), new Response(p.stderr).text()]);
|
|
299
288
|
const settled = await Promise.race([
|
|
@@ -306,14 +295,13 @@ async function spawnClaudeBounded(
|
|
|
306
295
|
return { exitCode: p.exitCode, stdout, stderr };
|
|
307
296
|
} finally {
|
|
308
297
|
clearTimeout(killer);
|
|
309
|
-
signal?.removeEventListener("abort", abort);
|
|
310
298
|
}
|
|
311
299
|
}
|
|
312
300
|
|
|
313
|
-
async function probeUsageOnce(env: Record<string, string>, now: number
|
|
301
|
+
async function probeUsageOnce(env: Record<string, string>, now: number): Promise<FullUsage | null> {
|
|
314
302
|
let out: string;
|
|
315
303
|
try {
|
|
316
|
-
const r = await spawnClaudeBounded([resolveRealClaude(), "-p", "/usage", "--output-format", "json"], env
|
|
304
|
+
const r = await spawnClaudeBounded([resolveRealClaude(), "-p", "/usage", "--output-format", "json"], env);
|
|
317
305
|
if (r === null) {
|
|
318
306
|
log("usage.probe_failed", { err: "output pipes still open after child exit (leaked descendant)" });
|
|
319
307
|
return null;
|
|
@@ -351,81 +339,16 @@ function probeEnv(configDir?: string): Record<string, string> {
|
|
|
351
339
|
return env;
|
|
352
340
|
}
|
|
353
341
|
|
|
354
|
-
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> {
|
|
355
343
|
const env = probeEnv(configDir);
|
|
356
344
|
|
|
357
345
|
for (let attempt = 0; ; attempt++) {
|
|
358
|
-
const full = await probeUsageOnce(env, now
|
|
346
|
+
const full = await probeUsageOnce(env, now);
|
|
359
347
|
if (full) return full;
|
|
360
|
-
if (attempt >= (opts.retries ?? PROBE_RETRY_DELAYS_MS.length)
|
|
348
|
+
if (attempt >= (opts.retries ?? PROBE_RETRY_DELAYS_MS.length)) {
|
|
361
349
|
log("usage.probe_gave_up", { attempts: attempt + 1 });
|
|
362
350
|
return null;
|
|
363
351
|
}
|
|
364
352
|
await delay(PROBE_RETRY_DELAYS_MS[Math.min(attempt, PROBE_RETRY_DELAYS_MS.length - 1)]!);
|
|
365
353
|
}
|
|
366
354
|
}
|
|
367
|
-
|
|
368
|
-
const PING_ARGS = [
|
|
369
|
-
"-p", "Reply with exactly: ok",
|
|
370
|
-
"--model", "haiku",
|
|
371
|
-
"--settings", '{"disableAllHooks":true}',
|
|
372
|
-
"--output-format", "json",
|
|
373
|
-
];
|
|
374
|
-
|
|
375
|
-
const PingResultSchema = z.looseObject({ is_error: z.boolean().optional(), result: z.string().optional(), session_id: z.string().optional() });
|
|
376
|
-
|
|
377
|
-
export const PingFailureSchema = z.object({ reason: z.string(), rejected: z.boolean() });
|
|
378
|
-
export type PingFailure = z.infer<typeof PingFailureSchema>;
|
|
379
|
-
|
|
380
|
-
export function transcriptSlug(cwd: string): string {
|
|
381
|
-
return cwd.replace(/[^a-zA-Z0-9]/g, "-");
|
|
382
|
-
}
|
|
383
|
-
|
|
384
|
-
export function pingTranscriptPath(input: { configDir: string; cwd: string; sessionId: string }): string {
|
|
385
|
-
return join(input.configDir, "projects", transcriptSlug(input.cwd), `${input.sessionId}.jsonl`);
|
|
386
|
-
}
|
|
387
|
-
|
|
388
|
-
const LIMIT_LABELS: Record<string, string> = { five_hour: "5h", seven_day: "weekly" };
|
|
389
|
-
|
|
390
|
-
export function pingRejection(rows: TranscriptRow[]): string | null {
|
|
391
|
-
const row = rows.findLast((r) => r.isApiErrorMessage === true);
|
|
392
|
-
if (!row || row.error !== "rate_limit" || row.apiErrorIsTransient === true) return null;
|
|
393
|
-
const type = row.quotaLimits?.rateLimitType ?? "";
|
|
394
|
-
if (type === "" && parseErrorBody(row.errorDetails)?.error?.type !== "rate_limit_error") return null;
|
|
395
|
-
const label = LIMIT_LABELS[type] ?? (type !== "" ? type.replace(/_/g, " ") : "usage");
|
|
396
|
-
const text = transcriptRowText(row);
|
|
397
|
-
return `${label} limit${text ? `: ${text}` : ""}`;
|
|
398
|
-
}
|
|
399
|
-
|
|
400
|
-
export async function pingSession(configDir?: string): Promise<PingFailure | null> {
|
|
401
|
-
const cwd = join(paths.sampleDir, "ping-cwd");
|
|
402
|
-
const fail = (reason: string, rejected = false): PingFailure => {
|
|
403
|
-
log("usage.ping_failed", { dir: configDir ?? "live", rejected, reason: reason.slice(0, 200) });
|
|
404
|
-
return { reason, rejected };
|
|
405
|
-
};
|
|
406
|
-
let r: SpawnResult | null;
|
|
407
|
-
try {
|
|
408
|
-
mkdirSync(cwd, { recursive: true });
|
|
409
|
-
r = await spawnClaudeBounded([resolveRealClaude(), ...PING_ARGS], probeEnv(configDir), cwd);
|
|
410
|
-
} catch (e) {
|
|
411
|
-
return fail(e instanceof Error ? e.message : String(e));
|
|
412
|
-
}
|
|
413
|
-
if (r === null) return fail("output pipes still open after child exit (leaked descendant)");
|
|
414
|
-
const parsed = PingResultSchema.safeParse((() => { try { return JSON.parse(r.stdout); } catch { return null; } })());
|
|
415
|
-
const result = parsed.success ? parsed.data : null;
|
|
416
|
-
if (r.exitCode === 0 && result?.is_error === false) {
|
|
417
|
-
log("usage.ping_ok", { dir: configDir ?? "live" });
|
|
418
|
-
return null;
|
|
419
|
-
}
|
|
420
|
-
if (result?.session_id) {
|
|
421
|
-
const transcript = pingTranscriptPath({ configDir: configDir ?? paths.claudeDir, cwd, sessionId: result.session_id });
|
|
422
|
-
const rejection = existsSync(transcript) ? pingRejection(readTranscriptTail(transcript)) : null;
|
|
423
|
-
if (rejection) return fail(`rejected at the ${rejection}`.slice(0, 200), true);
|
|
424
|
-
}
|
|
425
|
-
if (r.exitCode !== 0) {
|
|
426
|
-
const detail = result?.result?.trim() || r.stderr.trim() || r.stdout.trim();
|
|
427
|
-
return fail(`claude exited ${r.exitCode ?? "on signal"}: ${detail.slice(0, 160)}`);
|
|
428
|
-
}
|
|
429
|
-
if (result?.is_error === true) return fail((result.result?.trim() || "request errored").slice(0, 160));
|
|
430
|
-
return fail(`unrecognized ping output: ${r.stdout.trim().slice(0, 120)}`);
|
|
431
|
-
}
|
package/src/main.ts
CHANGED
|
@@ -45,8 +45,7 @@ function printHelp(): void {
|
|
|
45
45
|
${c.cyan("tokenmaxxing switch --codex")} [sel] switch the codex pool (takes effect on next codex start)
|
|
46
46
|
${c.cyan("tokenmaxxing ls")} list pooled accounts
|
|
47
47
|
${c.cyan("tokenmaxxing status")} accounts with 5h / weekly / per-model usage bars
|
|
48
|
-
${c.cyan("tokenmaxxing
|
|
49
|
-
${c.cyan("tokenmaxxing watch")} [seconds] live status: re-render every N seconds (default 120, never pings)
|
|
48
|
+
${c.cyan("tokenmaxxing watch")} [seconds] live status: re-render every N seconds (default 120)
|
|
50
49
|
${c.cyan("tokenmaxxing config")} [get|set|unset|tidy] inspect and edit config.json (bare = effective config with sources)
|
|
51
50
|
${c.cyan("tokenmaxxing doctor")} verify the install is intact
|
|
52
51
|
${c.cyan("tokenmaxxing rename")} [--codex] <sel> <label>
|
|
@@ -60,28 +59,6 @@ function printHelp(): void {
|
|
|
60
59
|
|
|
61
60
|
let jsonMode = false;
|
|
62
61
|
|
|
63
|
-
function statusFlags(rest: string[]): { ping: boolean; pingCount: number | undefined } | { error: string } {
|
|
64
|
-
let ping = false;
|
|
65
|
-
let pingCount: number | undefined;
|
|
66
|
-
for (let i = 0; i < rest.length; i++) {
|
|
67
|
-
const arg = rest[i]!;
|
|
68
|
-
if (arg === "--ping") {
|
|
69
|
-
ping = true;
|
|
70
|
-
continue;
|
|
71
|
-
}
|
|
72
|
-
if (arg === "--count") {
|
|
73
|
-
if (pingCount != null) return { error: "--count may only be given once" };
|
|
74
|
-
const raw = rest[++i];
|
|
75
|
-
pingCount = Number(raw);
|
|
76
|
-
if (!Number.isInteger(pingCount) || pingCount < 1) return { error: `--count needs a positive whole number of accounts, got: ${raw ?? "nothing"}` };
|
|
77
|
-
continue;
|
|
78
|
-
}
|
|
79
|
-
return { error: `unknown status option: ${arg} (status takes --ping and --count N)` };
|
|
80
|
-
}
|
|
81
|
-
if (pingCount != null && !ping) return { error: "--count only applies together with --ping" };
|
|
82
|
-
return { ping, pingCount };
|
|
83
|
-
}
|
|
84
|
-
|
|
85
62
|
async function main(): Promise<number> {
|
|
86
63
|
if (process.platform !== "darwin" && process.platform !== "linux") {
|
|
87
64
|
console.error(`tokenmaxxing supports macOS and Linux only (this is ${process.platform})`);
|
|
@@ -126,15 +103,13 @@ async function main(): Promise<number> {
|
|
|
126
103
|
case "__session-start": return runSessionStart();
|
|
127
104
|
case "__codex-stop-hook": return runCodexStopHook();
|
|
128
105
|
case undefined:
|
|
129
|
-
case "status":
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
if ("error" in flags) {
|
|
134
|
-
emitError({ json, message: flags.error });
|
|
106
|
+
case "status": {
|
|
107
|
+
const extra = args[1];
|
|
108
|
+
if (extra != null) {
|
|
109
|
+
emitError({ json, message: `unknown status option: ${extra} (status takes no options)` });
|
|
135
110
|
return 2;
|
|
136
111
|
}
|
|
137
|
-
return cmdStatus({
|
|
112
|
+
return cmdStatus({ json });
|
|
138
113
|
}
|
|
139
114
|
case "switch": {
|
|
140
115
|
const rest = args.slice(1).filter((a) => a !== "--codex");
|