tokenmaxxing 1.19.1 → 1.21.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/DESIGN.md +17 -6
- package/README.md +11 -5
- package/agent-plugin/plugin.json +1 -1
- package/agent-plugin/skills/switching-policy/SKILL.md +7 -3
- package/agent-plugin/skills/switching-policy/references/policy.md +4 -3
- package/package.json +1 -1
- package/src/cli/check.ts +0 -3
- package/src/cli/config.ts +0 -1
- package/src/cli/status.ts +2 -12
- package/src/cli/switch.ts +5 -1
- package/src/entries/codexstophook.ts +0 -1
- package/src/entries/stopfailurehook.ts +6 -9
- package/src/entries/stophook.ts +0 -1
- package/src/entries/supervisor.ts +1 -2
- package/src/lib/codexdecide.ts +1 -25
- package/src/lib/codexpick.ts +3 -3
- package/src/lib/codexusage.ts +0 -9
- package/src/lib/decide.ts +59 -76
- package/src/lib/oauth.ts +2 -1
- package/src/lib/paths.ts +0 -1
- package/src/lib/picker.ts +25 -5
- package/src/lib/sample.ts +25 -11
- package/src/lib/state.ts +2 -82
- package/src/lib/swap.ts +17 -1
- package/src/lib/types.ts +0 -53
- package/src/lib/usage.ts +13 -7
- package/src/lib/bankedreset.ts +0 -234
- package/src/lib/codexreset.ts +0 -122
package/DESIGN.md
CHANGED
|
@@ -49,8 +49,8 @@ The switching path runs no long-lived daemon. The statusline pushes usage. Hooks
|
|
|
49
49
|
The Stop hook's stdin has no usage data, but the **statusLine does** (`rate_limits.{five_hour,seven_day}.{used_percentage,resets_at}`, after every turn, 300ms debounce, zero cost). tokenmaxxing's statusLine tees that to `usage.json` (write-on-change, O(ms)) and renders its own native line (it replaced the earlier pass-through delegation on 2026-07-11: install takes the statusLine slot outright, so a pre-existing custom statusline command is overwritten). Cold-start fallback if `usage.json` is absent: `TOKENMAXXING_PROBE=1 claude -p '/usage'`, with `[ -n "$TOKENMAXXING_PROBE" ] && exit 0` as the hook's first line to stop the nested process recursing (hooks fire in `-p` too). The probe scrubs every ambient credential override claude reads before the keychain (`CLAUDE_CODE_OAUTH_TOKEN`, `CLAUDE_SECURESTORAGE_CONFIG_DIR`, etc.) so it can only meter the credential in the keychain item, and retries the transient empty-footer case (claude prints local stats with no percentages when its own usage fetch throttles).
|
|
50
50
|
|
|
51
51
|
### 3.2 Detect + swap + signal (Stop hook, per turn)
|
|
52
|
-
1. Read `usage.json
|
|
53
|
-
2.
|
|
52
|
+
1. Read `usage.json` and exit below the engagement floor unless a screening bar is crossed, then follow the greedy or hard path defined in the [switching policy](docs/content/docs/switching.mdx).
|
|
53
|
+
2. Take the pool lock, re-check state, select a candidate under the [switching policy](docs/content/docs/switching.mdx), and attempt bounded verification of stale usage before swapping the credential.
|
|
54
54
|
3. Done - the running session adopts the new credential on its own within a request or two. Only when the pool is depleted (the decision returned a `waitUntil`: pre-parked on the soonest-recovering account, or staying on the current one when it recovers first) does the hook write `respawn/<session_id>` (atomic temp+rename).
|
|
55
55
|
|
|
56
56
|
### 3.3 Depleted-pool pause (supervisor)
|
|
@@ -79,13 +79,24 @@ Each terminal ran the supervisor, so each has its own child `claude` and its own
|
|
|
79
79
|
---
|
|
80
80
|
|
|
81
81
|
## 5. Rotation policy
|
|
82
|
-
The
|
|
82
|
+
The [switching policy](docs/content/docs/switching.mdx) defines engagement, screening bars, organization preference, incumbent hysteresis and the wall squeeze.
|
|
83
83
|
|
|
84
|
-
|
|
84
|
+
- Automatic lateral moves stay within the incumbent organization, and a crossed bar can trigger a cross-organization handoff.
|
|
85
|
+
- Organization preference requires measured headroom in every applicable window and is an optimization heuristic, not a cache-hit guarantee.
|
|
86
|
+
- Manual switching retains pure pace-pressure ranking.
|
|
87
|
+
- Successful candidate probes persist their results and restart selection, preserving candidates that fail a screening bar but still clear the wall.
|
|
88
|
+
- Verification attempts at most two candidates with a shared 12-second deadline per candidate for identity reads and CLI execution.
|
|
89
|
+
- Expiring access tokens are not refreshed for verification, and the isolated CLI receives no refresh grant.
|
|
90
|
+
- Failed or budget-limited verification uses the existing cached data, so stale-target handoffs remain possible.
|
|
85
91
|
|
|
86
|
-
|
|
92
|
+
The [profile](docs/content/docs/switching-profile.mdx) distinguishes observed cache rewrites from pricing-weighted estimates and does not infer subscription quota savings.
|
|
93
|
+
|
|
94
|
+
The [wall policy](docs/content/docs/switching.mdx#the-wall) defines Layer 2 recovery and its conditional headroom preference, with pace-only selection under default bars.
|
|
87
95
|
|
|
88
|
-
|
|
96
|
+
- Layer 2 is Claude-only because its running sessions can adopt a credential change.
|
|
97
|
+
- Codex keeps its current account until the wall because a running sibling cannot adopt another account without restarting.
|
|
98
|
+
|
|
99
|
+
**Model-aware trigger.** Claude subscriptions also enforce **per-model weekly caps** - currently only for Sonnet and Fable (there is no Opus-only quota), and Fable's tighter limit binds *before* the aggregate (e.g. 80% week-Fable at only 50% week-all-models). This cap isn't in statusLine stdin, so when the active model is in `policy.switchModels` we read it from `claude -p '/usage'` (free, 0 tokens, TTL-cached) and add `week(<activeModel>) >= threshold` to the trigger. A Fable session switches on the Fable cap; a Sonnet session rides the aggregate. Both layers apply the per-model gate: a burnt Fable cap screens an account out of a Layer 1 switch, and a Fable cap at the wall screens it out of the Layer 2 squeeze too.
|
|
89
100
|
|
|
90
101
|
---
|
|
91
102
|
|
package/README.md
CHANGED
|
@@ -87,7 +87,14 @@ Switching engages (configurable) once the active account's 5-hour session window
|
|
|
87
87
|
|
|
88
88
|
The bars' headroom is deliberate: it's the budget to reach a clean turn boundary (plus up to one turn of adoption lag on macOS) before the wall. The session ladder tops out lower (90) because a 5-hour reset is cheap to sit out, and any lower rungs you add keep the pool draining level by level; weekly quota is use-it-or-lose-it, so it drains closer to the wall (98). The greedy engagement floor (80) sits below both: weekly allowance is forfeited at each account's fixed reset, so once most of a session window is spent, quota is best burned on whichever account has the most at risk.
|
|
89
89
|
|
|
90
|
-
|
|
90
|
+
Automatic selection prefers same-organization candidates with measured session and weekly headroom, then uses remaining weekly percentage divided by time to reset.
|
|
91
|
+
|
|
92
|
+
- Lateral moves stay within the current organization, while a crossed bar or enforced limit may trigger a cross-organization handoff.
|
|
93
|
+
- Candidate verification makes at most two bounded `/usage` attempts per evaluation and re-ranks successful samples before switching.
|
|
94
|
+
- Failed verification leaves cached figures in force, so it cannot guarantee a fresh target.
|
|
95
|
+
- Manual `tokenmaxxing switch` retains pure pace-pressure selection.
|
|
96
|
+
|
|
97
|
+
See [How switching decides](docs/content/docs/switching.mdx) for the policy and the [cache-cost profile](docs/content/docs/switching-profile.mdx) for measurements and their limits.
|
|
91
98
|
|
|
92
99
|
## Configuration
|
|
93
100
|
|
|
@@ -101,13 +108,12 @@ The **target** is chosen greedily off each account's cached windows: among usabl
|
|
|
101
108
|
"greedySessionFloor": 80,
|
|
102
109
|
"switchModels": ["fable"],
|
|
103
110
|
"usagePollTtlMs": 90000,
|
|
104
|
-
"checkIntervalMs": 60000
|
|
105
|
-
"preferToUseBankedReset": []
|
|
111
|
+
"checkIntervalMs": 60000
|
|
106
112
|
}
|
|
107
113
|
}
|
|
108
114
|
```
|
|
109
115
|
|
|
110
|
-
`thresholds.session` is a ladder: the bar is the lowest rung some pooled account still clears, so the default `[90]` is one bar, while with `[50, 80, 95]` a seat past 50 hands off while a sibling is under 50, and the bar climbs to 80 and then 95 as the whole pool fills; `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; `greedySessionFloor` is the session-used % at which the greedy convergence engages; `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 and which every headroom band is a multiple of, one tick near a bar up to five with plenty of headroom (a depleted wait and the post-swap cooldown sleep to their own deadlines instead) - re-run `tokenmaxxing init` after changing it so the timer unit picks up the new tick
|
|
116
|
+
`thresholds.session` is a ladder: the bar is the lowest rung some pooled account still clears, so the default `[90]` is one bar, while with `[50, 80, 95]` a seat past 50 hands off while a sibling is under 50, and the bar climbs to 80 and then 95 as the whole pool fills; `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; `greedySessionFloor` is the session-used % at which the greedy convergence engages; `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 and which every headroom band is a multiple of, one tick near a bar up to five with plenty of headroom (a depleted wait and the post-swap cooldown sleep to their own deadlines instead) - re-run `tokenmaxxing init` after changing it so the timer unit picks up the new tick.
|
|
111
117
|
|
|
112
118
|
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`).
|
|
113
119
|
|
|
@@ -149,7 +155,7 @@ codex # use codex as always
|
|
|
149
155
|
|
|
150
156
|
Codex mechanics differ from Claude Code in one hard way: a running codex process refuses a credential swapped to a different account, so **a restart is the switch**. The installed Stop hook runs the same greedy pace-pressure decision at each turn boundary (usage read free from codex's own rate-limit endpoint: percentages plus absolute reset times, weekly aggregate and per-model caps alike); when it swaps, the supervisor relaunches `codex resume <session-id>` on the fresh account with the transcript intact. `tokenmaxxing switch --codex [sel]` does it manually, `status`/`watch`/`ls` show both pools.
|
|
151
157
|
|
|
152
|
-
Two codex-specific facts worth knowing: codex does not run hooks it has not been told to trust, so after `init --codex` you must open codex once and trust the tokenmaxxing Stop hook via `/hooks` (auto-switching is inert until then); and codex has no cross-process lock on `auth.json`, so tokenmaxxing serializes all of its own credential writes behind its own lock and swaps only at idle turn boundaries.
|
|
158
|
+
Two codex-specific facts worth knowing: codex does not run hooks it has not been told to trust, so after `init --codex` you must open codex once and trust the tokenmaxxing Stop hook via `/hooks` (auto-switching is inert until then); and codex has no cross-process lock on `auth.json`, so tokenmaxxing serializes all of its own credential writes behind its own lock and swaps only at idle turn boundaries.
|
|
153
159
|
|
|
154
160
|
## Honest limitations
|
|
155
161
|
|
package/agent-plugin/plugin.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
|
|
3
3
|
"name": "tokenmaxxing",
|
|
4
|
-
"version": "1.
|
|
4
|
+
"version": "1.21.0",
|
|
5
5
|
"description": "Pool Claude Code and Codex logins, switch on pace pressure, and expose safe ops tools to agent clients.",
|
|
6
6
|
"author": {
|
|
7
7
|
"name": "anaclumos",
|
|
@@ -8,11 +8,15 @@ description: Explain and apply tokenmaxxing switch policy (greedy vs hard path,
|
|
|
8
8
|
## Vocabulary
|
|
9
9
|
|
|
10
10
|
- **Engaged**: session used >= `policy.greedySessionFloor` (default 80) or any screening bar crossed.
|
|
11
|
-
- **GREEDY path
|
|
12
|
-
- **HARD path
|
|
11
|
+
- **GREEDY path** permits lateral moves only within the incumbent organization and retains `greedySwapMargin` hysteresis.
|
|
12
|
+
- **HARD path** starts at a crossed screening bar and may cross organizations, with Layer 2 as its fallback.
|
|
13
|
+
- **Organization preference** requires measured headroom in session, aggregate weekly and gated model windows under the [policy](references/policy.md).
|
|
14
|
+
- **Verification** attempts at most two stale candidates per evaluation, with a 12-second deadline covering identity reads and the CLI for each candidate.
|
|
15
|
+
- **Probe results** are persisted and re-ranked, while screening exhaustion remains eligible for the wall squeeze and reset selection.
|
|
16
|
+
- **Unavailable verification** leaves cached figures in force, and verification never spends a parked refresh grant.
|
|
17
|
+
- **Manual switch** retains pure pace ranking with no organization preference or incumbent margin.
|
|
13
18
|
- **Pace pressure**: remaining weekly percent / time to weekly reset (highest first). Not most-remaining.
|
|
14
19
|
- **Effective bars**: `effectiveBars(cfg, pool)` = the active rung of the 5h ladder (`thresholds.session`, default `[90]`, a single rung: the lowest rung some pooled account, the current one included, still clears) and the weekly bar, each minus `policy.projectionMargin`. Trigger and screening must share these bars or swaps ping-pong. Codex reads `terminalBars(cfg)`, the top rung only. The check cadence is capped one band per rung climbed, and every band is a multiple of the tick `policy.checkIntervalMs` (default 60000).
|
|
15
|
-
- **Banked reset**: opt-in via `policy.preferToUseBankedReset` (a provider list, default `[]`), hard path only. Claude: the seat holds between the session rung and the wall while a reset is believed available and the weekly windows have room for one more measured session window (`sessionWindowWeeklyCost`; unmeasured swaps), claims the `/limit-reset` server call at the wall, and keeps the seat on `reset` (the failed turn is retriggered in place). Codex: rides to the wall while a reset credit exists, consumes one, no restart. The greedy path never resets.
|
|
16
20
|
|
|
17
21
|
## Layer 2 (Claude only)
|
|
18
22
|
|
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
- `docs/content/docs/switching.mdx`
|
|
4
4
|
- `src/lib/decide.ts`, `src/lib/picker.ts`
|
|
5
|
-
-
|
|
6
|
-
- `.memory/switch-policy-pace-pressure.md`
|
|
5
|
+
- `.cursor/rules/switch-verification.md`
|
|
7
6
|
|
|
8
|
-
|
|
7
|
+
Automatic organization preference requires session usage below `greedySessionFloor` and more than `max(1, session bar - greedySessionFloor)` points below every applicable bar.
|
|
8
|
+
|
|
9
|
+
The exact policy, defaults, verification limits and manual-switch behavior live in `docs/content/docs/switching.mdx`.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tokenmaxxing",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.21.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
|
@@ -30,7 +30,6 @@ export async function cmdCheck(args: string[] = [], json = false): Promise<numbe
|
|
|
30
30
|
ok: true,
|
|
31
31
|
due: true,
|
|
32
32
|
swapped: d.swapped,
|
|
33
|
-
reset: d.reset === true,
|
|
34
33
|
account: d.account?.label ?? null,
|
|
35
34
|
reason: d.reason,
|
|
36
35
|
waitUntil: d.waitUntil ?? null,
|
|
@@ -41,8 +40,6 @@ export async function cmdCheck(args: string[] = [], json = false): Promise<numbe
|
|
|
41
40
|
const next = c.dim(`next in ${Math.round(delayMs / 1000)}s`);
|
|
42
41
|
if (d.swapped && d.account) {
|
|
43
42
|
console.log(`${c.green("↻")} switched to ${c.bold(d.account.label)} ${next}`);
|
|
44
|
-
} else if (d.reset && d.account) {
|
|
45
|
-
console.log(`${c.green("↻")} reset the session limit on ${c.bold(d.account.label)} ${next}`);
|
|
46
43
|
} else if (d.waitUntil !== undefined && d.account) {
|
|
47
44
|
console.log(c.yellow(`all accounts at limit - staying on ${c.bold(d.account.label)} (${fmtReset(d.waitUntil)})`) + ` ${next}`);
|
|
48
45
|
} else {
|
package/src/cli/config.ts
CHANGED
|
@@ -21,7 +21,6 @@ export const KNOWN_KEYS = [
|
|
|
21
21
|
"policy.usagePollTtlMs",
|
|
22
22
|
"policy.maxWaitMs",
|
|
23
23
|
"policy.checkIntervalMs",
|
|
24
|
-
"policy.preferToUseBankedReset",
|
|
25
24
|
] as const;
|
|
26
25
|
|
|
27
26
|
const TIMER_REWRITE_NOTE = "note: run `tokenmaxxing init` to rewrite the periodic timer at the new tick";
|
package/src/cli/status.ts
CHANGED
|
@@ -12,7 +12,7 @@ import { isCodexExhausted } from "../lib/codexpick.ts";
|
|
|
12
12
|
import { codexLimitLabel, isSessionWindow } from "../lib/codexusage.ts";
|
|
13
13
|
import { bar, c, claudeTierLabel, count, emitJson, fmtAgo } from "./render.ts";
|
|
14
14
|
import { gatedFamilies, type FullUsage } from "../lib/usage.ts";
|
|
15
|
-
import {
|
|
15
|
+
import { ThresholdsSchema, UsageWindowSchema, type Account, type CodexWindow, type Config, type UsageWindow } from "../lib/types.ts";
|
|
16
16
|
|
|
17
17
|
const SampleReportSchema = z.discriminatedUnion("ok", [
|
|
18
18
|
z.object({ ok: z.literal(true), source: z.enum(["statusline", "probe"]) }),
|
|
@@ -35,9 +35,6 @@ const ClaudeStatusAccountSchema = z.object({
|
|
|
35
35
|
pingError: z.string().nullable(),
|
|
36
36
|
pingRejected: z.boolean(),
|
|
37
37
|
pinged: z.boolean(),
|
|
38
|
-
bankedReset: BankedResetRecordSchema.nullable(),
|
|
39
|
-
sessionWindowWeeklyCost: z.number().nullable(),
|
|
40
|
-
sessionWindowFamilyCosts: z.record(z.string(), z.number()).nullable(),
|
|
41
38
|
});
|
|
42
39
|
type ClaudeStatusAccount = z.infer<typeof ClaudeStatusAccountSchema>;
|
|
43
40
|
|
|
@@ -59,7 +56,6 @@ const CodexStatusAccountSchema = z.object({
|
|
|
59
56
|
.nullable(),
|
|
60
57
|
usageAt: z.number().nullable(),
|
|
61
58
|
sample: SampleReportSchema,
|
|
62
|
-
resetCredits: z.number().int().nullable(),
|
|
63
59
|
});
|
|
64
60
|
type CodexStatusAccount = z.infer<typeof CodexStatusAccountSchema>;
|
|
65
61
|
|
|
@@ -158,7 +154,6 @@ async function collectClaude(input: { cfg: Config; ping: boolean; pingCount: num
|
|
|
158
154
|
if (!outcome.ok) return;
|
|
159
155
|
a.lastUsage = { fiveHour: outcome.usage.session, sevenDay: outcome.usage.weekAll };
|
|
160
156
|
a.lastUsageAt = viaTee && teeAt != null ? (live?.sampledAt ?? teeAt) : Date.now();
|
|
161
|
-
if (viaTee && live?.sessionWindowWeeklyCost != null) a.sessionWindowWeeklyCost = live.sessionWindowWeeklyCost;
|
|
162
157
|
if (Object.keys(outcome.usage.perModel).length > 0) {
|
|
163
158
|
a.lastPerModel = outcome.usage.perModel;
|
|
164
159
|
a.lastPerModelAt = viaTee && modelUsage ? (modelUsage.sampledAt ?? modelUsage.ts) : a.lastUsageAt;
|
|
@@ -178,7 +173,6 @@ async function collectClaude(input: { cfg: Config; ping: boolean; pingCount: num
|
|
|
178
173
|
const families = gatedFamilies(loadUsage()?.model ?? null, cfg.policy.switchModels);
|
|
179
174
|
const bars = effectiveBars(cfg, { accounts: idx.accounts, now, switchFamilies: families });
|
|
180
175
|
const liveAccount = readOAuthAccount()?.accountUuid ?? null;
|
|
181
|
-
const liveModelUsage = loadModelUsage();
|
|
182
176
|
const ordered = sortBy(idx.accounts, [(a) => (a.needsReauth ? 1 : 0), (a) => earliestReset(a, now)]);
|
|
183
177
|
const accounts = ordered.map((a): ClaudeStatusAccount => {
|
|
184
178
|
const sampled = samples.get(a.accountUuid) ?? { outcome: { ok: false, reason: "not sampled" }, viaTee: false };
|
|
@@ -201,9 +195,6 @@ async function collectClaude(input: { cfg: Config; ping: boolean; pingCount: num
|
|
|
201
195
|
pingError: sampled.outcome.pingError ?? null,
|
|
202
196
|
pingRejected: sampled.outcome.pingRejected === true,
|
|
203
197
|
pinged: pings.has(a.accountUuid) && sampled.outcome.ok && sampled.outcome.pingError == null && aggregate != null && aggregate.fiveHour.resetsAt == null,
|
|
204
|
-
bankedReset: a.bankedReset ?? null,
|
|
205
|
-
sessionWindowWeeklyCost: a.sessionWindowWeeklyCost ?? null,
|
|
206
|
-
sessionWindowFamilyCosts: liveModelUsage && liveModelUsage.account === a.accountUuid ? (liveModelUsage.familyCosts ?? null) : null,
|
|
207
198
|
};
|
|
208
199
|
});
|
|
209
200
|
return {
|
|
@@ -231,7 +222,7 @@ async function collectCodex(input: { cfg: Config; now: number }): Promise<Status
|
|
|
231
222
|
const outcome = await sampleCodexAccount({ account, liveAccountId: liveId, now });
|
|
232
223
|
outcomes.set(account.accountId, outcome);
|
|
233
224
|
if (outcome.ok) {
|
|
234
|
-
account.lastUsage = { aggregate: outcome.usage.aggregate, perLimit: outcome.usage.perLimit
|
|
225
|
+
account.lastUsage = { aggregate: outcome.usage.aggregate, perLimit: outcome.usage.perLimit };
|
|
235
226
|
account.lastUsageAt = Date.now();
|
|
236
227
|
if (outcome.usage.email != null) account.email = outcome.usage.email;
|
|
237
228
|
if (outcome.usage.planType != null) account.planType = outcome.usage.planType;
|
|
@@ -272,7 +263,6 @@ async function collectCodex(input: { cfg: Config; now: number }): Promise<Status
|
|
|
272
263
|
: null,
|
|
273
264
|
usageAt: account.lastUsageAt ?? null,
|
|
274
265
|
sample: outcome.ok ? { ok: true, source: "probe" } : { ok: false, reason: outcome.reason },
|
|
275
|
-
resetCredits: usage?.resetCredits ?? null,
|
|
276
266
|
};
|
|
277
267
|
});
|
|
278
268
|
return { bars, accounts };
|
package/src/cli/switch.ts
CHANGED
|
@@ -7,6 +7,7 @@ import { currentWins, effectiveBars, pickBest, pickEarliestReset, weeklyExpiry,
|
|
|
7
7
|
import { InvalidGrantError } from "../lib/oauth.ts";
|
|
8
8
|
import { gatedFamilies } from "../lib/usage.ts";
|
|
9
9
|
import { findAccount } from "./rename.ts";
|
|
10
|
+
import { log } from "../lib/log.ts";
|
|
10
11
|
import { c, emitError, emitJson, fmtReset } from "./render.ts";
|
|
11
12
|
import type { Account } from "../lib/types.ts";
|
|
12
13
|
|
|
@@ -44,6 +45,7 @@ export async function cmdSwitch(selector?: string, json = false): Promise<number
|
|
|
44
45
|
}
|
|
45
46
|
throw e;
|
|
46
47
|
}
|
|
48
|
+
log("switch.manual", { account: target.accountUuid.slice(0, 8), reason });
|
|
47
49
|
emit(`${c.green("↻")} switched to ${c.bold(target.label)}`, { switched: true, account: target.label, reason, ...extra });
|
|
48
50
|
return 0;
|
|
49
51
|
};
|
|
@@ -69,12 +71,12 @@ export async function cmdSwitch(selector?: string, json = false): Promise<number
|
|
|
69
71
|
const rejected = new Set<string>();
|
|
70
72
|
while (true) {
|
|
71
73
|
const cur = loadAccounts();
|
|
72
|
-
const everyone = everyoneIn(cur.accounts);
|
|
73
74
|
const pool = cur.accounts.filter((a) => !rejected.has(a.accountUuid));
|
|
74
75
|
const active =
|
|
75
76
|
(claimed != null ? cur.accounts.find((a) => a.accountUuid === claimed) : null) ??
|
|
76
77
|
cur.accounts.find((a) => a.accountUuid === cur.activeAccountUuid) ??
|
|
77
78
|
null;
|
|
79
|
+
const everyone = everyoneIn(cur.accounts);
|
|
78
80
|
if (active != null && currentWins(active, pool, everyone)) {
|
|
79
81
|
if (drifted) return swapTo(active, "drift-reconciled");
|
|
80
82
|
const expiry = weeklyExpiry(active, now);
|
|
@@ -104,6 +106,7 @@ export async function cmdSwitch(selector?: string, json = false): Promise<number
|
|
|
104
106
|
}
|
|
105
107
|
throw e;
|
|
106
108
|
}
|
|
109
|
+
log("switch.manual", { account: best.accountUuid.slice(0, 8), reason: "best" });
|
|
107
110
|
emit(`${c.green("↻")} switched to ${c.bold(best.label)}`, { switched: true, account: best.label, reason: "best" });
|
|
108
111
|
return 0;
|
|
109
112
|
}
|
|
@@ -159,6 +162,7 @@ export async function cmdSwitch(selector?: string, json = false): Promise<number
|
|
|
159
162
|
}
|
|
160
163
|
throw e;
|
|
161
164
|
}
|
|
165
|
+
log("switch.manual", { account: earliest.account.accountUuid.slice(0, 8), reason: "earliest-reset" });
|
|
162
166
|
emit(`${c.green("↻")} switched to ${c.bold(earliest.account.label)}`, { switched: true, account: earliest.account.label, reason: "earliest-reset", availableAt, reauthNeeded: reauth });
|
|
163
167
|
if (availableAt != null && !json) {
|
|
164
168
|
console.log(c.yellow(`all accounts at limit - ${c.bold(earliest.account.label)} recovers soonest (${fmtReset(availableAt, now)})${reauthNote}`));
|
|
@@ -99,7 +99,6 @@ export async function handleCodexStop(input: { rawStdin: string }): Promise<void
|
|
|
99
99
|
}
|
|
100
100
|
if (await promoteReconcile({ supervisorId, sessionId })) return;
|
|
101
101
|
const decision = await evaluateAndMaybeSwapCodex({});
|
|
102
|
-
if (decision.reset && decision.account) log("codexstop.reset", { account: decision.account.accountId.slice(0, 8) });
|
|
103
102
|
if (decision.swapped && decision.account) {
|
|
104
103
|
mkdirSync(codexPaths.respawnDir, { recursive: true });
|
|
105
104
|
const payload = CodexRespawnMarkerSchema.parse({
|
|
@@ -4,13 +4,12 @@ import { paths } from "../lib/paths.ts";
|
|
|
4
4
|
import { writeFileAtomic } from "../lib/atomic.ts";
|
|
5
5
|
import { readOAuthAccount } from "../lib/claudejson.ts";
|
|
6
6
|
import { enforcedWindowMs, evaluateAndMaybeSwap, postSwapProof, recordEnforcedLimit } from "../lib/decide.ts";
|
|
7
|
-
import { loadConfig,
|
|
7
|
+
import { loadConfig, loadLastSwapAt } from "../lib/state.ts";
|
|
8
8
|
import { classifyEnforcedLimit, findEnforcedRow, parseErrorBody, readTranscriptTail } from "../lib/usage.ts";
|
|
9
9
|
import { RespawnMarkerSchema, type EnforcedLimit } from "../lib/types.ts";
|
|
10
10
|
import { log } from "../lib/log.ts";
|
|
11
11
|
|
|
12
12
|
export const RETRIGGER_PROMPT = "Continue where the previous turn left off; it was interrupted by a usage limit and tokenmaxxing switched accounts.";
|
|
13
|
-
export const RETRIGGER_RESET_PROMPT = "Continue where the previous turn left off; it was interrupted by the session limit and tokenmaxxing reset that limit on the same account.";
|
|
14
13
|
|
|
15
14
|
const StopFailureStdin = z.looseObject({
|
|
16
15
|
session_id: z.uuid().optional().catch(undefined),
|
|
@@ -53,11 +52,11 @@ export async function runStopFailureHook(): Promise<number> {
|
|
|
53
52
|
|
|
54
53
|
let enforced: EnforcedLimit | null = null;
|
|
55
54
|
if (limit && found && account) {
|
|
56
|
-
if (postSwapProof({ swapAt:
|
|
55
|
+
if (postSwapProof({ swapAt: loadLastSwapAt(), launchedAt, errorAt: found.errorAt, now })) {
|
|
57
56
|
const stamp = await recordEnforcedLimit({ limit, account, now });
|
|
58
57
|
log("stopfailure.enforced", { kind: limit.kind, family: limit.kind === "model" ? limit.family : undefined, outcome: stamp.outcome, resetsAt: stamp.resetsAt, subagent: !mainLoop });
|
|
59
58
|
if (stamp.outcome !== "account-moved") {
|
|
60
|
-
enforced = { account,
|
|
59
|
+
enforced = { account, family: limit.kind === "model" ? limit.family : null, resetsAt: stamp.resetsAt, windowMs: enforcedWindowMs(limit) };
|
|
61
60
|
}
|
|
62
61
|
} else {
|
|
63
62
|
log("stopfailure.unproven", { kind: limit.kind });
|
|
@@ -72,20 +71,18 @@ export async function runStopFailureHook(): Promise<number> {
|
|
|
72
71
|
}
|
|
73
72
|
|
|
74
73
|
const decision = await evaluateAndMaybeSwap(now, canPause && enforced != null, enforced);
|
|
75
|
-
|
|
76
|
-
if (enforced && canPause && pinnedSid && decision.account && (decision.swapped || decision.waitUntil !== undefined || reset)) {
|
|
74
|
+
if (enforced && canPause && pinnedSid && decision.account && (decision.swapped || decision.waitUntil !== undefined)) {
|
|
77
75
|
const marker = join(paths.respawnDir, pinnedSid);
|
|
78
76
|
const payload = RespawnMarkerSchema.parse({
|
|
79
77
|
account: decision.account.label,
|
|
80
78
|
ts: Date.now(),
|
|
81
79
|
waitUntil: decision.waitUntil ?? now,
|
|
82
80
|
sessionId: stdinSid ?? pinnedSid,
|
|
83
|
-
prompt:
|
|
81
|
+
prompt: RETRIGGER_PROMPT,
|
|
84
82
|
...(launchedAt != null ? { launchedAt } : {}),
|
|
85
|
-
...(reset ? { reset } : {}),
|
|
86
83
|
});
|
|
87
84
|
writeFileAtomic(marker, JSON.stringify(payload));
|
|
88
|
-
log("stopfailure.marker", { session: (stdinSid ?? pinnedSid).slice(0, 8), account: decision.account.accountUuid.slice(0, 8), waitUntil: payload.waitUntil
|
|
85
|
+
log("stopfailure.marker", { session: (stdinSid ?? pinnedSid).slice(0, 8), account: decision.account.accountUuid.slice(0, 8), waitUntil: payload.waitUntil });
|
|
89
86
|
} else {
|
|
90
87
|
log("stopfailure.decision", { reason: decision.reason, swapped: decision.swapped, account: decision.account?.accountUuid.slice(0, 8), waitUntil: decision.waitUntil });
|
|
91
88
|
}
|
package/src/entries/stophook.ts
CHANGED
|
@@ -25,7 +25,6 @@ export async function runStopHook(): Promise<number> {
|
|
|
25
25
|
try {
|
|
26
26
|
const canPause = process.env.TOKENMAXXING_SUPERVISED === "1" && pinnedSid != null;
|
|
27
27
|
const decision = await evaluateAndMaybeSwap(Date.now(), canPause);
|
|
28
|
-
if (decision.reset && decision.account) log("stop.reset", { account: decision.account.accountUuid.slice(0, 8) });
|
|
29
28
|
if (decision.account && (decision.swapped || decision.waitUntil !== undefined)) {
|
|
30
29
|
log(decision.swapped ? "stop.swapped" : "stop.wait", { account: decision.account.accountUuid.slice(0, 8), waitUntil: decision.waitUntil });
|
|
31
30
|
if (decision.waitUntil !== undefined && canPause && pinnedSid) {
|
|
@@ -286,8 +286,7 @@ export async function runSupervisor(argv: string[]): Promise<number> {
|
|
|
286
286
|
respawns++;
|
|
287
287
|
if (m.waitUntil > Date.now()) {
|
|
288
288
|
if (await countdownWait(m.account, m.waitUntil)) overriddenUntil = m.waitUntil;
|
|
289
|
-
} else
|
|
290
|
-
else process.stdout.write(`\n\x1b[36m↻ tokenmaxxing: switched to ${m.account} - resuming...\x1b[0m\n`);
|
|
289
|
+
} else process.stdout.write(`\n\x1b[36m↻ tokenmaxxing: switched to ${m.account} - resuming...\x1b[0m\n`);
|
|
291
290
|
saveSessionFlags(m.sessionId, persistable, process.cwd());
|
|
292
291
|
launchArgs = ["--resume", m.sessionId, ...persistable, ...(m.prompt ? ["--", m.prompt] : [])];
|
|
293
292
|
continue;
|
package/src/lib/codexdecide.ts
CHANGED
|
@@ -11,7 +11,6 @@ import { performCodexSwap } from "./codexswap.ts";
|
|
|
11
11
|
import { CodexInvalidGrantError, refreshCodexAuth } from "./codexoauth.ts";
|
|
12
12
|
import { fetchCodexUsage } from "./codexusage.ts";
|
|
13
13
|
import { codexIdentityOf, isCodexAccessExpiring, readLiveCodexAuth, writeLiveCodexAuth, writeParkedCodexAuth } from "./codexauth.ts";
|
|
14
|
-
import { codexBankedResetVerdict, consumeLiveCodexBankedReset } from "./codexreset.ts";
|
|
15
14
|
import { liveCodexAccountId } from "./codexsample.ts";
|
|
16
15
|
import { livingCodexPresences, presentCodexAccountIds, targetableCodexAccounts } from "./codexpresence.ts";
|
|
17
16
|
import { terminalBars } from "./picker.ts";
|
|
@@ -22,7 +21,6 @@ const CodexSwapDecisionSchema = z.object({
|
|
|
22
21
|
swapped: z.boolean(),
|
|
23
22
|
account: CodexAccountSchema.nullable(),
|
|
24
23
|
reason: z.string(),
|
|
25
|
-
reset: z.boolean().optional(),
|
|
26
24
|
});
|
|
27
25
|
export type CodexSwapDecision = z.infer<typeof CodexSwapDecisionSchema>;
|
|
28
26
|
|
|
@@ -53,7 +51,7 @@ async function sampleLiveOntoOwner(input: { now: number }): Promise<string | nul
|
|
|
53
51
|
writeParkedCodexAuth({ credFile: owner.credFile, auth: live });
|
|
54
52
|
}
|
|
55
53
|
const usage = await fetchCodexUsage({ auth: live });
|
|
56
|
-
owner.lastUsage = { aggregate: usage.aggregate, perLimit: usage.perLimit
|
|
54
|
+
owner.lastUsage = { aggregate: usage.aggregate, perLimit: usage.perLimit };
|
|
57
55
|
owner.lastUsageAt = now;
|
|
58
56
|
if (usage.email != null) owner.email = usage.email;
|
|
59
57
|
if (usage.planType != null) owner.planType = usage.planType;
|
|
@@ -139,28 +137,6 @@ export async function evaluateAndMaybeSwapCodex(input: { now?: number }): Promis
|
|
|
139
137
|
isCodexExhausted({ account: active, thresholds: bars, now });
|
|
140
138
|
if (!engaged) return { swapped: false, account: null, reason: "under-threshold-or-stale" };
|
|
141
139
|
|
|
142
|
-
if (cfg.policy.preferToUseBankedReset.includes("codex") && active.needsReauth !== true) {
|
|
143
|
-
const seat = active;
|
|
144
|
-
const verdict = codexBankedResetVerdict({ account: seat, thresholds: bars, now, pollTtlMs: cfg.policy.usagePollTtlMs });
|
|
145
|
-
if (verdict === "hold") {
|
|
146
|
-
log("codexdecide.banked_reset_hold", { account: seat.accountId.slice(0, 8) });
|
|
147
|
-
return { swapped: false, account: seat, reason: "banked-reset-hold" };
|
|
148
|
-
}
|
|
149
|
-
if (verdict === "claim") {
|
|
150
|
-
const claim = await consumeLiveCodexBankedReset({ account: seat, now });
|
|
151
|
-
if (claim === "reset") {
|
|
152
|
-
try {
|
|
153
|
-
await sampleLiveOntoOwner({ now: Date.now() });
|
|
154
|
-
} catch (e) {
|
|
155
|
-
log("codexdecide.resample_failed", { err: e instanceof Error ? e.message : String(e) });
|
|
156
|
-
}
|
|
157
|
-
const fresh = loadCodexAccounts().accounts.find((account) => account.accountId === activeId) ?? seat;
|
|
158
|
-
if (fresh.lastUsage) postSwapResweep({ liveAccountId: activeId, bars, now });
|
|
159
|
-
return { swapped: false, account: fresh, reason: "banked-reset", reset: true };
|
|
160
|
-
}
|
|
161
|
-
}
|
|
162
|
-
}
|
|
163
|
-
|
|
164
140
|
if (!isCodexExhausted({ account: active, thresholds: bars, now })) {
|
|
165
141
|
while (true) {
|
|
166
142
|
const current = loadCodexAccounts();
|
package/src/lib/codexpick.ts
CHANGED
|
@@ -3,20 +3,20 @@ import { nextWeeklyReset } from "./picker.ts";
|
|
|
3
3
|
import { isSessionWindow, weeklyWindowOf } from "./codexusage.ts";
|
|
4
4
|
import type { CodexAccount, CodexWindow, Thresholds } from "./types.ts";
|
|
5
5
|
|
|
6
|
-
|
|
6
|
+
function liveUsed(input: { window: CodexWindow; now: number; sampledAt: number | null }): number {
|
|
7
7
|
const { window, now, sampledAt } = input;
|
|
8
8
|
if (window.resetsAt != null) return window.resetsAt <= now ? 0 : window.usedPercentage;
|
|
9
9
|
if (sampledAt != null && window.windowSeconds != null && now >= sampledAt + window.windowSeconds * 1000) return 0;
|
|
10
10
|
return window.usedPercentage;
|
|
11
11
|
}
|
|
12
12
|
|
|
13
|
-
|
|
13
|
+
function allWindows(account: CodexAccount): CodexWindow[] {
|
|
14
14
|
const usage = account.lastUsage;
|
|
15
15
|
if (!usage) return [];
|
|
16
16
|
return [...usage.aggregate, ...Object.values(usage.perLimit).flat()];
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
-
|
|
19
|
+
function barFor(input: { window: CodexWindow; thresholds: Thresholds }): number {
|
|
20
20
|
return isSessionWindow({ window: input.window }) ? input.thresholds.session : input.thresholds.weekly;
|
|
21
21
|
}
|
|
22
22
|
|
package/src/lib/codexusage.ts
CHANGED
|
@@ -25,8 +25,6 @@ const WireRateLimitSchema = z.looseObject({
|
|
|
25
25
|
secondary_window: WireWindowSchema.nullish(),
|
|
26
26
|
});
|
|
27
27
|
|
|
28
|
-
const WireReachedTypeSchema = z.union([z.string(), z.looseObject({ type: z.string().nullish(), kind: z.string().nullish() })]).nullish().catch(null);
|
|
29
|
-
|
|
30
28
|
const WireUsageSchema = z.looseObject({
|
|
31
29
|
account_id: z.string(),
|
|
32
30
|
email: z.string().nullish(),
|
|
@@ -35,8 +33,6 @@ const WireUsageSchema = z.looseObject({
|
|
|
35
33
|
additional_rate_limits: z
|
|
36
34
|
.array(z.looseObject({ limit_name: z.string(), rate_limit: WireRateLimitSchema.nullish() }))
|
|
37
35
|
.nullish(),
|
|
38
|
-
rate_limit_reset_credits: z.looseObject({ available_count: z.number().int().nullish() }).nullish(),
|
|
39
|
-
rate_limit_reached_type: WireReachedTypeSchema,
|
|
40
36
|
});
|
|
41
37
|
|
|
42
38
|
function toWindows(rateLimit: z.infer<typeof WireRateLimitSchema> | null | undefined): CodexWindow[] {
|
|
@@ -96,11 +92,6 @@ export async function fetchCodexUsage(input: { auth: CodexAuthJson }): Promise<C
|
|
|
96
92
|
planType: wire.plan_type ?? null,
|
|
97
93
|
aggregate: toWindows(wire.rate_limit),
|
|
98
94
|
perLimit,
|
|
99
|
-
resetCredits: wire.rate_limit_reset_credits?.available_count ?? null,
|
|
100
|
-
reachedType:
|
|
101
|
-
typeof wire.rate_limit_reached_type === "string"
|
|
102
|
-
? wire.rate_limit_reached_type
|
|
103
|
-
: (wire.rate_limit_reached_type?.type ?? wire.rate_limit_reached_type?.kind ?? null),
|
|
104
95
|
});
|
|
105
96
|
}
|
|
106
97
|
|