tokenmaxxing 0.8.0 → 0.10.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 +8 -8
- package/README.md +8 -6
- package/package.json +5 -2
- package/src/cli/init.ts +18 -2
- package/src/cli/status.ts +44 -11
- package/src/cli/switch.ts +5 -9
- package/src/entries/statusline.ts +5 -4
- package/src/lib/decide.ts +60 -11
- package/src/lib/picker.ts +40 -8
- package/src/lib/sample.ts +23 -9
- package/src/lib/state.ts +11 -5
- package/src/lib/types.ts +20 -1
- package/src/lib/usage.ts +110 -36
- package/src/main.ts +3 -1
package/DESIGN.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# tokenmaxxing - design
|
|
2
2
|
|
|
3
|
-
Automatic Claude Code account switching. You run `claude` exactly as always; when the active account crosses **98%**
|
|
3
|
+
Automatic Claude Code account switching. You run `claude` exactly as always; when the active account crosses its swap threshold (**95%** of the 5h session window, **98%** of a weekly window), tokenmaxxing swaps to a fresh account and - at the next safe turn boundary - **restarts your session resumed on it, automatically**. Works across many concurrent sessions at once.
|
|
4
4
|
|
|
5
5
|
> Scope: **Claude Code only, macOS first.** Codex and other CLIs deferred (see `.memory/cc-codex-auth-mechanics.md`).
|
|
6
6
|
>
|
|
@@ -10,9 +10,9 @@ Automatic Claude Code account switching. You run `claude` exactly as always; whe
|
|
|
10
10
|
|
|
11
11
|
## 1. Why there is a thin supervisor (and why that's the whole trick)
|
|
12
12
|
|
|
13
|
-
A **running** `claude`
|
|
13
|
+
A **running** `claude` DOES adopt an externally swapped credential (verified live 2026-07-10, correcting this document's original claim): an ensure-fresh poll re-reads the credential store around every request, so a swap lands within ~30s on macOS (raw keychain cache) and on the next request on Linux. What a swap alone cannot give you is a *clean cutover*: a mid-flight 429 retry keeps its already-snapshotted token, and an account already at the wall still needs the session paused until something resets.
|
|
14
14
|
|
|
15
|
-
|
|
15
|
+
So the supervisor's job is to **replace the process at a salvageable moment**: after a turn completes, the conversation is fully written to the transcript JSONL and `claude` is idle at the prompt - killing it there loses nothing, and a `claude --resume <session-id>` comes back cold on the new account and continues exactly where it left off. **This is why we swap below 100%: the headroom is the budget to reach a clean turn boundary and respawn before the account actually hits the wall.** The session window swaps at 95 (a 5h reset is cheap to sit out) while the weekly windows drain to 98 (weekly allowance is use-it-or-lose-it).
|
|
16
16
|
|
|
17
17
|
A hook can't do the respawn - when `claude` exits, the shell owns the terminal. So tokenmaxxing installs a **supervisor** (aliased to `claude`) that owns the process lifecycle:
|
|
18
18
|
|
|
@@ -47,7 +47,7 @@ No background daemon - it's event-driven (statusline pushes usage; hooks + super
|
|
|
47
47
|
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 shim tees that to `usage.json` (write-on-change, O(ms)) and passes your real statusline through unchanged. 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).
|
|
48
48
|
|
|
49
49
|
### 3.2 Detect + swap + signal (Stop hook, per turn)
|
|
50
|
-
1. Read `usage.json`; `exit 0` fast if
|
|
50
|
+
1. Read `usage.json`; `exit 0` fast if every window is under its threshold (metered per `organizationUuid`).
|
|
51
51
|
2. Else take a `flock` on `~/.config/tokenmaxxing/lock`, re-check under it (parallel sessions race - first winner already swapped), pick the best parked account (not rate-limited, furthest behind its own weekly pace first: highest remaining% / time-to-weekly-reset, since unused allowance is forfeited at the fixed per-account reset; tiebreak soonest expiry then lowest 7-day usage), and **swap the credential** (§3.4).
|
|
52
52
|
3. Write `respawn/<session_id>` (atomic temp+rename) and `SIGTERM` the parent `claude` (`kill -TERM $PPID`). The turn is already committed, so this is a clean stop.
|
|
53
53
|
|
|
@@ -62,7 +62,7 @@ The supervisor's `claude` call returns; it sees `respawn/<sid>`, deletes it, res
|
|
|
62
62
|
5. Do steps 1, 3, 4 inside Claude's own `~/.claude.lock` so the writes can't collide with a token refresh.
|
|
63
63
|
|
|
64
64
|
### 3.5 Multiple concurrent sessions
|
|
65
|
-
Each terminal ran the supervisor, so each has its own child `claude`, its own `--session-id`, and its own respawn marker. When the shared account hits
|
|
65
|
+
Each terminal ran the supervisor, so each has its own child `claude`, its own `--session-id`, and its own respawn marker. When the shared account hits a threshold, the first Stop hook to win the `flock` performs the one swap; **every** session's Stop hook writes its own respawn marker and SIGTERMs its own `claude`; **every** supervisor independently relaunches `claude --resume <its-own-sid>`. All of them come back on the new account, each continuing its own conversation. (They share one credential, so they always move together - consistent with "one current account, many windows.")
|
|
66
66
|
|
|
67
67
|
---
|
|
68
68
|
|
|
@@ -76,7 +76,7 @@ Each terminal ran the supervisor, so each has its own child `claude`, its own `-
|
|
|
76
76
|
---
|
|
77
77
|
|
|
78
78
|
## 5. Rotation policy
|
|
79
|
-
|
|
79
|
+
The decision engages at `five_hour >= 50%` (policy.greedySessionFloor): from there it greedily converges on the usable account furthest behind its weekly pace, staying put whenever the current account wins or ties. The hard bars - `five_hour >= 95%` OR `seven_day >= 98%`, per org - always force a switch and also screen candidates. "Exhausted" is a **timestamped state** (`resets_at`), not a flag - an account is a candidate again after it resets. Optional projected threshold (`bar - EMA(per-turn Δ%)`) so a single large turn can't blow past 100% before the next Stop hook.
|
|
80
80
|
|
|
81
81
|
**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.
|
|
82
82
|
|
|
@@ -85,7 +85,7 @@ Trigger at `five_hour >= 98%` OR `seven_day >= 98%`, per org. "Exhausted" is a *
|
|
|
85
85
|
## 6. Honest papercuts
|
|
86
86
|
- **Respawn hiccup.** At the swap turn you see `claude` restart (~1–2s) and resume. Anything you typed in the split second before respawn is lost - mitigate by respawning fast and showing a clear "switching" state; the supervisor resets terminal mode so nothing is left garbled.
|
|
87
87
|
- **One cold turn.** Prompt cache is org-scoped: the first turn after resuming on B re-uploads context once (bigger on long transcripts).
|
|
88
|
-
- **Single-turn overshoot.** If one turn jumps from
|
|
88
|
+
- **Single-turn overshoot.** If one turn jumps from under the threshold straight past the wall, that turn can end rate-limited before the Stop hook swaps; the respawn then still recovers it. Projected threshold reduces this.
|
|
89
89
|
- **Shared blast radius.** All default-profile sessions share one keychain item, so a swap moves them all (each via its own respawn). The `flock` + re-check is mandatory or racing hooks burn two accounts at once.
|
|
90
90
|
- **Refresh-token rotation / parked-token rot.** Step 1 re-harvest is mandatory; a parked refresh token can be invalidated by logging in elsewhere → picker must catch `invalid_grant`, mark `needs_reauth`, fall through.
|
|
91
91
|
- **statusLine fragility.** The shim is the most visible surface - a bug flickers or breaks your real status line. Keep it O(ms), write-on-change.
|
|
@@ -95,7 +95,7 @@ Trigger at `five_hour >= 98%` OR `seven_day >= 98%`, per org. "Exhausted" is a *
|
|
|
95
95
|
---
|
|
96
96
|
|
|
97
97
|
## 7. Scope
|
|
98
|
-
**v1:** `tokenmaxxing init` / `add` / `ls` / `status` / `doctor`; the supervisor; statusLine shim + Stop/SessionStart hooks;
|
|
98
|
+
**v1:** `tokenmaxxing init` / `add` / `ls` / `status` / `doctor`; the supervisor; statusLine shim + Stop/SessionStart hooks; threshold swap with `flock` + reset-aware picker; platform credential store (macOS keychain / Linux 0600 files, one facade); auto-respawn across concurrent sessions. macOS + Linux.
|
|
99
99
|
|
|
100
100
|
**v2:** projected-threshold pre-emption; a `UserPromptSubmit` guard that respawns *before* a turn starts when already over; Windows.
|
|
101
101
|
|
package/README.md
CHANGED
|
@@ -13,7 +13,7 @@ $ claude
|
|
|
13
13
|
|
|
14
14
|
## Why
|
|
15
15
|
|
|
16
|
-
A running `claude`
|
|
16
|
+
A running `claude` re-checks the credential store between requests, so a swapped credential is adopted in-place (within ~30s on macOS, the next request on Linux). tokenmaxxing performs the swap at a committed turn boundary and **respawns** `claude --resume <id>` (the transcript is already on disk, so nothing is lost) - the respawn is what gives you a clean cutover and, when the whole pool is depleted, a countdown that auto-resumes at the soonest reset. A thin `claude` supervisor on your PATH owns that respawn; everything else about `claude` is unchanged - all flags, MCP, hooks, and skills pass through.
|
|
17
17
|
|
|
18
18
|
## Install
|
|
19
19
|
|
|
@@ -39,20 +39,21 @@ claude # use claude as always
|
|
|
39
39
|
| `tokenmaxxing add` | register an additional account (isolated login, harvested into the pool) |
|
|
40
40
|
| `tokenmaxxing ls` | list pooled accounts |
|
|
41
41
|
| `tokenmaxxing status` | accounts with 5h / weekly usage bars, active + exhausted-until-reset |
|
|
42
|
+
| `tokenmaxxing status --force` | additionally ping every account (one tiny haiku request each) so all 5h session timers start now, then sample fresh |
|
|
42
43
|
| `tokenmaxxing doctor` | verify the supervisor + settings entries survived |
|
|
43
44
|
| `tokenmaxxing rename <sel> <label>` · `rm <sel>` | manage the pool |
|
|
44
45
|
| `tokenmaxxing uninstall` | remove supervisor + settings entries (accounts/credentials kept) |
|
|
45
46
|
|
|
46
47
|
## How switching decides
|
|
47
48
|
|
|
48
|
-
Switching
|
|
49
|
+
Switching engages (configurable) once the active account's 5-hour session window is **50% used** - from there, every evaluation greedily converges on the usable account **furthest behind its own weekly pace**, and does nothing when the current account already wins. Independent of that, crossing a hard screening bar - **95%** session or **98%** weekly - always forces a switch. The bars also screen candidates on any of:
|
|
49
50
|
|
|
50
51
|
- **Session** (5-hour) or **week (all models)** - the aggregate windows, fed free/push-based by the statusLine.
|
|
51
52
|
- **Per-model weekly cap** - the most capable model (Fable) has its own tighter weekly limit that binds *before* the aggregate (per-model caps currently exist only for Sonnet and Fable, and Sonnet's is generous). tokenmaxxing reads it from `claude -p '/usage'` (free, 0 tokens, TTL-cached) whenever the active model is one of `policy.switchModels`, so a Fable session switches on the Fable cap while a Sonnet session rides the aggregate.
|
|
52
53
|
|
|
53
|
-
The
|
|
54
|
+
The bars' headroom is deliberate: it's the budget to reach a clean turn boundary and respawn before the wall. The session bar sits lower (95) because a 5-hour reset is cheap to sit out; weekly quota is use-it-or-lose-it, so it drains closer to the wall (98). The greedy engagement floor sits far below both: weekly allowance is forfeited at each account's fixed reset, so once half a session window justifies the swap, quota is best burned on whichever account has the most at risk.
|
|
54
55
|
|
|
55
|
-
The **target** is chosen greedily off each account's cached windows:
|
|
56
|
+
The **target** is chosen greedily off each account's cached windows: among usable accounts (every window under its bar, or past its reset), the one **furthest behind its own weekly pace** - highest remaining% divided by time to its weekly reset - because unused weekly allowance is forfeited at the fixed per-account reset. Cached resets are absolute UTC epochs, so a stale snapshot still resolves correctly: a weekly reset that has passed extrapolates forward in 7-day steps, and a session window past its reset counts as empty. Both `tokenmaxxing switch` and the automatic path rank the current account too and do nothing when it already wins, so they are idempotent - evaluating periodically converges on the right account.
|
|
56
57
|
|
|
57
58
|
## Configuration
|
|
58
59
|
|
|
@@ -60,16 +61,17 @@ The **target** is chosen greedily off each account's cached windows: the usable
|
|
|
60
61
|
|
|
61
62
|
```json
|
|
62
63
|
{
|
|
63
|
-
"
|
|
64
|
+
"thresholds": { "session": 95, "weekly": 98 },
|
|
64
65
|
"policy": {
|
|
65
66
|
"projectionMargin": 0,
|
|
67
|
+
"greedySessionFloor": 50,
|
|
66
68
|
"switchModels": ["fable"],
|
|
67
69
|
"usagePollTtlMs": 90000
|
|
68
70
|
}
|
|
69
71
|
}
|
|
70
72
|
```
|
|
71
73
|
|
|
72
|
-
`projectionMargin` subtracts an EMA of per-turn Δ% for pre-emption; `switchModels` names the models whose per-model cap triggers a switch; `usagePollTtlMs` is how long a `/usage` per-model poll stays fresh.
|
|
74
|
+
`projectionMargin` subtracts an EMA of per-turn Δ% for pre-emption; `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.
|
|
73
75
|
|
|
74
76
|
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`).
|
|
75
77
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tokenmaxxing",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.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",
|
|
@@ -21,7 +21,10 @@
|
|
|
21
21
|
"engines": {
|
|
22
22
|
"bun": ">=1.2.6"
|
|
23
23
|
},
|
|
24
|
-
"os": [
|
|
24
|
+
"os": [
|
|
25
|
+
"darwin",
|
|
26
|
+
"linux"
|
|
27
|
+
],
|
|
25
28
|
"scripts": {
|
|
26
29
|
"dev": "bun run src/main.ts",
|
|
27
30
|
"test": "bun test",
|
package/src/cli/init.ts
CHANGED
|
@@ -30,6 +30,20 @@ function reportTimer(out: InstallOutcome): void {
|
|
|
30
30
|
else console.log(c.yellow(`⚠ check timer written but not activated - run: ${timerActivationHint()}`));
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
+
/** The how-to-use epilogue both init paths end on: the whole point of the tool
|
|
34
|
+
* is that after init you just run `claude`, so say exactly that, and teach the
|
|
35
|
+
* `xx` shorthand every other command hangs off. Exported for the render test. */
|
|
36
|
+
export function printUsage(): void {
|
|
37
|
+
console.log();
|
|
38
|
+
console.log(` ${c.bold("how to use")} - ${c.cyan("xx")} is shorthand for ${c.cyan("tokenmaxxing")}:`);
|
|
39
|
+
console.log(` ${c.cyan("claude")} use claude as always; it switches accounts near quota automatically`);
|
|
40
|
+
console.log(` ${c.cyan("xx")} show the pool with usage bars (same as ${c.cyan("xx status")})`);
|
|
41
|
+
console.log(` ${c.cyan("xx status --force")} ping every account (one tiny haiku request each) so all 5h timers start now, then sample fresh`);
|
|
42
|
+
console.log(` ${c.cyan("xx add")} log in and pool another account`);
|
|
43
|
+
console.log(` ${c.cyan("xx switch")} hop to the best account right now (the automatic switching needs no command)`);
|
|
44
|
+
console.log(` ${c.cyan("xx help")} everything else`);
|
|
45
|
+
}
|
|
46
|
+
|
|
33
47
|
export async function cmdInit(): Promise<number> {
|
|
34
48
|
mkdirSync(paths.home, { recursive: true });
|
|
35
49
|
|
|
@@ -50,7 +64,8 @@ export async function cmdInit(): Promise<number> {
|
|
|
50
64
|
console.log(`${c.green("✓")} re-installed supervisor + hooks (pool already has ${existingIdx.accounts.length} account${existingIdx.accounts.length === 1 ? "" : "s"} - not re-importing)`);
|
|
51
65
|
reportTimer(out);
|
|
52
66
|
if (!out.pathAhead) ensurePathAhead();
|
|
53
|
-
console.log(` active: ${c.bold(active?.label ?? "unknown")}
|
|
67
|
+
console.log(` active: ${c.bold(active?.label ?? "unknown")}`);
|
|
68
|
+
printUsage();
|
|
54
69
|
return 0;
|
|
55
70
|
}
|
|
56
71
|
|
|
@@ -127,6 +142,7 @@ export async function cmdInit(): Promise<number> {
|
|
|
127
142
|
ensurePathAhead();
|
|
128
143
|
}
|
|
129
144
|
console.log();
|
|
130
|
-
console.log(` pool ready (${idx.accounts.length} account${idx.accounts.length === 1 ? "" : "s"})
|
|
145
|
+
console.log(` pool ready (${idx.accounts.length} account${idx.accounts.length === 1 ? "" : "s"})`);
|
|
146
|
+
printUsage();
|
|
131
147
|
return 0;
|
|
132
148
|
}
|
package/src/cli/status.ts
CHANGED
|
@@ -4,18 +4,25 @@
|
|
|
4
4
|
// own busy token. A sample that fails falls back to the last-known values with a
|
|
5
5
|
// visible "(cached)" note - never a silent stale number. Fresh figures are
|
|
6
6
|
// persisted onto each account for the picker/switch logic.
|
|
7
|
+
//
|
|
8
|
+
// `--force` additionally PINGS every account (one minimal haiku request each)
|
|
9
|
+
// before sampling, so every account's 5h session window starts ticking NOW
|
|
10
|
+
// instead of lying dormant until first real use, and every bar is a live probe
|
|
11
|
+
// taken after the ping. The active account still prefers the tee only as a
|
|
12
|
+
// fallback: its own `/usage` fail-silents exactly when a live session is
|
|
13
|
+
// running it, and that session's tee is fresher than any cache.
|
|
7
14
|
|
|
8
15
|
import { loadAccounts, loadConfig, loadUsage, loadModelUsage, saveAccounts } from "../lib/state.ts";
|
|
9
16
|
import { readOAuthAccount } from "../lib/claudejson.ts";
|
|
10
17
|
import { probeActiveUsage, probeParkedUsage, type SampleOutcome } from "../lib/sample.ts";
|
|
11
18
|
import { withLock } from "../lib/lock.ts";
|
|
12
19
|
import { paths } from "../lib/paths.ts";
|
|
13
|
-
import { isExhausted, nextWeeklyReset } from "../lib/picker.ts";
|
|
20
|
+
import { effectiveBars, isExhausted, nextWeeklyReset } from "../lib/picker.ts";
|
|
14
21
|
import { bar, c, fmtAgo, fmtReset } from "./render.ts";
|
|
15
22
|
import type { FullUsage } from "../lib/usage.ts";
|
|
16
23
|
import type { UsageWindow } from "../lib/types.ts";
|
|
17
24
|
|
|
18
|
-
export async function cmdStatus(): Promise<number> {
|
|
25
|
+
export async function cmdStatus(force = false): Promise<number> {
|
|
19
26
|
let idx = loadAccounts();
|
|
20
27
|
const cfg = loadConfig();
|
|
21
28
|
const now = Date.now();
|
|
@@ -28,7 +35,7 @@ export async function cmdStatus(): Promise<number> {
|
|
|
28
35
|
// Load, sample, and save entirely under the flock: parked refreshes must not
|
|
29
36
|
// collide with an in-flight swap, and a save of an index loaded before a
|
|
30
37
|
// concurrent swap would clobber the swap's activeAccountUuid.
|
|
31
|
-
console.error(c.dim("sampling live usage..."));
|
|
38
|
+
console.error(c.dim(force ? "pinging every account (starts each 5h session timer) + sampling live usage..." : "sampling live usage..."));
|
|
32
39
|
const outcomes = new Map<string, SampleOutcome>();
|
|
33
40
|
await withLock(paths.lockFile, async () => {
|
|
34
41
|
idx = loadAccounts();
|
|
@@ -49,24 +56,40 @@ export async function cmdStatus(): Promise<number> {
|
|
|
49
56
|
perModel: modelUsage && modelUsage.org === a.organizationUuid ? modelUsage.perModel : {},
|
|
50
57
|
}
|
|
51
58
|
: null;
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
59
|
+
let viaTee = false;
|
|
60
|
+
let outcome: SampleOutcome;
|
|
61
|
+
if (force) {
|
|
62
|
+
// Force: ping + live probe for everyone. The tee predates the ping,
|
|
63
|
+
// so it serves only as the active account's fallback when its own
|
|
64
|
+
// `/usage` fail-silents (a running session's tee is still fresh).
|
|
65
|
+
outcome = isActive ? await probeActiveUsage(a, { ping: true }) : await probeParkedUsage(a, { ping: true });
|
|
66
|
+
if (!outcome.ok && fromStatusLine) {
|
|
67
|
+
const failed = outcome;
|
|
68
|
+
outcome = { ok: true, usage: fromStatusLine };
|
|
69
|
+
if (failed.pingError != null) outcome.pingError = failed.pingError;
|
|
70
|
+
viaTee = true;
|
|
71
|
+
}
|
|
72
|
+
} else {
|
|
73
|
+
viaTee = fromStatusLine != null;
|
|
74
|
+
outcome = fromStatusLine
|
|
75
|
+
? { ok: true, usage: fromStatusLine }
|
|
76
|
+
: isActive
|
|
77
|
+
? await probeActiveUsage(a)
|
|
78
|
+
: await probeParkedUsage(a);
|
|
79
|
+
}
|
|
57
80
|
outcomes.set(a.accountUuid, outcome);
|
|
58
81
|
if (!outcome.ok) return;
|
|
59
82
|
a.lastUsage = { fiveHour: outcome.usage.session, sevenDay: outcome.usage.weekAll };
|
|
60
83
|
if (Object.keys(outcome.usage.perModel).length > 0) a.lastPerModel = outcome.usage.perModel;
|
|
61
84
|
// stamp when the figures were actually measured: the statusLine tee's
|
|
62
85
|
// own write time for the push-fed active account, else the probe time.
|
|
63
|
-
a.lastUsageAt =
|
|
86
|
+
a.lastUsageAt = viaTee && live ? live.ts : Date.now();
|
|
64
87
|
}),
|
|
65
88
|
);
|
|
66
89
|
saveAccounts(idx);
|
|
67
90
|
});
|
|
68
91
|
|
|
69
|
-
console.log(c.dim(`threshold ${cfg.
|
|
92
|
+
console.log(c.dim(`threshold 5h ${cfg.thresholds.session}% · week ${cfg.thresholds.weekly}% · ${idx.accounts.length} account(s)`));
|
|
70
93
|
console.log();
|
|
71
94
|
|
|
72
95
|
// A window whose cached reset has passed is empty again; weekly windows recur
|
|
@@ -92,7 +115,7 @@ export async function cmdStatus(): Promise<number> {
|
|
|
92
115
|
const badges: string[] = [];
|
|
93
116
|
if (active) badges.push(c.green("active"));
|
|
94
117
|
if (a.needsReauth) badges.push(c.red("needs-reauth"));
|
|
95
|
-
if (isExhausted(a, { now,
|
|
118
|
+
if (isExhausted(a, { now, thresholds: effectiveBars(cfg), currentAccountUuid: idx.activeAccountUuid, switchFamilies: cfg.policy.switchModels }))
|
|
96
119
|
badges.push(c.yellow("exhausted"));
|
|
97
120
|
|
|
98
121
|
console.log(`${marker} ${c.bold(a.label || a.email)} ${badges.join(" ")}`);
|
|
@@ -105,6 +128,16 @@ export async function cmdStatus(): Promise<number> {
|
|
|
105
128
|
const cached = aggregate || perModel ? `cached${a.lastUsageAt != null ? ` ${fmtAgo(a.lastUsageAt, now)}` : ""} · ` : "";
|
|
106
129
|
console.log(` ${c.yellow(`${cached}live sample failed`)}: ${c.dim(outcome.reason)}`);
|
|
107
130
|
}
|
|
131
|
+
if (outcome?.pingError != null) {
|
|
132
|
+
console.log(` ${c.yellow("ping failed (5h timer may not have started)")}: ${c.dim(outcome.pingError)}`);
|
|
133
|
+
}
|
|
134
|
+
// A successful ping ALWAYS opens the 5h window (live-verified 2026-07-16),
|
|
135
|
+
// but the server's usage feed reflects it with a lag of up to a few
|
|
136
|
+
// minutes, so a probe taken seconds later can still show a dormant window.
|
|
137
|
+
// Say so rather than looking like the ping did nothing.
|
|
138
|
+
if (force && outcome?.ok && outcome.pingError == null && aggregate && aggregate.fiveHour.resetsAt == null) {
|
|
139
|
+
console.log(` ${c.dim("pinged - 5h timer started this run; the usage feed lags, re-run status shortly for the fresh window")}`);
|
|
140
|
+
}
|
|
108
141
|
console.log();
|
|
109
142
|
}
|
|
110
143
|
return 0;
|
package/src/cli/switch.ts
CHANGED
|
@@ -19,7 +19,7 @@ import { paths } from "../lib/paths.ts";
|
|
|
19
19
|
import { loadAccounts, loadConfig } from "../lib/state.ts";
|
|
20
20
|
import { readOAuthAccount } from "../lib/claudejson.ts";
|
|
21
21
|
import { performSwap, chooseAndSwap } from "../lib/swap.ts";
|
|
22
|
-
import {
|
|
22
|
+
import { currentWins, effectiveBars, pickEarliestReset, weeklyExpiry, type PickCtx } from "../lib/picker.ts";
|
|
23
23
|
import { InvalidGrantError } from "../lib/oauth.ts";
|
|
24
24
|
import { findAccount } from "./rename.ts";
|
|
25
25
|
import { c, fmtReset } from "./render.ts";
|
|
@@ -60,21 +60,17 @@ export async function cmdSwitch(selector?: string): Promise<number> {
|
|
|
60
60
|
|
|
61
61
|
// auto: greedy over everyone, current included - a no-op when current wins.
|
|
62
62
|
// No session context here, so every configured per-model family gates.
|
|
63
|
-
const everyone: PickCtx = { now,
|
|
63
|
+
const everyone: PickCtx = { now, thresholds: effectiveBars(cfg), currentAccountUuid: null, switchFamilies: cfg.policy.switchModels };
|
|
64
64
|
const active = idx.accounts.find((a) => a.accountUuid === idx.activeAccountUuid) ?? null;
|
|
65
|
-
|
|
66
|
-
const currentWins =
|
|
67
|
-
best != null && active != null && !active.needsReauth && !isExhausted(active, everyone) &&
|
|
68
|
-
(best.accountUuid === active.accountUuid || swapPreference(now).every((k) => k(active) === k(best)));
|
|
69
|
-
if (currentWins) {
|
|
65
|
+
if (active != null && currentWins(active, idx.accounts, everyone)) {
|
|
70
66
|
if (drifted) return swapTo(active);
|
|
71
67
|
const expiry = weeklyExpiry(active, now);
|
|
72
68
|
const why = Number.isFinite(expiry) ? ` (weekly ${fmtReset(expiry, now)})` : "";
|
|
73
69
|
console.log(`already on the best account: ${c.bold(active.label)}${why}`);
|
|
74
70
|
return 0;
|
|
75
71
|
}
|
|
76
|
-
|
|
77
|
-
const landed = await chooseAndSwap({ now,
|
|
72
|
+
{
|
|
73
|
+
const landed = await chooseAndSwap({ now, thresholds: effectiveBars(cfg), switchFamilies: cfg.policy.switchModels });
|
|
78
74
|
if (landed) {
|
|
79
75
|
console.log(`${c.green("↻")} switched to ${c.bold(landed.label)}`);
|
|
80
76
|
return 0;
|
|
@@ -18,12 +18,13 @@ import { z } from "zod";
|
|
|
18
18
|
import { readOAuthAccount } from "../lib/claudejson.ts";
|
|
19
19
|
import { loadAccounts, loadConfig, loadLastSwapAt, loadModelUsage, writeUsage } from "../lib/state.ts";
|
|
20
20
|
import { familyTokens, gatedFamilies, matchedFamily, parseStatusLineStdin, parseStatusLineModel } from "../lib/usage.ts";
|
|
21
|
-
import { isExhausted, swapPreference, weeklyExpiry } from "../lib/picker.ts";
|
|
21
|
+
import { effectiveBars, isExhausted, swapPreference, weeklyExpiry } from "../lib/picker.ts";
|
|
22
22
|
import { worktreeName } from "../lib/worktree.ts";
|
|
23
23
|
import { fmtResetShort, makeColors } from "../cli/render.ts";
|
|
24
24
|
import {
|
|
25
25
|
AccountsIndexSchema,
|
|
26
26
|
StatusLineStdinSchema,
|
|
27
|
+
ThresholdsSchema,
|
|
27
28
|
UsageWindowSchema,
|
|
28
29
|
type Account,
|
|
29
30
|
type UsageState,
|
|
@@ -41,7 +42,7 @@ const RenderCtxSchema = z.object({
|
|
|
41
42
|
perModel: z.record(z.string(), UsageWindowSchema),
|
|
42
43
|
/** families (lowercased) whose per-model weekly cap gates a switch. */
|
|
43
44
|
switchModels: z.array(z.string()),
|
|
44
|
-
|
|
45
|
+
thresholds: ThresholdsSchema,
|
|
45
46
|
/** linked-worktree basename, null in a main checkout. */
|
|
46
47
|
worktree: z.string().nullable(),
|
|
47
48
|
now: z.number(),
|
|
@@ -107,7 +108,7 @@ export function renderStatusline(stdinObj: unknown, ctx: RenderCtx): string {
|
|
|
107
108
|
// ---- parked accounts, in swap order: the first usable ◇ is the next target
|
|
108
109
|
const pickCtx = {
|
|
109
110
|
now: ctx.now,
|
|
110
|
-
|
|
111
|
+
thresholds: ctx.thresholds,
|
|
111
112
|
currentAccountUuid: ctx.accounts.activeAccountUuid,
|
|
112
113
|
switchFamilies: gatedFamilies(parseStatusLineModel(stdinObj), ctx.switchModels),
|
|
113
114
|
};
|
|
@@ -181,7 +182,7 @@ export async function runStatusline(): Promise<number> {
|
|
|
181
182
|
accounts: loadAccounts(),
|
|
182
183
|
perModel: modelUsage && modelUsage.org === org ? modelUsage.perModel : {},
|
|
183
184
|
switchModels: cfg.policy.switchModels,
|
|
184
|
-
|
|
185
|
+
thresholds: effectiveBars(cfg),
|
|
185
186
|
worktree: dir == null ? null : worktreeName(dir),
|
|
186
187
|
now,
|
|
187
188
|
color: !process.env.NO_COLOR,
|
package/src/lib/decide.ts
CHANGED
|
@@ -2,7 +2,14 @@
|
|
|
2
2
|
// `check` timer. Cheap pre-check off the lock; the authoritative re-check + swap
|
|
3
3
|
// under the flock.
|
|
4
4
|
//
|
|
5
|
-
//
|
|
5
|
+
// The decision ENGAGES (user policy 2026-07-16) once the active account's 5h
|
|
6
|
+
// session window reaches policy.greedySessionFloor, or once any screening bar
|
|
7
|
+
// is crossed. Engaged-but-under-every-bar runs the same greedy pace-pressure
|
|
8
|
+
// convergence as bare `xx switch` (swap only onto a STRICTLY better usable
|
|
9
|
+
// account, current keeps its seat on ties, never a depleted pre-park); over a
|
|
10
|
+
// bar keeps the original hard semantics (swap or depleted-wait).
|
|
11
|
+
//
|
|
12
|
+
// The windows feeding that, all metered against the CURRENTLY-active org:
|
|
6
13
|
// 1. AGGREGATE windows (session=five_hour, week-all=seven_day). A rendering
|
|
7
14
|
// statusLine tees them fresh every turn; when nothing renders (headless
|
|
8
15
|
// boxes, idle TUIs) they come from `claude -p '/usage'`, re-probed once the
|
|
@@ -23,7 +30,7 @@ import { paths } from "./paths.ts";
|
|
|
23
30
|
import { loadAccounts, loadConfig, loadLastSwapAt, loadUsage, loadModelUsage, saveAccounts, saveModelUsage, usageTeeAt, writeUsage } from "./state.ts";
|
|
24
31
|
import { readOAuthAccount } from "./claudejson.ts";
|
|
25
32
|
import { chooseAndSwap, performSwap } from "./swap.ts";
|
|
26
|
-
import { pickEarliestReset, usableAt } from "./picker.ts";
|
|
33
|
+
import { currentWins, effectiveBars, pickBest, pickEarliestReset, usableAt } from "./picker.ts";
|
|
27
34
|
import { InvalidGrantError } from "./oauth.ts";
|
|
28
35
|
import { familyTokens, gatedFamilies, probeUsage } from "./usage.ts";
|
|
29
36
|
import { log } from "./log.ts";
|
|
@@ -54,14 +61,18 @@ function capForFamily(mu: ModelUsageState, family: string, now: number): UsageWi
|
|
|
54
61
|
return maxBy(rows, (w) => liveUsed(w, now));
|
|
55
62
|
}
|
|
56
63
|
|
|
57
|
-
/** True if the active account is over
|
|
58
|
-
|
|
64
|
+
/** True if the active account is over its floor on ANY screening bar: the 5h
|
|
65
|
+
* session against thresholds.session, the 7-day aggregate and gated per-model
|
|
66
|
+
* caps against thresholds.weekly. Over a bar means the hard path (swap away
|
|
67
|
+
* or depleted-wait); the greedy path may fire well before this. */
|
|
68
|
+
function isOver(u: UsageState | null, mu: ModelUsageState | null, org: string | null, cfg: Config, now: number): boolean {
|
|
59
69
|
if (!u || !org || u.org !== org) return false;
|
|
60
|
-
|
|
70
|
+
const bars = effectiveBars(cfg);
|
|
71
|
+
if (liveUsed(u.fiveHour, now) >= bars.session || liveUsed(u.sevenDay, now) >= bars.weekly) return true;
|
|
61
72
|
if (mu && mu.org === org) {
|
|
62
73
|
for (const family of gatedFamilies(u.model, cfg.policy.switchModels)) {
|
|
63
74
|
const cap = capForFamily(mu, family, now);
|
|
64
|
-
if (cap && liveUsed(cap, now) >=
|
|
75
|
+
if (cap && liveUsed(cap, now) >= bars.weekly) return true;
|
|
65
76
|
}
|
|
66
77
|
}
|
|
67
78
|
return false;
|
|
@@ -72,6 +83,14 @@ function needsPerModel(u: UsageState | null, cfg: Config): boolean {
|
|
|
72
83
|
return u != null && gatedFamilies(u.model, cfg.policy.switchModels).length > 0;
|
|
73
84
|
}
|
|
74
85
|
|
|
86
|
+
/** Whether the decision engages at all: half a session window buys the swap
|
|
87
|
+
* (greedySessionFloor), and a crossed screening bar always does. Below both,
|
|
88
|
+
* a fresh session rides its account - no churn. */
|
|
89
|
+
function isEngaged(u: UsageState | null, mu: ModelUsageState | null, org: string | null, cfg: Config, now: number): boolean {
|
|
90
|
+
if (!u || !org || u.org !== org) return false;
|
|
91
|
+
return liveUsed(u.fiveHour, now) >= cfg.policy.greedySessionFloor || isOver(u, mu, org, cfg, now);
|
|
92
|
+
}
|
|
93
|
+
|
|
75
94
|
const SnapshotsSchema = z.object({
|
|
76
95
|
u: UsageStateSchema.nullable(),
|
|
77
96
|
mu: ModelUsageStateSchema.nullable(),
|
|
@@ -155,13 +174,12 @@ export async function evaluateAndMaybeSwap(now = Date.now(), anticipatory = fals
|
|
|
155
174
|
}
|
|
156
175
|
|
|
157
176
|
const cfg = loadConfig();
|
|
158
|
-
const floor = cfg.threshold - cfg.policy.projectionMargin;
|
|
159
177
|
const activeOrg = readOAuthAccount()?.organizationUuid ?? null;
|
|
160
178
|
|
|
161
179
|
const { u: usage, mu } = await loadFreshSnapshots(cfg, activeOrg, now);
|
|
162
180
|
|
|
163
181
|
// cheap pre-check off the lock - the common case exits here.
|
|
164
|
-
if (!
|
|
182
|
+
if (!isEngaged(usage, mu, activeOrg, cfg, now)) {
|
|
165
183
|
return { swapped: false, account: null, reason: "under-threshold-or-stale" };
|
|
166
184
|
}
|
|
167
185
|
|
|
@@ -185,20 +203,51 @@ export async function evaluateAndMaybeSwap(now = Date.now(), anticipatory = fals
|
|
|
185
203
|
}
|
|
186
204
|
}
|
|
187
205
|
|
|
188
|
-
if (!
|
|
206
|
+
if (!isEngaged(u2, mu2, org2, cfg, now)) {
|
|
189
207
|
return { swapped: false, account: null, reason: "raced-already-swapped" };
|
|
190
208
|
}
|
|
191
209
|
|
|
192
210
|
// Candidates are screened by the same families that drove this decision, so
|
|
193
211
|
// the pool cannot ping-pong onto an account the gate would immediately flag.
|
|
194
212
|
const switchFamilies = gatedFamilies(u2?.model ?? null, cfg.policy.switchModels);
|
|
195
|
-
|
|
213
|
+
|
|
214
|
+
// Greedy path: engaged but under every screening bar. Converge like bare
|
|
215
|
+
// `xx switch` - swap only onto a strictly better usable account - and stay
|
|
216
|
+
// put otherwise: with a usable current account, a depleted pre-park or wait
|
|
217
|
+
// would trade a working session for nothing. NOT chooseAndSwap: its dead-
|
|
218
|
+
// token fallback lands on the next usable candidate unconditionally, but
|
|
219
|
+
// here the fallback must ALSO strictly beat the current account, or a dead
|
|
220
|
+
// refresh token on the winner would bounce a healthy session onto a worse
|
|
221
|
+
// account and back. performSwap marks needs-reauth before throwing, so each
|
|
222
|
+
// reload re-ranks without the dead account and the loop must terminate.
|
|
223
|
+
if (!isOver(u2, mu2, org2, cfg, now)) {
|
|
224
|
+
const ctxAll = { now, thresholds: effectiveBars(cfg), currentAccountUuid: null, switchFamilies };
|
|
225
|
+
while (true) {
|
|
226
|
+
const cur = loadAccounts();
|
|
227
|
+
const active = cur.accounts.find((a) => a.accountUuid === cur.activeAccountUuid) ?? null;
|
|
228
|
+
if (currentWins(active, cur.accounts, ctxAll)) {
|
|
229
|
+
return { swapped: false, account: null, reason: "current-best" };
|
|
230
|
+
}
|
|
231
|
+
const best = pickBest(cur.accounts, { ...ctxAll, currentAccountUuid: cur.activeAccountUuid });
|
|
232
|
+
if (!best) return { swapped: false, account: null, reason: "no-usable-target" };
|
|
233
|
+
try {
|
|
234
|
+
await performSwap(best);
|
|
235
|
+
} catch (e) {
|
|
236
|
+
if (e instanceof InvalidGrantError) continue;
|
|
237
|
+
throw e;
|
|
238
|
+
}
|
|
239
|
+
log("decide.greedy_swap", { account: best.accountUuid.slice(0, 8) });
|
|
240
|
+
return { swapped: true, account: best, reason: "swapped" };
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const landed = await chooseAndSwap({ now, thresholds: effectiveBars(cfg), switchFamilies });
|
|
196
245
|
if (landed) return { swapped: true, account: landed, reason: "swapped" };
|
|
197
246
|
|
|
198
247
|
// Every account is depleted. Wait for whichever recovers soonest (including the
|
|
199
248
|
// current one), if that reset is within the auto-wait window.
|
|
200
249
|
const fresh = loadAccounts();
|
|
201
|
-
const ctx = { now,
|
|
250
|
+
const ctx = { now, thresholds: effectiveBars(cfg), currentAccountUuid: fresh.activeAccountUuid, switchFamilies };
|
|
202
251
|
const current = fresh.accounts.find((a) => a.accountUuid === fresh.activeAccountUuid);
|
|
203
252
|
const currentAt = current ? usableAt(current, ctx) : Number.POSITIVE_INFINITY;
|
|
204
253
|
const other = pickEarliestReset(fresh.accounts, ctx);
|
package/src/lib/picker.ts
CHANGED
|
@@ -13,11 +13,23 @@
|
|
|
13
13
|
import { minBy, sortBy } from "es-toolkit";
|
|
14
14
|
import { z } from "zod";
|
|
15
15
|
import { familyTokens } from "./usage.ts";
|
|
16
|
-
import { AccountSchema, type Account, type UsageWindow } from "./types.ts";
|
|
16
|
+
import { AccountSchema, ThresholdsSchema, type Account, type Config, type Thresholds, type UsageWindow } from "./types.ts";
|
|
17
|
+
|
|
18
|
+
/** The effective per-window bars: configured thresholds minus the projection
|
|
19
|
+
* margin. ONE bar per window for BOTH the trigger and candidate screening -
|
|
20
|
+
* a screen laxer than the trigger would land swaps on accounts the trigger
|
|
21
|
+
* immediately re-flags (cooldown-throttled ping-pong). Every PickCtx and the
|
|
22
|
+
* trigger floors must be built from this. */
|
|
23
|
+
export function effectiveBars(cfg: Config): Thresholds {
|
|
24
|
+
return {
|
|
25
|
+
session: cfg.thresholds.session - cfg.policy.projectionMargin,
|
|
26
|
+
weekly: cfg.thresholds.weekly - cfg.policy.projectionMargin,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
17
29
|
|
|
18
30
|
const PickCtxSchema = z.object({
|
|
19
31
|
now: z.number(),
|
|
20
|
-
|
|
32
|
+
thresholds: ThresholdsSchema,
|
|
21
33
|
/** account to exclude (hooks switch AWAY from it); null ranks everyone. */
|
|
22
34
|
currentAccountUuid: z.string().nullable(),
|
|
23
35
|
/** families whose per-model weekly cap counts toward exhaustion (from
|
|
@@ -43,23 +55,31 @@ const FIVE_HOURS_MS = 5 * 60 * 60 * 1000;
|
|
|
43
55
|
* certainly reset - so a benched account always recovers by itself. With no
|
|
44
56
|
* sample time either, it blocks indefinitely: guessing "usable now" would
|
|
45
57
|
* swap onto it with waitUntil=now and churn kill/respawn. */
|
|
46
|
-
function blockedUntil(w: UsageWindow, windowMs: number, sampledAt: number | undefined,
|
|
47
|
-
if (w.usedPercentage <
|
|
58
|
+
function blockedUntil(w: UsageWindow, windowMs: number, sampledAt: number | undefined, threshold: number): number {
|
|
59
|
+
if (w.usedPercentage < threshold) return 0;
|
|
48
60
|
if (w.resetsAt != null) return w.resetsAt;
|
|
49
61
|
return sampledAt != null ? sampledAt + windowMs : Number.POSITIVE_INFINITY;
|
|
50
62
|
}
|
|
51
63
|
|
|
52
64
|
/** When each of the account's windows stops blocking: the two aggregates plus
|
|
53
|
-
* the gated per-model caps
|
|
65
|
+
* the gated per-model caps, each against its own threshold (session swaps
|
|
66
|
+
* earlier than the weekly windows, and a candidate is screened by the same
|
|
67
|
+
* bar that would trigger a switch off it - landing below the trigger bar
|
|
68
|
+
* would ping-pong). */
|
|
54
69
|
function blockingUntil(a: Account, ctx: PickCtx): number[] {
|
|
55
70
|
const u = a.lastUsage;
|
|
56
71
|
return [
|
|
57
|
-
...(u
|
|
58
|
-
|
|
72
|
+
...(u
|
|
73
|
+
? [
|
|
74
|
+
blockedUntil(u.fiveHour, FIVE_HOURS_MS, a.lastUsageAt, ctx.thresholds.session),
|
|
75
|
+
blockedUntil(u.sevenDay, WEEK_MS, a.lastUsageAt, ctx.thresholds.weekly),
|
|
76
|
+
]
|
|
77
|
+
: []),
|
|
78
|
+
...gatedPerModelWindows(a, ctx.switchFamilies).map((w) => blockedUntil(w, WEEK_MS, a.lastUsageAt, ctx.thresholds.weekly)),
|
|
59
79
|
];
|
|
60
80
|
}
|
|
61
81
|
|
|
62
|
-
/** An account is "exhausted" if a window is >= threshold and hasn't reset yet. */
|
|
82
|
+
/** An account is "exhausted" if a window is >= its threshold and hasn't reset yet. */
|
|
63
83
|
export function isExhausted(a: Account, ctx: PickCtx): boolean {
|
|
64
84
|
return blockingUntil(a, ctx).some((t) => t > ctx.now);
|
|
65
85
|
}
|
|
@@ -111,6 +131,18 @@ export function pickBest(accounts: Account[], ctx: PickCtx): Account | null {
|
|
|
111
131
|
return sortBy(usable, swapPreference(ctx.now))[0] ?? null;
|
|
112
132
|
}
|
|
113
133
|
|
|
134
|
+
/** Greedy idempotence, shared by bare `xx switch` and the hooks/timer path:
|
|
135
|
+
* the active account keeps its seat while it is usable and no other usable
|
|
136
|
+
* account ranks STRICTLY better on swapPreference - swapping between equals
|
|
137
|
+
* buys nothing and would ping-pong. Rank with currentAccountUuid null so the
|
|
138
|
+
* active account competes. */
|
|
139
|
+
export function currentWins(active: Account | null, accounts: Account[], ctx: PickCtx): boolean {
|
|
140
|
+
if (!active || active.needsReauth || isExhausted(active, ctx)) return false;
|
|
141
|
+
const best = pickBest(accounts, { ...ctx, currentAccountUuid: null });
|
|
142
|
+
if (best == null || best.accountUuid === active.accountUuid) return true;
|
|
143
|
+
return swapPreference(ctx.now).every((k) => k(active) === k(best));
|
|
144
|
+
}
|
|
145
|
+
|
|
114
146
|
/** When an account becomes usable again: the latest blocking bound among its
|
|
115
147
|
* windows (all must clear), or `now` if nothing blocks. Consistent with
|
|
116
148
|
* isExhausted by construction (same blockingUntil). */
|
package/src/lib/sample.ts
CHANGED
|
@@ -25,13 +25,15 @@ import { readItem, writeItem, deleteItem, liveTarget, parkedTarget, isolatedTarg
|
|
|
25
25
|
import { credItemFor, paths } from "./paths.ts";
|
|
26
26
|
import { withClaudeRefreshLock } from "./claudelock.ts";
|
|
27
27
|
import { refreshCredential, isAccessTokenExpiring, fetchTokenOrg, InvalidGrantError } from "./oauth.ts";
|
|
28
|
-
import { FullUsageSchema, probeUsage } from "./usage.ts";
|
|
28
|
+
import { FullUsageSchema, pingSession, probeUsage } from "./usage.ts";
|
|
29
29
|
import { CredentialBlobSchema, type Account, type OAuthCreds, type RolesResponse } from "./types.ts";
|
|
30
30
|
|
|
31
|
-
/** Result of a live sample: the fresh usage, or why it could not be taken.
|
|
31
|
+
/** Result of a live sample: the fresh usage, or why it could not be taken.
|
|
32
|
+
* `pingError` is set only when a requested ping (status --force) failed - the
|
|
33
|
+
* account's 5h timer may not have started even if the sample itself succeeded. */
|
|
32
34
|
export const SampleOutcomeSchema = z.discriminatedUnion("ok", [
|
|
33
|
-
z.object({ ok: z.literal(true), usage: FullUsageSchema }),
|
|
34
|
-
z.object({ ok: z.literal(false), reason: z.string() }),
|
|
35
|
+
z.object({ ok: z.literal(true), usage: FullUsageSchema, pingError: z.string().optional() }),
|
|
36
|
+
z.object({ ok: z.literal(false), reason: z.string(), pingError: z.string().optional() }),
|
|
35
37
|
]);
|
|
36
38
|
export type SampleOutcome = z.infer<typeof SampleOutcomeSchema>;
|
|
37
39
|
|
|
@@ -51,8 +53,11 @@ async function identityMismatch(creds: OAuthCreds, account: Account): Promise<st
|
|
|
51
53
|
* Live-sample `account`'s `/usage` in isolation. On a dead refresh token or a
|
|
52
54
|
* mislabeled credential it sets `account.needsReauth` in place (the caller
|
|
53
55
|
* persists accounts.json). Mutates only the passed object and keychain items.
|
|
56
|
+
* With `ping`, one minimal metered request runs first (through the same
|
|
57
|
+
* isolated credential) so the account's 5h session window starts now and the
|
|
58
|
+
* sample that follows reports the freshly opened window.
|
|
54
59
|
*/
|
|
55
|
-
export async function probeParkedUsage(account: Account): Promise<SampleOutcome> {
|
|
60
|
+
export async function probeParkedUsage(account: Account, opts: { ping?: boolean } = {}): Promise<SampleOutcome> {
|
|
56
61
|
const backup = parkedTarget(account.keychainItem);
|
|
57
62
|
const parkedRaw = await readItem(backup);
|
|
58
63
|
if (!parkedRaw) return { ok: false, reason: "no parked credential - re-add with `tokenmaxxing add`" };
|
|
@@ -94,8 +99,13 @@ export async function probeParkedUsage(account: Account): Promise<SampleOutcome>
|
|
|
94
99
|
try {
|
|
95
100
|
await writeItem(isoTarget, installed);
|
|
96
101
|
writeFileSync(join(dir, ".claude.json"), JSON.stringify({ oauthAccount: account.oauthAccount, hasCompletedOnboarding: true }));
|
|
102
|
+
const pingError = opts.ping ? await pingSession(dir) : null;
|
|
97
103
|
const usage = await probeUsage(dir);
|
|
98
|
-
|
|
104
|
+
const outcome: SampleOutcome = usage
|
|
105
|
+
? { ok: true, usage }
|
|
106
|
+
: { ok: false, reason: "`/usage` returned no limit data (see log)" };
|
|
107
|
+
if (pingError != null) outcome.pingError = pingError;
|
|
108
|
+
return outcome;
|
|
99
109
|
} finally {
|
|
100
110
|
// capture-before-delete: never discard a rotation claude may have performed.
|
|
101
111
|
const afterIso = await readItem(isoTarget);
|
|
@@ -109,9 +119,10 @@ export async function probeParkedUsage(account: Account): Promise<SampleOutcome>
|
|
|
109
119
|
* Live-sample the ACTIVE account off the live login, verifying the live
|
|
110
120
|
* credential belongs to it. `/usage` with no CLAUDE_CONFIG_DIR meters the live
|
|
111
121
|
* keychain item. A drifted active label surfaces as an error, not another
|
|
112
|
-
* account's bars.
|
|
122
|
+
* account's bars. With `ping`, one minimal metered request runs first (after
|
|
123
|
+
* the identity check - never spend quota on a drifted credential).
|
|
113
124
|
*/
|
|
114
|
-
export async function probeActiveUsage(account: Account): Promise<SampleOutcome> {
|
|
125
|
+
export async function probeActiveUsage(account: Account, opts: { ping?: boolean } = {}): Promise<SampleOutcome> {
|
|
115
126
|
const liveRaw = await readItem(liveTarget());
|
|
116
127
|
if (!liveRaw) return { ok: false, reason: "no live credential - run `claude` and `/login`" };
|
|
117
128
|
let creds: OAuthCreds;
|
|
@@ -139,6 +150,9 @@ export async function probeActiveUsage(account: Account): Promise<SampleOutcome>
|
|
|
139
150
|
const mismatch = await identityMismatch(creds, account);
|
|
140
151
|
if (mismatch) return { ok: false, reason: `live ${mismatch} - active label drifted; run \`tokenmaxxing switch\`` };
|
|
141
152
|
|
|
153
|
+
const pingError = opts.ping ? await pingSession() : null;
|
|
142
154
|
const usage = await probeUsage();
|
|
143
|
-
|
|
155
|
+
const outcome: SampleOutcome = usage ? { ok: true, usage } : { ok: false, reason: "`/usage` returned no limit data (see log)" };
|
|
156
|
+
if (pingError != null) outcome.pingError = pingError;
|
|
157
|
+
return outcome;
|
|
144
158
|
}
|
package/src/lib/state.ts
CHANGED
|
@@ -20,21 +20,25 @@ import {
|
|
|
20
20
|
// ---- config.json (minimal, fixed schema) ---------------------------------
|
|
21
21
|
|
|
22
22
|
const DEFAULT_CONFIG: Config = {
|
|
23
|
-
|
|
23
|
+
// Screening bars, split per window (user 2026-07-16): a session reset is
|
|
24
|
+
// cheap to sit out, weekly quota is use-it-or-lose-it so it drains to 98.
|
|
25
|
+
thresholds: { session: 95, weekly: 98 },
|
|
24
26
|
claudeBin: "",
|
|
25
27
|
// per-model weekly caps exist only for Sonnet and Fable (no Opus-only quota,
|
|
26
28
|
// per the user 2026-07-12), and only Fable's is worth switching on.
|
|
27
|
-
|
|
29
|
+
// greedySessionFloor 50: half a session window buys the swap (user 2026-07-16).
|
|
30
|
+
policy: { projectionMargin: 0, greedySessionFloor: 50, switchModels: ["fable"], usagePollTtlMs: 90_000, maxWaitMs: 3_600_000 },
|
|
28
31
|
};
|
|
29
32
|
|
|
30
33
|
/** On-disk shape (all optional); validated via Zod, merged over defaults. */
|
|
31
34
|
const ConfigFileSchema = z
|
|
32
35
|
.object({
|
|
33
|
-
|
|
36
|
+
thresholds: z.object({ session: z.number(), weekly: z.number() }).partial(),
|
|
34
37
|
claudeBin: z.string(),
|
|
35
38
|
policy: z
|
|
36
39
|
.object({
|
|
37
40
|
projectionMargin: z.number(),
|
|
41
|
+
greedySessionFloor: z.number(),
|
|
38
42
|
switchModels: z.array(z.string()),
|
|
39
43
|
usagePollTtlMs: z.number(),
|
|
40
44
|
maxWaitMs: z.number(),
|
|
@@ -44,7 +48,7 @@ const ConfigFileSchema = z
|
|
|
44
48
|
.partial();
|
|
45
49
|
|
|
46
50
|
export function loadConfig(): Config {
|
|
47
|
-
const cfg: Config = { ...DEFAULT_CONFIG, policy: { ...DEFAULT_CONFIG.policy } };
|
|
51
|
+
const cfg: Config = { ...DEFAULT_CONFIG, thresholds: { ...DEFAULT_CONFIG.thresholds }, policy: { ...DEFAULT_CONFIG.policy } };
|
|
48
52
|
if (existsSync(paths.configJson)) {
|
|
49
53
|
let raw: unknown = {};
|
|
50
54
|
try {
|
|
@@ -54,9 +58,11 @@ export function loadConfig(): Config {
|
|
|
54
58
|
}
|
|
55
59
|
const parsed = ConfigFileSchema.safeParse(raw);
|
|
56
60
|
const p = parsed.success ? parsed.data : {};
|
|
57
|
-
cfg.
|
|
61
|
+
cfg.thresholds.session = p.thresholds?.session ?? cfg.thresholds.session;
|
|
62
|
+
cfg.thresholds.weekly = p.thresholds?.weekly ?? cfg.thresholds.weekly;
|
|
58
63
|
cfg.claudeBin = p.claudeBin ?? cfg.claudeBin;
|
|
59
64
|
cfg.policy.projectionMargin = p.policy?.projectionMargin ?? cfg.policy.projectionMargin;
|
|
65
|
+
cfg.policy.greedySessionFloor = p.policy?.greedySessionFloor ?? cfg.policy.greedySessionFloor;
|
|
60
66
|
cfg.policy.usagePollTtlMs = p.policy?.usagePollTtlMs ?? cfg.policy.usagePollTtlMs;
|
|
61
67
|
cfg.policy.maxWaitMs = p.policy?.maxWaitMs ?? cfg.policy.maxWaitMs;
|
|
62
68
|
if (p.policy?.switchModels) {
|
package/src/lib/types.ts
CHANGED
|
@@ -105,11 +105,30 @@ export const AccountsIndexSchema = z.object({
|
|
|
105
105
|
export const LastSwapSchema = z.object({ ts: z.number() });
|
|
106
106
|
export type AccountsIndex = z.infer<typeof AccountsIndexSchema>;
|
|
107
107
|
|
|
108
|
+
/** Per-window screening bars (used %): an account with a window at/over its bar
|
|
109
|
+
* is no switch candidate until that window resets. The session bar is lower
|
|
110
|
+
* than the weekly one: a session reset is at most 5h away, so burning a little
|
|
111
|
+
* headroom there is cheap, while weekly quota is use-it-or-lose-it and worth
|
|
112
|
+
* draining closer to the wall. Screening is these bars' ONLY job - the switch
|
|
113
|
+
* trigger is the greedy pace-pressure convergence (policy.greedySessionFloor). */
|
|
114
|
+
export const ThresholdsSchema = z.object({
|
|
115
|
+
/** 5h session window. */
|
|
116
|
+
session: z.number(),
|
|
117
|
+
/** 7-day aggregate AND per-model weekly caps. */
|
|
118
|
+
weekly: z.number(),
|
|
119
|
+
});
|
|
120
|
+
export type Thresholds = z.infer<typeof ThresholdsSchema>;
|
|
121
|
+
|
|
108
122
|
export const ConfigSchema = z.object({
|
|
109
|
-
|
|
123
|
+
thresholds: ThresholdsSchema,
|
|
110
124
|
claudeBin: z.string(),
|
|
111
125
|
policy: z.object({
|
|
112
126
|
projectionMargin: z.number(),
|
|
127
|
+
/** session-used % at which the greedy convergence engages: from here on,
|
|
128
|
+
* every evaluation swaps to the usable account furthest behind its weekly
|
|
129
|
+
* pace whenever that beats the current one (idempotent; current keeps its
|
|
130
|
+
* seat on ties). Below the floor a fresh session rides its account. */
|
|
131
|
+
greedySessionFloor: z.number(),
|
|
113
132
|
/** models whose PER-MODEL weekly cap should trigger a switch (display names, lowercased). */
|
|
114
133
|
switchModels: z.array(z.string()),
|
|
115
134
|
/** how long a `/usage` per-model poll stays fresh before we re-poll (ms). */
|
package/src/lib/usage.ts
CHANGED
|
@@ -4,10 +4,13 @@
|
|
|
4
4
|
// figures claude's own usage screen shows; we run it in a throwaway
|
|
5
5
|
// CLAUDE_CONFIG_DIR to sample a parked account without disturbing the live login.
|
|
6
6
|
|
|
7
|
+
import { mkdirSync } from "node:fs";
|
|
8
|
+
import { join } from "node:path";
|
|
7
9
|
import { delay } from "es-toolkit";
|
|
8
10
|
import { z } from "zod";
|
|
9
11
|
import { MAX_WRAP_DEPTH, WRAP_DEPTH_ENV, resolveRealClaude } from "./claudebin.ts";
|
|
10
12
|
import { log } from "./log.ts";
|
|
13
|
+
import { paths } from "./paths.ts";
|
|
11
14
|
import { RateLimitsStdinSchema, UsageWindowSchema, type ModelInfo, type UsageWindow, type UsageWindows } from "./types.ts";
|
|
12
15
|
|
|
13
16
|
/** Normalize a resets_at value (epoch s, epoch ms, or ISO string) to epoch ms. */
|
|
@@ -215,41 +218,56 @@ const PROBE_KILL_MS = 60_000;
|
|
|
215
218
|
/** How long after the child's death to keep waiting for pipe EOF. */
|
|
216
219
|
const PIPE_GRACE_MS = 2_000;
|
|
217
220
|
|
|
221
|
+
const SpawnResultSchema = z.object({
|
|
222
|
+
exitCode: z.number().nullable(),
|
|
223
|
+
stdout: z.string(),
|
|
224
|
+
stderr: z.string(),
|
|
225
|
+
});
|
|
226
|
+
type SpawnResult = z.infer<typeof SpawnResultSchema>;
|
|
227
|
+
|
|
228
|
+
/** Spawn one bounded claude invocation (shared by the `/usage` probe and the
|
|
229
|
+
* `--force` ping). SIGKILL after PROBE_KILL_MS: claude traps SIGTERM, and a
|
|
230
|
+
* wedged child that survives the kill would keep p.exited pending and re-wedge
|
|
231
|
+
* the read race. Descendants inherit the output pipes, so EOF can lag the
|
|
232
|
+
* child's death or never arrive at all - a leaked grandchild holding the pipe
|
|
233
|
+
* wedged the 2026-07-12 probes forever, defeating the kill guard - so the
|
|
234
|
+
* reads are bounded by child-exit + grace instead of awaiting EOF
|
|
235
|
+
* unconditionally. Returns null when the pipes were withheld past the grace. */
|
|
236
|
+
async function spawnClaudeBounded(
|
|
237
|
+
cmd: string[],
|
|
238
|
+
env: Record<string, string>,
|
|
239
|
+
cwd?: string,
|
|
240
|
+
): Promise<SpawnResult | null> {
|
|
241
|
+
const p = Bun.spawn(cmd, { env, cwd, stdout: "pipe", stderr: "pipe" });
|
|
242
|
+
const killer = setTimeout(() => p.kill("SIGKILL"), PROBE_KILL_MS);
|
|
243
|
+
try {
|
|
244
|
+
const reads = Promise.all([new Response(p.stdout).text(), new Response(p.stderr).text()]);
|
|
245
|
+
const settled = await Promise.race([
|
|
246
|
+
reads,
|
|
247
|
+
p.exited.then(() => delay(PIPE_GRACE_MS)).then(() => null),
|
|
248
|
+
]);
|
|
249
|
+
if (settled === null) return null;
|
|
250
|
+
const [stdout, stderr] = settled;
|
|
251
|
+
await p.exited;
|
|
252
|
+
return { exitCode: p.exitCode, stdout, stderr };
|
|
253
|
+
} finally {
|
|
254
|
+
clearTimeout(killer);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
218
258
|
async function probeUsageOnce(env: Record<string, string>, now: number): Promise<FullUsage | null> {
|
|
219
259
|
let out: string;
|
|
220
260
|
try {
|
|
221
|
-
const
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
try {
|
|
230
|
-
// Descendants inherit the output pipes, so EOF can lag the child's death
|
|
231
|
-
// or never arrive at all - a leaked grandchild holding the pipe wedged
|
|
232
|
-
// the 2026-07-12 probes forever, defeating the kill guard above. Bound
|
|
233
|
-
// the reads by child-exit + grace instead of awaiting EOF unconditionally.
|
|
234
|
-
const reads = Promise.all([new Response(p.stdout).text(), new Response(p.stderr).text()]);
|
|
235
|
-
const settled = await Promise.race([
|
|
236
|
-
reads,
|
|
237
|
-
p.exited.then(() => delay(PIPE_GRACE_MS)).then(() => null),
|
|
238
|
-
]);
|
|
239
|
-
if (settled === null) {
|
|
240
|
-
log("usage.probe_failed", { err: "output pipes still open after child exit (leaked descendant)" });
|
|
241
|
-
return null;
|
|
242
|
-
}
|
|
243
|
-
const [text, errText] = settled;
|
|
244
|
-
await p.exited;
|
|
245
|
-
if (p.exitCode !== 0) {
|
|
246
|
-
log("usage.probe_failed", { exit: p.exitCode ?? "signal", stderr: errText.trim().slice(0, 200) });
|
|
247
|
-
return null;
|
|
248
|
-
}
|
|
249
|
-
out = text;
|
|
250
|
-
} finally {
|
|
251
|
-
clearTimeout(killer);
|
|
261
|
+
const r = await spawnClaudeBounded([resolveRealClaude(), "-p", "/usage", "--output-format", "json"], env);
|
|
262
|
+
if (r === null) {
|
|
263
|
+
log("usage.probe_failed", { err: "output pipes still open after child exit (leaked descendant)" });
|
|
264
|
+
return null;
|
|
265
|
+
}
|
|
266
|
+
if (r.exitCode !== 0) {
|
|
267
|
+
log("usage.probe_failed", { exit: r.exitCode ?? "signal", stderr: r.stderr.trim().slice(0, 200) });
|
|
268
|
+
return null;
|
|
252
269
|
}
|
|
270
|
+
out = r.stdout;
|
|
253
271
|
} catch (e) {
|
|
254
272
|
log("usage.probe_failed", { err: String((e as Error).message ?? e) });
|
|
255
273
|
return null;
|
|
@@ -285,14 +303,20 @@ const PROBE_RETRY_DELAYS_MS = [2000, 5000];
|
|
|
285
303
|
* keychain item. The empty-footer case (claude's own usage call throttled) is
|
|
286
304
|
* transient, so retry with backoff. Returns null if it never yields data.
|
|
287
305
|
*/
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
306
|
+
/** The scrubbed env every probe/ping spawn uses. The child spawns the real
|
|
307
|
+
* claude DIRECTLY - it never legitimately passes through the wrapper again -
|
|
308
|
+
* so the depth is preset to the cap and a poisoned pin that leads back to the
|
|
309
|
+
* wrapper aborts on its first entry (the 2026-07-12 ~1800-process recursion
|
|
310
|
+
* started as exactly this probe). */
|
|
311
|
+
function probeEnv(configDir?: string): Record<string, string> {
|
|
293
312
|
const env: Record<string, string> = { ...process.env, TOKENMAXXING_PROBE: "1", [WRAP_DEPTH_ENV]: String(MAX_WRAP_DEPTH) };
|
|
294
313
|
for (const k of CRED_ENV_OVERRIDES) delete env[k];
|
|
295
314
|
if (configDir) env.CLAUDE_CONFIG_DIR = configDir;
|
|
315
|
+
return env;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
export async function probeUsage(configDir?: string, now = Date.now()): Promise<FullUsage | null> {
|
|
319
|
+
const env = probeEnv(configDir);
|
|
296
320
|
|
|
297
321
|
for (let attempt = 0; ; attempt++) {
|
|
298
322
|
const full = await probeUsageOnce(env, now);
|
|
@@ -304,3 +328,53 @@ export async function probeUsage(configDir?: string, now = Date.now()): Promise<
|
|
|
304
328
|
await delay(PROBE_RETRY_DELAYS_MS[attempt]!);
|
|
305
329
|
}
|
|
306
330
|
}
|
|
331
|
+
|
|
332
|
+
// ---- `--force` ping --------------------------------------------------------
|
|
333
|
+
|
|
334
|
+
/** The ping is a REAL (but minimal) inference request: `/usage` is free and
|
|
335
|
+
* starts nothing, while any metered request opens the account's 5h session
|
|
336
|
+
* window at the current instant. haiku: the cheapest model (verified $1/$5 per
|
|
337
|
+
* MTok vs $3+ for every other current tier), and one with no per-model weekly
|
|
338
|
+
* cap (those exist only for Sonnet and Fable), so a ping never adds to a
|
|
339
|
+
* per-model cap the policy gates on; its dent in the aggregate 5h/7d windows
|
|
340
|
+
* is negligible. Hooks are disabled for the nested call
|
|
341
|
+
* (`--settings`, the only supported way; `--bare` would kill keychain reads)
|
|
342
|
+
* even though our own hooks already no-op on the probe env. */
|
|
343
|
+
const PING_ARGS = [
|
|
344
|
+
"-p", "Reply with exactly: ok",
|
|
345
|
+
"--model", "haiku",
|
|
346
|
+
"--settings", '{"disableAllHooks":true}',
|
|
347
|
+
"--output-format", "json",
|
|
348
|
+
];
|
|
349
|
+
|
|
350
|
+
const PingResultSchema = z.looseObject({ is_error: z.boolean(), result: z.string().optional() });
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* Send one minimal metered request so the account's 5h session window starts
|
|
354
|
+
* NOW instead of lying dormant until first real use. Pass `configDir` to ping a
|
|
355
|
+
* parked account through its isolated dir (credential already installed); omit
|
|
356
|
+
* to ping the live login. Runs from an empty scratch cwd so no project context
|
|
357
|
+
* (CLAUDE.md, project settings) inflates the request. Returns null on success,
|
|
358
|
+
* else the failure reason.
|
|
359
|
+
*/
|
|
360
|
+
export async function pingSession(configDir?: string): Promise<string | null> {
|
|
361
|
+
const cwd = join(paths.sampleDir, "ping-cwd");
|
|
362
|
+
const fail = (reason: string): string => {
|
|
363
|
+
log("usage.ping_failed", { dir: configDir ?? "live", reason: reason.slice(0, 200) });
|
|
364
|
+
return reason;
|
|
365
|
+
};
|
|
366
|
+
let r: SpawnResult | null;
|
|
367
|
+
try {
|
|
368
|
+
mkdirSync(cwd, { recursive: true });
|
|
369
|
+
r = await spawnClaudeBounded([resolveRealClaude(), ...PING_ARGS], probeEnv(configDir), cwd);
|
|
370
|
+
} catch (e) {
|
|
371
|
+
return fail(String((e as Error).message ?? e));
|
|
372
|
+
}
|
|
373
|
+
if (r === null) return fail("output pipes still open after child exit (leaked descendant)");
|
|
374
|
+
if (r.exitCode !== 0) return fail(`claude exited ${r.exitCode ?? "on signal"}: ${(r.stderr.trim() || r.stdout.trim()).slice(0, 160)}`);
|
|
375
|
+
const parsed = PingResultSchema.safeParse((() => { try { return JSON.parse(r.stdout); } catch { return null; } })());
|
|
376
|
+
if (!parsed.success) return fail(`unrecognized ping output: ${r.stdout.trim().slice(0, 120)}`);
|
|
377
|
+
if (parsed.data.is_error) return fail((parsed.data.result?.trim() || "request errored").slice(0, 160));
|
|
378
|
+
log("usage.ping_ok", { dir: configDir ?? "live" });
|
|
379
|
+
return null;
|
|
380
|
+
}
|
package/src/main.ts
CHANGED
|
@@ -30,6 +30,7 @@ function printHelp(): void {
|
|
|
30
30
|
${c.cyan("tokenmaxxing add")} register an additional account (isolated login)
|
|
31
31
|
${c.cyan("tokenmaxxing ls")} list pooled accounts
|
|
32
32
|
${c.cyan("tokenmaxxing status")} accounts with 5h / weekly / per-model usage bars
|
|
33
|
+
${c.cyan("tokenmaxxing status --force")} ping every account (one tiny haiku request each) so all 5h session timers start now, then sample fresh; ${c.cyan("xx --force")} works too
|
|
33
34
|
${c.cyan("tokenmaxxing doctor")} verify the install is intact
|
|
34
35
|
${c.cyan("tokenmaxxing rename")} <sel> <label>
|
|
35
36
|
${c.cyan("tokenmaxxing rm")} <sel>
|
|
@@ -57,12 +58,13 @@ async function main(): Promise<number> {
|
|
|
57
58
|
case "__stop-hook": return runStopHook();
|
|
58
59
|
case "__session-start": return runSessionStart();
|
|
59
60
|
case undefined: return cmdStatus(); // bare `tokenmaxxing` / `xx` → status
|
|
61
|
+
case "--force": return cmdStatus(true); // bare `xx --force` → status --force
|
|
60
62
|
case "switch": return cmdSwitch(args[1]);
|
|
61
63
|
case "check": return cmdCheck();
|
|
62
64
|
case "init": return cmdInit();
|
|
63
65
|
case "add": return cmdAdd();
|
|
64
66
|
case "ls": return cmdLs();
|
|
65
|
-
case "status": return cmdStatus();
|
|
67
|
+
case "status": return cmdStatus(args.includes("--force"));
|
|
66
68
|
case "doctor": return cmdDoctor();
|
|
67
69
|
case "rm": return cmdRm(args[1]);
|
|
68
70
|
case "rename": return cmdRename(args[1], args[2]);
|