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 +10 -10
- package/README.md +1 -1
- package/package.json +1 -1
- package/src/cli/check.ts +2 -0
- package/src/cli/seat.ts +62 -0
- package/src/entries/codexstophook.ts +7 -0
- package/src/entries/codexsupervisor.ts +25 -8
- package/src/entries/stopfailurehook.ts +4 -0
- package/src/entries/stophook.ts +5 -0
- package/src/entries/supervisor.ts +37 -3
- package/src/lib/codex.ts +14 -23
- package/src/lib/codexauth.ts +8 -0
- package/src/lib/decide.ts +15 -4
- 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/src/main.ts +10 -1
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
|
|
|
@@ -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.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tokenmaxxing",
|
|
3
|
-
"version": "1.
|
|
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();
|
package/src/cli/seat.ts
ADDED
|
@@ -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
|
-
|
|
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 {
|
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/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
|
|
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
|
|
220
|
-
const
|
|
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
|
|
229
|
-
return pickBest(
|
|
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,
|
package/src/lib/codexauth.ts
CHANGED
|
@@ -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
|
-
|
|
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/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
|
|
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));
|