tokenmaxxing 1.57.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.
Files changed (53) hide show
  1. package/DESIGN.md +6 -6
  2. package/README.md +3 -3
  3. package/package.json +1 -1
  4. package/src/cli/auth.ts +6 -17
  5. package/src/cli/check.ts +2 -2
  6. package/src/cli/cloudrun.ts +12 -19
  7. package/src/cli/config.ts +2 -1
  8. package/src/cli/doctor.ts +5 -4
  9. package/src/cli/init.ts +0 -1
  10. package/src/cli/render.ts +24 -9
  11. package/src/cli/rm.ts +0 -4
  12. package/src/cli/status.ts +34 -57
  13. package/src/entries/codexstophook.ts +10 -23
  14. package/src/entries/codexsupervisor.ts +30 -72
  15. package/src/entries/statusline.ts +17 -26
  16. package/src/entries/stopfailurehook.ts +4 -5
  17. package/src/entries/stophook.ts +12 -10
  18. package/src/entries/subagentstatusline.ts +4 -10
  19. package/src/entries/supervisor.ts +51 -103
  20. package/src/lib/claude.ts +11 -12
  21. package/src/lib/claudebin.ts +3 -2
  22. package/src/lib/claudelock.ts +2 -2
  23. package/src/lib/cloud.ts +4 -8
  24. package/src/lib/codex.ts +3 -3
  25. package/src/lib/codexauth.ts +2 -11
  26. package/src/lib/codexbin.ts +2 -1
  27. package/src/lib/codexoauth.ts +3 -8
  28. package/src/lib/codexusage.ts +5 -5
  29. package/src/lib/compact.ts +5 -18
  30. package/src/lib/credstore.ts +1 -6
  31. package/src/lib/decide.ts +6 -24
  32. package/src/lib/grok.ts +38 -152
  33. package/src/lib/http.ts +1 -2
  34. package/src/lib/install.ts +20 -38
  35. package/src/lib/keychain.ts +1 -4
  36. package/src/lib/log.ts +5 -5
  37. package/src/lib/oauth.ts +6 -5
  38. package/src/lib/opencodego.ts +31 -140
  39. package/src/lib/paths.ts +6 -22
  40. package/src/lib/picker.ts +9 -20
  41. package/src/lib/presence.ts +4 -5
  42. package/src/lib/proc.ts +22 -0
  43. package/src/lib/sample.ts +25 -24
  44. package/src/lib/sessions.ts +5 -6
  45. package/src/lib/settings.ts +1 -8
  46. package/src/lib/state.ts +6 -29
  47. package/src/lib/statusonly.ts +143 -0
  48. package/src/lib/supervise.ts +111 -0
  49. package/src/lib/types.ts +27 -36
  50. package/src/lib/update.ts +6 -7
  51. package/src/lib/usage.ts +40 -191
  52. package/src/main.ts +5 -5
  53. 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`). `activeId` is `null` for both pools: there is no pool-wide active account.
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 for a near-expiry token or a failed read), and on a Bun global install checks npm for a newer version once a day, so the cached figures every placement reads stay fresh.
44
+ - A periodic check job (`com.tokenmaxxing.check` launchd agent on macOS, `tokenmaxxing-check.timer` systemd user timer on Linux) running `tokenmaxxing check` once per tick (`policy.checkIntervalMs`, 60s by default): it runs the decision with no seat (nothing moves), folds fresh tees into the index, samples up to three accounts whose last sample attempt is oldest (the direct no-spend usage GET 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. Fallback when the seat's tee is older than `policy.usagePollTtlMs`, and the per-model read when the active model is a gated family: `claude -p '/usage'` under the seat's store, 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). The probe 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. It takes one attempt per interval; a silent result (claude prints local stats with no percentages when its own usage fetch throttles) doubles the interval up to 30 minutes, and a success resets it.
53
+ The Stop hook's stdin has no usage data, but the **statusLine does** (`rate_limits.{five_hour,seven_day}.{used_percentage,resets_at}`, after every turn, 300ms debounce, zero cost). tokenmaxxing's statusLine tees that to `usage/<uuid8>.json` for the session's seat, read from the inherited `CLAUDE_SECURESTORAGE_CONFIG_DIR` (write-on-change, O(ms)), and renders its own native line (it replaced the earlier pass-through delegation on 2026-07-11: install takes the statusLine slot outright, so a pre-existing custom statusline command is overwritten). A session with no pooled seat writes no tee. The seat is sampled when its tee is older than `policy.usagePollTtlMs`, and for the per-model rows when the active model is a gated family: the direct no-spend usage read with the seat's stored token, 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.
@@ -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 when the token is near expiry or the read fails, so every account is attempted about once per three ticks per pooled account, or once per `policy.usagePollTtlMs` plus a tick when that is longer, and its cached figure is as fresh as its last successful attempt or its sessions' latest push. The pool lock covers only the reservation and the result write, never the read or the `/usage` child.
87
+ - Selection reads the cached figures only, after folding every account's tee. Each periodic check tick samples up to three accounts whose last sample attempt is oldest, skipping any attempted within `policy.usagePollTtlMs`, over the direct no-spend usage read with the account's stored token, 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 `claude -p '/usage'` under the seat's store (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, and a launch, whose model is unknown, gates every configured family.
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 `claude -p '/usage'` under the seat's store (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 silent probes, so a Fable session moves on the Fable cap while a Sonnet session rides the aggregate.
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 only for a near-expiry token or a failed read; a move reads the cached figures, which are as fresh as each account's last successful attempt or its sessions' latest push.
93
+ - Every periodic check tick folds fresh statusline tees into the index and samples up to three accounts whose last sample attempt is oldest, skipping any attempted within `policy.usagePollTtlMs`: 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 `/usage` attempt stays fresh, for a seat's probe 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`.
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.57.0",
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
- const AuthPlanSchema = z.discriminatedUnion("kind", [
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 result = await withLock(p.pool.lockFile, async () => {
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 { account, isActive: idx.activeId === target.id };
88
+ return account;
97
89
  });
98
- if (result === null) return false;
90
+ if (account === null) return false;
99
91
 
100
- const note = harvested.sample ? usageNote(result.account) : "";
101
- console.log(`${c.green("✓")} reauthed ${c.bold(result.account.email ?? result.account.label)} (${result.account.tier ?? "?"})${note}`);
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 = e instanceof Error ? e.message : String(e);
17
+ const detail = errorMessage(e);
18
18
  log("check.error", { err: detail });
19
19
  emitError({ json, message: `check failed: ${detail}` });
20
20
  return 1;
@@ -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 session: string | null = null;
21
- let maxTurns: number | null = null;
22
- const positional: string[] = [];
23
- for (let i = 0; i < args.length; i++) {
24
- const arg = args[i]!;
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
- return { session, maxTurns, prompt: positional.length > 0 ? positional.join(" ") : null };
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: e instanceof Error ? e.message : String(e), extra: { path: paths.configJson } });
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 instanceof Error ? e.message : String(e)).slice(0, 100));
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 instanceof Error ? e.message : String(e)).slice(0, 100));
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 instanceof Error ? e.message : String(e)).slice(0, 100);
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 instanceof Error ? e.message : String(e)).slice(0, 100);
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 = Math.round((epochMs - now) / 1000);
61
+ const { dsec, d, h, m } = splitDuration(epochMs - now);
52
62
  if (dsec <= 0) return "reset now";
53
- const h = Math.floor(dsec / 3600);
54
- const m = Math.floor((dsec % 3600) / 60);
55
- if (h > 24) return `resets in ${Math.floor(h / 24)}d${h % 24}h`;
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 = Math.max(0, Math.round((now - epochMs) / 1000));
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 { ThresholdsSchema, type Account, type Config, type Window } from "../lib/types.ts";
12
+ import type { Account, Config, Thresholds, Window } from "../lib/types.ts";
14
13
 
15
- const SampleReportSchema = z.discriminatedUnion("ok", [
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
- const WindowReportSchema = z.object({
21
- usedPercentage: z.number(),
22
- resetsAt: z.number().nullable(),
23
- windowSeconds: z.number().nullable(),
24
- });
25
- type WindowReport = z.infer<typeof WindowReportSchema>;
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
- const StatusAccountSchema = z.object({
28
- label: z.string(),
29
- email: z.string().nullable(),
30
- id: z.string(),
31
- tier: z.string().nullable(),
32
- active: z.boolean(),
33
- sessions: z.number(),
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
- const PoolReportSchema = z.object({
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 = p.seats === "live" ? idx.activeId : null;
108
- if (!cached) {
109
- liveId = null;
110
- if (idx.accounts.length > 0) {
111
- await withLock(p.pool.lockFile, async () => {
112
- idx = loadAccounts(p.pool);
113
- console.error(c.dim(`sampling ${p.name} usage...`));
114
- liveId = p.liveId();
115
- reports = await p.samplePool(idx.accounts, liveId, now);
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: idx.activeId, families: p.gatedFamilies(cfg), seats: null };
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 readStdin(): Promise<string> {
34
- const chunks: Uint8Array[] = [];
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 = SupervisorIdSchema.parse(process.env[CODEX_SUPERVISOR_ID_ENV]);
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 = CodexRespawnMarkerSchema.parse({
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: e instanceof Error ? e.message : String(e) });
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
  }