tokenmaxxing 1.59.0 → 1.61.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 +11 -11
- package/README.md +2 -2
- package/package.json +1 -1
- package/src/cli/check.ts +2 -0
- package/src/entries/stopfailurehook.ts +4 -0
- package/src/entries/stophook.ts +5 -0
- package/src/entries/supervisor.ts +37 -3
- package/src/lib/decide.ts +15 -4
- package/src/lib/grok.ts +85 -5
- package/src/lib/grokusage.ts +75 -0
- package/src/lib/opencodego.ts +1 -1
- package/src/lib/sample.ts +10 -27
- package/src/lib/sessions.ts +12 -0
- package/src/lib/supervise.ts +1 -1
- package/src/lib/usage.ts +0 -30
package/DESIGN.md
CHANGED
|
@@ -38,10 +38,10 @@ It is a process manager only - spawn with inherited stdio (stdin through a relay
|
|
|
38
38
|
- `live/<session-id>` - PID-validated presence per supervised session, naming its account; placement counts them.
|
|
39
39
|
- `respawn/<session-id>` - per-session respawn markers (the hook→supervisor signal): the target account, the session to resume, a `waitUntil`, a `compact` flag, and the launch time the marker was written for.
|
|
40
40
|
- `bin/claude` and `bin/codex` - the supervisors, beside the `tokenmaxxing` and `xx` entry points.
|
|
41
|
-
- `sessions/<sid>.json`, `
|
|
41
|
+
- `sessions/<sid>.json`, `onboard/`, `setup-tokens.json`, `setup-token/`, `cloud/`, `update.json`, `spawnrate.json`, `tokenmaxxing.log`, the codex `codex-onboard/`, `codex-respawn/`, and `codex-live/`, and the `grok-*` and `opencode-go-*` state of the status-only pools: the full table is in [State directory layout](docs/content/docs/state-directory.mdx).
|
|
42
42
|
- Per-account **credential stores** follow the platform's Claude Code store: macOS = a login-keychain item keyed by the store path (never plaintext on disk); Linux = a 0600 `.credentials.json` inside the 0700 store directory (the same plaintext model claude itself uses - its Linux build has no keyring path at all, binary-verified 2.1.205). One `credstore` facade dispatches on a `{kind: keychain|file}` target; call sites never branch on platform. tokenmaxxing writes a store once, at onboarding; Claude Code's own refresh is the only writer afterwards.
|
|
43
43
|
|
|
44
|
-
- A periodic check job (`com.tokenmaxxing.check` launchd agent on macOS, `tokenmaxxing-check.timer` systemd user timer on Linux) running `tokenmaxxing check` once per tick (`policy.checkIntervalMs`, 60s by default): it runs the decision with no seat (nothing moves), folds fresh tees into the index, samples up to three accounts whose last sample attempt is oldest (the direct no-spend usage GET
|
|
44
|
+
- A periodic check job (`com.tokenmaxxing.check` launchd agent on macOS, `tokenmaxxing-check.timer` systemd user timer on Linux) running `tokenmaxxing check` once per tick (`policy.checkIntervalMs`, 60s by default): it runs the decision with no seat (nothing moves), folds fresh tees into the index, samples up to three accounts whose last sample attempt is oldest (the direct no-spend usage GET), deletes leftover dead state files at the state root, and on a Bun global install checks npm for a newer version once a day, so the cached figures every placement reads stay fresh.
|
|
45
45
|
|
|
46
46
|
The switching path runs no long-lived daemon. The statusline pushes usage. Hooks and the supervisor react. The periodic check job above is the only recurring process. The `claude` binary, `~/.claude.json`, and Claude Code's default credential store are untouched; inside `~/.claude` only `settings.json` gets the five entries.
|
|
47
47
|
|
|
@@ -50,7 +50,7 @@ The switching path runs no long-lived daemon. The statusline pushes usage. Hooks
|
|
|
50
50
|
## 3. How a move happens
|
|
51
51
|
|
|
52
52
|
### 3.1 Usage feed (free, push-based)
|
|
53
|
-
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/<uuid8>.json` for the session's seat, read from the inherited `CLAUDE_SECURESTORAGE_CONFIG_DIR` (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). A session with no pooled seat writes no tee. The seat is sampled when its tee is older than `policy.usagePollTtlMs`, and for the per-model rows when the active model is a gated family: the direct no-spend usage read with the seat's stored token
|
|
53
|
+
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/<uuid8>.json` for the session's seat, read from the inherited `CLAUDE_SECURESTORAGE_CONFIG_DIR` (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). A session with no pooled seat writes no tee. The seat is sampled when its tee is older than `policy.usagePollTtlMs`, and for the per-model rows when the active model is a gated family: the direct no-spend usage read with the seat's stored token. The compact child still sets `TOKENMAXXING_PROBE=1` so the hook entries return at once. The sample takes one attempt per interval; a failed sample doubles the interval up to 30 minutes, and a success resets it. An expired stored access token stays unmeasured until a session on that store refreshes it.
|
|
54
54
|
|
|
55
55
|
### 3.2 Detect + decide + signal (Stop hook, per turn)
|
|
56
56
|
1. Resolve the seat: the pooled account whose store the inherited `CLAUDE_SECURESTORAGE_CONFIG_DIR` names. No seat (a session started outside the supervisor) means nothing to move.
|
|
@@ -59,7 +59,7 @@ The Stop hook's stdin has no usage data, but the **statusLine does** (`rate_limi
|
|
|
59
59
|
4. Write `respawn/<session-id>` (atomic temp+rename) naming the target, with `waitUntil` now for a plain move or the soonest reset when the pool is depleted. No credential is written.
|
|
60
60
|
|
|
61
61
|
### 3.3 Move and depleted-pool pause (supervisor)
|
|
62
|
-
The supervisor sees `respawn/<sid>`, SIGTERMs its child at the already-committed turn boundary, deletes the marker, resets the terminal, compacts the conversation when the marker asks for it (`compact: true` from the Stop and SessionStart hooks and from the supervisor's own seat watch, `false` from StopFailure: it runs `claude -p --resume <sid> /compact` under the old seat's store, bounded at five minutes, and a failure only prints why), and (when `waitUntil` is in the future) shows an interruptible countdown to the reset; then it relaunches `claude --resume <sid>` with `CLAUDE_SECURESTORAGE_CONFIG_DIR` set to the target's store and rewrites the session's presence. The resumed process reads that store cold → runs on the target account, same conversation. The relaunch submits a first prompt that names the move (and the compaction, when it landed) and asks Claude to continue the task, so the conversation goes on without a keystroke: in a terminal session it is the positional prompt after the session id; in a stream-json session (`--input-format stream-json`, where the binary ignores a positional prompt) the supervisor owns stdin, relays the harness's lines to the child through a pipe, and writes the prompt as the first `user` line of the new process. In a terminal session the supervisor's own notices go to stderr. In a stream-json session (`--output-format stream-json`, read from the effective flags like the relay) there is no terminal and no statusline, so the supervisor writes each notice (the compaction and its failure, the move, the depleted-pool wait with its resume time, the resume) to stdout as a `system` line of subtype `informational` and level `warning`, the shape Claude Code emits for its own print-mode notices, which the harness renders in the conversation while the stream stays parseable; the per-second countdown ticks stay terminal-only, and the supervisor writes only while no child is running. The `SessionStart` hook (every source) re-runs the decision before the first turn and writes the same marker if the seat is already over its bar or the pool is depleted. The supervisor also watches its own seat every ten seconds while the child runs (account record plus tee, the same bars) and writes the marker itself when the seat is at or over a bar, so a move never depends on a hook firing: a harness session between turns whose background workflow burns the window, or a subagent refusal that stamped the wall, is moved within seconds. SIGTERM to the supervisor is forwarded to the child and the supervisor exits after it, so a harness that stops the wrapper (the Agent SDK does) never leaves an orphaned claude on the old seat. A marker whose `launchedAt` does not match the running child is discarded, and after a Ctrl-C on the countdown a later marker whose `waitUntil` falls inside the skipped wait is ignored. A marker that fails to parse is never deleted and never treated as absent: the supervisor stops the child, restores the terminal, and exits naming the marker path.
|
|
62
|
+
The supervisor sees `respawn/<sid>`, SIGTERMs its child at the already-committed turn boundary, deletes the marker, resets the terminal, compacts the conversation when the marker asks for it (`compact: true` from the Stop and SessionStart hooks and from the supervisor's own seat watch, `false` from StopFailure: it runs `claude -p --resume <sid> /compact` under the old seat's store, bounded at five minutes, and a failure only prints why), and (when `waitUntil` is in the future) shows an interruptible countdown to the reset; then it relaunches `claude --resume <sid>` with `CLAUDE_SECURESTORAGE_CONFIG_DIR` set to the target's store and rewrites the session's presence. The resumed process reads that store cold → runs on the target account, same conversation. The relaunch submits a first prompt that names the move (and the compaction, when it landed) and asks Claude to continue the task, so the conversation goes on without a keystroke: in a terminal session it is the positional prompt after the session id; in a stream-json session (`--input-format stream-json`, where the binary ignores a positional prompt) the supervisor owns stdin, relays the harness's lines to the child through a pipe, and writes the prompt as the first `user` line of the new process. In a terminal session the supervisor's own notices go to stderr. In a stream-json session (`--output-format stream-json`, read from the effective flags like the relay) there is no terminal and no statusline, so the supervisor writes each notice (the compaction and its failure, the move, the depleted-pool wait with its resume time, the resume) to stdout as a `system` line of subtype `informational` and level `warning`, the shape Claude Code emits for its own print-mode notices, which the harness renders in the conversation while the stream stays parseable; the per-second countdown ticks stay terminal-only, and the supervisor writes only while no child is running. The `SessionStart` hook (every source) re-runs the decision before the first turn and writes the same marker if the seat is already over its bar or the pool is depleted. The supervisor also watches its own seat every ten seconds while the child runs (account record plus tee, the same bars) and writes the marker itself when the seat is at or over a bar, so a move never depends on a hook firing: a harness session between turns whose background workflow burns the window, or a subagent refusal that stamped the wall, is moved within seconds. SIGTERM to the supervisor is forwarded to the child and the supervisor exits after it, so a harness that stops the wrapper (the Agent SDK does) never leaves an orphaned claude on the old seat. A marker whose `launchedAt` does not match the running child is discarded, and after a Ctrl-C on the countdown a later marker whose `waitUntil` falls inside the skipped wait is ignored. A marker that fails to parse is never deleted and never treated as absent: the supervisor stops the child, restores the terminal, and exits naming the marker path. In a stream-json session that fatal, the wrap-depth abort, the wrap-rate abort, and a failed presence write also go to stdout as the same `informational`/`warning` line.
|
|
63
63
|
|
|
64
64
|
### 3.4 Placement (supervisor, under the pool lock)
|
|
65
65
|
1. Fold every account's tee into the index.
|
|
@@ -67,14 +67,14 @@ The supervisor sees `respawn/<sid>`, SIGTERMs its child at the already-committed
|
|
|
67
67
|
3. Spawn claude with the store env set, then write `live/<sid>` with the child's pid and start time. The lock covers the pick and the presence write, so concurrent launches see each other.
|
|
68
68
|
|
|
69
69
|
### 3.5 Multiple concurrent sessions
|
|
70
|
-
Each terminal ran the supervisor, so each has its own child `claude`, its own `--session-id`, its own seat, and its own presence file. Sessions spread over the pool at launch (the second launch never lands on the first's account while another fresh account exists), sessions on one account share its store and coordinate refreshes through Claude Code's own store-rooted refresh lock, and each session moves on its own seat's figures. A move never touches another session. Only a shared state read is serialized: the pool lock covers decisions, placement, and the sampler's reservation; the tick's
|
|
70
|
+
Each terminal ran the supervisor, so each has its own child `claude`, its own `--session-id`, its own seat, and its own presence file. Sessions spread over the pool at launch (the second launch never lands on the first's account while another fresh account exists), sessions on one account share its store and coordinate refreshes through Claude Code's own store-rooted refresh lock, and each session moves on its own seat's figures. A move never touches another session. Only a shared state read is serialized: the pool lock covers decisions, placement, and the sampler's reservation; the tick's usage GET runs outside it, while a live `status` samples under it.
|
|
71
71
|
|
|
72
72
|
---
|
|
73
73
|
|
|
74
74
|
## 4. Onboarding (no `adopt`)
|
|
75
75
|
|
|
76
76
|
- **`tokenmaxxing init` logs your first pooled account in through an isolated browser sign-in, even if you are already signed in.** The login runs in a throwaway `CLAUDE_CONFIG_DIR=~/.config/tokenmaxxing/onboard`, its token's owner is verified through the profile endpoint (`GET /api/oauth/profile`, matched on `account.uuid`), and the credential is harvested once into that account's store; the throwaway home and its credential item are deleted. The login you already had keeps its own grant in Claude Code's default store for sessions started outside the supervisor; it is never copied, because a copy of one grant in two stores dies at the first refresh. `init` also installs the supervisor + the five settings entries.
|
|
77
|
-
- **`tokenmaxxing add`** - registers *additional* accounts the same way, one store each. `CLAUDE_CONFIG_DIR` is SET by tokenmaxxing only for throwaway homes like this one
|
|
77
|
+
- **`tokenmaxxing add`** - registers *additional* accounts the same way, one store each. `CLAUDE_CONFIG_DIR` is SET by tokenmaxxing only for throwaway homes like this one and the `setup-token/` mint homes; a session's store is always set through `CLAUDE_SECURESTORAGE_CONFIG_DIR`.
|
|
78
78
|
- **`tokenmaxxing auth [sel | --all]`** - reauthenticates an *existing* pool member whose store died (a needs-reauth account can never heal on its own: the dead grant is exactly what Claude Code's refresh would need). Same isolated-login harvest as `add`, but it states which email to sign in with and **requires the login to land on the target account** (verified `accountUuid` must match, else nothing changes). Bare `auth` lists the pool with emails and asks which; `--all` walks every account that is flagged or whose store holds no usable credential, one by one.
|
|
79
79
|
- Both commands exercise `security` reads/writes interactively (where a macOS keychain ACL prompt is acceptable), so the first access never happens cold inside a headless hook.
|
|
80
80
|
|
|
@@ -84,13 +84,13 @@ Each terminal ran the supervisor, so each has its own child `claude`, its own `-
|
|
|
84
84
|
The [switching policy](docs/content/docs/switching.mdx) is one rule: under both bars, hold; at or over a bar, move to the usable account that ranks first; nothing usable, wait for the soonest reset. The same ranking places a session at launch.
|
|
85
85
|
|
|
86
86
|
- Ranking (decided by consult for #79): greatest remaining headroom to the session bar divided by the number of sessions after placement, `(bar - used) / (sessions + 1)`, then highest pace pressure (remaining percent of the binding gated per-model cap, or of the weekly aggregate for an account with no gated row, over time to its reset), then soonest weekly reset, then lowest weekly usage; unknown usage ranks last. Organization membership is not an input. Codex drops the headroom key, keeps the pace ordering and the weekly tie-breaks, and excludes accounts running in another codex session, because codex has no cross-process refresh lock and two sessions must never share a login.
|
|
87
|
-
- Selection reads the cached figures only, after folding every account's tee. Each periodic check tick samples up to three accounts whose last sample attempt is oldest, skipping any attempted within `policy.usagePollTtlMs`, over the direct no-spend usage read with the account's stored token,
|
|
87
|
+
- Selection reads the cached figures only, after folding every account's tee. Each periodic check tick samples up to three accounts whose last sample attempt is oldest, skipping any attempted within `policy.usagePollTtlMs`, over the direct no-spend usage read with the account's stored token, so every account is attempted about once per three ticks per pooled account, or once per `policy.usagePollTtlMs` plus a tick when that is longer, and its cached figure is as fresh as its last successful attempt or its sessions' latest push. The pool lock covers only the reservation and the result write, never the usage GET. An expired stored access token stays unmeasured until a session on that store refreshes it.
|
|
88
88
|
- The depleted pause happens at the bar, not at 100; Codex has no pause and rides its account until the server refuses it, because a running sibling cannot adopt another account without restarting.
|
|
89
89
|
- Neither pool has a post-swap cooldown or a manual `switch`: a move is a respawn of one session under a fixed store, so nothing another session reads is invalidated by it, and placement at launch replaces the manual pick.
|
|
90
90
|
|
|
91
91
|
The [profile](docs/content/docs/switching-profile.mdx) records observed cache rewrites across swaps and does not infer subscription quota savings.
|
|
92
92
|
|
|
93
|
-
**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 session's model is in `policy.switchModels`, or the seat's tee is older than the TTL, we read it from the `limits[]` rows of the direct no-spend usage read for the seat (free, 0 tokens, one attempt with backoff
|
|
93
|
+
**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 session's model is in `policy.switchModels`, or the seat's tee is older than the TTL, we read it from the `limits[]` rows of the direct no-spend usage read for the seat (free, 0 tokens, one attempt with backoff) and add `week(<activeModel>) >= threshold` to the trigger. A Fable session moves on the Fable cap; a Sonnet session rides the aggregate. Candidate screening applies the same per-model gate: a burnt Fable cap screens an account out of a move. A session's model is the `--model` value it was launched with: the supervisor reads it from the launch flags, uses it for launch placement and the seat watch, and hands it to the session's hooks in `TOKENMAXXING_MODEL`, which the hooks read ahead of the per-account tee. With the default `policy.switchModels`, a session launched as Opus, Sonnet, or Haiku gates no per-model cap, so it runs on an account whose Fable cap is spent while the session and weekly windows have headroom. A launch without `--model`, or with a value that names no model family (`best`, `opusplan`), is unknown: launch placement and the seat watch gate every configured family for it, and its hooks fall back to the model in the account's tee. A move off a walled seat, or one that follows a subagent's five-hour or weekly refusal, screens candidates on every configured family whatever the launch model.
|
|
94
94
|
|
|
95
95
|
---
|
|
96
96
|
|
|
@@ -115,7 +115,7 @@ The [profile](docs/content/docs/switching-profile.mdx) records observed cache re
|
|
|
115
115
|
|
|
116
116
|
**Removed (1.37.0, issue #79):** the shared live credential and everything that served it: the swap-time harvest, park, and install, tokenmaxxing's own OAuth refresh grant and its interlock with Claude Code's refresh lock, the `~/.claude.json` `oauthAccount` rewrite, the 45-second post-swap cooldown, the pool-wide depleted-wait record, the parked-credential slots (`creds/`, `tokenmaxxing-cred-*`), the ambient `CLAUDE_CONFIG_DIR` refusal, and manual `switch` for the Claude pool.
|
|
117
117
|
|
|
118
|
-
**Shipped since (0.13.0):** Codex as a second pool with its own state (`codex-accounts.json`, per-account `codex-stores/`, own flock). Since 1.35.0 the engine is one: a generic account record (`id`, `label`, `email`, `tier`, `windows`), one picker, one decision, and one set of account commands run against a `Provider` (`src/lib/provider.ts`); `src/lib/claude.ts` and `src/lib/codex.ts` hold only what differs per product (credential store, identity, usage source, presence, login and install flows), and the provider's `seats` capability (`shared` for Claude, `live` for Codex) is how the engine knows whether present accounts stay candidates and whether a move needs the caller's respawn. Codex differences that shaped it: restart IS the switch (a running codex refuses another account's credential), one store per pooled account with `CODEX_HOME` set per session (everything but `auth.json` symlinked to the shared home, so resume works across stores), usage is a free direct GET with epoch resets and a duration-classified window set (the weekly window is primary on current plans), the refresh token rotates with reuse punished (persisted into the account's own store the instant it returns, never refreshed for an account running in another session), and the codex Stop hook drives the auto-switch through a codex supervisor shim that respawns `codex resume <session-id>` under the target's store (hooks must be trusted once via `/hooks`). Since 1.44.0 two status-only pools, grok and opencode-go, share the engine's account commands and state layout with `statusOnly: true` and no supervisor
|
|
118
|
+
**Shipped since (0.13.0):** Codex as a second pool with its own state (`codex-accounts.json`, per-account `codex-stores/`, own flock). Since 1.35.0 the engine is one: a generic account record (`id`, `label`, `email`, `tier`, `windows`), one picker, one decision, and one set of account commands run against a `Provider` (`src/lib/provider.ts`); `src/lib/claude.ts` and `src/lib/codex.ts` hold only what differs per product (credential store, identity, usage source, presence, login and install flows), and the provider's `seats` capability (`shared` for Claude, `live` for Codex) is how the engine knows whether present accounts stay candidates and whether a move needs the caller's respawn. Codex differences that shaped it: restart IS the switch (a running codex refuses another account's credential), one store per pooled account with `CODEX_HOME` set per session (everything but `auth.json` symlinked to the shared home, so resume works across stores), usage is a free direct GET with epoch resets and a duration-classified window set (the weekly window is primary on current plans), the refresh token rotates with reuse punished (persisted into the account's own store the instant it returns, never refreshed for an account running in another session), and the codex Stop hook drives the auto-switch through a codex supervisor shim that respawns `codex resume <session-id>` under the target's store (hooks must be trusted once via `/hooks`). Since 1.44.0 two status-only pools, grok and opencode-go, share the engine's account commands and state layout with `statusOnly: true` and no supervisor or hooks ([Status-only pools](docs/content/docs/status-only-pools.mdx)). grok `status` reads the weekly subscription credit window from the credits billing GET; opencode-go has no usage source.
|
|
119
119
|
|
|
120
120
|
**Shipped since (1.29.0):** a Cursor Cloud relay. A cloud VM has no keychain, no pool state, and no supervisor, so the path uses what it does have (environment variables, a shell, repo files) and a different credential primitive, one-year inference-only setup tokens, minted once per pooled account by `tokenmaxxing setup-token` on the owner's machine and carried to the VM as one user-scoped Runtime Secret (`TOKENMAXXING_TOKENS`). `tokenmaxxing cursor init` writes `.cursor/environment.json` and a `claude` project subagent (`.cursor/agents/claude.md`) that forwards each task to `tokenmaxxing cloud run`, which spawns `claude -p --dangerously-skip-permissions --output-format json` under `CLAUDE_CODE_OAUTH_TOKEN` and pins the thread to that token (the API caches by exact prefix, so a rotation busts one thread, not all). A setup token carries no usage signal, so rotation is reactive. A run whose transcript gained a non-transient rate-limit row during that run (the structured row the StopFailure hook reads) walls the label until the reset the row names (five hours without one) and resumes the same session on the next token; the result's `api_error_status` alone never walls, so a transient 429 or an older row from a previous token does not cascade across the pool. The local switching engine never reads a setup token; `/usage` prints no percentages under one. Details in [Cursor Cloud](docs/content/docs/cursor-cloud.mdx).
|
|
121
121
|
|
|
@@ -132,12 +132,12 @@ TypeScript on Bun, shipped as source: one multi-call entry (`src/main.ts`, `#!/u
|
|
|
132
132
|
|
|
133
133
|
### 2026-09-12 (per-account stores, 1.37.0)
|
|
134
134
|
|
|
135
|
-
Hermetic run on Linux under a throwaway `HOME` and `TOKENMAXXING_HOME`, a stub `claude` that records its environment
|
|
135
|
+
Hermetic run on Linux under a throwaway `HOME` and `TOKENMAXXING_HOME`, a stub `claude` that records its environment, and a synthetic three-account version 2 pool with equal headroom on two accounts and the third over its session bar. Usage comes from a stub usage endpoint named by `TOKENMAXXING_OAUTH_USAGE_URL` that answers per access token. There is no `/usage` spawn.
|
|
136
136
|
|
|
137
137
|
1. **Placement - ✅.** The first supervised launch spawned the stub with `CLAUDE_SECURESTORAGE_CONFIG_DIR=stores/<first account>` and wrote its presence; the second launch, with equal headroom, landed on the other account.
|
|
138
138
|
2. **Plain move - ✅.** A 95% session tee for the second session's account made its Stop hook write the marker; the supervisor relaunched `--resume <sid>` under the first account's store within a second and rewrote the presence file.
|
|
139
139
|
3. **Wall move - ✅.** A five-hour refusal row fed to the first session's StopFailure hook stamped `enforcedUntil` on its seat and moved the session to the account that had just been freed; a Stop hook on the walled seat moved the remaining session off it too.
|
|
140
|
-
4. **Sampling - ✅.** The check tick
|
|
140
|
+
4. **Sampling - ✅.** The check tick sampled the stalest account over the direct usage GET with the stored token; live `status` sampled the accounts without a fresh tee the same way and read the one with a fresh tee from the tee.
|
|
141
141
|
5. **Stores never move - ✅.** SHA-256 of every store's `.credentials.json` was identical before and after; no `creds/` directory and no root `usage.json` appeared; the SessionStart hook on a seat under its bars wrote no marker; stopping the children cleared every presence file.
|
|
142
142
|
|
|
143
143
|
The `pooledSpawnEnv` helper the issue names left with the SDK surface in 1.23.0; the supervisor's launch environment is the one spawn-env site.
|
package/README.md
CHANGED
|
@@ -90,7 +90,7 @@ The bars' headroom is deliberate: it's the budget to reach a clean turn boundary
|
|
|
90
90
|
|
|
91
91
|
Launches and moves rank usable accounts by session-window headroom per running session, `(bar - used) / (sessions + 1)`, so two fresh accounts alternate and a third session goes to whichever has more headroom left per session; ties break by pace pressure (remaining percent of the binding gated per-model cap, or of the weekly aggregate for an account with no gated row, divided by time to its reset, highest first), then soonest weekly reset, then lowest weekly use. Organization membership is not an input.
|
|
92
92
|
|
|
93
|
-
- Every periodic check tick folds fresh statusline tees into the index and samples up to three accounts whose last sample attempt is oldest, skipping any attempted within `policy.usagePollTtlMs
|
|
93
|
+
- Every periodic check tick folds fresh statusline tees into the index and samples up to three accounts whose last sample attempt is oldest, skipping any attempted within `policy.usagePollTtlMs`, with the direct no-spend usage read; a move reads the cached figures, which are as fresh as each account's last successful attempt or its sessions' latest push. An expired stored access token stays unmeasured until a session on that store refreshes it.
|
|
94
94
|
- When no account is usable, the session pauses until the soonest reset if that lands within `policy.maxWaitMs`, and otherwise stays put.
|
|
95
95
|
|
|
96
96
|
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.
|
|
@@ -142,7 +142,7 @@ A Cursor Cloud Agent can hand substantial work to Claude Code running on your ow
|
|
|
142
142
|
- **One shared identity file.** `~/.claude.json` holds one `oauthAccount` that Claude Code rewrites after whichever session refreshed last, so `/status` can show another session's email; the statusline's `◆` marks the account a session actually uses.
|
|
143
143
|
- **Keychain (macOS).** The first keychain access for a new store happens inside the interactive `init`, `add`, or `auth` run, where a prompt can be answered, not inside a headless hook.
|
|
144
144
|
- **Plaintext credentials (Linux).** Claude Code itself stores Linux credentials as a 0600 plaintext file; a store follows the same model.
|
|
145
|
-
- **Status-only pools
|
|
145
|
+
- **Status-only pools do not switch.** grok `status` shows the weekly subscription credit window; opencode-go accounts still render with no bars. Neither pool moves.
|
|
146
146
|
|
|
147
147
|
## How it's built
|
|
148
148
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tokenmaxxing",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.61.0",
|
|
4
4
|
"description": "Automatic Claude Code account switching: pool multiple accounts, start each session on its own account, and resume a session on a fresher account when quota fills.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
package/src/cli/check.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { claude } from "../lib/claude.ts";
|
|
2
2
|
import { evaluateAndMaybeSwap } from "../lib/decide.ts";
|
|
3
3
|
import { sampleOldest } from "../lib/sample.ts";
|
|
4
|
+
import { pruneStaleSessions } from "../lib/sessions.ts";
|
|
4
5
|
import { loadConfig } from "../lib/state.ts";
|
|
5
6
|
import { maybeAutoUpdate } from "../lib/update.ts";
|
|
6
7
|
import { errorMessage, log } from "../lib/log.ts";
|
|
@@ -10,6 +11,7 @@ export async function cmdCheck(json = false): Promise<number> {
|
|
|
10
11
|
const now = Date.now();
|
|
11
12
|
let d;
|
|
12
13
|
try {
|
|
14
|
+
pruneStaleSessions(now);
|
|
13
15
|
d = await evaluateAndMaybeSwap(claude, now);
|
|
14
16
|
await sampleOldest(loadConfig());
|
|
15
17
|
await maybeAutoUpdate();
|
|
@@ -41,6 +41,10 @@ export async function runStopFailureHook(): Promise<number> {
|
|
|
41
41
|
const stdinSid = stdin.session_id;
|
|
42
42
|
const session = supervisedSession();
|
|
43
43
|
const mainLoop = stdin.agent_id === undefined;
|
|
44
|
+
if (stdinSid != null && session != null && stdinSid !== session.sid) {
|
|
45
|
+
log("stopfailure.nested_session", { stdin: stdinSid.slice(0, 8), supervised: session.sid.slice(0, 8) });
|
|
46
|
+
return 0;
|
|
47
|
+
}
|
|
44
48
|
const canRespawn = session != null && mainLoop;
|
|
45
49
|
|
|
46
50
|
try {
|
package/src/entries/stophook.ts
CHANGED
|
@@ -17,6 +17,11 @@ export async function runBoundaryHook(event: "stop" | "sessionstart"): Promise<n
|
|
|
17
17
|
const parsed = BoundaryStdin.safeParse(JsonTextSchema.safeParse(await readStdin()).data);
|
|
18
18
|
const stdin = parsed.success ? parsed.data : {};
|
|
19
19
|
const session = supervisedSession();
|
|
20
|
+
const nested = stdin.session_id != null && session != null && stdin.session_id !== session.sid;
|
|
21
|
+
if (nested) {
|
|
22
|
+
log(`${event}.nested_session`, { stdin: stdin.session_id?.slice(0, 8), supervised: session?.sid.slice(0, 8) });
|
|
23
|
+
return 0;
|
|
24
|
+
}
|
|
20
25
|
|
|
21
26
|
try {
|
|
22
27
|
const decision = await evaluateAndMaybeSwap(claude, Date.now(), session != null);
|
|
@@ -187,7 +187,7 @@ function consumableMarker(marker: string, gate: MarkerGate): z.infer<typeof Resp
|
|
|
187
187
|
} catch (e) {
|
|
188
188
|
log("supervisor.marker_invalid", { err: errorMessage(e) });
|
|
189
189
|
throw new Error(
|
|
190
|
-
|
|
190
|
+
`tokenmaxxing: the session was stopped because the respawn marker is corrupt. Inspect ${marker}, then run \`claude --resume ${basename(marker)}\` (a fresh launch clears it)`,
|
|
191
191
|
);
|
|
192
192
|
}
|
|
193
193
|
if (m.launchedAt !== undefined && m.launchedAt !== gate.launchedAt) {
|
|
@@ -330,10 +330,36 @@ class StdinRelay {
|
|
|
330
330
|
}
|
|
331
331
|
}
|
|
332
332
|
|
|
333
|
+
function bareResumeStream(info: Analysis, argv: string[], sid: string | null): boolean {
|
|
334
|
+
if (!info.manage || process.env[UNMANAGED_ENV] || info.sessionId != null || stripSessionFlags(argv).length !== 0 || sid == null) return false;
|
|
335
|
+
try {
|
|
336
|
+
const persisted = loadSessionFlags(sid);
|
|
337
|
+
return persisted != null && analyzeArgs(persisted).streamOutput;
|
|
338
|
+
} catch {
|
|
339
|
+
return false;
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
333
343
|
export async function runSupervisor(argv: string[]): Promise<number> {
|
|
334
|
-
if (loopGuardTripped("claude")) return 1;
|
|
335
|
-
const real = resolveRealClaude();
|
|
336
344
|
const info = analyzeArgs(argv);
|
|
345
|
+
const drivenSid = info.sessionId ?? info.resumeId ?? (info.continueLatest ? latestSessionForCwd() : null);
|
|
346
|
+
const earlyStream = info.streamOutput || bareResumeStream(info, argv, drivenSid);
|
|
347
|
+
const earlySid = drivenSid ?? crypto.randomUUID();
|
|
348
|
+
if (loopGuardTripped("claude")) {
|
|
349
|
+
if (earlyStream) {
|
|
350
|
+
process.stdout.write(systemLine(earlySid, "tokenmaxxing: wrapper re-entered without reaching the real Claude. Fix claudeBin, then run tokenmaxxing doctor."));
|
|
351
|
+
}
|
|
352
|
+
return 1;
|
|
353
|
+
}
|
|
354
|
+
let real: string;
|
|
355
|
+
try {
|
|
356
|
+
real = resolveRealClaude();
|
|
357
|
+
} catch (e) {
|
|
358
|
+
if (!earlyStream) throw e;
|
|
359
|
+
process.stdout.write(systemLine(earlySid, `tokenmaxxing: ${errorMessage(e)}`));
|
|
360
|
+
log("supervisor.resolve_failed", { err: errorMessage(e) });
|
|
361
|
+
return 1;
|
|
362
|
+
}
|
|
337
363
|
const childEnv = { ...process.env, [WRAP_DEPTH_ENV]: String(wrapDepth() + 1) };
|
|
338
364
|
|
|
339
365
|
let child: Subprocess | null = null;
|
|
@@ -397,6 +423,7 @@ export async function runSupervisor(argv: string[]): Promise<number> {
|
|
|
397
423
|
let respawns = 0;
|
|
398
424
|
let overriddenUntil = 0;
|
|
399
425
|
let wanted: string | null = null;
|
|
426
|
+
try {
|
|
400
427
|
while (true) {
|
|
401
428
|
if (existsSync(marker)) rmSync(marker, { force: true });
|
|
402
429
|
|
|
@@ -488,4 +515,11 @@ export async function runSupervisor(argv: string[]): Promise<number> {
|
|
|
488
515
|
log("supervisor.exit", { sid, respawns, code: proc.exitCode, signal: proc.signalCode, terminated: terminating || undefined });
|
|
489
516
|
return exitStatus(proc);
|
|
490
517
|
}
|
|
518
|
+
} catch (e) {
|
|
519
|
+
const msg = errorMessage(e);
|
|
520
|
+
const text = msg.startsWith("tokenmaxxing:") ? msg : `tokenmaxxing: ${msg}`;
|
|
521
|
+
say(`\n\x1b[31m${text}\x1b[0m\n`, text);
|
|
522
|
+
log("supervisor.fatal", { err: msg });
|
|
523
|
+
return 1;
|
|
524
|
+
}
|
|
491
525
|
}
|
package/src/lib/decide.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { withLock } from "./lock.ts";
|
|
2
2
|
import { loadAccounts, loadConfig, saveAccounts } from "./state.ts";
|
|
3
|
-
import { isExhausted, limitWindows, nextWeeklyReset, pickBest, pickEarliestReset, sessionWindow, thresholdBars, usableAt, weeklyWindow, type PickCtx } from "./picker.ts";
|
|
3
|
+
import { isExhausted, limitWindows, liveUsed, nextWeeklyReset, pickBest, pickEarliestReset, sessionWindow, thresholdBars, usableAt, weeklyWindow, type PickCtx } from "./picker.ts";
|
|
4
4
|
import { familyTokens } from "./usage.ts";
|
|
5
5
|
import { errorMessage, log } from "./log.ts";
|
|
6
6
|
import type { Observation, Provider } from "./provider.ts";
|
|
@@ -19,6 +19,12 @@ function isOver(account: Account | undefined, observed: Observation | null, ctx:
|
|
|
19
19
|
return isExhausted({ ...account, windows: observed.windows }, ctx);
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
+
function modelKindCovered(limit: EnforcedLimit, account: Account, now: number, weeklyBar: number): boolean {
|
|
23
|
+
if (limit.kind !== "model" || limit.family == null) return false;
|
|
24
|
+
const family = limit.family;
|
|
25
|
+
return limitWindows(account).some((w) => familyTokens(w.name ?? "").includes(family) && liveUsed(w, now) >= weeklyBar);
|
|
26
|
+
}
|
|
27
|
+
|
|
22
28
|
function enforcedWall(limit: EnforcedLimit, account: Account, now: number): number {
|
|
23
29
|
const { family } = limit;
|
|
24
30
|
const familyReset =
|
|
@@ -57,13 +63,18 @@ export async function evaluateAndMaybeSwap(p: Provider, now = Date.now(), canRes
|
|
|
57
63
|
|
|
58
64
|
const origin = enforced ? idx.accounts.find((a) => a.id === enforced.account) : undefined;
|
|
59
65
|
const prior = origin?.enforcedUntil != null && origin.enforcedUntil > now;
|
|
66
|
+
let stamped = false;
|
|
60
67
|
if (enforced && origin) {
|
|
61
|
-
|
|
68
|
+
const covered = modelKindCovered(enforced, origin, now, bars.weekly);
|
|
69
|
+
if (!covered || prior) {
|
|
70
|
+
origin.enforcedUntil = Math.max(origin.enforcedUntil ?? 0, enforcedWall(enforced, origin, now));
|
|
71
|
+
stamped = true;
|
|
72
|
+
}
|
|
62
73
|
origin.lastProbeAt = now;
|
|
63
74
|
saveAccounts(p.pool, idx);
|
|
64
|
-
log("usage.enforced_limit", { kind: enforced.kind, family: enforced.family ?? undefined, resetsAt: origin.enforcedUntil, blind: prior || enforced.blind, live: origin === active });
|
|
75
|
+
log("usage.enforced_limit", { kind: enforced.kind, family: enforced.family ?? undefined, resetsAt: origin.enforcedUntil, blind: prior || enforced.blind, live: origin === active, covered: covered && !prior });
|
|
65
76
|
}
|
|
66
|
-
const enforced2 =
|
|
77
|
+
const enforced2 = stamped && origin === active ? enforced : null;
|
|
67
78
|
|
|
68
79
|
if (id2 != null && !active) {
|
|
69
80
|
return { swapped: false, account: null, reason: "live-credential-not-in-pool" };
|
package/src/lib/grok.ts
CHANGED
|
@@ -2,10 +2,13 @@ import { mkdirSync, readFileSync } from "node:fs";
|
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import { z } from "zod";
|
|
4
4
|
import { writeFileAtomic } from "./atomic.ts";
|
|
5
|
+
import { fetchGrokUsage, GrokUsageReadError } from "./grokusage.ts";
|
|
6
|
+
import { errorMessage } from "./log.ts";
|
|
5
7
|
import { grokAuthJsonFor, grokPaths, grokPool, grokSeatFromEnv, grokStoreDirFor } from "./paths.ts";
|
|
6
|
-
import type { Provider } from "./provider.ts";
|
|
8
|
+
import type { Provider, SampleReport } from "./provider.ts";
|
|
7
9
|
import { loadAccounts, type Harvest } from "./state.ts";
|
|
8
10
|
import { statusOnlyProvider, type AuthEntry } from "./statusonly.ts";
|
|
11
|
+
import type { Account } from "./types.ts";
|
|
9
12
|
import { c } from "../cli/render.ts";
|
|
10
13
|
|
|
11
14
|
const GrokAuthEntrySchema = z.looseObject({
|
|
@@ -13,11 +16,14 @@ const GrokAuthEntrySchema = z.looseObject({
|
|
|
13
16
|
principal_id: z.string().optional(),
|
|
14
17
|
email: z.string().nullish(),
|
|
15
18
|
first_name: z.string().nullish(),
|
|
19
|
+
key: z.string().optional(),
|
|
16
20
|
refresh_token: z.string().optional(),
|
|
17
21
|
expires_at: z.string().nullish(),
|
|
18
22
|
});
|
|
19
23
|
type GrokAuthEntry = z.infer<typeof GrokAuthEntrySchema>;
|
|
20
24
|
|
|
25
|
+
type GrokCred = { id: string; token: string; expiresAt: number | null };
|
|
26
|
+
|
|
21
27
|
function harvestOf(id: string, key: string, entry: GrokAuthEntry): Harvest {
|
|
22
28
|
return {
|
|
23
29
|
id,
|
|
@@ -31,7 +37,7 @@ function harvestOf(id: string, key: string, entry: GrokAuthEntry): Harvest {
|
|
|
31
37
|
};
|
|
32
38
|
}
|
|
33
39
|
|
|
34
|
-
function
|
|
40
|
+
function readMap(path: string): [string, GrokAuthEntry][] {
|
|
35
41
|
let raw: unknown;
|
|
36
42
|
try {
|
|
37
43
|
raw = JSON.parse(readFileSync(path, "utf8"));
|
|
@@ -40,18 +46,87 @@ function readAuth(path: string): AuthEntry[] {
|
|
|
40
46
|
}
|
|
41
47
|
const map = z.record(z.string(), z.unknown()).safeParse(raw);
|
|
42
48
|
if (!map.success) return [];
|
|
43
|
-
const out:
|
|
49
|
+
const out: [string, GrokAuthEntry][] = [];
|
|
44
50
|
for (const [key, value] of Object.entries(map.data)) {
|
|
45
51
|
const parsed = GrokAuthEntrySchema.safeParse(value);
|
|
46
52
|
if (!parsed.success) continue;
|
|
47
|
-
|
|
53
|
+
out.push([key, parsed.data]);
|
|
54
|
+
}
|
|
55
|
+
return out;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function readAuth(path: string): AuthEntry[] {
|
|
59
|
+
const out: AuthEntry[] = [];
|
|
60
|
+
for (const [key, entry] of readMap(path)) {
|
|
48
61
|
const id = entry.user_id ?? entry.principal_id ?? key;
|
|
49
62
|
out.push({ id, usable: (entry.refresh_token ?? "") !== "", harvest: id === "" ? null : harvestOf(id, key, entry) });
|
|
50
63
|
}
|
|
51
64
|
return out;
|
|
52
65
|
}
|
|
53
66
|
|
|
54
|
-
|
|
67
|
+
function credsIn(path: string): GrokCred[] {
|
|
68
|
+
const out: GrokCred[] = [];
|
|
69
|
+
for (const [key, entry] of readMap(path)) {
|
|
70
|
+
const id = entry.user_id ?? entry.principal_id ?? key;
|
|
71
|
+
const token = entry.key ?? "";
|
|
72
|
+
if (id === "" || token === "") continue;
|
|
73
|
+
const expiresAt = entry.expires_at != null ? Date.parse(entry.expires_at) : null;
|
|
74
|
+
out.push({ id, token, expiresAt: expiresAt != null && Number.isFinite(expiresAt) ? expiresAt : null });
|
|
75
|
+
}
|
|
76
|
+
return out;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function liveCred(accountId: string): GrokCred | null {
|
|
80
|
+
return credsIn(join(grokPaths.home, "auth.json")).find((c) => c.id === accountId) ?? null;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function bearerFor(accountId: string, now: number): { token: string; from: "store" | "live" } | null {
|
|
84
|
+
const store = credsIn(grokAuthJsonFor(accountId)).find((c) => c.id === accountId) ?? null;
|
|
85
|
+
const live = liveCred(accountId);
|
|
86
|
+
if (store != null && (store.expiresAt == null || store.expiresAt > now + 60_000)) return { token: store.token, from: "store" };
|
|
87
|
+
if (live != null) return { token: live.token, from: "live" };
|
|
88
|
+
if (store != null) return { token: store.token, from: "store" };
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async function readGrokUsage(account: Account, now: number): Promise<{ ok: true; at: number } | { ok: false; reason: string }> {
|
|
93
|
+
const first = bearerFor(account.id, now);
|
|
94
|
+
if (first == null) return { ok: false, reason: "no usable credential in this account's store - run `tokenmaxxing auth --grok`" };
|
|
95
|
+
try {
|
|
96
|
+
const usage = await fetchGrokUsage({ token: first.token, at: now });
|
|
97
|
+
account.windows = usage.windows;
|
|
98
|
+
account.lastUsageAt = usage.at;
|
|
99
|
+
return { ok: true, at: usage.at };
|
|
100
|
+
} catch (e) {
|
|
101
|
+
if (e instanceof GrokUsageReadError && e.status === 401 && first.from === "store") {
|
|
102
|
+
const live = liveCred(account.id);
|
|
103
|
+
if (live != null && live.token !== first.token) {
|
|
104
|
+
try {
|
|
105
|
+
const usage = await fetchGrokUsage({ token: live.token, at: now });
|
|
106
|
+
account.windows = usage.windows;
|
|
107
|
+
account.lastUsageAt = usage.at;
|
|
108
|
+
return { ok: true, at: usage.at };
|
|
109
|
+
} catch (retry) {
|
|
110
|
+
return { ok: false, reason: retry instanceof GrokUsageReadError ? retry.message : errorMessage(retry) };
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return { ok: false, reason: e instanceof GrokUsageReadError ? e.message : errorMessage(e) };
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async function samplePool(accounts: Account[], _liveId: string | null, now: number): Promise<Map<string, SampleReport>> {
|
|
119
|
+
const reports = new Map<string, SampleReport>();
|
|
120
|
+
await Promise.all(
|
|
121
|
+
accounts.map(async (account) => {
|
|
122
|
+
const outcome = await readGrokUsage(account, now);
|
|
123
|
+
reports.set(account.id, outcome.ok ? { ok: true, source: "probe" } : { ok: false, reason: outcome.reason });
|
|
124
|
+
}),
|
|
125
|
+
);
|
|
126
|
+
return reports;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const base = statusOnlyProvider({
|
|
55
130
|
name: "grok",
|
|
56
131
|
flag: " --grok",
|
|
57
132
|
pool: grokPool,
|
|
@@ -72,3 +147,8 @@ export const grok: Provider = statusOnlyProvider({
|
|
|
72
147
|
installNotice: "grok pool is status-only: no supervisor or hooks installed. Use `tokenmaxxing status` to view pooled grok accounts.",
|
|
73
148
|
loginStep: () => `Sign in in the browser session that opens (or run ${c.bold("grok login --device-auth")} on headless hosts first).`,
|
|
74
149
|
});
|
|
150
|
+
|
|
151
|
+
export const grok: Provider = {
|
|
152
|
+
...base,
|
|
153
|
+
samplePool,
|
|
154
|
+
};
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { http, safeErrorDetail } from "./http.ts";
|
|
3
|
+
import { errorMessage } from "./log.ts";
|
|
4
|
+
import { env } from "./paths.ts";
|
|
5
|
+
import { JsonTextSchema, type Window } from "./types.ts";
|
|
6
|
+
|
|
7
|
+
export class GrokUsageReadError extends Error {
|
|
8
|
+
status: number | null;
|
|
9
|
+
constructor(detail: string, status: number | null = null) {
|
|
10
|
+
super(`grok usage read failed: ${detail}`);
|
|
11
|
+
this.name = "GrokUsageReadError";
|
|
12
|
+
this.status = status;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const InstantSchema = z.iso.datetime({ offset: true });
|
|
17
|
+
|
|
18
|
+
const CreditsPeriodSchema = z.looseObject({
|
|
19
|
+
start: InstantSchema,
|
|
20
|
+
end: InstantSchema,
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
const CreditsConfigSchema = z.looseObject({
|
|
24
|
+
currentPeriod: CreditsPeriodSchema,
|
|
25
|
+
creditUsagePercent: z.number(),
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
const CreditsBillingSchema = z.looseObject({
|
|
29
|
+
config: CreditsConfigSchema,
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
export type GrokUsage = { windows: Window[]; at: number };
|
|
33
|
+
|
|
34
|
+
function usageUrl(): string {
|
|
35
|
+
return env("TOKENMAXXING_GROK_USAGE_URL", "https://cli-chat-proxy.grok.com/v1/billing?format=credits");
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export async function fetchGrokUsage(input: { token: string; at: number }): Promise<GrokUsage> {
|
|
39
|
+
const { token, at } = input;
|
|
40
|
+
let res: Response;
|
|
41
|
+
try {
|
|
42
|
+
res = await http.get(usageUrl(), {
|
|
43
|
+
headers: {
|
|
44
|
+
Authorization: `Bearer ${token}`,
|
|
45
|
+
Accept: "application/json",
|
|
46
|
+
"User-Agent": "grok",
|
|
47
|
+
},
|
|
48
|
+
});
|
|
49
|
+
} catch (e) {
|
|
50
|
+
throw new GrokUsageReadError(`endpoint unreachable: ${errorMessage(e)}`);
|
|
51
|
+
}
|
|
52
|
+
const text = await res.text();
|
|
53
|
+
if (!res.ok) {
|
|
54
|
+
throw new GrokUsageReadError(`HTTP ${res.status}: ${safeErrorDetail({ text })}`, res.status);
|
|
55
|
+
}
|
|
56
|
+
const parsed = CreditsBillingSchema.safeParse(JsonTextSchema.safeParse(text).data);
|
|
57
|
+
if (!parsed.success) {
|
|
58
|
+
throw new GrokUsageReadError("endpoint returned an unexpected body shape (withheld)");
|
|
59
|
+
}
|
|
60
|
+
const period = parsed.data.config.currentPeriod;
|
|
61
|
+
const start = Date.parse(period.start);
|
|
62
|
+
const end = Date.parse(period.end);
|
|
63
|
+
return {
|
|
64
|
+
at,
|
|
65
|
+
windows: [
|
|
66
|
+
{
|
|
67
|
+
name: null,
|
|
68
|
+
usedPercentage: parsed.data.config.creditUsagePercent,
|
|
69
|
+
resetsAt: Number.isFinite(end) ? end : null,
|
|
70
|
+
windowSeconds: Number.isFinite(start) && Number.isFinite(end) && end > start ? Math.round((end - start) / 1000) : null,
|
|
71
|
+
sampledAt: at,
|
|
72
|
+
},
|
|
73
|
+
],
|
|
74
|
+
};
|
|
75
|
+
}
|
package/src/lib/opencodego.ts
CHANGED
|
@@ -23,7 +23,7 @@ function liveAuthPath(): string {
|
|
|
23
23
|
function idOfKey(key: string): string {
|
|
24
24
|
const h = new Bun.CryptoHasher("sha256");
|
|
25
25
|
h.update(`opencode-go:${key}`);
|
|
26
|
-
return
|
|
26
|
+
return h.digest("hex");
|
|
27
27
|
}
|
|
28
28
|
|
|
29
29
|
function harvestOf(key: string): Harvest {
|
package/src/lib/sample.ts
CHANGED
|
@@ -1,18 +1,15 @@
|
|
|
1
|
-
import { mkdirSync } from "node:fs";
|
|
2
|
-
import { join } from "node:path";
|
|
3
|
-
import { writeFileAtomic } from "./atomic.ts";
|
|
4
1
|
import { readStore } from "./credstore.ts";
|
|
5
2
|
import { withLock } from "./lock.ts";
|
|
6
3
|
import { errorMessage, log } from "./log.ts";
|
|
7
|
-
import { claudeTierLabel,
|
|
8
|
-
import { claudePool, paths
|
|
4
|
+
import { claudeTierLabel, isDeadCredential } from "./oauth.ts";
|
|
5
|
+
import { claudePool, paths } from "./paths.ts";
|
|
9
6
|
import { seatCounts } from "./presence.ts";
|
|
10
7
|
import type { Observation } from "./provider.ts";
|
|
11
8
|
import { loadAccounts, loadUsageSnapshot, saveAccounts } from "./state.ts";
|
|
12
|
-
import { fetchUsageDirect, mergeWindows,
|
|
9
|
+
import { fetchUsageDirect, mergeWindows, windowsOf } from "./usage.ts";
|
|
13
10
|
import type { Account, Config, UsageWindows } from "./types.ts";
|
|
14
11
|
|
|
15
|
-
export type SampleOutcome = { ok: true; usage: UsageWindows; via: "get"
|
|
12
|
+
export type SampleOutcome = { ok: true; usage: UsageWindows; via: "get" } | { ok: false; reason: string };
|
|
16
13
|
|
|
17
14
|
type PreparedSample = { ok: true; token: string | null } | { ok: false; reason: string };
|
|
18
15
|
|
|
@@ -48,27 +45,13 @@ export async function prepareSample(account: Account): Promise<PreparedSample> {
|
|
|
48
45
|
}
|
|
49
46
|
if (!account.oauthAccount) return { ok: false, reason: "account record has no oauthAccount - run `tokenmaxxing auth`" };
|
|
50
47
|
account.tier = claudeTierLabel(creds) ?? account.tier;
|
|
51
|
-
return { ok: true, token:
|
|
48
|
+
return { ok: true, token: creds.accessToken };
|
|
52
49
|
}
|
|
53
50
|
|
|
54
|
-
async function
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
if (!(await refreshViaProbe({ configDir: dir, store: storeDirFor(account.id) }))) return null;
|
|
59
|
-
const prepared = await prepareSample(account);
|
|
60
|
-
return prepared.ok ? prepared.token : null;
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
export async function runSample(account: Account, token: string | null, suffix = ""): Promise<SampleOutcome> {
|
|
64
|
-
if (token) {
|
|
65
|
-
const usage = await fetchUsageDirect(token);
|
|
66
|
-
if (usage) return { ok: true, usage, via: "get" };
|
|
67
|
-
}
|
|
68
|
-
const fresh = await refreshedToken(account, suffix);
|
|
69
|
-
if (!fresh) return { ok: false, reason: "the `/usage` probe left no usable access token in the store (see log)" };
|
|
70
|
-
const usage = await fetchUsageDirect(fresh);
|
|
71
|
-
return usage ? { ok: true, usage, via: "probe" } : { ok: false, reason: "usage read failed after the `/usage` probe (see log)" };
|
|
51
|
+
export async function runSample(account: Account, token: string | null): Promise<SampleOutcome> {
|
|
52
|
+
if (!token) return { ok: false, reason: "no usable access token in the store - run `tokenmaxxing auth`" };
|
|
53
|
+
const usage = await fetchUsageDirect(token);
|
|
54
|
+
return usage ? { ok: true, usage, via: "get" } : { ok: false, reason: "usage read failed (see log)" };
|
|
72
55
|
}
|
|
73
56
|
|
|
74
57
|
export async function sampleAccountUsage(account: Account): Promise<SampleOutcome> {
|
|
@@ -114,7 +97,7 @@ export async function sampleOldest(cfg: Config): Promise<void> {
|
|
|
114
97
|
await Promise.all(
|
|
115
98
|
reserved.map(async ({ account, token }) => {
|
|
116
99
|
const startedAt = Date.now();
|
|
117
|
-
const outcome = await runSample(account, token
|
|
100
|
+
const outcome = await runSample(account, token);
|
|
118
101
|
await withLock(claudePool.lockFile, () => {
|
|
119
102
|
const idx = loadAccounts(claudePool);
|
|
120
103
|
const stored = idx.accounts.find((a) => a.id === account.id);
|
package/src/lib/sessions.ts
CHANGED
|
@@ -24,7 +24,19 @@ export function loadSessionFlags(sid: string): string[] | null {
|
|
|
24
24
|
return SessionSchema.parse(JSON.parse(readFileSync(f, "utf8"))).flags;
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
+
const DEAD_STATE_FILES = ["model-usage.json", "accounts.json.v1-backup", "codex-accounts.json.v1-backup"];
|
|
28
|
+
|
|
29
|
+
function pruneDeadState(): void {
|
|
30
|
+
for (const name of DEAD_STATE_FILES) {
|
|
31
|
+
try {
|
|
32
|
+
rmSync(join(paths.home, name), { force: true });
|
|
33
|
+
} catch {
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
27
38
|
export function pruneStaleSessions(now: number): void {
|
|
39
|
+
pruneDeadState();
|
|
28
40
|
const dir = join(paths.home, "sessions");
|
|
29
41
|
if (!existsSync(dir)) return;
|
|
30
42
|
for (const f of readdirSync(dir)) {
|
package/src/lib/supervise.ts
CHANGED
|
@@ -63,7 +63,7 @@ export async function recordPresenceOrStop(input: { child: ChildHandle; dir: str
|
|
|
63
63
|
input.child.kill();
|
|
64
64
|
await input.child.exited;
|
|
65
65
|
restoreTermios(input.savedTermios);
|
|
66
|
-
throw new Error(input.message);
|
|
66
|
+
throw new Error(input.message.startsWith("tokenmaxxing:") ? input.message : `tokenmaxxing: ${input.message}`);
|
|
67
67
|
}
|
|
68
68
|
await Bun.sleep(100);
|
|
69
69
|
}
|
package/src/lib/usage.ts
CHANGED
|
@@ -1,7 +1,5 @@
|
|
|
1
1
|
import { closeSync, fstatSync, openSync, readSync } from "node:fs";
|
|
2
|
-
import { delay } from "es-toolkit";
|
|
3
2
|
import { z } from "zod";
|
|
4
|
-
import { MAX_WRAP_DEPTH, WRAP_DEPTH_ENV, resolveRealClaude } from "./claudebin.ts";
|
|
5
3
|
import { http } from "./http.ts";
|
|
6
4
|
import { errorMessage, log } from "./log.ts";
|
|
7
5
|
import { env } from "./paths.ts";
|
|
@@ -177,40 +175,12 @@ export const CRED_ENV_OVERRIDES = [
|
|
|
177
175
|
"CLAUDE_CODE_USE_VERTEX",
|
|
178
176
|
] as const;
|
|
179
177
|
|
|
180
|
-
const PROBE_KILL_MS = 60_000;
|
|
181
|
-
const PIPE_GRACE_MS = 2_000;
|
|
182
|
-
|
|
183
178
|
export function scrubCredentialEnv(env: Record<string, string>): Record<string, string> {
|
|
184
179
|
const scrubbed = { ...env };
|
|
185
180
|
for (const k of CRED_ENV_OVERRIDES) delete scrubbed[k];
|
|
186
181
|
return scrubbed;
|
|
187
182
|
}
|
|
188
183
|
|
|
189
|
-
export type ProbeTarget = { configDir: string; store: string };
|
|
190
|
-
|
|
191
|
-
export async function refreshViaProbe(target: ProbeTarget): Promise<boolean> {
|
|
192
|
-
const env = scrubCredentialEnv({ ...process.env, TOKENMAXXING_PROBE: "1", [WRAP_DEPTH_ENV]: String(MAX_WRAP_DEPTH) });
|
|
193
|
-
env.CLAUDE_CONFIG_DIR = target.configDir;
|
|
194
|
-
env.CLAUDE_SECURESTORAGE_CONFIG_DIR = target.store;
|
|
195
|
-
try {
|
|
196
|
-
const p = Bun.spawn([resolveRealClaude(), "-p", "/usage", "--output-format", "json"], { env, stdout: "ignore", stderr: "pipe", timeout: PROBE_KILL_MS, killSignal: "SIGKILL" });
|
|
197
|
-
const stderr = await Promise.race([new Response(p.stderr).text(), p.exited.then(() => delay(PIPE_GRACE_MS)).then(() => null)]);
|
|
198
|
-
await p.exited;
|
|
199
|
-
if (stderr === null) {
|
|
200
|
-
log("usage.probe_failed", { err: "output pipes still open after child exit (leaked descendant)" });
|
|
201
|
-
return false;
|
|
202
|
-
}
|
|
203
|
-
if (p.exitCode !== 0) {
|
|
204
|
-
log("usage.probe_failed", { exit: p.exitCode ?? "signal", stderr: stderr.trim().slice(0, 200) });
|
|
205
|
-
return false;
|
|
206
|
-
}
|
|
207
|
-
return true;
|
|
208
|
-
} catch (e) {
|
|
209
|
-
log("usage.probe_failed", { err: errorMessage(e) });
|
|
210
|
-
return false;
|
|
211
|
-
}
|
|
212
|
-
}
|
|
213
|
-
|
|
214
184
|
const USAGE_URL = env("TOKENMAXXING_OAUTH_USAGE_URL", "https://api.anthropic.com/api/oauth/usage");
|
|
215
185
|
const USAGE_DEADLINE_MS = 10_000;
|
|
216
186
|
|