tokenmaxxing 1.56.0 → 1.58.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 +7 -7
- package/README.md +3 -3
- package/package.json +1 -1
- package/src/cli/auth.ts +6 -17
- package/src/cli/check.ts +2 -2
- package/src/cli/cloudrun.ts +12 -19
- package/src/cli/config.ts +2 -1
- package/src/cli/doctor.ts +5 -4
- package/src/cli/init.ts +0 -1
- package/src/cli/render.ts +24 -9
- package/src/cli/rm.ts +0 -4
- package/src/cli/status.ts +34 -57
- package/src/entries/codexstophook.ts +10 -23
- package/src/entries/codexsupervisor.ts +30 -72
- package/src/entries/statusline.ts +17 -26
- package/src/entries/stopfailurehook.ts +4 -5
- package/src/entries/stophook.ts +12 -10
- package/src/entries/subagentstatusline.ts +4 -10
- package/src/entries/supervisor.ts +91 -116
- package/src/lib/claude.ts +11 -12
- package/src/lib/claudebin.ts +3 -2
- package/src/lib/claudelock.ts +2 -2
- package/src/lib/cloud.ts +4 -8
- package/src/lib/codex.ts +3 -3
- package/src/lib/codexauth.ts +2 -11
- package/src/lib/codexbin.ts +2 -1
- package/src/lib/codexoauth.ts +3 -8
- package/src/lib/codexusage.ts +5 -5
- package/src/lib/compact.ts +5 -18
- package/src/lib/credstore.ts +1 -6
- package/src/lib/decide.ts +6 -24
- package/src/lib/grok.ts +38 -152
- package/src/lib/http.ts +1 -2
- package/src/lib/install.ts +20 -38
- package/src/lib/keychain.ts +1 -4
- package/src/lib/log.ts +5 -5
- package/src/lib/oauth.ts +6 -5
- package/src/lib/opencodego.ts +31 -140
- package/src/lib/paths.ts +6 -22
- package/src/lib/picker.ts +9 -20
- package/src/lib/presence.ts +4 -5
- package/src/lib/proc.ts +22 -0
- package/src/lib/sample.ts +25 -24
- package/src/lib/sessions.ts +5 -6
- package/src/lib/settings.ts +1 -8
- package/src/lib/state.ts +6 -29
- package/src/lib/statusonly.ts +143 -0
- package/src/lib/supervise.ts +111 -0
- package/src/lib/types.ts +27 -36
- package/src/lib/update.ts +6 -7
- package/src/lib/usage.ts +40 -191
- package/src/main.ts +5 -5
- package/src/entries/sessionstart.ts +0 -33
package/DESIGN.md
CHANGED
|
@@ -31,7 +31,7 @@ It is a process manager only - spawn with inherited stdio (stdin through a relay
|
|
|
31
31
|
- Five `~/.claude/settings.json` entries (merged - other settings keys are preserved, but the `statusLine` slot is taken over): the tokenmaxxing `statusLine` renderer (native since 2026-07-11; it also tees usage), a `subagentStatusLine` (per-subagent rows in the agents panel), a `Stop` hook, a `StopFailure` hook, a `SessionStart` hook.
|
|
32
32
|
- **`~/.config/tokenmaxxing/`** - the single home for config and state:
|
|
33
33
|
- `config.json` - SPARSE overrides only (thresholds.session/weekly, claudeBin/codexBin/grokBin/opencodeBin pins, policy.*); defaults apply at read time, `xx config` prints the path and the effective values, and edits happen in an editor.
|
|
34
|
-
- `accounts.json` - non-secret index `{id, label, email, tier, addedAt, windows, lastUsageAt, lastProbeAt, probeFails, storeFails, enforcedUntil, needsReauth, oauthAccount}` (each cached window carries its name, duration, reset, and sample time; the same shape serves the Codex pool as `codex-accounts.json`).
|
|
34
|
+
- `accounts.json` - non-secret index `{id, label, email, tier, addedAt, windows, lastUsageAt, lastProbeAt, probeFails, storeFails, enforcedUntil, needsReauth, oauthAccount}` (each cached window carries its name, duration, reset, and sample time; the same shape serves the Codex pool as `codex-accounts.json`). Neither index names an active account: the schema is `{version, accounts}`, and an older file that still carries `activeId` loads and drops the key on the next save.
|
|
35
35
|
- `stores/<uuid8>/` - one Claude Code credential store per pooled account (see below).
|
|
36
36
|
- `codex-stores/<uuid8>/` - one Codex credential store per pooled account: its `auth.json`, with everything else symlinked to the shared `~/.codex` so sessions, history, and config stay shared and `codex resume` works across stores.
|
|
37
37
|
- `usage/<uuid8>.json` - the statusline tee per account, written by the sessions running on it.
|
|
@@ -41,7 +41,7 @@ It is a process manager only - spawn with inherited stdio (stdin through a relay
|
|
|
41
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).
|
|
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, the `/usage` probe under the store
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
63
63
|
|
|
64
64
|
### 3.4 Placement (supervisor, under the pool lock)
|
|
65
65
|
1. Fold every account's tee into the index.
|
|
@@ -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, or the `/usage` child under its store
|
|
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.
|
|
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 active model is in `policy.switchModels`, or the seat's tee is older than the TTL, we read it from `
|
|
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 active 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, and a launch, whose model is unknown, gates every configured family.
|
|
94
94
|
|
|
95
95
|
---
|
|
96
96
|
|
|
@@ -132,7 +132,7 @@ 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:
|
|
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.
|
|
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.
|
package/README.md
CHANGED
|
@@ -84,13 +84,13 @@ claude # use claude as always; each session gets its own accoun
|
|
|
84
84
|
Each session runs on its own seat. One rule: while the seat is under its bars, nothing happens; once it is at or over a bar, the session moves to the usable account that ranks first. The session bar is `thresholds.session` (90) minus `policy.projectionMargin` (3), so **87%** of the 5-hour window by default, and the weekly bar is **98%**. The bars also screen candidates on any of:
|
|
85
85
|
|
|
86
86
|
- **Session** (5-hour) or **week (all models)** - the aggregate windows, fed free/push-based by the statusLine into one tee per account.
|
|
87
|
-
- **Per-model weekly cap** - the most capable model (Fable) has its own tighter weekly limit that binds *before* the aggregate (per-model caps currently exist only for Sonnet and Fable, and Sonnet's is generous). tokenmaxxing reads it from
|
|
87
|
+
- **Per-model weekly cap** - the most capable model (Fable) has its own tighter weekly limit that binds *before* the aggregate (per-model caps currently exist only for Sonnet and Fable, and Sonnet's is generous). tokenmaxxing reads it from the direct no-spend usage read for the seat (free, 0 tokens, one attempt per interval) whenever the seat's tee is older than `policy.usagePollTtlMs` or the active model is one of `policy.switchModels`, backing off after a failed sample, so a Fable session moves on the Fable cap while a Sonnet session rides the aggregate.
|
|
88
88
|
|
|
89
89
|
The bars' headroom is deliberate: it's the budget to reach a clean turn boundary before the account's real limit. The session bar sits lower (90, minus the margin) because a 5-hour reset is cheap to sit out; weekly quota is use-it-or-lose-it, so it drains closer to the limit (98).
|
|
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 `claude -p '/usage'` under the account's store
|
|
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.
|
|
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.
|
|
@@ -112,7 +112,7 @@ See [How switching decides](docs/content/docs/switching.mdx) for the policy and
|
|
|
112
112
|
}
|
|
113
113
|
```
|
|
114
114
|
|
|
115
|
-
`thresholds.session` and `thresholds.weekly` are the two bars, one number each (an array `thresholds.session`, the former ladder, fails config loading with a message naming the field); `projectionMargin` is a fixed safety margin subtracted from the session bar only (default 3), so a large turn is less likely to blow past the 5-hour bar between checks; the weekly bar takes no margin because one turn cannot overshoot a week; `switchModels` names the models whose per-model cap triggers a move; `usagePollTtlMs` is how long a
|
|
115
|
+
`thresholds.session` and `thresholds.weekly` are the two bars, one number each (an array `thresholds.session`, the former ladder, fails config loading with a message naming the field); `projectionMargin` is a fixed safety margin subtracted from the session bar only (default 3), so a large turn is less likely to blow past the 5-hour bar between checks; the weekly bar takes no margin because one turn cannot overshoot a week; `switchModels` names the models whose per-model cap triggers a move; `usagePollTtlMs` is how long a usage sample stays fresh, for a seat's own sample and for the accounts each check tick samples; `maxWaitMs` bounds the depleted-pool countdown - a soonest reset further out than this does not pause the session (no respawn marker is written and the session simply keeps hitting its limit until an account recovers); `checkIntervalMs` is the periodic check tick (default 60s), which `init` writes into the timer - re-run `tokenmaxxing init` after changing it so the timer unit picks up the new tick. `claudeBin`, `codexBin`, `grokBin`, and `opencodeBin` pin the real binaries; `init` writes them, and `TOKENMAXXING_CLAUDE_BIN`, `TOKENMAXXING_CODEX_BIN`, `TOKENMAXXING_GROK_BIN`, and `TOKENMAXXING_OPENCODE_BIN` override them for one process, except that `init` pins the binary it resolved into `config.json`.
|
|
116
116
|
|
|
117
117
|
State lives in `~/.config/tokenmaxxing/`; outside it the install touches Claude Code's `settings.json`, codex's `hooks.json`, the timer unit, and your shell rc. Each account's credential store is `stores/<uuid8>/`, which Claude Code reads and refreshes as its own: on macOS the credential is a login-keychain item keyed by the store path (never plaintext on disk), on Linux a 0600 `.credentials.json` inside the store (the same plaintext model claude itself uses for `~/.claude/.credentials.json`).
|
|
118
118
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tokenmaxxing",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.58.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/auth.ts
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import { partition } from "es-toolkit";
|
|
2
|
-
import { z } from "zod";
|
|
3
2
|
import { withLock } from "../lib/lock.ts";
|
|
4
3
|
import type { Provider } from "../lib/provider.ts";
|
|
5
4
|
import { loadAccounts, saveAccounts, upsertAccount } from "../lib/state.ts";
|
|
@@ -10,13 +9,7 @@ import type { Account, AccountsIndex } from "../lib/types.ts";
|
|
|
10
9
|
|
|
11
10
|
const AUTH_USAGE = "usage: tokenmaxxing auth [--codex | --grok | --opencode-go] [<email|label|id> | --all]";
|
|
12
11
|
|
|
13
|
-
|
|
14
|
-
z.object({ kind: z.literal("usage") }),
|
|
15
|
-
z.object({ kind: z.literal("error"), message: z.string() }),
|
|
16
|
-
z.object({ kind: z.literal("pick") }),
|
|
17
|
-
z.object({ kind: z.literal("targets"), ids: z.array(z.string()) }),
|
|
18
|
-
]);
|
|
19
|
-
export type AuthPlan = z.infer<typeof AuthPlanSchema>;
|
|
12
|
+
export type AuthPlan = { kind: "usage" } | { kind: "error"; message: string } | { kind: "pick" } | { kind: "targets"; ids: string[] };
|
|
20
13
|
|
|
21
14
|
export function planAuth(input: { p: Provider; accounts: Account[]; argv: string[]; needsAuth: Set<string> }): AuthPlan {
|
|
22
15
|
const all = input.argv.includes("--all");
|
|
@@ -45,7 +38,6 @@ function askWhichAccount(idx: AccountsIndex, needsAuth: Set<string>): Account |
|
|
|
45
38
|
console.log("which account do you want to reauthenticate?");
|
|
46
39
|
for (const [i, a] of ordered.entries()) {
|
|
47
40
|
const flags: string[] = [];
|
|
48
|
-
if (a.id === idx.activeId) flags.push(c.green("active"));
|
|
49
41
|
if (a.needsReauth) flags.push(c.red("needs-reauth"));
|
|
50
42
|
else if (needsAuth.has(a.id)) flags.push(c.red("no-credential"));
|
|
51
43
|
const labelNote = a.email != null && a.label !== a.email ? ` (${a.label})` : "";
|
|
@@ -84,7 +76,7 @@ async function reauthOne(p: Provider, target: Account): Promise<boolean> {
|
|
|
84
76
|
return false;
|
|
85
77
|
}
|
|
86
78
|
|
|
87
|
-
const
|
|
79
|
+
const account = await withLock(p.pool.lockFile, async () => {
|
|
88
80
|
const idx = loadAccounts(p.pool);
|
|
89
81
|
if (!idx.accounts.some((a) => a.id === target.id)) {
|
|
90
82
|
console.error(c.red(`${target.label} was removed from the pool while the login was open - nothing written; re-add it with \`tokenmaxxing add${p.flag}\` if wanted`));
|
|
@@ -93,15 +85,12 @@ async function reauthOne(p: Provider, target: Account): Promise<boolean> {
|
|
|
93
85
|
await harvested.park();
|
|
94
86
|
const account = upsertAccount(idx, harvested, p.mergeWindows);
|
|
95
87
|
saveAccounts(p.pool, idx);
|
|
96
|
-
return
|
|
88
|
+
return account;
|
|
97
89
|
});
|
|
98
|
-
if (
|
|
90
|
+
if (account === null) return false;
|
|
99
91
|
|
|
100
|
-
const note = harvested.sample ? usageNote(
|
|
101
|
-
console.log(`${c.green("✓")} reauthed ${c.bold(
|
|
102
|
-
if (result.isActive) {
|
|
103
|
-
console.log(c.dim("this account has a running session: it keeps its current login until it respawns."));
|
|
104
|
-
}
|
|
92
|
+
const note = harvested.sample ? usageNote(account) : "";
|
|
93
|
+
console.log(`${c.green("✓")} reauthed ${c.bold(account.email ?? account.label)} (${account.tier ?? "?"})${note}`);
|
|
105
94
|
return true;
|
|
106
95
|
}
|
|
107
96
|
|
package/src/cli/check.ts
CHANGED
|
@@ -3,7 +3,7 @@ import { evaluateAndMaybeSwap } from "../lib/decide.ts";
|
|
|
3
3
|
import { sampleOldest } from "../lib/sample.ts";
|
|
4
4
|
import { loadConfig } from "../lib/state.ts";
|
|
5
5
|
import { maybeAutoUpdate } from "../lib/update.ts";
|
|
6
|
-
import { log } from "../lib/log.ts";
|
|
6
|
+
import { errorMessage, log } from "../lib/log.ts";
|
|
7
7
|
import { c, emitError, emitJson, fmtReset } from "./render.ts";
|
|
8
8
|
|
|
9
9
|
export async function cmdCheck(json = false): Promise<number> {
|
|
@@ -14,7 +14,7 @@ export async function cmdCheck(json = false): Promise<number> {
|
|
|
14
14
|
await sampleOldest(loadConfig());
|
|
15
15
|
await maybeAutoUpdate();
|
|
16
16
|
} catch (e) {
|
|
17
|
-
const detail =
|
|
17
|
+
const detail = errorMessage(e);
|
|
18
18
|
log("check.error", { err: detail });
|
|
19
19
|
emitError({ json, message: `check failed: ${detail}` });
|
|
20
20
|
return 1;
|
package/src/cli/cloudrun.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
|
+
import { parseArgs } from "node:util";
|
|
1
2
|
import { withLock } from "../lib/lock.ts";
|
|
3
|
+
import { errorMessage } from "../lib/log.ts";
|
|
2
4
|
import { paths } from "../lib/paths.ts";
|
|
3
5
|
import {
|
|
4
6
|
limitWallUntil,
|
|
@@ -17,26 +19,17 @@ import { c, emitError, fmtReset } from "./render.ts";
|
|
|
17
19
|
const USAGE = "usage: tokenmaxxing cloud run [--session <id>] [--max-turns <n>] \"<prompt>\" (the prompt is also read from stdin)";
|
|
18
20
|
|
|
19
21
|
function parseFlags(args: string[]): { session: string | null; maxTurns: number | null; prompt: string | null } | { error: string } {
|
|
20
|
-
let
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
if (arg === "--session") {
|
|
26
|
-
const raw = args[++i];
|
|
27
|
-
if (raw == null || raw === "") return { error: "--session needs a session id" };
|
|
28
|
-
session = raw;
|
|
29
|
-
continue;
|
|
30
|
-
}
|
|
31
|
-
if (arg === "--max-turns") {
|
|
32
|
-
const raw = args[++i];
|
|
33
|
-
maxTurns = Number(raw);
|
|
34
|
-
if (!Number.isInteger(maxTurns) || maxTurns < 1) return { error: `--max-turns needs a positive whole number, got: ${raw ?? "nothing"}` };
|
|
35
|
-
continue;
|
|
36
|
-
}
|
|
37
|
-
positional.push(arg);
|
|
22
|
+
let parsed;
|
|
23
|
+
try {
|
|
24
|
+
parsed = parseArgs({ args, options: { session: { type: "string" }, "max-turns": { type: "string" } }, allowPositionals: true });
|
|
25
|
+
} catch (e) {
|
|
26
|
+
return { error: errorMessage(e) };
|
|
38
27
|
}
|
|
39
|
-
|
|
28
|
+
const { session, "max-turns": rawTurns } = parsed.values;
|
|
29
|
+
if (session === "") return { error: "--session needs a session id" };
|
|
30
|
+
const maxTurns = rawTurns == null ? null : Number(rawTurns);
|
|
31
|
+
if (maxTurns != null && (!Number.isInteger(maxTurns) || maxTurns < 1)) return { error: `--max-turns needs a positive whole number, got: ${rawTurns}` };
|
|
32
|
+
return { session: session ?? null, maxTurns, prompt: parsed.positionals.length > 0 ? parsed.positionals.join(" ") : null };
|
|
40
33
|
}
|
|
41
34
|
|
|
42
35
|
export async function cmdCloudRun(args: string[]): Promise<number> {
|
package/src/cli/config.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { errorMessage } from "../lib/log.ts";
|
|
1
2
|
import { paths } from "../lib/paths.ts";
|
|
2
3
|
import { loadConfig } from "../lib/state.ts";
|
|
3
4
|
import type { Config } from "../lib/types.ts";
|
|
@@ -12,7 +13,7 @@ export function cmdConfig(args: string[], json = false): number {
|
|
|
12
13
|
try {
|
|
13
14
|
effective = loadConfig();
|
|
14
15
|
} catch (e) {
|
|
15
|
-
emitError({ json, message:
|
|
16
|
+
emitError({ json, message: errorMessage(e), extra: { path: paths.configJson } });
|
|
16
17
|
return 1;
|
|
17
18
|
}
|
|
18
19
|
if (json) {
|
package/src/cli/doctor.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { existsSync, readFileSync } from "node:fs";
|
|
|
2
2
|
import { verifyRealClaude } from "../lib/claudebin.ts";
|
|
3
3
|
import { checkSettings, installedBin } from "../lib/settings.ts";
|
|
4
4
|
import { checkTimerHealthy, codexStoreHookTrust, findClaudeShadowers, isBinDirAhead, shellRcPath, timerActivationHint } from "../lib/install.ts";
|
|
5
|
+
import { errorMessage } from "../lib/log.ts";
|
|
5
6
|
import { claudePool, codexPool, paths } from "../lib/paths.ts";
|
|
6
7
|
import { loadAccounts, loadConfig } from "../lib/state.ts";
|
|
7
8
|
import { readStore } from "../lib/credstore.ts";
|
|
@@ -39,7 +40,7 @@ export async function cmdDoctor(): Promise<number> {
|
|
|
39
40
|
try {
|
|
40
41
|
creds = await readStore(a.id);
|
|
41
42
|
} catch (e) {
|
|
42
|
-
check(false, `store credential readable for ${a.label}`, (e
|
|
43
|
+
check(false, `store credential readable for ${a.label}`, errorMessage(e).slice(0, 100));
|
|
43
44
|
continue;
|
|
44
45
|
}
|
|
45
46
|
check(creds != null, `store credential present for ${a.label}`, `run \`tokenmaxxing auth ${a.label}\``);
|
|
@@ -51,7 +52,7 @@ export async function cmdDoctor(): Promise<number> {
|
|
|
51
52
|
const identity = await fetchTokenIdentity(creds.accessToken);
|
|
52
53
|
check(identity.accountUuid === a.id, `store credential identity matches ${a.label}`, `token belongs to ${describeIdentity(identity)} - run \`tokenmaxxing auth ${a.label}\``);
|
|
53
54
|
} catch (e) {
|
|
54
|
-
check(false, `store credential identity matches ${a.label}`, (e
|
|
55
|
+
check(false, `store credential identity matches ${a.label}`, errorMessage(e).slice(0, 100));
|
|
55
56
|
}
|
|
56
57
|
}
|
|
57
58
|
}
|
|
@@ -68,7 +69,7 @@ export async function cmdDoctor(): Promise<number> {
|
|
|
68
69
|
try {
|
|
69
70
|
auth = readCodexStoreAuth(a.id);
|
|
70
71
|
} catch (e) {
|
|
71
|
-
authErr = (e
|
|
72
|
+
authErr = errorMessage(e).slice(0, 100);
|
|
72
73
|
}
|
|
73
74
|
check(auth != null, `codex store credential present for ${a.label}`, authErr ?? `run \`tokenmaxxing auth --codex ${a.label}\``);
|
|
74
75
|
if (auth) {
|
|
@@ -78,7 +79,7 @@ export async function cmdDoctor(): Promise<number> {
|
|
|
78
79
|
match = codexIdentityOf({ auth }).accountId === a.id;
|
|
79
80
|
if (!match) matchHint = `store credential belongs to another account - ${matchHint}`;
|
|
80
81
|
} catch (e) {
|
|
81
|
-
matchHint = (e
|
|
82
|
+
matchHint = errorMessage(e).slice(0, 100);
|
|
82
83
|
}
|
|
83
84
|
check(match, `codex store credential identity matches ${a.label}`, matchHint);
|
|
84
85
|
}
|
package/src/cli/init.ts
CHANGED
|
@@ -38,7 +38,6 @@ export async function cmdInit(p: Provider): Promise<number> {
|
|
|
38
38
|
await harvested.park();
|
|
39
39
|
const idx = loadAccounts(p.pool);
|
|
40
40
|
const imported = upsertAccount(idx, harvested, p.mergeWindows);
|
|
41
|
-
idx.activeId = null;
|
|
42
41
|
saveAccounts(p.pool, idx);
|
|
43
42
|
return imported;
|
|
44
43
|
});
|
package/src/cli/render.ts
CHANGED
|
@@ -14,6 +14,11 @@ export function makeColors(enabled: boolean) {
|
|
|
14
14
|
|
|
15
15
|
export const c = makeColors(!process.env.NO_COLOR && !!process.stdout.isTTY);
|
|
16
16
|
|
|
17
|
+
export function statuslineColor(): { color: boolean; truecolor: boolean } {
|
|
18
|
+
const colorterm = process.env.COLORTERM ?? "";
|
|
19
|
+
return { color: !process.env.NO_COLOR, truecolor: colorterm.includes("truecolor") || colorterm.includes("24bit") };
|
|
20
|
+
}
|
|
21
|
+
|
|
17
22
|
function rampRgb(usedPct: number): { r: number; g: number } {
|
|
18
23
|
const u = clamp(usedPct, 0, 100);
|
|
19
24
|
if (u >= 95) return { r: 255, g: 0 };
|
|
@@ -46,23 +51,33 @@ export function bar(pct: number, width = 16): string {
|
|
|
46
51
|
return `${paint(body)} ${label}`;
|
|
47
52
|
}
|
|
48
53
|
|
|
54
|
+
function splitDuration(ms: number): { dsec: number; d: number; h: number; m: number } {
|
|
55
|
+
const dsec = Math.round(ms / 1000);
|
|
56
|
+
return { dsec, d: Math.floor(dsec / 86400), h: Math.floor((dsec % 86400) / 3600), m: Math.floor((dsec % 3600) / 60) };
|
|
57
|
+
}
|
|
58
|
+
|
|
49
59
|
export function fmtReset(epochMs: number | null | undefined, now = Date.now()): string {
|
|
50
60
|
if (epochMs == null) return "";
|
|
51
|
-
const dsec =
|
|
61
|
+
const { dsec, d, h, m } = splitDuration(epochMs - now);
|
|
52
62
|
if (dsec <= 0) return "reset now";
|
|
53
|
-
const
|
|
54
|
-
|
|
55
|
-
if (
|
|
56
|
-
if (h > 0) return `resets in ${h}h${m}m`;
|
|
63
|
+
const hours = d * 24 + h;
|
|
64
|
+
if (hours > 24) return `resets in ${d}d${h}h`;
|
|
65
|
+
if (hours > 0) return `resets in ${hours}h${m}m`;
|
|
57
66
|
return `resets in ${m}m`;
|
|
58
67
|
}
|
|
59
68
|
|
|
69
|
+
export function fmtResetShort(epochMs: number | null | undefined, now = Date.now()): string {
|
|
70
|
+
if (epochMs == null) return "";
|
|
71
|
+
const { dsec, d, h, m } = splitDuration(epochMs - now);
|
|
72
|
+
if (dsec <= 0) return "";
|
|
73
|
+
if (d > 0) return `${d}d`;
|
|
74
|
+
if (h > 0) return `${h}h`;
|
|
75
|
+
return `${Math.max(m, 1)}m`;
|
|
76
|
+
}
|
|
77
|
+
|
|
60
78
|
export function fmtAgo(epochMs: number, now = Date.now()): string {
|
|
61
|
-
const dsec
|
|
79
|
+
const { dsec, d, h, m } = splitDuration(now - epochMs);
|
|
62
80
|
if (dsec < 60) return "just now";
|
|
63
|
-
const d = Math.floor(dsec / 86400);
|
|
64
|
-
const h = Math.floor((dsec % 86400) / 3600);
|
|
65
|
-
const m = Math.floor((dsec % 3600) / 60);
|
|
66
81
|
if (d > 0) return `${d}d ago`;
|
|
67
82
|
if (h > 0) return `${h}h ago`;
|
|
68
83
|
return `${m}m ago`;
|
package/src/cli/rm.ts
CHANGED
|
@@ -16,10 +16,6 @@ export async function cmdRm(p: Provider, selector?: string): Promise<number> {
|
|
|
16
16
|
emitError({ message: `no account matches "${selector}"` });
|
|
17
17
|
return 1;
|
|
18
18
|
}
|
|
19
|
-
if (a.id === idx.activeId) {
|
|
20
|
-
emitError({ message: `${a.label} is the ACTIVE account - switch away before removing it.` });
|
|
21
|
-
return 1;
|
|
22
|
-
}
|
|
23
19
|
if (p.liveId() === a.id) {
|
|
24
20
|
emitError({ message: `${a.label} is the ${p.name} login this command runs under - remove it from a session on another account.` });
|
|
25
21
|
return 1;
|
package/src/cli/status.ts
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import { chunk, sortBy } from "es-toolkit";
|
|
2
|
-
import { z } from "zod";
|
|
3
2
|
import { claude } from "../lib/claude.ts";
|
|
4
3
|
import { codex } from "../lib/codex.ts";
|
|
5
4
|
import { grok } from "../lib/grok.ts";
|
|
@@ -10,53 +9,34 @@ import { codexPool, grokPool, opencodeGoPool } from "../lib/paths.ts";
|
|
|
10
9
|
import { earliestReset, gatedWindows, isExhausted, isSessionWindow, limitWindows, liveUsed, nextWeeklyReset, sessionWindow, thresholdBars, weeklyWindow } from "../lib/picker.ts";
|
|
11
10
|
import type { Provider, SampleReport } from "../lib/provider.ts";
|
|
12
11
|
import { bar, c, count, emitJson, fmtAgo } from "./render.ts";
|
|
13
|
-
import {
|
|
12
|
+
import type { Account, Config, Thresholds, Window } from "../lib/types.ts";
|
|
14
13
|
|
|
15
|
-
|
|
16
|
-
z.object({ ok: z.literal(true), source: z.enum(["statusline", "probe", "cached"]) }),
|
|
17
|
-
z.object({ ok: z.literal(false), reason: z.string() }),
|
|
18
|
-
]);
|
|
14
|
+
type WindowReport = { usedPercentage: number; resetsAt: number | null; windowSeconds: number | null };
|
|
19
15
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
16
|
+
type StatusAccount = {
|
|
17
|
+
label: string;
|
|
18
|
+
email: string | null;
|
|
19
|
+
id: string;
|
|
20
|
+
tier: string | null;
|
|
21
|
+
active: boolean;
|
|
22
|
+
sessions: number;
|
|
23
|
+
needsReauth: boolean;
|
|
24
|
+
exhausted: boolean;
|
|
25
|
+
usage: { fiveHour: WindowReport | null; week: WindowReport | null; limits: (WindowReport & { name: string })[] } | null;
|
|
26
|
+
usageAt: number | null;
|
|
27
|
+
limitsAt: number | null;
|
|
28
|
+
sample: SampleReport | { ok: true; source: "cached" };
|
|
29
|
+
};
|
|
26
30
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
needsReauth: z.boolean(),
|
|
35
|
-
exhausted: z.boolean(),
|
|
36
|
-
usage: z
|
|
37
|
-
.object({
|
|
38
|
-
fiveHour: WindowReportSchema.nullable(),
|
|
39
|
-
week: WindowReportSchema.nullable(),
|
|
40
|
-
limits: z.array(WindowReportSchema.extend({ name: z.string() })),
|
|
41
|
-
})
|
|
42
|
-
.nullable(),
|
|
43
|
-
usageAt: z.number().nullable(),
|
|
44
|
-
limitsAt: z.number().nullable(),
|
|
45
|
-
sample: SampleReportSchema,
|
|
46
|
-
});
|
|
47
|
-
type StatusAccount = z.infer<typeof StatusAccountSchema>;
|
|
31
|
+
type PoolReport = {
|
|
32
|
+
thresholds: Thresholds;
|
|
33
|
+
bars: Thresholds;
|
|
34
|
+
projectionMargin: number;
|
|
35
|
+
gatedNote: string | null;
|
|
36
|
+
accounts: StatusAccount[];
|
|
37
|
+
};
|
|
48
38
|
|
|
49
|
-
|
|
50
|
-
thresholds: ThresholdsSchema,
|
|
51
|
-
bars: ThresholdsSchema,
|
|
52
|
-
projectionMargin: z.number(),
|
|
53
|
-
gatedNote: z.string().nullable(),
|
|
54
|
-
accounts: z.array(StatusAccountSchema),
|
|
55
|
-
});
|
|
56
|
-
type PoolReport = z.infer<typeof PoolReportSchema>;
|
|
57
|
-
|
|
58
|
-
const StatusReportSchema = z.object({ now: z.number(), claude: PoolReportSchema, codex: PoolReportSchema, grok: PoolReportSchema, opencodeGo: PoolReportSchema });
|
|
59
|
-
export type StatusReport = z.infer<typeof StatusReportSchema>;
|
|
39
|
+
export type StatusReport = { now: number; claude: PoolReport; codex: PoolReport; grok: PoolReport; opencodeGo: PoolReport };
|
|
60
40
|
|
|
61
41
|
function currentWindow(w: Window, now: number): WindowReport {
|
|
62
42
|
const passed = w.resetsAt != null && w.resetsAt <= now;
|
|
@@ -104,23 +84,20 @@ function gatedNote(accounts: Account[], families: string[] | null, weeklyBar: nu
|
|
|
104
84
|
async function collect(p: Provider, cfg: Config, now: number, cached: boolean): Promise<PoolReport> {
|
|
105
85
|
let idx = loadAccounts(p.pool);
|
|
106
86
|
let reports = new Map<string, SampleReport>();
|
|
107
|
-
let liveId: string | null =
|
|
108
|
-
if (!cached) {
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
saveAccounts(p.pool, idx);
|
|
117
|
-
});
|
|
118
|
-
}
|
|
87
|
+
let liveId: string | null = null;
|
|
88
|
+
if (!cached && idx.accounts.length > 0) {
|
|
89
|
+
await withLock(p.pool.lockFile, async () => {
|
|
90
|
+
idx = loadAccounts(p.pool);
|
|
91
|
+
console.error(c.dim(`sampling ${p.name} usage...`));
|
|
92
|
+
liveId = p.liveId();
|
|
93
|
+
reports = await p.samplePool(idx.accounts, liveId, now);
|
|
94
|
+
saveAccounts(p.pool, idx);
|
|
95
|
+
});
|
|
119
96
|
}
|
|
120
97
|
|
|
121
98
|
const bars = thresholdBars(cfg);
|
|
122
99
|
const present = p.presence();
|
|
123
|
-
const ctx = { now, thresholds: bars, currentId:
|
|
100
|
+
const ctx = { now, thresholds: bars, currentId: null, families: p.gatedFamilies(cfg), seats: null };
|
|
124
101
|
const ordered = sortBy(idx.accounts, [(a) => (a.needsReauth ? 1 : 0), (a) => earliestReset(a, now)]);
|
|
125
102
|
const accounts = ordered.map((a): StatusAccount => {
|
|
126
103
|
const limits = limitWindows(a);
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { join } from "node:path";
|
|
2
2
|
import { mkdirSync } from "node:fs";
|
|
3
|
-
import { z } from "zod";
|
|
4
|
-
import { codexPaths } from "../lib/paths.ts";
|
|
3
|
+
import type { z } from "zod";
|
|
4
|
+
import { codexPaths, optionalEnv } from "../lib/paths.ts";
|
|
5
5
|
import { writeFileAtomic } from "../lib/atomic.ts";
|
|
6
6
|
import { MAX_WRAP_DEPTH, WRAP_DEPTH_ENV } from "../lib/claudebin.ts";
|
|
7
7
|
import { codex, codexPickCtx } from "../lib/codex.ts";
|
|
@@ -9,12 +9,11 @@ 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 { readStdin } from "../lib/proc.ts";
|
|
12
13
|
import { loadAccounts, loadConfig } from "../lib/state.ts";
|
|
13
14
|
import { CODEX_SUPERVISOR_ID_ENV } from "./codexsupervisor.ts";
|
|
14
15
|
import { CodexRespawnMarkerSchema, CodexStopStdinSchema, JsonTextSchema } from "../lib/types.ts";
|
|
15
|
-
import { log } from "../lib/log.ts";
|
|
16
|
-
|
|
17
|
-
const SupervisorIdSchema = z.string().min(1).optional().catch(undefined);
|
|
16
|
+
import { errorMessage, log } from "../lib/log.ts";
|
|
18
17
|
|
|
19
18
|
async function compactBeforeMove(input: { sessionId: string | null; now: number }): Promise<void> {
|
|
20
19
|
const { sessionId, now } = input;
|
|
@@ -30,18 +29,12 @@ async function compactBeforeMove(input: { sessionId: string | null; now: number
|
|
|
30
29
|
await compactCodexThread({ real: resolveRealCodex(), threadId: sessionId, env });
|
|
31
30
|
}
|
|
32
31
|
|
|
33
|
-
async function
|
|
34
|
-
const
|
|
35
|
-
for await (const chunk of Bun.stdin.stream()) chunks.push(chunk);
|
|
36
|
-
return Buffer.concat(chunks).toString("utf8");
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
export async function handleCodexStop(input: { rawStdin: string }): Promise<void> {
|
|
40
|
-
const parsed = CodexStopStdinSchema.safeParse(JsonTextSchema.safeParse(input.rawStdin).data);
|
|
32
|
+
async function handleCodexStop(rawStdin: string): Promise<void> {
|
|
33
|
+
const parsed = CodexStopStdinSchema.safeParse(JsonTextSchema.safeParse(rawStdin).data);
|
|
41
34
|
const sessionId = parsed.success ? (parsed.data.session_id ?? null) : null;
|
|
42
35
|
|
|
43
36
|
try {
|
|
44
|
-
const supervisorId =
|
|
37
|
+
const supervisorId = optionalEnv(CODEX_SUPERVISOR_ID_ENV);
|
|
45
38
|
if (supervisorId === undefined) {
|
|
46
39
|
log("codexstop.unsupervised_skip", {});
|
|
47
40
|
return;
|
|
@@ -51,24 +44,18 @@ export async function handleCodexStop(input: { rawStdin: string }): Promise<void
|
|
|
51
44
|
const decision = await evaluateAndMaybeSwap(codex, now);
|
|
52
45
|
if (decision.swapped && decision.account) {
|
|
53
46
|
mkdirSync(codexPaths.respawnDir, { recursive: true });
|
|
54
|
-
const payload =
|
|
55
|
-
accountId: decision.account.id,
|
|
56
|
-
sessionId,
|
|
57
|
-
ts: Date.now(),
|
|
58
|
-
});
|
|
47
|
+
const payload: z.infer<typeof CodexRespawnMarkerSchema> = { accountId: decision.account.id, sessionId, ts: Date.now() };
|
|
59
48
|
writeFileAtomic(join(codexPaths.respawnDir, supervisorId), JSON.stringify(payload));
|
|
60
49
|
log("codexstop.marker", { supervisorId: supervisorId.slice(0, 8) });
|
|
61
50
|
return;
|
|
62
51
|
}
|
|
63
52
|
} catch (e) {
|
|
64
|
-
log("codexstop.error", { err:
|
|
53
|
+
log("codexstop.error", { err: errorMessage(e) });
|
|
65
54
|
}
|
|
66
55
|
}
|
|
67
56
|
|
|
68
57
|
export async function runCodexStopHook(): Promise<number> {
|
|
69
|
-
if (!process.env.TOKENMAXXING_PROBE)
|
|
70
|
-
await handleCodexStop({ rawStdin: await readStdin() });
|
|
71
|
-
}
|
|
58
|
+
if (!process.env.TOKENMAXXING_PROBE) await handleCodexStop(await readStdin());
|
|
72
59
|
process.stdout.write("{}");
|
|
73
60
|
return 0;
|
|
74
61
|
}
|