tokenmaxxing 0.19.0 → 0.21.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 (59) hide show
  1. package/DESIGN.md +34 -23
  2. package/README.md +4 -4
  3. package/package.json +1 -1
  4. package/src/cli/add.ts +1 -0
  5. package/src/cli/auth.ts +25 -14
  6. package/src/cli/check.ts +3 -2
  7. package/src/cli/codexadd.ts +44 -40
  8. package/src/cli/codexinit.ts +59 -12
  9. package/src/cli/codexswitch.ts +15 -1
  10. package/src/cli/config.ts +10 -1
  11. package/src/cli/doctor.ts +3 -3
  12. package/src/cli/init.ts +54 -32
  13. package/src/cli/onboard.ts +62 -45
  14. package/src/cli/render.ts +0 -16
  15. package/src/cli/rm.ts +40 -2
  16. package/src/cli/serve.ts +650 -78
  17. package/src/cli/status.ts +69 -23
  18. package/src/cli/switch.ts +54 -19
  19. package/src/entries/codexstophook.ts +123 -4
  20. package/src/entries/codexsupervisor.ts +87 -13
  21. package/src/entries/sessionstart.ts +1 -1
  22. package/src/entries/statusline.ts +56 -20
  23. package/src/entries/stophook.ts +23 -9
  24. package/src/entries/supervisor.ts +134 -18
  25. package/src/lib/atomic.ts +28 -6
  26. package/src/lib/claudebin.ts +2 -2
  27. package/src/lib/claudejson.ts +5 -5
  28. package/src/lib/claudelock.ts +112 -37
  29. package/src/lib/codexauth.ts +10 -2
  30. package/src/lib/codexbin.ts +1 -1
  31. package/src/lib/codexdecide.ts +149 -19
  32. package/src/lib/codexpick.ts +17 -6
  33. package/src/lib/codexpresence.ts +59 -21
  34. package/src/lib/codexsample.ts +17 -8
  35. package/src/lib/codexswap.ts +10 -1
  36. package/src/lib/credstore.ts +6 -2
  37. package/src/lib/decide.ts +114 -42
  38. package/src/lib/install.ts +125 -17
  39. package/src/lib/keychain.ts +41 -15
  40. package/src/lib/lock.ts +57 -35
  41. package/src/lib/log.ts +36 -7
  42. package/src/lib/oauth.ts +18 -11
  43. package/src/lib/paths.ts +17 -11
  44. package/src/lib/picker.ts +11 -3
  45. package/src/lib/proc.ts +37 -0
  46. package/src/lib/sample.ts +91 -31
  47. package/src/lib/sessions.ts +23 -1
  48. package/src/lib/settings.ts +59 -18
  49. package/src/lib/slackbridge.ts +583 -76
  50. package/src/lib/slackstate.ts +159 -12
  51. package/src/lib/slackstream.ts +127 -21
  52. package/src/lib/state.ts +131 -35
  53. package/src/lib/swap.ts +109 -47
  54. package/src/lib/types.ts +79 -37
  55. package/src/lib/usage.ts +114 -16
  56. package/src/main.ts +61 -7
  57. package/src/serve-plugin/.claude-plugin/plugin.json +4 -0
  58. package/src/serve-plugin/skills/ask-the-user/SKILL.md +36 -0
  59. package/src/serve-plugin/skills/serve-session/SKILL.md +50 -0
package/DESIGN.md CHANGED
@@ -1,10 +1,10 @@
1
1
  # tokenmaxxing - design
2
2
 
3
- Automatic Claude Code account switching. You run `claude` exactly as always; when the active account crosses its swap threshold (**95%** of the 5h session window, **98%** of a weekly window), tokenmaxxing swaps the credential to a fresh account at a safe turn boundary and **your running session adopts it in place - no restart**. Works across many concurrent sessions at once; a fully depleted pool pauses with a countdown and auto-resumes at the soonest reset.
3
+ Automatic Claude Code account switching. You run `claude` exactly as always; when the active account crosses its swap threshold (**95%** of the 5h session window, **98%** of a weekly window), tokenmaxxing swaps the credential to a fresh account at a safe turn boundary and **your running session adopts it in place - no restart**. Works across many concurrent sessions at once; a fully depleted pool pauses with a countdown and auto-resumes at the soonest reset when it lands within `policy.maxWaitMs` (default 1h; further out, the session stays put rather than parking for hours).
4
4
 
5
- > Scope: **Claude Code only, macOS first.** Codex and other CLIs deferred (see `.memory/cc-codex-auth-mechanics.md`).
5
+ > Scope: **Claude Code first (macOS + Linux, the latter since 2026-07-09).** Codex support landed in 0.13.0 (2026-07-16) with its own parallel state, decision engine, and supervisor; its verified internals live in AGENTS.md's Codex sections.
6
6
  >
7
- > Status: **implemented** (v0.1.0, 2026-07-09). TypeScript on Bun single binary; Zod validates every external-boundary payload, JSON config, es-toolkit for utilities, `flock(2)` via `bun:ffi`. All load-bearing external facts were adversarially verified against the `2.1.204` binary + docs (OAuth token endpoint is `platform.claude.com/v1/oauth/token`, client_id `9d1c250a-...`, JSON body). What the acceptance gate actually shows is in §9.
7
+ > Status: **implemented** (v0.1.0, 2026-07-09). TypeScript on Bun, shipped as SOURCE with a bun-shebang bin (the compiled-binary distribution was deleted in 0.2.1); Zod validates every external-boundary payload, JSON config, es-toolkit for utilities, `flock(2)` via `bun:ffi`. All load-bearing external facts were adversarially verified against the `2.1.204` binary + docs (OAuth token endpoint is `platform.claude.com/v1/oauth/token`, client_id `9d1c250a-...`, JSON body). What the acceptance gate actually shows is in §9.
8
8
 
9
9
  ---
10
10
 
@@ -17,37 +17,39 @@ So the supervisor's job is narrow: on a depleted pool it **replaces the process
17
17
  A hook can't do the pause-and-relaunch - when `claude` exits, the shell owns the terminal. So tokenmaxxing installs a **supervisor** (aliased to `claude`) that owns the process lifecycle:
18
18
 
19
19
  ```
20
- supervisor (you type `claude`) → real claude (in a PTY) → Stop hook
20
+ supervisor (you type `claude`) → real claude (inherited stdio) → Stop hook
21
21
  ▲_______________ relaunch --resume <sid> ______________|
22
22
  ```
23
23
 
24
- It is a process/PTY manager only - spawn, forward the terminal, wait, restore terminal, relaunch. It never proxies API traffic or handles tokens. Everything else about `claude` is unchanged.
24
+ It is a process manager only - spawn with inherited stdio plus saved `stty -g` termios (not a PTY copy), wait, restore the terminal, relaunch. It never proxies API traffic or handles tokens. Everything else about `claude` is unchanged.
25
25
 
26
26
  ---
27
27
 
28
28
  ## 2. What tokenmaxxing installs
29
29
 
30
30
  - A `claude` **supervisor** on your PATH ahead of the real binary (`~/.config/tokenmaxxing/bin/claude`), or a shell function - you invoke it identically.
31
- - Four `~/.claude/settings.json` entries (merged, preserving anything you already have): a transparent `statusLine` shim, a `subagentStatusLine` shim (per-subagent rows in the agents panel), a `Stop` hook, a `SessionStart` hook.
31
+ - Four `~/.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 `SessionStart` hook.
32
32
  - **`~/.config/tokenmaxxing/`** - the single home for config and state:
33
- - `config.json` - threshold, account order/policy.
34
- - `accounts.json` - non-secret index `{email, organizationUuid, accountUuid, lastUsage, resetsAt, needs_reauth}`.
33
+ - `config.json` - SPARSE overrides only (thresholds.session/weekly, claudeBin/codexBin pins, policy.*); defaults merge at read time, `xx config` edits it.
34
+ - `accounts.json` - non-secret index `{email, organizationUuid, accountUuid, label, lastUsage, lastPerModel, needsReauth, ...}` (window resets live inside lastUsage).
35
35
  - `usage.json` - live usage, written by the statusLine shim.
36
36
  - `respawn/<session-id>` - per-session respawn markers (the hook→supervisor signal, depleted-pool waits only).
37
37
  - `bin/claude` - the supervisor.
38
38
  - Per-account **credentials** follow the platform's Claude Code store: macOS = login-keychain items `tokenmaxxing-cred-<accountUuid[:8]>` (never plaintext on disk); Linux = 0600 files `creds/tokenmaxxing-cred-<accountUuid[:8]>.json` (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.
39
39
 
40
- No background daemon - it's event-driven (statusline pushes usage; hooks + supervisor react). The `claude` binary and `~/.claude` layout are untouched.
40
+ - A periodic check job (`com.tokenmaxxing.check` launchd agent on macOS, `tokenmaxxing-check.timer` systemd user timer on Linux) running `tokenmaxxing check` every 180s: hooks alone miss long agentic turns, so the timer is the backstop that keeps switching engaged mid-turn.
41
+
42
+ The switching path runs no long-lived daemon - the statusline pushes usage and hooks + supervisor react, with the periodic check job above as its only recurring process (`xx serve`, the opt-in Slack bridge, is a separate long-lived daemon). The `claude` binary and `~/.claude` layout are untouched.
41
43
 
42
44
  ---
43
45
 
44
46
  ## 3. How a switch happens
45
47
 
46
48
  ### 3.1 Usage feed (free, push-based)
47
- 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 shim tees that to `usage.json` (write-on-change, O(ms)) and passes your real statusline through unchanged. Cold-start fallback if `usage.json` is absent: `TOKENMAXXING_PROBE=1 claude -p '/usage'`, with `[ -n "$TOKENMAXXING_PROBE" ] && exit 0` as the hook's first line to stop the nested process recursing (hooks fire in `-p` too). The probe scrubs every ambient credential override claude reads before the keychain (`CLAUDE_CODE_OAUTH_TOKEN`, `CLAUDE_SECURESTORAGE_CONFIG_DIR`, etc.) so it can only meter the credential in the keychain item, and retries the transient empty-footer case (claude prints local stats with no percentages when its own usage fetch throttles).
49
+ 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.json` (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). Cold-start fallback if `usage.json` is absent: `TOKENMAXXING_PROBE=1 claude -p '/usage'`, with `[ -n "$TOKENMAXXING_PROBE" ] && exit 0` as the hook's first line to stop the nested process recursing (hooks fire in `-p` too). The probe scrubs every ambient credential override claude reads before the keychain (`CLAUDE_CODE_OAUTH_TOKEN`, `CLAUDE_SECURESTORAGE_CONFIG_DIR`, etc.) so it can only meter the credential in the keychain item, and retries the transient empty-footer case (claude prints local stats with no percentages when its own usage fetch throttles).
48
50
 
49
51
  ### 3.2 Detect + swap + signal (Stop hook, per turn)
50
- 1. Read `usage.json`; `exit 0` fast if every window is under its threshold (metered per `organizationUuid`).
52
+ 1. Read `usage.json`; `exit 0` fast below the engagement floor (`policy.greedySessionFloor`, default 50% of the 5h window) unless a screening bar is already crossed. Engaged-but-under-every-bar runs the GREEDY convergence: stay when the current account wins or ties, else swap onto the strictly better account. A crossed bar forces the hard path (metered per `organizationUuid`).
51
53
  2. Else take a `flock` on `~/.config/tokenmaxxing/lock`, re-check under it (parallel sessions race - first winner already swapped), pick the best parked account (not rate-limited, furthest behind its own weekly pace first: highest remaining% / time-to-weekly-reset, since unused allowance is forfeited at the fixed per-account reset; tiebreak soonest expiry then lowest 7-day usage), and **swap the credential** (§3.4).
52
54
  3. Done - the running session adopts the new credential on its own within a request or two. Only when the pool is depleted (the decision returned a `waitUntil`: pre-parked on the soonest-recovering account, or staying on the current one when it recovers first) does the hook write `respawn/<session_id>` (atomic temp+rename).
53
55
 
@@ -59,7 +61,7 @@ The supervisor sees `respawn/<sid>`, SIGTERMs its child at the already-committed
59
61
  2. **Refresh B** - OAuth refresh-grant with B's parked refresh token → fresh access token; persist the rotated refresh token. On `invalid_grant`, mark B `needs_reauth`, notify, try the next account.
60
62
  3. **Install B** - `security add-generic-password -U ... 'Claude Code-credentials' ...` with B's fresh (non-expired) `claudeAiOauth` JSON.
61
63
  4. **Swap identity + mark B active** - atomically rewrite only the `oauthAccount` object in `~/.claude.json` (temp+rename) to B's, and write `activeAccountUuid = B` in the SAME critical section, so a crash can't leave the installed credential and the active label pointing at different accounts.
62
- 5. Do steps 1, 3, 4 inside Claude's own `~/.claude.lock` so the writes can't collide with a token refresh.
64
+ 5. Do steps 1, 3, 4 inside Claude's own refresh locks - the primary `<credDir>/.oauth_refresh.lock` plus the legacy sibling `<realpath(credDir)>.lock` (`~/.claude.lock` by default), both mkdir-based proper-lockfile locks, binary-verified against 2.1.214 - so the writes can't collide with a token refresh. Contention fails the swap fast (claude itself gives up with `lock_timeout` rather than refreshing unlocked); the next check retries.
63
65
 
64
66
  ### 3.5 Multiple concurrent sessions
65
67
  Each terminal ran the supervisor, so each has its own child `claude` and its own `--session-id`. When the shared account hits a threshold, the first Stop hook to win the `flock` performs the one swap; every running session then adopts the new credential in place - no restarts. (They share one credential, so they always move together - consistent with "one current account, many windows.") Only a depleted pool fans out: each supervised session's Stop hook writes its own `respawn/<sid>` marker, and each supervisor independently pauses and later relaunches `claude --resume <its-own-sid>`.
@@ -68,7 +70,7 @@ Each terminal ran the supervisor, so each has its own child `claude` and its own
68
70
 
69
71
  ## 4. Onboarding (no `adopt`)
70
72
 
71
- - **`tokenmaxxing init` imports the account you're already on - automatically, no prompts, no re-login.** It reads the live `Claude Code-credentials` keychain blob plus the `oauthAccount` object in `~/.claude.json` (email, `organizationUuid`, `accountUuid`, plan tier) and writes them as **account #1** into tokenmaxxing's store (`tokenmaxxing-cred-<accountUuid[:8]>` + an `accounts.json` index entry). Nothing about your current session changes - that account stays active; it's now just also a registered pool member. After this one command you already have a working (single-account) pool. `init` also installs the supervisor + the three settings entries.
73
+ - **`tokenmaxxing init` imports the account you're already on - automatically, no prompts, no re-login.** It reads the live `Claude Code-credentials` keychain blob plus the `oauthAccount` object in `~/.claude.json` (email, `organizationUuid`, `accountUuid`, plan tier) and writes them as **account #1** into tokenmaxxing's store (`tokenmaxxing-cred-<accountUuid[:8]>` + an `accounts.json` index entry). Nothing about your current session changes - that account stays active; it's now just also a registered pool member. After this one command you already have a working (single-account) pool. `init` also installs the supervisor + the four settings entries.
72
74
  - If the current auth is API-key mode (`ANTHROPIC_API_KEY`/`apiKeyHelper`) rather than a subscription `/login`, there's no quota-poolable subscription credential to import - `init` says so and points you to `/login` first (per-token API billing isn't what tokenmaxxing pools).
73
75
  - **`tokenmaxxing add`** - registers *additional* accounts: logs one in via a throwaway `CLAUDE_CONFIG_DIR=~/.config/tokenmaxxing/onboard` (your primary login untouched), harvests it into the store, deletes the temp dir + its namespaced item. This is the **only** time `CLAUDE_CONFIG_DIR` is ever used.
74
76
  - **`tokenmaxxing auth [sel | --all]`** - reauthenticates an *existing* pool member whose refresh token died (a needs-reauth account can never heal through a swap: the dead token is exactly what a swap would need). Same isolated-login harvest as `add`, but it states which email to sign in with and **requires the login to land on the target account** (harvested `accountUuid` must match, else nothing changes) - the credential write and the needs-reauth clear happen in one flock critical section so a concurrent swap's harvest cannot clobber the fresh backup. Bare `auth` lists the pool with emails and asks which; `--all` walks every flagged account one by one.
@@ -77,7 +79,7 @@ Each terminal ran the supervisor, so each has its own child `claude` and its own
77
79
  ---
78
80
 
79
81
  ## 5. Rotation policy
80
- The decision engages at `five_hour >= 50%` (policy.greedySessionFloor): from there it greedily converges on the usable account furthest behind its weekly pace, staying put whenever the current account wins or ties. The hard bars - `five_hour >= 95%` OR `seven_day >= 98%`, per org - always force a switch and also screen candidates. "Exhausted" is a **timestamped state** (`resets_at`), not a flag - an account is a candidate again after it resets. Optional projected threshold (`bar - EMA(per-turn Δ%)`) so a single large turn can't blow past 100% before the next Stop hook.
82
+ The decision engages at `five_hour >= 50%` (policy.greedySessionFloor): from there it greedily converges on the usable account furthest behind its weekly pace, staying put whenever the current account wins or ties. The hard bars - `five_hour >= 95%` OR `seven_day >= 98%`, per org - always force a switch and also screen candidates. "Exhausted" is a **timestamped state** (`resets_at`), not a flag - an account is a candidate again after it resets. Optional projected threshold (`bar - policy.projectionMargin`, a fixed configured margin) so a single large turn is less likely to blow past 100% before the next Stop hook.
81
83
 
82
84
  **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` we read it from `claude -p '/usage'` (free, 0 tokens, TTL-cached) and add `week(<activeModel>) >= threshold` to the trigger. A Fable session switches on the Fable cap; a Sonnet session rides the aggregate.
83
85
 
@@ -87,14 +89,23 @@ The decision engages at `five_hour >= 50%` (policy.greedySessionFloor): from the
87
89
 
88
90
  A local Socket Mode daemon (no public URL) that turns Slack threads into Claude Code sessions on the pooled accounts. Stack (user decision 2026-07-18): Vercel's Chat SDK (`chat` + `@chat-adapter/slack`) for the Slack side; the Claude Agent SDK driven through `src/sdk.ts`'s pooled surface for the claude side - `xx serve` is that surface's first in-repo consumer. EVE (Vercel's agent framework) was researched and explicitly dropped: it owns its own model loop via AI Gateway, so it would replace Claude Code rather than drive it.
89
91
 
90
- - **Config**: `slack.json` (0600 - it holds the xoxb-/xapp- tokens) with per-channel links `{channel, repo, worktree, permissionMode, model?}`. `serve setup` prints the app manifest (minimal scopes: app_mentions:read, channels:history, groups:history, chat:write, files:write, users:read + socket mode) and prompts for the tokens; `serve link <channel-id> <repo>` manages links (channel IDs only - names drift, ids don't).
91
- - **Thread = session**: a bot mention in a linked channel subscribes the thread, creates `slack-worktrees/<threadKey>` (branch `tm-slack-<threadKey>` cut from the repo's HEAD; `--no-worktree` links run in the repo itself), and records `{threadId, cwd, sessionId}` under `slack-threads/`. Resume is cwd-keyed in claude, so the cwd stays byte-stable for the thread's life; worktrees are never auto-deleted (they hold the thread's work).
92
+ - **Config**: `slack.json` (0600 - it holds the xoxb-/xapp- tokens) with per-channel links `{channel, repo, permissionMode, model?}`. `serve setup` prints the app manifest (bot scopes: app_mentions:read, assistant:write, channels:history, groups:history, chat:write, files:write, im:history, users:read; agent_view enabled; events incl. app_mention, message.channels/groups/im, app_home_opened, app_context_changed; socket mode) and prompts for the tokens; `serve link <channel-id> <repo>` manages links (channel IDs only - names drift, ids don't).
93
+ - **Thread = session**: a bot mention in a linked channel subscribes the thread and opens the session in the linked repo checkout (normal mode, user decision 2026-07-18 superseding the same-day worktree-per-thread default; a later same-day decision: the agent cuts its own worktree FIRST for any mutating task - read-only turns stay parallel in the shared checkout - taught by the serve-session skill, since the recorded cwd can never move), and records `{threadId, cwd, sessionId}` under `slack-threads/`. Resume is cwd-keyed in claude, so the cwd stays byte-stable for the thread's life; records from the worktree era pin their old `slack-worktrees/<threadKey>` cwd and keep working (those worktrees are never auto-deleted - they hold the thread's work).
94
+ - **Thread close-out (finish_thread)**: when the user says the work is finished, the model calls the in-process MCP tool `finish_thread` (a per-turn `createSdkMcpServer` in relayThread, `alwaysLoad: true`, granted via `allowedTools` since nobody can answer a permission prompt over Slack). The handler runs in the daemon but only flags the turn outcome; after the turn ends (claude subprocess gone, segments posted) serve.ts runs `cleanupThread`: delete the `slack-threads/` record, `thread.unsubscribe()`, and post one confirmation line. Threads run in the shared repo checkout, so there is nothing on disk to collect and the checkout is never touched; a fresh mention after close-out starts a new session.
92
95
  - **Turn = spawn**: each thread message runs ONE `query()` with `resume: sessionId` (never a persistent streaming query - the SDK subprocess reads credentials at spawn, so per-turn spawns are what let `ensureBestAccount()` land each turn on the freshest account, and the daemon can restart without losing threads). `stopHookCheck` rides along as the SDK Stop hook. Streamed `text_delta`s feed `thread.post(AsyncIterable)` (the adapter debounces edits); tool-only turns post the final result text.
93
- - **Safety posture**: per-link `permissionMode`, default `acceptEdits`; `--yolo` (alias `--dangerous`) opts a link into `bypassPermissions`, and relayThread pairs it with the SDK's mandatory `allowDangerouslySkipPermissions: true` opt-in. `AskUserQuestion` is disallowed (unanswerable over Slack; the model asks in prose instead). Turn failures post a trimmed message-only diagnostic (never a raw error body).
96
+ - **Safety posture**: per-link `permissionMode`, default `acceptEdits`; `--yolo` (alias `--dangerous`) opts a link into `bypassPermissions`, and relayThread pairs it with the SDK's mandatory `allowDangerouslySkipPermissions: true` opt-in. `AskUserQuestion` is disallowed (unanswerable over Slack; the model asks in prose instead). Turn failures post a trimmed message-only diagnostic (never a raw error body). Outsiders must not drive sessions (harvested from Slaude at its 2026-07-18 shutdown): `isOutsideAuthor` fail-closed rejects any message whose team-origin fields disagree with the home workspace's `workspaceTeamId` (captured via auth.test at setup and re-captured at every daemon start, so the reference can never go stale against a rotated token), so Slack Connect externals and cross-workspace guests are silently ignored and can never open a session.
97
+ - **Serve skills** (`src/serve-plugin/`, ships in the package via `files: ["src"]`): a Claude Code plugin loaded per turn (`plugins: [{type: "local", path}]`; discovered skills are enabled by default, so no `skills` option). `tokenmaxxing:ask-the-user` teaches the decision protocol - when input is needed, tag the requester with the raw Slack mention token `<@U...>`, ask compactly, END the turn (the thread reply is the next turn); `tokenmaxxing:serve-session` documents how the session runs (shared repo checkout, resume across turns, worktree-by-default for mutating tasks, handoff). The one dynamic fact skills cannot carry - who asked - rides in per turn via a `UserPromptSubmit` hook whose `additionalContext` ("Slack relay context: ...", built by `serveTurnContext`) names the triggering message author's mention token. The mention survives the pipeline because streamed `markdown_text` deltas pass verbatim and the post-and-edit fallback's `finalize` only linkifies bare `@U...`, never escaping an already-formed `<@U...>`. Skills are independent of the system prompt choice (probe-verified 2026-07-18 under the custom-string `SLACK_SYSTEM_PROMPT`: the init message lists the plugin + both skills + the Skill tool; the skill listing arrives as a conversation system-reminder, and CLAUDE.md loads via settingSources).
94
98
  - **Socket lifecycle**: `bot.initialize()` starts the persistent auto-reconnecting SocketModeClient; the daemon then just stays alive. The leased `startSocketModeListener` API must never be looped: it returns instantly without `waitUntil` and the loop starves the event loop (live incident 2026-07-18 - connected but silent).
95
- - **Id mapping**: Chat SDK ids are adapter-prefixed (`thread.channelId` = `slack:C0123`, `thread.id` = `slack:C0123:<threadTs>`) while links store bare Slack ids - lookups strip the prefix via `bareChannelId`. Subscriptions live in the daemon's memory state, so every mention re-subscribes its thread (a restarted daemon revives an old thread on the next mention); queue-skipped messages (`context.skipped`) fold into the next prompt, with the queue-entry TTL raised to 900s; unlinked-channel traffic logs `serve.unlinked_channel` and stays silent in Slack.
99
+ - **Id mapping**: Chat SDK ids are adapter-prefixed (`thread.channelId` = `slack:C0123`, `thread.id` = `slack:C0123:<threadTs>`) while links store bare Slack ids - lookups strip the prefix via `bareChannelId`. Subscriptions live in the daemon's memory state, so every mention re-subscribes its thread; queue-skipped messages (`context.skipped`) fold into the next prompt, with the queue-entry TTL raised to 1h (expiry is silent - no app callback in chat 4.34.0 - so it must outlast a depleted-pool park plus a long turn); per-thread turn serialization is owned by the daemon itself (a promise chain per thread id), because the SDK's queue dispatch lock has a 30s TTL extended only between dispatches and every claude turn outlives it; unlinked-channel traffic logs `serve.unlinked_channel` and stays silent in Slack.
100
+ - **Restart resilience** (0.19.0, from the 2026-07-18 dead-thread incident: a deploy restart cut a turn mid-answer and left the thread deaf to follow-ups): startup re-subscribes every `slack-threads/` record straight on the state adapter (`state.subscribe(threadId)`; message routing checks `stateAdapter.isSubscribed`, verified in chat 4.34.0), so open threads survive restarts without needing a fresh mention. SIGTERM/SIGINT drains instead of dying: new turns are dropped loudly (`serve.drain_dropped` plus a tracked in-thread notice - a log-only drop reads as the bot thinking), tracked in-flight turns get up to 300s to finish (a re-snapshotting wait, so late-added notices still flush), then `Chat.shutdown()`; a second signal forces exit; the drain also aborts any depleted-pool park so a countdown never delays a restart. The claude child spawns detached in its own process group (`detachedClaudeSpawn` via the SDK's `spawnClaudeCodeProcess` hook), because a terminal Ctrl-C signals the whole foreground group and a non-detached child died with the daemon before the drain could save the turn; the signal handlers register before `bot.initialize()` so no turn can start while the process still has default signal disposition, and a rejecting `Chat.shutdown()` is caught so the drain always reaches its exit. When a `thread.post` rejects mid-turn (e.g. Slack finalizes an idle stream: `message_not_in_streaming_state`), relayThread drops the dead segment so the rest of the turn opens fresh messages instead of vanishing.
101
+ - **Interrupted-turn recovery** (from the second 2026-07-18 restart incident: a redeploy killed a ship turn 8 minutes in with zero notice - drain cannot save a long turn, and a group-wide kill can take the claude child even detached): the thread record persists the claude session id the moment the init message assigns it, and every turn is wrapped in a durable `activeTurn` marker (original prompt, start time, resume count) written before the spawn and cleared when the turn returns - except that a turn failing DURING a drain keeps its marker, since that failure is presumed to be the shutdown signal killing the child. On startup, a surviving marker means a restart killed that turn: the daemon posts a notice into the thread (the chat-sdk's documented proactive handle, rebuilt with the thread's newest human message as streaming recipient context so the resumed turn keeps its native task cards, that message's author becoming the turn's requester) and auto-resumes the work - resuming the recorded session with a continuation prompt, or replaying the original prompt fresh when the kill landed before the session opened; resumed turns settle like inbound ones (outcome log + finish_thread GC). Retries cap at 3 (each spends real quota) with a loud give-up notice posted before its marker clears; a per-thread turn lock keeps a startup resume from ever racing an inbound message turn in the same cwd, every recovery branch recomputes its decision from a fresh record reload under that lock (so a resume superseded by a faster inbound turn no-ops instead of double-running), and a blocking singleton flock makes a new daemon generation wait for the previous one - drain included - to fully exit before touching any thread record. Uncatchable deaths cannot leak a working child either: SIGHUP drains like SIGTERM (its default disposition skips the exit hook that kills the detached group), and the marker records the child's group pid plus its C-locale ps start-time token at spawn, so recovery kills only an exactly-identified SIGKILL-orphaned claude (never a recycled pid) before resuming its turn.
102
+ - **Depleted-pool recovery** (harvested from Slaude, reshaped around the pool): relayThread consumes the spawn-boundary switch decision instead of discarding it - a depleted pool with a known recovery inside the message's one 14min parking deadline (shared across chained parks, so the thread's queue slot is never held longer in total) posts a park notice and retries at the reset; unknown or past-deadline recovery posts an honest drop notice (dropping beats a false will-resume promise). A mid-turn limit - detected ONLY on errored results, since `is_error` can ride subtype `"success"` (`Claude AI usage limit reached|<epoch>`) and a successful answer discussing limits must never be re-run - is persisted into usage.json first (`recordObservedLimit`: the serve process has no statusLine tee, and the snapshot TTL would otherwise feed the retry the stale pre-limit state), then retried silently after a short beat, so a pool swap makes the hiccup invisible. Bounded recoveries; every drop the relay performs is announced in-thread.
103
+ - **Slack-native output hygiene**: relayed turns run a small standalone `systemPrompt` telling the model replies render as Slack markdown, never HTML (a live turn once answered with a literal `<br>`; the SDK's default system prompt is minimal since 0.1.0, so the string replaces nothing), and whitespace-only text deltas do not count as reply text for segment breaking (no stranded near-blank messages).
104
+ - **Slash commands** (2026-07-18): a mention-stripped thread message that starts with `/` runs as a claude slash command - the SDK delivers a string prompt as one stream-json user message and the CLI routes a leading-slash prompt through its command table, on fresh and resumed sessions alike (live-verified with free `/usage` and `/context` reads). Local command output (`num_turns` 0) arrives as a non-streamed assistant message plus `result.result`, which relayThread's no-text fallback posts; `slackstream` also maps the documented `system/local_command_output` wire subtype in case a claude update flips the emitter. `/goal` (built-in since 2.1.139) works headless and its active goal is restored on resume, so it survives the per-message resume pattern. Slack gotcha: the composer eats ANY message whose first character is `/` client-side (channels and thread replies; registered app commands cannot even dispatch from threads), so the supported forms are `@bot /usage` (mention-first) and ` /usage` (leading space, Slack's own documented workaround) - both normalize to a position-0 command via `stripLeadingMention`.
96
105
  - **Agent representation** (`src/lib/slackstream.ts`): turns stream natively (`chat.startStream`, which works in channel threads regardless of the assistant:write scope): thinking and tool calls as task cards ("Thinking"/tool name/"Turn", input summary + truncated output), reply text as native markdown with rendered code fences, and a segment break whenever a tool starts after streamed text, so one turn posts as separate ordered Slack messages around its tool runs.
97
- - **Live-verified end-to-end** (2026-07-18, #tokenmaxxing-dogfooding): mention opens worktree + session, replies stream, thread follow-ups resume with context, cards + fenced code render, segmentation and queue folding behave. Plus the hermetic suite: schemas/links, worktree idempotency, stream mapping, fail-fast paths.
106
+ - **Todo checklist card**: TodoWrite is bookkeeping, not a real tool run, so it never opens a generic card and never breaks a segment. Instead each stream gets one stable-id "Todos" card (id `todos`, subagents `todos-<parent_tool_use_id>`) that updates in place on every TodoWrite: `✅ content` for completed, `🔄 activeForm` for the in-progress item (live narration), `⬜ content` for pending (the Chat SDK Plan object's own iconography); card status goes complete only when every item is completed. The "Todos have been modified successfully" tool_result is suppressed, but a FAILED TodoWrite flips the card to error (the optimistic checklist must not claim a state that never took effect). Card ids do not carry across segments, so a post-break TodoWrite starts a fresh card in the new message: accepted, the latest state is always in the newest message. claude >= 2.1.142 defaults to the structured Task tools and never emits TodoWrite, so relayThread sets `CLAUDE_CODE_ENABLE_TASKS: "0"` in the spawn env (the documented opt-out; without it the checklist card is inert).
107
+ - **Terminal echo** (2026-07-18): the daemon registers a `log()` echo (`setLogEcho` in log.ts, off by default so hooks and the statusline keep their stdout protocols clean), so every event while it runs - serve.* plus the in-process swap/decision events from `ensureBestAccount()`/`stopHookCheck` - also prints one colored line in the foreground terminal (`formatLogLine`: dim HH:MM:SS, event painted red/yellow/cyan by structural endsWith severity, redacted fields). `serve.turn_done`/`serve.turn_failed` (with seconds) close every relayed turn, so a foreground `xx serve` is observable without tailing `tokenmaxxing.log`.
108
+ - **Live-verified end-to-end** (2026-07-18, #tokenmaxxing-dogfooding): mention opens a session (worktree-per-thread at the time), replies stream, thread follow-ups resume with context, cards + fenced code render, segmentation and queue folding behave. Plus the hermetic suite: schemas/links, stream mapping, fail-fast paths.
98
109
 
99
110
  ---
100
111
 
@@ -105,9 +116,9 @@ A local Socket Mode daemon (no public URL) that turns Slack threads into Claude
105
116
  - **Single-turn overshoot.** If one turn jumps from under the threshold straight past the wall, that turn can end rate-limited before the Stop hook swaps; the swap then still recovers the session (its next turn adopts the fresh account). Projected threshold reduces this.
106
117
  - **Shared blast radius.** All default-profile sessions share one keychain item, so a swap moves them all (each adopts in place). The `flock` + re-check is mandatory or racing hooks burn two accounts at once.
107
118
  - **Refresh-token rotation / parked-token rot.** Step 1 re-harvest is mandatory; a parked refresh token can be invalidated by logging in elsewhere → picker must catch `invalid_grant`, mark `needs_reauth`, fall through.
108
- - **statusLine fragility.** The shim is the most visible surface - a bug flickers or breaks your real status line. Keep it O(ms), write-on-change.
119
+ - **statusLine fragility.** The native statusline is the most visible surface - a bug flickers or blanks the line for every session (and install takes the slot outright, replacing any custom statusline you had). Keep it O(ms), write-on-change.
109
120
  - **Keychain blob size & ps-safety.** The live `Claude Code-credentials` item also holds per-MCP-server OAuth state, so it can exceed `security -i`'s ~4KB interactive line buffer (verified on a real machine - a 4.3KB blob truncated). tokenmaxxing therefore stores parked backups as **`claudeAiOauth`-only** (small → always the ps-safe stdin write) and, on a swap, **merges** the fresh `claudeAiOauth` into the *current* live blob so MCP tokens survive the switch - using the argv write path (secret briefly visible in `ps` on your own machine) only for that one oversized live write.
110
- - **settings.json is user-owned.** Install by merge; ship `tokenmaxxing doctor` to verify the supervisor + 3 entries survive a `/config` edit or update.
121
+ - **settings.json is user-owned.** Install by merge; ship `tokenmaxxing doctor` to verify the supervisor + 4 entries survive a `/config` edit or update.
111
122
 
112
123
  ---
113
124
 
@@ -120,14 +131,14 @@ A local Socket Mode daemon (no public URL) that turns Slack threads into Claude
120
131
 
121
132
  **Later:** tool-agnostic picker.
122
133
 
123
- **Shipped since (0.13.0):** Codex as a second pool, parallel state (`codex-accounts.json`, `codex-creds/`, own flock), same pace-pressure policy. Codex differences that shaped it: restart IS the switch (a running codex refuses another account's credential), usage is a free direct GET with epoch resets and a duration-classified window set (the weekly window is primary on current plans), the refresh token rotates with reuse punished (harvest-by-true-owner, persist every rotation), and codex's new Stop-hook system drives the auto-swap through a codex supervisor shim that respawns `codex resume <session-id>` (hooks must be trusted once via `/hooks`).
134
+ **Shipped since (0.13.0):** Codex as a second pool, parallel state (`codex-accounts.json`, `codex-creds/`, own flock), same pace-pressure policy. Codex differences that shaped it: restart IS the switch (a running codex refuses another account's credential), usage is a free direct GET with epoch resets and a duration-classified window set (the weekly window is primary on current plans), the refresh token rotates with reuse punished (harvest-by-true-owner, persist every rotation), and codex's new Stop-hook system drives the auto-swap through a codex supervisor shim that respawns `codex resume <session-id>` (hooks must be trusted once via `/hooks`). Sibling sessions left on ANY non-live account are reconciled cross-session (owner decisions 2026-07-20; a non-live session cannot refresh cross-account and would wedge at token expiry, healthy or not): the deciding actor drops a reconcile marker addressed to the sibling's supervisor, and the sibling's own Stop hook promotes it into a respawn onto the live account at its next turn boundary - the only safe respawn point - while a blocked live seat never receives a signal.
124
135
 
125
136
  **Non-goals:** an API/MITM proxy; reimplementing OAuth beyond the single refresh-grant call in the swap.
126
137
 
127
138
  ---
128
139
 
129
140
  ## 8. Stack
130
- TypeScript on Bun, shipped as source: one multi-call entry (`src/main.ts`, `#!/usr/bin/env bun`) serves the CLI, the `claude` supervisor, the statusLine shim, and the hooks; `init` installs a 2-line shim that `exec`s bun on the installed package's entry (the Stop path runs every turn; bun's start-up stays low-millisecond). Published to npm as `tokenmaxxing` (source, platform-independent - a compiled binary was tried and shipped one architecture's Mach-O to every platform). Shipping is PR-based since 2026-07-18: work reaches main only through a pull request (branch, PR, CI green, review handled, merge), and a release is a PR-landed version bump followed by `gh release create` (details in AGENTS.md "Release and CI"). The supervisor needs a real PTY layer (spawn claude on a pty, forward resize/signals, restore mode between runs).
141
+ TypeScript on Bun, shipped as source: one multi-call entry (`src/main.ts`, `#!/usr/bin/env bun`) serves the CLI, the `claude` supervisor, the statusLine shim, and the hooks; `init` installs a 2-line shim that `exec`s bun on the installed package's entry (the Stop path runs every turn; bun's start-up stays low-millisecond). Published to npm as `tokenmaxxing` (source, platform-independent - a compiled binary was tried and shipped one architecture's Mach-O to every platform). Shipping is PR-based since 2026-07-18: work reaches main only through a pull request (branch, PR, CI green, a 10-minute review wait, every review handled, merge, teardown), and a release is a PR-landed version bump followed by `gh release create` (details in AGENTS.md "Release and CI"). The supervisor spawns claude with inherited stdio and restores saved `stty -g` termios between runs - no PTY layer; resize and signals flow through the shared foreground process group.
131
142
 
132
143
  ---
133
144
 
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # tokenmaxxing
2
2
 
3
- **Automatic Claude Code account switching.** Run `claude` exactly as you always do; when the active account nears its usage limit, tokenmaxxing swaps the credential to a fresher account at a safe turn boundary and your session keeps running on it - no restart, same conversation. Works across many concurrent sessions. Only when the whole pool is at its limit does anything visible happen: a countdown that auto-resumes at the soonest reset.
3
+ **Automatic Claude Code account switching.** Run `claude` exactly as you always do; when the active account nears its usage limit, tokenmaxxing swaps the credential to a fresher account at a safe turn boundary and your session keeps running on it - no restart, same conversation. Works across many concurrent sessions. Only when the whole pool is at its limit does anything visible happen: a countdown that auto-resumes at the soonest reset, when that reset falls within `policy.maxWaitMs` (default 1h - a longer wait stays put rather than parking your terminal for hours).
4
4
 
5
5
  > **Scope:** Claude Code only, macOS and Linux. It pools **subscription** accounts (Pro/Max), not API keys.
6
6
 
@@ -23,7 +23,7 @@ bun add -g tokenmaxxing
23
23
  tokenmaxxing init
24
24
  ```
25
25
 
26
- `init` imports the account you're already on, installs the `claude` supervisor + three `settings.json` entries (the tokenmaxxing statusLine, a Stop hook, a SessionStart hook), and adds the supervisor's bin dir to PATH in your shell rc (idempotent; it must sit ahead of the real `claude` to intercept it). Restart your shell, then add more accounts and go:
26
+ `init` imports the account you're already on, installs the `claude` supervisor + four `settings.json` entries (the tokenmaxxing statusLine, a subagentStatusLine, a Stop hook, a SessionStart hook), and adds the supervisor's bin dir to PATH in your shell rc (idempotent; it must sit ahead of the real `claude` to intercept it). Restart your shell, then add more accounts and go:
27
27
 
28
28
  ```sh
29
29
  tokenmaxxing add # logs one in, in isolation, and pools it
@@ -45,7 +45,7 @@ claude # use claude as always
45
45
  | `tokenmaxxing status --force` | additionally ping every account (one tiny haiku request each) so all 5h session timers start now, then sample fresh |
46
46
  | `tokenmaxxing watch [seconds]` | live status: re-render every N seconds (default 120, floor 30; never pings) |
47
47
  | `tokenmaxxing config` | effective config with sources; `get`/`set`/`unset` dotted keys, `tidy` prunes unknown keys |
48
- | `tokenmaxxing serve` | Slack bridge daemon (Socket Mode, no public URL): `setup` prints the app manifest and stores the two tokens, `link <channel-id> <repo>` ties a channel to a repo (`--yolo` for full-autonomy bypassPermissions sessions), then mentioning the bot in that channel opens a Claude Code session per thread (own git worktree by default) and thread messages relay in and out |
48
+ | `tokenmaxxing serve` | Slack bridge daemon (Socket Mode, no public URL): `setup` prints the app manifest and stores the two tokens, `link <channel-id> <repo>` ties a channel to a repo (`--yolo` for full-autonomy bypassPermissions sessions), then mentioning the bot in that channel opens a Claude Code session per thread in the repo checkout (the session cuts its own git worktree only when a task needs isolation) and thread messages relay in and out |
49
49
  | `tokenmaxxing doctor` | verify the supervisor + settings entries survived |
50
50
  | `tokenmaxxing rename [--codex] <sel> <label>` / `rm <sel>` | manage the pool (`--codex` targets the codex pool: one email can hold both a claude and a codex account) |
51
51
  | `tokenmaxxing uninstall` | remove supervisor + settings entries (accounts/credentials kept) |
@@ -77,7 +77,7 @@ The **target** is chosen greedily off each account's cached windows: among usabl
77
77
  }
78
78
  ```
79
79
 
80
- `projectionMargin` subtracts an EMA of per-turn Δ% for pre-emption; `greedySessionFloor` is the session-used % at which the greedy convergence engages; `switchModels` names the models whose per-model cap triggers a switch; `usagePollTtlMs` is how long a `/usage` per-model poll stays fresh.
80
+ `projectionMargin` is a fixed safety margin subtracted from each threshold bar (effective bar = threshold - margin), so a large turn is less likely to blow past a bar between checks; `greedySessionFloor` is the session-used % at which the greedy convergence engages; `switchModels` names the models whose per-model cap triggers a switch; `usagePollTtlMs` is how long a `/usage` per-model poll stays fresh; `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).
81
81
 
82
82
  State lives entirely in `~/.config/tokenmaxxing/`. Per-account credentials follow the platform's Claude Code store: the login keychain on macOS (`tokenmaxxing-cred-<uuid8>` items, never plaintext on disk), 0600 files under `~/.config/tokenmaxxing/creds/` on Linux (the same plaintext model claude itself uses for `~/.claude/.credentials.json`).
83
83
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tokenmaxxing",
3
- "version": "0.19.0",
3
+ "version": "0.21.0",
4
4
  "description": "Automatic Claude Code account switching: pool multiple accounts and hot-swap when quota fills, resuming your session on the fresh account.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/cli/add.ts CHANGED
@@ -42,6 +42,7 @@ export async function cmdAdd(): Promise<number> {
42
42
  needsReauth: false,
43
43
  lastUsage: sampled ? { fiveHour: sampled.session, sevenDay: sampled.weekAll } : existing?.lastUsage,
44
44
  lastPerModel: sampled && Object.keys(sampled.perModel).length > 0 ? sampled.perModel : existing?.lastPerModel,
45
+ lastPerModelAt: sampled && Object.keys(sampled.perModel).length > 0 ? Date.now() : existing?.lastPerModelAt,
45
46
  lastUsageAt: sampled ? Date.now() : existing?.lastUsageAt,
46
47
  };
47
48
  if (existing) Object.assign(existing, fresh);
package/src/cli/auth.ts CHANGED
@@ -19,7 +19,7 @@ import type { Account, AccountsIndex } from "../lib/types.ts";
19
19
 
20
20
  const AUTH_USAGE = "usage: tokenmaxxing auth [<email|label|id> | --all]";
21
21
 
22
- export const AuthPlanSchema = z.discriminatedUnion("kind", [
22
+ const AuthPlanSchema = z.discriminatedUnion("kind", [
23
23
  /** malformed argv: print AUTH_USAGE, exit 2. */
24
24
  z.object({ kind: z.literal("usage") }),
25
25
  /** unresolvable state (empty pool, unknown selector): exit 1. */
@@ -105,25 +105,36 @@ async function reauthOne(target: Account): Promise<boolean> {
105
105
  // land in one critical section (else a swap-away harvest of the live blob
106
106
  // could overwrite this fresh backup right before it is marked healthy).
107
107
  const isActive = await withLock(paths.lockFile, async () => {
108
- await writeItem(parkedTarget(target.keychainItem), claudeAiOauthOnly(blobRaw));
108
+ // The account must still be pooled BEFORE anything is written: the
109
+ // interactive login can sit open for minutes, and a concurrent `xx rm`
110
+ // completing in that window used to get its parked item recreated as an
111
+ // orphan no pool entry tracks - plus a false "reauthed" success
112
+ // (closing-review catch).
109
113
  const idx = loadAccounts();
110
114
  const account = idx.accounts.find((a) => a.accountUuid === target.accountUuid);
111
- if (account) {
112
- account.email = oauthAccount.emailAddress;
113
- account.organizationUuid = oauthAccount.organizationUuid;
114
- account.oauthAccount = oauthAccount;
115
- account.subscriptionType = blob.claudeAiOauth.subscriptionType;
116
- account.rateLimitTier = blob.claudeAiOauth.rateLimitTier;
117
- account.needsReauth = false;
118
- if (sampled) {
119
- account.lastUsage = { fiveHour: sampled.session, sevenDay: sampled.weekAll };
120
- if (Object.keys(sampled.perModel).length > 0) account.lastPerModel = sampled.perModel;
121
- account.lastUsageAt = Date.now();
115
+ if (!account) {
116
+ console.error(c.red(`${target.label} was removed from the pool while the login was open - nothing written; re-add it with \`tokenmaxxing add\` if wanted`));
117
+ return null;
118
+ }
119
+ await writeItem(parkedTarget(target.keychainItem), claudeAiOauthOnly(blobRaw));
120
+ account.email = oauthAccount.emailAddress;
121
+ account.organizationUuid = oauthAccount.organizationUuid;
122
+ account.oauthAccount = oauthAccount;
123
+ account.subscriptionType = blob.claudeAiOauth.subscriptionType;
124
+ account.rateLimitTier = blob.claudeAiOauth.rateLimitTier;
125
+ account.needsReauth = false;
126
+ if (sampled) {
127
+ account.lastUsage = { fiveHour: sampled.session, sevenDay: sampled.weekAll };
128
+ account.lastUsageAt = Date.now();
129
+ if (Object.keys(sampled.perModel).length > 0) {
130
+ account.lastPerModel = sampled.perModel;
131
+ account.lastPerModelAt = account.lastUsageAt;
122
132
  }
123
- saveAccounts(idx);
124
133
  }
134
+ saveAccounts(idx);
125
135
  return idx.activeAccountUuid === target.accountUuid;
126
136
  });
137
+ if (isActive === null) return false;
127
138
 
128
139
  const usageNote = sampled ? ` (session ${sampled.session.usedPercentage}% / week ${sampled.weekAll.usedPercentage}%)` : "";
129
140
  const tier = claudeTierLabel(blob.claudeAiOauth) ?? "?";
package/src/cli/check.ts CHANGED
@@ -14,8 +14,9 @@ export async function cmdCheck(): Promise<number> {
14
14
  d = await evaluateAndMaybeSwap();
15
15
  } catch (e) {
16
16
  // unattended under the timer: the log is the only place anyone will look.
17
- log("check.error", { err: String((e as Error).message ?? e) });
18
- console.error(c.red(`check failed: ${String((e as Error).message ?? e)}`));
17
+ const detail = e instanceof Error ? e.message : String(e);
18
+ log("check.error", { err: detail });
19
+ console.error(c.red(`check failed: ${detail}`));
19
20
  return 1;
20
21
  }
21
22
  if (d.swapped && d.account) {
@@ -28,7 +28,7 @@ export async function cmdCodexAdd(): Promise<number> {
28
28
  // Same rationale at the end-of-run cleanup below.
29
29
  rmSync(onboardDir, { recursive: true, force: true });
30
30
  mkdirSync(onboardDir, { recursive: true });
31
- writeFileAtomic(join(onboardDir, "config.toml"), 'cli_auth_credentials_store_mode = "file"\n');
31
+ writeFileAtomic(join(onboardDir, "config.toml"), 'cli_auth_credentials_store = "file"\n');
32
32
 
33
33
  console.log(c.cyan("Opening an isolated codex login - your primary login is untouched."));
34
34
  console.log(c.dim("Complete the browser sign-in with the account to add; the command exits once you're in."));
@@ -49,49 +49,53 @@ export async function cmdCodexAdd(): Promise<number> {
49
49
  await p.exited;
50
50
  restoreTermios(savedTermios);
51
51
 
52
- const cleanup = () => rmSync(onboardDir, { recursive: true, force: true });
53
-
54
- const auth = readCodexAuthAt({ path: join(onboardDir, "auth.json") });
55
- if (p.exitCode !== 0 || !auth) {
56
- console.error(c.red("no codex login landed in the isolated home - nothing added."));
57
- cleanup();
58
- return 1;
59
- }
60
-
61
- const identity = codexIdentityOf({ auth });
62
- console.log(c.dim("sampling usage..."));
63
- let usage: CodexUsage | null = null;
52
+ // The finally guarantees the plaintext onboard home is destroyed on every
53
+ // non-signal exit; an exception mid-registration must not strand it. (An
54
+ // interactive Ctrl-C is reaped by the next run's rmSync-first.)
55
+ let account: CodexAccount;
56
+ let poolSize: number;
64
57
  try {
65
- usage = await fetchCodexUsage({ auth });
66
- } catch (e) {
67
- if (!(e instanceof CodexUsageReadError)) throw e;
68
- console.log(c.yellow("could not sample usage now - it will fill in on first use."));
69
- }
58
+ const auth = readCodexAuthAt({ path: join(onboardDir, "auth.json") });
59
+ if (p.exitCode !== 0 || !auth) {
60
+ console.error(c.red("no codex login landed in the isolated home - nothing added."));
61
+ return 1;
62
+ }
70
63
 
71
- const credFile = codexCredItemFor(identity.accountId);
72
- writeParkedCodexAuth({ credFile, auth });
64
+ const identity = codexIdentityOf({ auth });
65
+ console.log(c.dim("sampling usage..."));
66
+ let usage: CodexUsage | null = null;
67
+ try {
68
+ usage = await fetchCodexUsage({ auth });
69
+ } catch (e) {
70
+ if (!(e instanceof CodexUsageReadError)) throw e;
71
+ console.log(c.yellow("could not sample usage now - it will fill in on first use."));
72
+ }
73
73
 
74
- const { account, poolSize } = await withLock(codexPaths.lockFile, () => {
75
- const index = loadCodexAccounts();
76
- const existing = index.accounts.find((entry) => entry.accountId === identity.accountId);
77
- const fresh: CodexAccount = {
78
- accountId: identity.accountId,
79
- email: usage?.email ?? identity.email,
80
- label: existing?.label ?? usage?.email ?? identity.email ?? identity.accountId.slice(0, 8),
81
- planType: usage?.planType ?? identity.planType,
82
- credFile,
83
- addedAt: existing?.addedAt ?? new Date().toISOString(),
84
- needsReauth: false,
85
- lastUsage: usage ? { aggregate: usage.aggregate, perLimit: usage.perLimit } : existing?.lastUsage,
86
- lastUsageAt: usage ? Date.now() : existing?.lastUsageAt,
87
- };
88
- if (existing) Object.assign(existing, fresh);
89
- else index.accounts.push(fresh);
90
- saveCodexAccounts({ index });
91
- return { account: fresh, poolSize: index.accounts.length };
92
- });
74
+ const credFile = codexCredItemFor(identity.accountId);
75
+ writeParkedCodexAuth({ credFile, auth });
93
76
 
94
- cleanup();
77
+ ({ account, poolSize } = await withLock(codexPaths.lockFile, () => {
78
+ const index = loadCodexAccounts();
79
+ const existing = index.accounts.find((entry) => entry.accountId === identity.accountId);
80
+ const fresh: CodexAccount = {
81
+ accountId: identity.accountId,
82
+ email: usage?.email ?? identity.email,
83
+ label: existing?.label ?? usage?.email ?? identity.email ?? identity.accountId.slice(0, 8),
84
+ planType: usage?.planType ?? identity.planType,
85
+ credFile,
86
+ addedAt: existing?.addedAt ?? new Date().toISOString(),
87
+ needsReauth: false,
88
+ lastUsage: usage ? { aggregate: usage.aggregate, perLimit: usage.perLimit } : existing?.lastUsage,
89
+ lastUsageAt: usage ? Date.now() : existing?.lastUsageAt,
90
+ };
91
+ if (existing) Object.assign(existing, fresh);
92
+ else index.accounts.push(fresh);
93
+ saveCodexAccounts({ index });
94
+ return { account: fresh, poolSize: index.accounts.length };
95
+ }));
96
+ } finally {
97
+ rmSync(onboardDir, { recursive: true, force: true });
98
+ }
95
99
 
96
100
  console.log();
97
101
  console.log(`${c.green("✓")} added codex account ${c.bold(account.label)} (${account.planType ?? "?"}) - codex pool now has ${count({ n: poolSize, noun: "account" })}`);
@@ -10,41 +10,56 @@ import { resolveRealCodex, verifyRealCodex } from "../lib/codexbin.ts";
10
10
  import { codexIdentityOf, readLiveCodexAuth, writeParkedCodexAuth } from "../lib/codexauth.ts";
11
11
  import { CodexUsageReadError, fetchCodexUsage } from "../lib/codexusage.ts";
12
12
  import { loadCodexAccounts, saveCodexAccounts } from "../lib/codexstate.ts";
13
- import { loadConfig, saveConfig } from "../lib/state.ts";
13
+ import { loadConfig, pinBinOverride } from "../lib/state.ts";
14
14
  import { installCodexSupervisor, codexSupervisorLink, ensurePathInRc, shellRcPath } from "../lib/install.ts";
15
15
  import { withLock } from "../lib/lock.ts";
16
+ import { presentCodexAccountIds } from "../lib/codexpresence.ts";
16
17
  import { codexCredItemFor, codexPaths } from "../lib/paths.ts";
17
18
  import type { CodexAccount, CodexUsage } from "../lib/types.ts";
18
19
  import { c } from "./render.ts";
19
20
 
20
21
  /** Fail fast when config.toml pins the credential store away from the plain
21
22
  * file tokenmaxxing swaps (structural line scan: the repo ships no TOML
22
- * parser, and this single key is the only one we ever inspect). */
23
- function keyringStoreConfigured(): boolean {
23
+ * parser, and this single key is the only one we ever inspect). The real key
24
+ * is `cli_auth_credentials_store` (binary-verified 0.144.5; an earlier
25
+ * verification misread the enum TYPE name as a `_mode` key). An ABSENT key is
26
+ * allowed: the DEFAULT at 0.144.5 is `file` itself (source-verified:
27
+ * AuthCredentialsStoreMode derives Default on the File variant), and init
28
+ * separately verifies a harvestable auth.json.
29
+ * Any EXPLICIT non-file pin (keyring, auto, ephemeral) is a deliberate store
30
+ * choice tokenmaxxing cannot honor. */
31
+ function storePinnedAwayFromFile(): boolean {
24
32
  const configToml = `${codexPaths.home}/config.toml`;
25
33
  if (!existsSync(configToml)) return false;
26
34
  for (const rawLine of readFileSync(configToml, "utf8").split("\n")) {
27
35
  const line = rawLine.trim();
28
- if (!line.startsWith("cli_auth_credentials_store_mode")) continue;
29
- return line.includes("keyring");
36
+ if (!line.startsWith("cli_auth_credentials_store")) continue;
37
+ const rest = line.slice("cli_auth_credentials_store".length).trimStart();
38
+ if (!rest.startsWith("=")) continue;
39
+ // Value only: strip an inline TOML comment and the quotes structurally so
40
+ // a comment mentioning "keyring" never false-positives the fail-fast.
41
+ const beforeComment = rest.slice(1).split("#", 1)[0]!;
42
+ const value = beforeComment.replaceAll('"', "").replaceAll("'", "").trim();
43
+ return value !== "file";
30
44
  }
31
45
  return false;
32
46
  }
33
47
 
34
48
  export async function cmdCodexInit(): Promise<number> {
49
+ // Fail fast on a broken merged config before installing (see cmdInit).
50
+ loadConfig();
35
51
  const real = resolveRealCodex();
36
52
  const fail = verifyRealCodex({ bin: real });
37
53
  if (fail !== null) {
38
54
  console.error(c.red(`codex binary failed verification: ${real}: ${fail}`));
39
55
  return 1;
40
56
  }
41
- const cfg = loadConfig();
42
- cfg.codexBin = real;
43
- saveConfig(cfg);
57
+ // Sparse write: only the pin lands in the file, never the merged config.
58
+ pinBinOverride({ key: "codexBin", bin: real });
44
59
 
45
- if (keyringStoreConfigured()) {
46
- console.error(c.red("codex config.toml pins cli_auth_credentials_store_mode to keyring - tokenmaxxing swaps the plain auth.json file."));
47
- console.error(c.dim('recovery: set cli_auth_credentials_store_mode = "file" in ~/.codex/config.toml, run `codex login`, then re-run this.'));
60
+ if (storePinnedAwayFromFile()) {
61
+ console.error(c.red("codex config.toml pins cli_auth_credentials_store away from the plain auth.json file tokenmaxxing swaps."));
62
+ console.error(c.dim('recovery: set cli_auth_credentials_store = "file" in ~/.codex/config.toml, run `codex login`, then re-run this.'));
48
63
  return 1;
49
64
  }
50
65
 
@@ -65,9 +80,41 @@ export async function cmdCodexInit(): Promise<number> {
65
80
  }
66
81
 
67
82
  const credFile = codexCredItemFor(identity.accountId);
68
- writeParkedCodexAuth({ credFile, auth: live });
83
+
84
+ // A RUNNING supervised session on this account can rotate auth.json at any
85
+ // moment (codex persists rotations instantly, and the flock serializes only
86
+ // tokenmaxxing actors) - a snapshot parked now could hold an already-
87
+ // superseded refresh token whose next refresh is reuse-punished (cubic
88
+ // review catch, PR #35). Refuse loudly, like the sampler's present-account
89
+ // rule; sessions launched around the shim are the same accepted gap as
90
+ // everywhere presence is the signal.
91
+ if (presentCodexAccountIds().has(identity.accountId)) {
92
+ console.error(c.red("a live supervised codex session is running this account - its token rotates under us, so parking a snapshot now could poison the backup."));
93
+ console.error(c.dim("close that codex session (or let it exit) and re-run `tokenmaxxing init --codex`."));
94
+ return 1;
95
+ }
69
96
 
70
97
  const account = await withLock(codexPaths.lockFile, () => {
98
+ // Re-check presence INSIDE the critical section (pullfrog review catch,
99
+ // PR #35): supervisor spawns are flock-serialized too, so one can start -
100
+ // check passed, presence written, session live - entirely between the
101
+ // friendly pre-lock check above and this lock acquisition. The throw
102
+ // routes through the CLI error boundary as a clean failure.
103
+ if (presentCodexAccountIds().has(identity.accountId)) {
104
+ throw new Error("a live supervised codex session started running this account mid-init - close it and re-run `tokenmaxxing init --codex`");
105
+ }
106
+ // Park INSIDE the flock, from a blob RE-READ inside it (closing-review
107
+ // catch): the pre-lock snapshot is seconds stale (a network usage GET sits
108
+ // in between), and parking it unlocked could clobber a concurrent swap's
109
+ // just-harvested newest rotation with a superseded refresh token - whose
110
+ // next refresh is reuse-punished into a dead grant family. An identity
111
+ // that changed since the pre-lock read means a swap landed mid-init:
112
+ // abort rather than file the wrong account.
113
+ const fresh2 = readLiveCodexAuth();
114
+ if (!fresh2 || codexIdentityOf({ auth: fresh2 }).accountId !== identity.accountId) {
115
+ throw new Error("the live codex login changed while init was running (a concurrent swap?) - re-run `tokenmaxxing init --codex`");
116
+ }
117
+ writeParkedCodexAuth({ credFile, auth: fresh2 });
71
118
  const index = loadCodexAccounts();
72
119
  const existing = index.accounts.find((entry) => entry.accountId === identity.accountId);
73
120
  const fresh: CodexAccount = {
@@ -49,7 +49,21 @@ export async function cmdCodexSwitch(sel?: string): Promise<number> {
49
49
  console.error(c.red(`${target.label} is running in a live codex session - swapping onto it would break that session's credential`));
50
50
  return 1;
51
51
  }
52
- await performCodexSwap({ target });
52
+ if (target.needsReauth) {
53
+ console.error(c.red(`${target.label} needs re-auth - run \`codex login\` in an isolated home and \`tokenmaxxing add --codex\``));
54
+ return 1;
55
+ }
56
+ try {
57
+ await performCodexSwap({ target });
58
+ } catch (e) {
59
+ // a dead grant is an expected operational state, not a stack trace
60
+ // (closing-review catch; mirrors the claude selector path).
61
+ if (e instanceof CodexInvalidGrantError) {
62
+ console.error(c.red(`${target.label}'s refresh token is dead - re-add it with \`tokenmaxxing add --codex\``));
63
+ return 1;
64
+ }
65
+ throw e;
66
+ }
53
67
  console.log(`${c.green("✓")} switched codex to ${c.bold(target.label)} (takes effect on the next codex start)`);
54
68
  return 0;
55
69
  }
package/src/cli/config.ts CHANGED
@@ -10,7 +10,7 @@ import { isPlainObject } from "es-toolkit";
10
10
  import { get, set, unset } from "es-toolkit/compat";
11
11
  import { z } from "zod";
12
12
  import { paths, realClaudeBinFromEnv, realCodexBinFromEnv } from "../lib/paths.ts";
13
- import { ConfigFileSchema, loadConfig } from "../lib/state.ts";
13
+ import { ConfigFileSchema, loadConfig, mergeConfigFile } from "../lib/state.ts";
14
14
  import { writeFileAtomic } from "../lib/atomic.ts";
15
15
  import { c } from "./render.ts";
16
16
 
@@ -115,6 +115,15 @@ function cmdSet(key: string, valueText: string): number {
115
115
  console.error(c.red(`rejected: ${validated.error.issues.map((issue) => `${issue.path.join(".")}: ${issue.message}`).join("; ")}`));
116
116
  return 1;
117
117
  }
118
+ // The per-field gate passed; the MERGED whole must too, or this write makes
119
+ // every later loadConfig throw (the projectionMargin-vs-thresholds refine),
120
+ // silently disabling status/switch/hooks/statusline until the file is
121
+ // hand-repaired (closing-review catch).
122
+ const mergedCheck = mergeConfigFile(validated.data);
123
+ if (!mergedCheck.ok) {
124
+ console.error(c.red(`rejected: ${mergedCheck.detail}`));
125
+ return 1;
126
+ }
118
127
  writeRawFile({ raw: next });
119
128
  // Report the FILE-level change: with an env override in place, the effective
120
129
  // value would not move, and an unchanged-looking arrow would misrepresent