tokenmaxxing 1.46.0 → 1.48.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 -2
- package/package.json +1 -1
- package/src/cli/status.ts +30 -1
- package/src/lib/claude.ts +13 -5
- package/src/lib/sample.ts +61 -47
- package/src/lib/types.ts +2 -0
package/DESIGN.md
CHANGED
|
@@ -40,7 +40,7 @@ It is a process manager only - spawn with inherited stdio plus saved `stty -g` t
|
|
|
40
40
|
- `bin/claude` - the supervisor.
|
|
41
41
|
- Per-account **credential stores** follow the platform's Claude Code store: macOS = a login-keychain item keyed by the store path (never plaintext on disk); Linux = a 0600 `.credentials.json` inside the 0700 store directory (the same plaintext model claude itself uses - its Linux build has no keyring path at all, binary-verified 2.1.205). One `credstore` facade dispatches on a `{kind: keychain|file}` target; call sites never branch on platform. tokenmaxxing writes a store once, at onboarding; Claude Code's own refresh is the only writer afterwards.
|
|
42
42
|
|
|
43
|
-
- A periodic check job (`com.tokenmaxxing.check` launchd agent on macOS, `tokenmaxxing-check.timer` systemd user timer on Linux) running `tokenmaxxing check` once per tick (`policy.checkIntervalMs`, 60s by default): it folds fresh tees into the index and samples the
|
|
43
|
+
- A periodic check job (`com.tokenmaxxing.check` launchd agent on macOS, `tokenmaxxing-check.timer` systemd user timer on Linux) running `tokenmaxxing check` once per tick (`policy.checkIntervalMs`, 60s by default): it folds fresh tees into the index and samples the accounts whose `/usage` figures are oldest (up to three per tick), so the cached figures every placement reads stay fresh.
|
|
44
44
|
|
|
45
45
|
The switching path runs no long-lived daemon. The statusline pushes usage. Hooks and the supervisor react. The periodic check job above is the only recurring process. The `claude` binary, `~/.claude`, `~/.claude.json`, and Claude Code's default credential store are untouched.
|
|
46
46
|
|
|
@@ -83,7 +83,7 @@ Each terminal ran the supervisor, so each has its own child `claude`, its own `-
|
|
|
83
83
|
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 that ranks first; nothing usable, wait for the soonest reset. The same ranking places a session at launch.
|
|
84
84
|
|
|
85
85
|
- Ranking (decided by consult for #79): greatest remaining headroom to the session bar divided by the number of sessions after placement, `(bar - used) / (sessions + 1)`, then highest weekly pace pressure (remaining weekly percent over time to the weekly reset), then soonest weekly reset, then lowest weekly usage; unknown usage ranks last. Organization membership is not an input. Codex keeps the pure pace ordering and excludes accounts running in another codex session, because codex has no cross-process refresh lock and two sessions must never share a login.
|
|
86
|
-
- Selection reads the cached figures only, after folding every account's tee. Each periodic check tick samples
|
|
86
|
+
- Selection reads the cached figures only, after folding every account's tee. Each periodic check tick samples up to three accounts whose last `/usage` attempt is oldest, skipping any attempted within `policy.usagePollTtlMs`, under each account's store, so every account is attempted about once per three ticks 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 or its sessions' latest push. The pool lock covers only the reservation and the result write, never the `/usage` child.
|
|
87
87
|
- 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.
|
|
88
88
|
- Neither pool has a post-swap cooldown or a manual `switch`: a move is a respawn of one session under a fixed store, so nothing another session reads is invalidated by it, and placement at launch replaces the manual pick.
|
|
89
89
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tokenmaxxing",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.48.0",
|
|
4
4
|
"description": "Automatic Claude Code account switching: pool multiple accounts, start each session on its own account, and resume a session on a fresher account when quota fills.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
package/src/cli/status.ts
CHANGED
|
@@ -7,7 +7,7 @@ import { opencodeGo } from "../lib/opencodego.ts";
|
|
|
7
7
|
import { loadAccounts, loadConfig, saveAccounts } from "../lib/state.ts";
|
|
8
8
|
import { withLock } from "../lib/lock.ts";
|
|
9
9
|
import { codexPool, grokPool, opencodeGoPool } from "../lib/paths.ts";
|
|
10
|
-
import { earliestReset, isExhausted, isSessionWindow, limitWindows, nextWeeklyReset, sessionWindow, thresholdBars, weeklyWindow } from "../lib/picker.ts";
|
|
10
|
+
import { earliestReset, gatedWindows, isExhausted, isSessionWindow, limitWindows, liveUsed, nextWeeklyReset, sessionWindow, thresholdBars, weeklyWindow } from "../lib/picker.ts";
|
|
11
11
|
import type { Provider, SampleReport } from "../lib/provider.ts";
|
|
12
12
|
import { bar, c, count, emitJson, fmtAgo } from "./render.ts";
|
|
13
13
|
import { ThresholdsSchema, type Account, type Config, type Window } from "../lib/types.ts";
|
|
@@ -50,6 +50,7 @@ const PoolReportSchema = z.object({
|
|
|
50
50
|
thresholds: ThresholdsSchema,
|
|
51
51
|
bars: ThresholdsSchema,
|
|
52
52
|
projectionMargin: z.number(),
|
|
53
|
+
gatedNote: z.string().nullable(),
|
|
53
54
|
accounts: z.array(StatusAccountSchema),
|
|
54
55
|
});
|
|
55
56
|
type PoolReport = z.infer<typeof PoolReportSchema>;
|
|
@@ -77,6 +78,29 @@ function usageReport(a: Account, now: number): StatusAccount["usage"] {
|
|
|
77
78
|
};
|
|
78
79
|
}
|
|
79
80
|
|
|
81
|
+
function gatedNote(accounts: Account[], families: string[] | null, weeklyBar: number, now: number): string | null {
|
|
82
|
+
const groups = new Map<string, { carriers: number; capped: number }>();
|
|
83
|
+
for (const a of accounts) {
|
|
84
|
+
if (a.needsReauth === true) continue;
|
|
85
|
+
for (const w of gatedWindows(a, families)) {
|
|
86
|
+
const key = (w.name ?? "").toLowerCase();
|
|
87
|
+
const g = groups.get(key) ?? { carriers: 0, capped: 0 };
|
|
88
|
+
g.carriers++;
|
|
89
|
+
if (liveUsed(w, now) >= weeklyBar) g.capped++;
|
|
90
|
+
groups.set(key, g);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
const capped = [...groups.entries()].filter(([, g]) => g.capped === g.carriers).map(([name]) => name);
|
|
94
|
+
if (capped.length === 0) return null;
|
|
95
|
+
const headroom = accounts.filter((a) => {
|
|
96
|
+
if (a.needsReauth === true) return false;
|
|
97
|
+
const w = weeklyWindow(a);
|
|
98
|
+
return w != null && liveUsed(w, now) < weeklyBar;
|
|
99
|
+
}).length;
|
|
100
|
+
if (headroom === 0) return null;
|
|
101
|
+
return `every ${capped.join(", ")} cap is at the weekly bar, but ${count({ n: headroom, noun: "account" })} still ha${headroom === 1 ? "s" : "ve"} weekly aggregate headroom for other models`;
|
|
102
|
+
}
|
|
103
|
+
|
|
80
104
|
async function collect(p: Provider, cfg: Config, now: number, cached: boolean): Promise<PoolReport> {
|
|
81
105
|
let idx = loadAccounts(p.pool);
|
|
82
106
|
let reports = new Map<string, SampleReport>();
|
|
@@ -119,6 +143,7 @@ async function collect(p: Provider, cfg: Config, now: number, cached: boolean):
|
|
|
119
143
|
thresholds: { session: cfg.thresholds.session, weekly: cfg.thresholds.weekly },
|
|
120
144
|
bars,
|
|
121
145
|
projectionMargin: cfg.policy.projectionMargin,
|
|
146
|
+
gatedNote: gatedNote(idx.accounts, ctx.families, bars.weekly, now),
|
|
122
147
|
accounts,
|
|
123
148
|
};
|
|
124
149
|
}
|
|
@@ -232,6 +257,10 @@ function renderPool(p: Provider, pool: PoolReport, header: string, now: number,
|
|
|
232
257
|
console.log(c.dim(header));
|
|
233
258
|
console.log();
|
|
234
259
|
renderGrid(pool.accounts.map((a) => card(p, a, now, staleAfterMs)));
|
|
260
|
+
if (pool.gatedNote) {
|
|
261
|
+
console.log(c.yellow(pool.gatedNote));
|
|
262
|
+
console.log();
|
|
263
|
+
}
|
|
235
264
|
}
|
|
236
265
|
|
|
237
266
|
export async function cmdStatus(opts: { json?: boolean; cached?: boolean } = {}): Promise<number> {
|
package/src/lib/claude.ts
CHANGED
|
@@ -35,15 +35,18 @@ function presence(): Map<string, number> {
|
|
|
35
35
|
return seatCounts(paths.presenceDir);
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
+
const PROBE_BACKOFF_CAP_MS = 30 * 60 * 1000;
|
|
39
|
+
|
|
38
40
|
async function observeLive(account: Account, cfg: Config, now: number, opts: { probe: boolean }): Promise<Observation | null> {
|
|
39
41
|
const ttl = cfg.policy.usagePollTtlMs;
|
|
40
|
-
const
|
|
42
|
+
const interval = Math.min(ttl * 2 ** (account.probeFails ?? 0), PROBE_BACKOFF_CAP_MS);
|
|
43
|
+
const probeAttempted = account.lastProbeAt != null && now - account.lastProbeAt <= interval;
|
|
41
44
|
const snap = loadUsageSnapshot(account.id);
|
|
42
45
|
const fresh = snap != null && now - snap.at <= ttl;
|
|
43
46
|
const needsPerModel = snap != null && gatedFamilies(snap.state.model, cfg.policy.switchModels).length > 0;
|
|
44
47
|
if (opts.probe && !probeAttempted && (!fresh || needsPerModel)) {
|
|
45
48
|
const startedAt = Date.now();
|
|
46
|
-
const outcome = await probeAccountUsage(account);
|
|
49
|
+
const outcome = await probeAccountUsage(account, { retries: 0 });
|
|
47
50
|
await withLock(claudePool.lockFile, () => {
|
|
48
51
|
const idx = loadAccounts(claudePool);
|
|
49
52
|
const a = idx.accounts.find((x) => x.id === account.id);
|
|
@@ -51,9 +54,14 @@ async function observeLive(account: Account, cfg: Config, now: number, opts: { p
|
|
|
51
54
|
a.lastProbeAt = startedAt;
|
|
52
55
|
a.tier = account.tier;
|
|
53
56
|
if (account.needsReauth) a.needsReauth = true;
|
|
54
|
-
if (outcome.ok
|
|
55
|
-
a.
|
|
56
|
-
a.lastUsageAt
|
|
57
|
+
if (outcome.ok) {
|
|
58
|
+
a.probeFails = 0;
|
|
59
|
+
if (a.lastUsageAt == null || startedAt > a.lastUsageAt) {
|
|
60
|
+
a.windows = mergeWindows(windowsOf(outcome.usage, startedAt), a.windows);
|
|
61
|
+
a.lastUsageAt = startedAt;
|
|
62
|
+
}
|
|
63
|
+
} else {
|
|
64
|
+
a.probeFails = (a.probeFails ?? 0) + 1;
|
|
57
65
|
}
|
|
58
66
|
saveAccounts(claudePool, idx);
|
|
59
67
|
});
|
package/src/lib/sample.ts
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import { mkdirSync } from "node:fs";
|
|
2
2
|
import { join } from "node:path";
|
|
3
|
-
import { minBy } from "es-toolkit";
|
|
4
3
|
import { z } from "zod";
|
|
5
4
|
import { writeFileAtomic } from "./atomic.ts";
|
|
6
5
|
import { readStore } from "./credstore.ts";
|
|
@@ -69,11 +68,14 @@ export async function runProbe(account: Account, dir: string, opts: { retries?:
|
|
|
69
68
|
return usage ? { ok: true, usage } : { ok: false, reason: "`/usage` returned no limit data (see log)" };
|
|
70
69
|
}
|
|
71
70
|
|
|
72
|
-
export async function probeAccountUsage(account: Account): Promise<SampleOutcome> {
|
|
71
|
+
export async function probeAccountUsage(account: Account, opts: { retries?: number } = {}): Promise<SampleOutcome> {
|
|
73
72
|
const prepared = await prepareProbe(account);
|
|
74
|
-
return prepared.ok ? runProbe(account, prepared.dir,
|
|
73
|
+
return prepared.ok ? runProbe(account, prepared.dir, opts) : prepared;
|
|
75
74
|
}
|
|
76
75
|
|
|
76
|
+
const SAMPLE_BATCH = 3;
|
|
77
|
+
const STORE_FAILS_REAUTH = 5;
|
|
78
|
+
|
|
77
79
|
export async function sampleOldest(cfg: Config): Promise<void> {
|
|
78
80
|
const reserved = await withLock(claudePool.lockFile, async () => {
|
|
79
81
|
const now = Date.now();
|
|
@@ -81,52 +83,64 @@ export async function sampleOldest(cfg: Config): Promise<void> {
|
|
|
81
83
|
let dirty = false;
|
|
82
84
|
for (const a of idx.accounts) dirty = foldTee(a) || dirty;
|
|
83
85
|
const sampledAt = (a: Account) => Math.max(a.lastUsageAt ?? 0, a.lastProbeAt ?? 0);
|
|
84
|
-
const stale = idx.accounts
|
|
85
|
-
|
|
86
|
-
|
|
86
|
+
const stale = idx.accounts
|
|
87
|
+
.filter((a) => a.needsReauth !== true && now - sampledAt(a) > cfg.policy.usagePollTtlMs)
|
|
88
|
+
.sort((a, b) => sampledAt(a) - sampledAt(b))
|
|
89
|
+
.slice(0, SAMPLE_BATCH);
|
|
90
|
+
if (stale.length === 0) {
|
|
87
91
|
if (dirty) saveAccounts(claudePool, idx);
|
|
88
|
-
return
|
|
89
|
-
}
|
|
90
|
-
target.lastProbeAt = now;
|
|
91
|
-
const prepared = await prepareProbe(target, "-tick");
|
|
92
|
-
saveAccounts(claudePool, idx);
|
|
93
|
-
if (!prepared.ok) {
|
|
94
|
-
log("sample.failed", { account: target.id.slice(0, 8), reason: prepared.reason.slice(0, 200) });
|
|
95
|
-
return null;
|
|
92
|
+
return [];
|
|
96
93
|
}
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
outcome = await runProbe(reserved.account, reserved.dir, { retries: 0 });
|
|
94
|
+
const batch: { account: Account; dir: string; token: string | null }[] = [];
|
|
95
|
+
for (const target of stale) {
|
|
96
|
+
target.lastProbeAt = now;
|
|
97
|
+
const prepared = await prepareProbe(target, "-tick");
|
|
98
|
+
if (!prepared.ok) {
|
|
99
|
+
target.storeFails = (target.storeFails ?? 0) + 1;
|
|
100
|
+
if (target.storeFails >= STORE_FAILS_REAUTH) target.needsReauth = true;
|
|
101
|
+
log("sample.failed", { account: target.id.slice(0, 8), reason: prepared.reason.slice(0, 200) });
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
target.storeFails = 0;
|
|
105
|
+
let token: string | null = null;
|
|
106
|
+
if (!seatCounts(paths.presenceDir).has(target.id)) {
|
|
107
|
+
const creds = await readStore(target.id).catch(() => null);
|
|
108
|
+
if (creds && !isDeadCredential(creds) && !isAccessTokenExpiring(creds)) token = creds.accessToken;
|
|
109
|
+
}
|
|
110
|
+
batch.push({ account: target, dir: prepared.dir, token });
|
|
115
111
|
}
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
}
|
|
119
|
-
await withLock(claudePool.lockFile, () => {
|
|
120
|
-
const idx = loadAccounts(claudePool);
|
|
121
|
-
const stored = idx.accounts.find((a) => a.id === reserved.account.id);
|
|
122
|
-
if (stored && outcome.ok && (stored.lastUsageAt == null || startedAt > stored.lastUsageAt)) {
|
|
123
|
-
stored.windows = mergeWindows(windowsOf(outcome.usage, startedAt), stored.windows);
|
|
124
|
-
stored.lastUsageAt = startedAt;
|
|
125
|
-
saveAccounts(claudePool, idx);
|
|
126
|
-
}
|
|
127
|
-
log(outcome.ok ? "sample.ok" : "sample.failed", {
|
|
128
|
-
account: reserved.account.id.slice(0, 8),
|
|
129
|
-
...(outcome.ok ? { via } : { reason: outcome.reason.slice(0, 200) }),
|
|
130
|
-
});
|
|
112
|
+
saveAccounts(claudePool, idx);
|
|
113
|
+
return batch;
|
|
131
114
|
});
|
|
115
|
+
await Promise.all(
|
|
116
|
+
reserved.map(async ({ account, dir, token }) => {
|
|
117
|
+
const startedAt = Date.now();
|
|
118
|
+
let via = "probe";
|
|
119
|
+
let outcome: SampleOutcome;
|
|
120
|
+
if (token) {
|
|
121
|
+
const usage = await fetchUsageDirect(token);
|
|
122
|
+
if (usage) {
|
|
123
|
+
via = "get";
|
|
124
|
+
outcome = { ok: true, usage };
|
|
125
|
+
} else {
|
|
126
|
+
outcome = await runProbe(account, dir, { retries: 0 });
|
|
127
|
+
}
|
|
128
|
+
} else {
|
|
129
|
+
outcome = await runProbe(account, dir, { retries: 0 });
|
|
130
|
+
}
|
|
131
|
+
await withLock(claudePool.lockFile, () => {
|
|
132
|
+
const idx = loadAccounts(claudePool);
|
|
133
|
+
const stored = idx.accounts.find((a) => a.id === account.id);
|
|
134
|
+
if (stored && outcome.ok && (stored.lastUsageAt == null || startedAt > stored.lastUsageAt)) {
|
|
135
|
+
stored.windows = mergeWindows(windowsOf(outcome.usage, startedAt), stored.windows);
|
|
136
|
+
stored.lastUsageAt = startedAt;
|
|
137
|
+
saveAccounts(claudePool, idx);
|
|
138
|
+
}
|
|
139
|
+
log(outcome.ok ? "sample.ok" : "sample.failed", {
|
|
140
|
+
account: account.id.slice(0, 8),
|
|
141
|
+
...(outcome.ok ? { via } : { reason: outcome.reason.slice(0, 200) }),
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
}),
|
|
145
|
+
);
|
|
132
146
|
}
|
package/src/lib/types.ts
CHANGED
|
@@ -70,6 +70,8 @@ export const AccountSchema = z.object({
|
|
|
70
70
|
windows: z.array(WindowSchema).default([]),
|
|
71
71
|
lastUsageAt: z.number().optional(),
|
|
72
72
|
lastProbeAt: z.number().optional(),
|
|
73
|
+
probeFails: z.number().optional(),
|
|
74
|
+
storeFails: z.number().optional(),
|
|
73
75
|
enforcedUntil: z.number().optional(),
|
|
74
76
|
needsReauth: z.boolean().optional(),
|
|
75
77
|
oauthAccount: OAuthAccountSchema.optional(),
|