tokenmaxxing 1.60.0 → 1.62.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 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`, `sample/`, `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).
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 first, and for a near-expiry token or a failed read the `/usage` probe under the store to refresh the token, then the GET again), and on a Bun global install checks npm for a newer version once a day, so the cached figures every placement reads stay fresh.
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, and, when that token is near expiry or the read fails, `claude -p '/usage' --output-format json` under the seat's store, whose output is discarded, then the direct read again with the token Claude Code refreshed into the store. The probe runs with a throwaway `CLAUDE_CONFIG_DIR` so no hooks load and `TOKENMAXXING_PROBE=1` in the environment so the hook entries return at once should any load (hooks fire in `-p` too; the compact child sets it as well). It scrubs every ambient credential override claude reads before the store (`CLAUDE_CODE_OAUTH_TOKEN`, `CLAUDE_SECURESTORAGE_CONFIG_DIR`, etc.) and then sets the store it means to read. The sample takes one attempt per interval; a failed sample doubles the interval up to 30 minutes, and a success resets it.
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 `/usage` child runs outside it, while a live `status` probes under it.
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, the probe homes under `sample/`, and the `setup-token/` mint homes; a session's store is always set through `CLAUDE_SECURESTORAGE_CONFIG_DIR`.
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, and, when the token is near expiry or that read fails, over the `/usage` child under its store followed by the read again with the refreshed 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 read or the `/usage` child.
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, with the `/usage` probe under the seat's store refreshing the token when the read needs it) 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.
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
 
@@ -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 and answers `-p /usage` from a per-store fixture, and a synthetic three-account version 2 pool with equal headroom on two accounts and the third over its session bar. Usage no longer comes from that output, so repeating the run against the current code needs two other pieces: a stub usage endpoint named by `TOKENMAXXING_OAUTH_USAGE_URL` that answers per access token, and a stub `claude` whose `-p /usage` run leaves a credential in the store with an access token that is not near expiry.
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 probed the stalest account under its store with the `-tick` probe home; live `status` probed the accounts without a fresh tee under their stores and read the one with a fresh tee from the tee.
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`: the direct no-spend usage read first, and, only for a near-expiry token or a failed read, `claude -p '/usage'` under the account's store to refresh the token, followed by that read again; a move reads the cached figures, which are as fresh as each account's last successful attempt or its sessions' latest push.
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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tokenmaxxing",
3
- "version": "1.60.0",
3
+ "version": "1.62.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();
@@ -0,0 +1,62 @@
1
+ import { observeCodex } from "../lib/codex.ts";
2
+ import { codexStoreUsable, ensureCodexStoreHome } from "../lib/codexauth.ts";
3
+ import { withLock } from "../lib/lock.ts";
4
+ import { log } from "../lib/log.ts";
5
+ import { codexPaths, codexPool } from "../lib/paths.ts";
6
+ import { livingPresences, writePresence } from "../lib/presence.ts";
7
+ import { isExhausted, pickBest, thresholdBars, type PickCtx } from "../lib/picker.ts";
8
+ import { loadAccounts, loadConfig } from "../lib/state.ts";
9
+ import { emitError } from "./render.ts";
10
+
11
+ export async function cmdSeat(pidRaw: string | undefined, extra: string[]): Promise<number> {
12
+ const pid = pidRaw != null && /^(0|[1-9][0-9]*)$/.test(pidRaw) ? Number(pidRaw) : NaN;
13
+ if (!Number.isSafeInteger(pid) || pid < 2 || extra.length > 0) {
14
+ emitError({ message: "usage: tokenmaxxing seat --codex <pid> - print the CODEX_HOME of one pooled account, reserved until <pid> exits" });
15
+ return 2;
16
+ }
17
+ const now = Date.now();
18
+ const seatId = `seat-${pid}`;
19
+ const cfg = loadConfig();
20
+ await Promise.all(
21
+ loadAccounts(codexPool)
22
+ .accounts.filter((a) => a.needsReauth !== true && codexStoreUsable(a.id))
23
+ .map((a) => observeCodex(a, cfg, now, { probe: true, refresh: false })),
24
+ );
25
+ const granted = await withLock(codexPool.lockFile, (): { store: string; id: string; reused: boolean } | { denied: string } | null => {
26
+ const ctx: PickCtx = { now, thresholds: thresholdBars(loadConfig()), currentId: null, families: null, seats: null };
27
+ const idx = loadAccounts(codexPool);
28
+ const living = livingPresences(codexPaths.presenceDir);
29
+ const held = living.find((p) => p.id === seatId);
30
+ const heldAccount = held ? (idx.accounts.find((x) => x.id === held.accountId) ?? null) : null;
31
+ if (heldAccount) {
32
+ if (heldAccount.needsReauth === true) {
33
+ return { denied: "the account this pid holds needs reauthentication - run `tokenmaxxing auth --codex` and borrow again" };
34
+ }
35
+ if (!codexStoreUsable(heldAccount.id)) {
36
+ return { denied: "the account this pid holds has no usable credential in its store - refusing to hand back a credential-less seat" };
37
+ }
38
+ return { store: ensureCodexStoreHome(heldAccount.id), id: heldAccount.id, reused: true };
39
+ }
40
+ const present = new Map<string, number>();
41
+ for (const p of living) present.set(p.accountId, (present.get(p.accountId) ?? 0) + 1);
42
+ const usable = idx.accounts.filter(
43
+ (a) => a.needsReauth !== true && !isExhausted(a, ctx) && !present.has(a.id) && codexStoreUsable(a.id)
44
+ );
45
+ const picked = pickBest(usable, ctx);
46
+ if (!picked) return null;
47
+ const store = ensureCodexStoreHome(picked.id);
48
+ writePresence({ dir: codexPaths.presenceDir, id: seatId, accountId: picked.id, pid });
49
+ return { store, id: picked.id, reused: false };
50
+ });
51
+ if (!granted) {
52
+ emitError({ message: "no usable codex account (pool empty, every account live in another session, or every account at a limit) - use the ambient codex login" });
53
+ return 1;
54
+ }
55
+ if ("denied" in granted) {
56
+ emitError({ message: granted.denied });
57
+ return 1;
58
+ }
59
+ log("seat.grant", { account: granted.id.slice(0, 8), pid, reused: granted.reused });
60
+ console.log(granted.store);
61
+ return 0;
62
+ }
@@ -9,6 +9,7 @@ import { resolveRealCodex } from "../lib/codexbin.ts";
9
9
  import { compactCodexThread } from "../lib/compact.ts";
10
10
  import { evaluateAndMaybeSwap } from "../lib/decide.ts";
11
11
  import { isExhausted } from "../lib/picker.ts";
12
+ import { livingPresences } from "../lib/presence.ts";
12
13
  import { readStdin } from "../lib/proc.ts";
13
14
  import { loadAccounts, loadConfig } from "../lib/state.ts";
14
15
  import { CODEX_SUPERVISOR_ID_ENV } from "./codexsupervisor.ts";
@@ -39,6 +40,12 @@ async function handleCodexStop(rawStdin: string): Promise<void> {
39
40
  log("codexstop.unsupervised_skip", {});
40
41
  return;
41
42
  }
43
+ const own = livingPresences(codexPaths.presenceDir).find((p) => p.id === supervisorId);
44
+ const seat = codex.liveId();
45
+ if (!own || own.accountId !== seat) {
46
+ log("codexstop.foreign_seat", { supervisorId: supervisorId.slice(0, 8), seat: seat?.slice(0, 8) ?? null });
47
+ return;
48
+ }
42
49
  const now = Date.now();
43
50
  await compactBeforeMove({ sessionId, now });
44
51
  const decision = await evaluateAndMaybeSwap(codex, now);
@@ -5,13 +5,14 @@ import { codexPaths, codexPool } from "../lib/paths.ts";
5
5
  import { withLock } from "../lib/lock.ts";
6
6
  import { UNMANAGED_ENV, WRAP_DEPTH_ENV, wrapDepth } from "../lib/claudebin.ts";
7
7
  import { resolveRealCodex } from "../lib/codexbin.ts";
8
- import { ensureCodexStoreHome } from "../lib/codexauth.ts";
9
- import { pickCodexSeat } from "../lib/codex.ts";
10
- import { clearPresence } from "../lib/presence.ts";
8
+ import { codexStoreUsable, ensureCodexStoreHome } from "../lib/codexauth.ts";
9
+ import { codexPickCtx, pickCodexSeat } from "../lib/codex.ts";
10
+ import { clearPresence, livingPresences } from "../lib/presence.ts";
11
+ import { isExhausted } from "../lib/picker.ts";
11
12
  import { exitStatus, loopGuardTripped, raceMarkerOrExit, recordPresenceOrStop, runPassthrough } from "../lib/supervise.ts";
12
13
  import { saveTermios } from "../lib/tty.ts";
13
14
  import { loadAccounts } from "../lib/state.ts";
14
- import { CodexRespawnMarkerSchema } from "../lib/types.ts";
15
+ import { CodexRespawnMarkerSchema, type Account } from "../lib/types.ts";
15
16
  import { errorMessage, log } from "../lib/log.ts";
16
17
 
17
18
  export const CODEX_SUPERVISOR_ID_ENV = "TOKENMAXXING_CODEX_SUPERVISOR_ID";
@@ -55,6 +56,20 @@ export function shouldManageCodex(input: { argv: string[] }): boolean {
55
56
  return firstPositional === null || !NONINTERACTIVE_SUBCMDS.has(firstPositional);
56
57
  }
57
58
 
59
+ function validWanted(input: { wanted: string | null; now: number; supervisorId: string }): Account | null {
60
+ if (input.wanted == null) return null;
61
+ const a = loadAccounts(codexPool).accounts.find((x) => x.id === input.wanted);
62
+ if (!a || a.needsReauth === true) return null;
63
+ if (isExhausted(a, codexPickCtx(input.now, input.wanted))) return null;
64
+ if (!codexStoreUsable(a.id)) return null;
65
+ const foreign = livingPresences(codexPaths.presenceDir).some((p) => p.accountId === a.id && p.id !== input.supervisorId);
66
+ if (foreign) {
67
+ log("codexsupervisor.wanted_present", { account: a.id.slice(0, 8) });
68
+ return null;
69
+ }
70
+ return a;
71
+ }
72
+
58
73
  export async function runCodexSupervisor(input: { argv: string[] }): Promise<number> {
59
74
  const { argv } = input;
60
75
  if (loopGuardTripped("codex")) return 1;
@@ -83,8 +98,9 @@ export async function runCodexSupervisor(input: { argv: string[] }): Promise<num
83
98
  if (existsSync(marker)) rmSync(marker, { force: true });
84
99
 
85
100
  const launchedAt = Date.now();
86
- const { child } = await withLock(codexPool.lockFile, async () => {
87
- const picked = (wanted == null ? null : (loadAccounts(codexPool).accounts.find((a) => a.id === wanted) ?? null)) ?? pickCodexSeat(launchedAt, wanted);
101
+ const { child, seat } = await withLock(codexPool.lockFile, async () => {
102
+ clearPresence({ dir: codexPaths.presenceDir, id: supervisorId });
103
+ const picked = validWanted({ wanted, now: launchedAt, supervisorId }) ?? pickCodexSeat(launchedAt);
88
104
  log("codexsupervisor.launch", { supervisorId: supervisorId.slice(0, 8), respawns, seat: picked?.id.slice(0, 8) ?? null, args: launchArgs.join(" ") });
89
105
  const store = picked ? ensureCodexStoreHome(picked.id) : undefined;
90
106
  const spawned = Bun.spawn([real, ...launchArgs], {
@@ -110,6 +126,9 @@ export async function runCodexSupervisor(input: { argv: string[] }): Promise<num
110
126
  }
111
127
  return { child: spawned, seat: picked };
112
128
  });
129
+ if (respawns > 0) {
130
+ process.stdout.write(`\n\x1b[36m↻ tokenmaxxing: switched codex to ${seat?.label ?? "the ambient codex login"} - resuming...\x1b[0m\n`);
131
+ }
113
132
 
114
133
  await raceMarkerOrExit({
115
134
  child,
@@ -125,8 +144,6 @@ export async function runCodexSupervisor(input: { argv: string[] }): Promise<num
125
144
  if (payload) {
126
145
  rmSync(marker, { force: true });
127
146
  respawns++;
128
- const label = loadAccounts(codexPool).accounts.find((a) => a.id === payload.accountId)?.label ?? payload.accountId.slice(0, 8);
129
- process.stdout.write(`\n\x1b[36m↻ tokenmaxxing: switched codex to ${label} - resuming...\x1b[0m\n`);
130
147
  wanted = payload.accountId;
131
148
  launchArgs = payload.sessionId ? ["resume", payload.sessionId] : ["resume", "--last"];
132
149
  continue;
@@ -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 {
@@ -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
- `${marker} is corrupt (unparsable JSON or off-schema) - the session was stopped instead of guessing whether the pool is depleted; inspect the marker, then run \`claude --resume ${basename(marker)}\` (a fresh launch clears it)`,
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/codex.ts CHANGED
@@ -2,7 +2,7 @@ import { existsSync, mkdirSync, readFileSync, rmSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { writeFileAtomic } from "./atomic.ts";
4
4
  import { MAX_WRAP_DEPTH, WRAP_DEPTH_ENV } from "./claudebin.ts";
5
- import { codexIdentityOf, deleteCodexStoreAuth, isCodexAccessExpiring, readCodexAuthAt, readCodexStoreAuth, writeCodexStoreAuth } from "./codexauth.ts";
5
+ import { codexIdentityOf, codexStoreUsable, deleteCodexStoreAuth, isCodexAccessExpiring, readCodexAuthAt, readCodexStoreAuth, writeCodexStoreAuth } from "./codexauth.ts";
6
6
  import { resolveRealCodex, verifyRealCodex } from "./codexbin.ts";
7
7
  import { CodexInvalidGrantError, CodexRefreshFailedError, refreshCodexAuth } from "./codexoauth.ts";
8
8
  import { seatCounts } from "./presence.ts";
@@ -42,7 +42,7 @@ function applyUsage(account: Account, usage: CodexUsage, at: number): void {
42
42
 
43
43
  type CodexReadOutcome = { ok: true; usage: CodexUsage; at: number } | { ok: false; reason: string; deadGrant: boolean };
44
44
 
45
- async function readCodexUsage(account: Account, now: number): Promise<CodexReadOutcome> {
45
+ async function readCodexUsage(account: Account, now: number, refresh: boolean): Promise<CodexReadOutcome> {
46
46
  let auth: CodexAuthJson | null;
47
47
  try {
48
48
  auth = readCodexStoreAuth(account.id);
@@ -52,6 +52,9 @@ async function readCodexUsage(account: Account, now: number): Promise<CodexReadO
52
52
  if (!auth) return { ok: false, reason: "no credential in this account's store - run `tokenmaxxing auth --codex`", deadGrant: false };
53
53
  try {
54
54
  if (isCodexAccessExpiring({ auth, now })) {
55
+ if (!refresh) {
56
+ return { ok: false, reason: "stored access token is expiring and this read never refreshes a store", deadGrant: false };
57
+ }
55
58
  if (seatCounts(codexPaths.presenceDir).has(account.id)) {
56
59
  return { ok: false, reason: "running in a live codex session (store refresh unsafe)", deadGrant: false };
57
60
  }
@@ -67,9 +70,9 @@ async function readCodexUsage(account: Account, now: number): Promise<CodexReadO
67
70
  }
68
71
  }
69
72
 
70
- async function observeLive(account: Account, cfg: Config, now: number, opts: { probe: boolean }): Promise<Observation | null> {
73
+ export async function observeCodex(account: Account, cfg: Config, now: number, opts: { probe: boolean; refresh: boolean }): Promise<Observation | null> {
71
74
  if (opts.probe && (account.lastUsageAt == null || now - account.lastUsageAt > cfg.policy.usagePollTtlMs)) {
72
- const outcome = await readCodexUsage(account, now);
75
+ const outcome = await readCodexUsage(account, now, opts.refresh);
73
76
  await withLock(codexPool.lockFile, () => {
74
77
  const idx = loadAccounts(codexPool);
75
78
  const a = idx.accounts.find((x) => x.id === account.id);
@@ -92,7 +95,7 @@ async function samplePool(accounts: Account[], _liveId: string | null, now: numb
92
95
  const reports = new Map<string, SampleReport>();
93
96
  await Promise.all(
94
97
  accounts.map(async (account) => {
95
- const outcome = await readCodexUsage(account, now);
98
+ const outcome = await readCodexUsage(account, now, true);
96
99
  if (outcome.ok) {
97
100
  applyUsage(account, outcome.usage, outcome.at);
98
101
  reports.set(account.id, { ok: true, source: "probe" });
@@ -216,17 +219,11 @@ export function codexPickCtx(now: number, currentId: string | null): PickCtx {
216
219
  return { now, thresholds: thresholdBars(loadConfig()), currentId, families: null, seats: null };
217
220
  }
218
221
 
219
- export function pickCodexSeat(now: number, wantedId: string | null = null): Account | null {
220
- const cfg = loadConfig();
221
- const idx = loadAccounts(codexPool);
222
- const ctx: PickCtx = { now, thresholds: thresholdBars(cfg), currentId: null, families: null, seats: null };
223
- if (wantedId != null) {
224
- const wanted = idx.accounts.find((a) => a.id === wantedId && a.needsReauth !== true && !isExhausted(a, { ...ctx, currentId: wantedId }));
225
- if (wanted) return wanted;
226
- }
222
+ export function pickCodexSeat(now: number): Account | null {
223
+ const ctx = codexPickCtx(now, null);
227
224
  const present = seatCounts(codexPaths.presenceDir);
228
- const usable = idx.accounts.filter((a) => a.needsReauth !== true && !isExhausted(a, ctx) && !present.has(a.id));
229
- return pickBest(usable, ctx) ?? pickEarliestReset(idx.accounts.filter((a) => a.needsReauth !== true && !present.has(a.id)), ctx)?.account ?? null;
225
+ const open = loadAccounts(codexPool).accounts.filter((a) => a.needsReauth !== true && !present.has(a.id) && codexStoreUsable(a.id));
226
+ return pickBest(open.filter((a) => !isExhausted(a, ctx)), ctx) ?? pickEarliestReset(open, ctx)?.account ?? null;
230
227
  }
231
228
 
232
229
  export const codex: Provider = {
@@ -239,19 +236,13 @@ export const codex: Provider = {
239
236
  liveId,
240
237
  presence,
241
238
  gatedFamilies: () => null,
242
- observeLive,
239
+ observeLive: (account, cfg, now, opts) => observeCodex(account, cfg, now, { probe: opts.probe, refresh: true }),
243
240
  samplePool,
244
241
  mergeWindows: (next) => next,
245
242
  swap: prepareMove,
246
243
  classifySwapError: (e) => (e instanceof CodexInvalidGrantError ? "dead-grant" : e instanceof StoreUnusableError ? "skip" : "fatal"),
247
244
  removeCredentials: async (a) => deleteCodexStoreAuth(a.id),
248
- storeUsable: async (a) => {
249
- try {
250
- return readCodexStoreAuth(a.id) != null;
251
- } catch {
252
- return false;
253
- }
254
- },
245
+ storeUsable: async (a) => codexStoreUsable(a.id),
255
246
  login,
256
247
  importLive,
257
248
  preflight,
@@ -23,6 +23,14 @@ export function readCodexStoreAuth(accountId: string): CodexAuthJson | null {
23
23
  return readCodexAuthAt({ path: codexAuthJsonFor(accountId) });
24
24
  }
25
25
 
26
+ export function codexStoreUsable(accountId: string): boolean {
27
+ try {
28
+ return readCodexStoreAuth(accountId) != null;
29
+ } catch {
30
+ return false;
31
+ }
32
+ }
33
+
26
34
  export function writeCodexStoreAuth(accountId: string, auth: CodexAuthJson): void {
27
35
  mkdirSync(codexStoreDirFor(accountId), { recursive: true });
28
36
  writeFileAtomic(codexAuthJsonFor(accountId), JSON.stringify(CodexAuthJsonSchema.parse(auth), null, 2), 0o600);
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
- origin.enforcedUntil = Math.max(origin.enforcedUntil ?? 0, enforcedWall(enforced, origin, now));
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 = enforced && origin && origin === active ? enforced : null;
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" };
@@ -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 `opencode-go-${h.digest("hex")}`;
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, isAccessTokenExpiring, isDeadCredential } from "./oauth.ts";
8
- import { claudePool, paths, sampleDirFor, storeDirFor } from "./paths.ts";
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, refreshViaProbe, windowsOf } from "./usage.ts";
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" | "probe" } | { ok: false; reason: string };
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: isAccessTokenExpiring(creds) ? null : creds.accessToken };
48
+ return { ok: true, token: creds.accessToken };
52
49
  }
53
50
 
54
- async function refreshedToken(account: Account, suffix: string): Promise<string | null> {
55
- const dir = sampleDirFor(account.id, suffix);
56
- mkdirSync(dir, { recursive: true });
57
- writeFileAtomic(join(dir, ".claude.json"), JSON.stringify({ oauthAccount: account.oauthAccount, hasCompletedOnboarding: true }));
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, "-tick");
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);
@@ -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)) {
@@ -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
 
package/src/main.ts CHANGED
@@ -21,6 +21,7 @@ import { cmdRm } from "./cli/rm.ts";
21
21
  import { cmdRename } from "./cli/rename.ts";
22
22
  import { cmdCheck } from "./cli/check.ts";
23
23
  import { cmdConfig } from "./cli/config.ts";
24
+ import { cmdSeat } from "./cli/seat.ts";
24
25
  import { cmdSetupToken } from "./cli/setuptoken.ts";
25
26
  import { cmdCloudRun } from "./cli/cloudrun.ts";
26
27
  import { cmdCursorInit } from "./cli/cursorinit.ts";
@@ -36,7 +37,7 @@ const CODEX_FLAG = "--codex";
36
37
  const GROK_FLAG = "--grok";
37
38
  const OPENCODE_GO_FLAG = "--opencode-go";
38
39
  const JSON_COMMANDS = new Set(["status", "config", "check"]);
39
- const CODEX_COMMANDS = new Set(["init", "add", "auth", "rm", "rename"]);
40
+ const CODEX_COMMANDS = new Set(["init", "add", "auth", "rm", "rename", "seat"]);
40
41
  const STATUS_ONLY_COMMANDS = new Set(["init", "add", "auth", "rm", "rename"]);
41
42
 
42
43
  function printHelp(): void {
@@ -56,6 +57,7 @@ function printHelp(): void {
56
57
  ${c.cyan("tokenmaxxing doctor")} verify the install is intact
57
58
  ${c.cyan("tokenmaxxing rename")} [--codex | --grok | --opencode-go] <sel> <label>
58
59
  ${c.cyan("tokenmaxxing rm")} [--codex | --grok | --opencode-go] <sel>
60
+ ${c.cyan("tokenmaxxing seat --codex")} <pid> borrow one pooled codex account for an unattended consumer (plugin, script): prints the CODEX_HOME to set, reserved until <pid> exits; exit 1 = none usable, fall back to the ambient login
59
61
  ${c.cyan("tokenmaxxing uninstall")} [--yes] print the targets, then remove supervisor + settings entries (refused without ${c.cyan("--yes")} when HOME is the login home)
60
62
  ${c.cyan("tokenmaxxing setup-token")} [--print | rm <label|uuid>] Cursor Cloud only: mint one \`claude setup-token\` per pooled account (browser sign-in each) and print the TOKENMAXXING_TOKENS secret value; ${c.cyan("--print")} prints the stored set, ${c.cyan("rm")} drops one
61
63
  ${c.cyan("tokenmaxxing cursor init")} [dir] write the Claude relay subagent (.cursor/agents/claude.md) and .cursor/environment.json into a repo
@@ -151,6 +153,13 @@ async function main(): Promise<number> {
151
153
  case "doctor": return cmdDoctor();
152
154
  case "rm": return cmdRm(provider, args[1]);
153
155
  case "rename": return cmdRename(provider, args.slice(1));
156
+ case "seat": {
157
+ if (provider !== codex) {
158
+ emitError({ message: `seat borrows a pooled codex account - pass ${CODEX_FLAG} (usage: tokenmaxxing seat ${CODEX_FLAG} <pid>)` });
159
+ return 2;
160
+ }
161
+ return cmdSeat(args[1], args.slice(2));
162
+ }
154
163
  case "setup-token": return cmdSetupToken(args.slice(1));
155
164
  case "cursor": {
156
165
  if (args[1] === "init") return cmdCursorInit(args.slice(2));