tokenmaxxing 1.7.0 → 1.9.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 (67) hide show
  1. package/DESIGN.md +2 -2
  2. package/README.md +1 -1
  3. package/agent-plugin/plugin.json +8 -2
  4. package/package.json +1 -1
  5. package/src/cli/add.ts +1 -8
  6. package/src/cli/auth.ts +0 -23
  7. package/src/cli/check.ts +19 -13
  8. package/src/cli/codexadd.ts +0 -17
  9. package/src/cli/codexinit.ts +11 -42
  10. package/src/cli/codexrm.ts +0 -13
  11. package/src/cli/codexswitch.ts +0 -15
  12. package/src/cli/config.ts +0 -30
  13. package/src/cli/doctor.ts +1 -14
  14. package/src/cli/init.ts +8 -34
  15. package/src/cli/ls.ts +0 -2
  16. package/src/cli/onboard.ts +0 -37
  17. package/src/cli/rename.ts +0 -19
  18. package/src/cli/render.ts +0 -23
  19. package/src/cli/rm.ts +0 -19
  20. package/src/cli/status.ts +0 -80
  21. package/src/cli/switch.ts +1 -49
  22. package/src/cli/watch.ts +0 -17
  23. package/src/entries/codexstophook.ts +2 -63
  24. package/src/entries/codexsupervisor.ts +1 -67
  25. package/src/entries/mcp.ts +0 -11
  26. package/src/entries/sessionstart.ts +1 -8
  27. package/src/entries/statusline.ts +0 -66
  28. package/src/entries/stopfailurehook.ts +93 -0
  29. package/src/entries/stophook.ts +3 -23
  30. package/src/entries/subagentstatusline.ts +0 -19
  31. package/src/entries/supervisor.ts +32 -132
  32. package/src/lib/atomic.ts +0 -16
  33. package/src/lib/claudebin.ts +4 -55
  34. package/src/lib/claudejson.ts +0 -10
  35. package/src/lib/claudelock.ts +13 -35
  36. package/src/lib/codexauth.ts +0 -29
  37. package/src/lib/codexbin.ts +0 -10
  38. package/src/lib/codexdecide.ts +1 -112
  39. package/src/lib/codexoauth.ts +0 -16
  40. package/src/lib/codexpick.ts +0 -31
  41. package/src/lib/codexpresence.ts +0 -35
  42. package/src/lib/codexsample.ts +0 -23
  43. package/src/lib/codexstate.ts +0 -7
  44. package/src/lib/codexswap.ts +0 -32
  45. package/src/lib/codexusage.ts +0 -28
  46. package/src/lib/credstore.ts +0 -24
  47. package/src/lib/decide.ts +127 -180
  48. package/src/lib/http.ts +0 -9
  49. package/src/lib/install.ts +57 -124
  50. package/src/lib/keychain.ts +1 -39
  51. package/src/lib/lock.ts +0 -24
  52. package/src/lib/log.ts +0 -14
  53. package/src/lib/oauth.ts +1 -31
  54. package/src/lib/paths.ts +1 -45
  55. package/src/lib/picker.ts +1 -84
  56. package/src/lib/proc.ts +0 -17
  57. package/src/lib/sample.ts +0 -68
  58. package/src/lib/sessions.ts +0 -13
  59. package/src/lib/settings.ts +15 -42
  60. package/src/lib/state.ts +23 -77
  61. package/src/lib/swap.ts +3 -87
  62. package/src/lib/tty.ts +0 -4
  63. package/src/lib/types.ts +13 -140
  64. package/src/lib/usage.ts +108 -196
  65. package/src/lib/worktree.ts +0 -8
  66. package/src/main.ts +5 -35
  67. package/src/sdk.ts +0 -59
package/DESIGN.md CHANGED
@@ -37,7 +37,7 @@ It is a process manager only - spawn with inherited stdio plus saved `stty -g` t
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
- - 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.
40
+ - A periodic check job (`com.tokenmaxxing.check` launchd agent on macOS, `tokenmaxxing-check.timer` systemd user timer on Linux) running `tokenmaxxing check --if-due` every 60s, self-paced up to 300s from the live account's headroom: hooks alone miss long agentic turns, so the timer is the backstop that keeps switching engaged mid-turn.
41
41
 
42
42
  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 and `~/.claude` layout are untouched.
43
43
 
@@ -111,7 +111,7 @@ The decision engages at `five_hour >= 50%` (policy.greedySessionFloor): from the
111
111
 
112
112
  **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.
113
113
 
114
- **Non-goals:** an API/MITM proxy; reimplementing OAuth beyond the single refresh-grant call in the swap.
114
+ **Non-goals:** an API/MITM proxy; reimplementing OAuth beyond the single refresh-grant call in the swap; Slack as a message bus; soft concurrency caps.
115
115
 
116
116
  ---
117
117
 
package/README.md CHANGED
@@ -50,7 +50,7 @@ programs.tokenmaxxing.package = inputs.tokenmaxxing.packages.${pkgs.system}.defa
50
50
  # then: tokenmaxxing init
51
51
  ```
52
52
 
53
- `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:
53
+ `init` imports the account you're already on, installs the `claude` supervisor + five `settings.json` entries (the tokenmaxxing statusLine, a subagentStatusLine, a Stop hook, a StopFailure 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:
54
54
 
55
55
  ```sh
56
56
  tokenmaxxing add # logs one in, in isolation, and pools it
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
3
3
  "name": "tokenmaxxing",
4
- "version": "1.7.0",
4
+ "version": "1.9.0",
5
5
  "description": "Pool Claude Code and Codex logins, switch on pace pressure, and expose safe ops tools to agent clients.",
6
6
  "author": {
7
7
  "name": "anaclumos",
@@ -10,5 +10,11 @@
10
10
  "homepage": "https://github.com/anaclumos/tokenmaxxing",
11
11
  "repository": "https://github.com/anaclumos/tokenmaxxing",
12
12
  "license": "MIT",
13
- "keywords": ["claude", "codex", "quota", "account-switching", "mcp"]
13
+ "keywords": [
14
+ "claude",
15
+ "codex",
16
+ "quota",
17
+ "account-switching",
18
+ "mcp"
19
+ ]
14
20
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tokenmaxxing",
3
- "version": "1.7.0",
3
+ "version": "1.9.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
@@ -1,7 +1,3 @@
1
- // `tokenmaxxing add` - register an ADDITIONAL account. Drives one isolated
2
- // login (see onboard.ts), then pools whatever account it landed on. Your
3
- // primary login is never touched.
4
-
5
1
  import { withLock } from "../lib/lock.ts";
6
2
  import { loadAccounts, saveAccounts } from "../lib/state.ts";
7
3
  import { credItemFor, paths } from "../lib/paths.ts";
@@ -22,11 +18,8 @@ export async function cmdAdd(): Promise<number> {
22
18
  const uuid = oauthAccount.accountUuid;
23
19
  const keychainItem = credItemFor(uuid);
24
20
 
25
- // under the flock: a concurrent swap both harvests into parked items and
26
- // writes the index, so the backup write and the index upsert must land in
27
- // one critical section.
28
21
  const { account, poolSize } = await withLock(paths.lockFile, async () => {
29
- await writeItem(parkedTarget(keychainItem), claudeAiOauthOnly(blobRaw)); // park a small backup
22
+ await writeItem(parkedTarget(keychainItem), claudeAiOauthOnly(blobRaw));
30
23
  const idx = loadAccounts();
31
24
  const existing = idx.accounts.find((a) => a.accountUuid === uuid);
32
25
  const fresh: Account = {
package/src/cli/auth.ts CHANGED
@@ -1,11 +1,3 @@
1
- // `tokenmaxxing auth` - reauthenticate a pooled account in place. The dead
2
- // refresh token cannot heal itself (a needs-reauth account can never win a
3
- // swap), so this drives one isolated login per target and REQUIRES the login
4
- // to land on the target account: the harvested identity must match, otherwise
5
- // nothing changes. Bare `auth` lists the pool (emails shown) and asks which;
6
- // `auth <sel>` targets one account, stating the email to sign in with;
7
- // `auth --all` walks every needs-reauth account one by one.
8
-
9
1
  import { partition } from "es-toolkit";
10
2
  import { z } from "zod";
11
3
  import { withLock } from "../lib/lock.ts";
@@ -20,18 +12,13 @@ import type { Account, AccountsIndex } from "../lib/types.ts";
20
12
  const AUTH_USAGE = "usage: tokenmaxxing auth [<email|label|id> | --all]";
21
13
 
22
14
  const AuthPlanSchema = z.discriminatedUnion("kind", [
23
- /** malformed argv: print AUTH_USAGE, exit 2. */
24
15
  z.object({ kind: z.literal("usage") }),
25
- /** unresolvable state (empty pool, unknown selector): exit 1. */
26
16
  z.object({ kind: z.literal("error"), message: z.string() }),
27
- /** bare `auth`: ask interactively. */
28
17
  z.object({ kind: z.literal("pick") }),
29
18
  z.object({ kind: z.literal("targets"), uuids: z.array(z.string()) }),
30
19
  ]);
31
20
  export type AuthPlan = z.infer<typeof AuthPlanSchema>;
32
21
 
33
- /** Resolve argv into a reauth plan (pure - unit-tested). Argv shape is
34
- * validated before any pool state is consulted. */
35
22
  export function planAuth(input: { accounts: Account[]; argv: string[] }): AuthPlan {
36
23
  const all = input.argv.includes("--all");
37
24
  const rest = input.argv.filter((a) => a !== "--all");
@@ -50,7 +37,6 @@ export function planAuth(input: { accounts: Account[]; argv: string[] }): AuthPl
50
37
  return { kind: "pick" };
51
38
  }
52
39
 
53
- /** Needs-reauth accounts first (they are why the user is here), pool order within. */
54
40
  export function pickerOrder(accounts: Account[]): Account[] {
55
41
  const [flagged, healthy] = partition(accounts, (a) => a.needsReauth === true);
56
42
  return [...flagged, ...healthy];
@@ -100,16 +86,7 @@ async function reauthOne(target: Account): Promise<boolean> {
100
86
  return false;
101
87
  }
102
88
 
103
- // under the flock: a concurrent swap both harvests into parked items and
104
- // writes the index, so the credential write and the needsReauth clear must
105
- // land in one critical section (else a swap-away harvest of the live blob
106
- // could overwrite this fresh backup right before it is marked healthy).
107
89
  const isActive = await withLock(paths.lockFile, async () => {
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).
113
90
  const idx = loadAccounts();
114
91
  const account = idx.accounts.find((a) => a.accountUuid === target.accountUuid);
115
92
  if (!account) {
package/src/cli/check.ts CHANGED
@@ -1,30 +1,36 @@
1
- // `tokenmaxxing check` - one auto-switch evaluation, the same decision the
2
- // Stop/SessionStart hooks make (flocked, idempotent). Installed as a periodic
3
- // launchd/systemd job so a limit crossed mid-turn, or with no session running,
4
- // still switches within minutes; the hooks only ever see turn boundaries.
5
- // Running claude sessions adopt the swapped credential in place (<=30s).
6
-
7
- import { evaluateAndMaybeSwap } from "../lib/decide.ts";
1
+ import { CHECK_DELAY_FLOOR_MS, checkDelayMs, evaluateAndMaybeSwap } from "../lib/decide.ts";
2
+ import { readOAuthAccount } from "../lib/claudejson.ts";
8
3
  import { log } from "../lib/log.ts";
4
+ import { loadConfig, loadNextCheckDueAt, saveNextCheckDueAt } from "../lib/state.ts";
9
5
  import { c, fmtReset } from "./render.ts";
10
6
 
11
- export async function cmdCheck(): Promise<number> {
7
+ const TICK_SLACK_MS = CHECK_DELAY_FLOOR_MS / 2;
8
+
9
+ export async function cmdCheck(args: string[] = []): Promise<number> {
10
+ const now = Date.now();
11
+ const dueAt = args.includes("--if-due") ? loadNextCheckDueAt(now) : null;
12
+ if (dueAt != null && now + TICK_SLACK_MS < dueAt) {
13
+ console.log(c.dim(`not due (${Math.ceil((dueAt - now) / 1000)}s)`));
14
+ return 0;
15
+ }
12
16
  let d;
13
17
  try {
14
- d = await evaluateAndMaybeSwap();
18
+ d = await evaluateAndMaybeSwap(now);
15
19
  } catch (e) {
16
- // unattended under the timer: the log is the only place anyone will look.
17
20
  const detail = e instanceof Error ? e.message : String(e);
18
21
  log("check.error", { err: detail });
19
22
  console.error(c.red(`check failed: ${detail}`));
20
23
  return 1;
21
24
  }
25
+ const delayMs = checkDelayMs({ cfg: loadConfig(), org: readOAuthAccount()?.organizationUuid ?? null, now, decision: d });
26
+ saveNextCheckDueAt({ dueAt: now + delayMs, ts: now });
27
+ const next = c.dim(`next in ${Math.round(delayMs / 1000)}s`);
22
28
  if (d.swapped && d.account) {
23
- console.log(`${c.green("↻")} switched to ${c.bold(d.account.label)}`);
29
+ console.log(`${c.green("↻")} switched to ${c.bold(d.account.label)} ${next}`);
24
30
  } else if (d.waitUntil !== undefined && d.account) {
25
- console.log(c.yellow(`all accounts at limit - staying on ${c.bold(d.account.label)} (${fmtReset(d.waitUntil)})`));
31
+ console.log(c.yellow(`all accounts at limit - staying on ${c.bold(d.account.label)} (${fmtReset(d.waitUntil)})`) + ` ${next}`);
26
32
  } else {
27
- console.log(c.dim(`no switch (${d.reason})`));
33
+ console.log(c.dim(`no switch (${d.reason}) `) + next);
28
34
  }
29
35
  return 0;
30
36
  }
@@ -1,12 +1,3 @@
1
- // `tokenmaxxing add --codex` - register an ADDITIONAL codex account. Runs
2
- // `codex login --device-auth` inside a throwaway CODEX_HOME pinned to
3
- // file-mode credential storage (auto mode would keyring-namespace the login on
4
- // macOS and leave nothing harvestable), then parks the resulting auth.json
5
- // under the token's OWN identity and indexes it. The primary login is never
6
- // touched. Device auth is the default (user decision 2026-07-20): the browser
7
- // flow binds localhost on the machine running the command, so it dead-ends
8
- // over ssh; the device code works from any browser.
9
-
10
1
  import { mkdirSync, rmSync } from "node:fs";
11
2
  import { join } from "node:path";
12
3
  import { MAX_WRAP_DEPTH, WRAP_DEPTH_ENV } from "../lib/claudebin.ts";
@@ -24,11 +15,6 @@ import { c, count } from "./render.ts";
24
15
  export async function cmdCodexAdd(): Promise<number> {
25
16
  const real = resolveRealCodex();
26
17
  const onboardDir = codexPaths.onboardDir;
27
- // Deliberate hard delete (not trash; user decision 2026-07-16): the onboard
28
- // dir holds a plaintext live credential after login, and a trashed copy
29
- // would keep that token readable. The dir is tokenmaxxing's own transient
30
- // artifact; its one durable output is parked separately before cleanup.
31
- // Same rationale at the end-of-run cleanup below.
32
18
  rmSync(onboardDir, { recursive: true, force: true });
33
19
  mkdirSync(onboardDir, { recursive: true });
34
20
  writeFileAtomic(join(onboardDir, "config.toml"), 'cli_auth_credentials_store = "file"\n');
@@ -52,9 +38,6 @@ export async function cmdCodexAdd(): Promise<number> {
52
38
  await p.exited;
53
39
  restoreTermios(savedTermios);
54
40
 
55
- // The finally guarantees the plaintext onboard home is destroyed on every
56
- // non-signal exit; an exception mid-registration must not strand it. (An
57
- // interactive Ctrl-C is reaped by the next run's rmSync-first.)
58
41
  let account: CodexAccount;
59
42
  let poolSize: number;
60
43
  try {
@@ -1,33 +1,16 @@
1
- // `tokenmaxxing init --codex` - bring the codex side up: verify + pin the real
2
- // codex binary, import the existing live login as codex account #1, install
3
- // the on-PATH codex supervisor shim and the Stop hook declaration, and tell
4
- // the user about the one step tokenmaxxing cannot do for them: codex skips
5
- // hooks it has not been TRUSTED to run (trust is recorded against the hook's
6
- // hash via /hooks in the codex TUI), so auto-switching stays inert until then.
7
-
8
1
  import { existsSync, readFileSync } from "node:fs";
9
2
  import { resolveRealCodex, verifyRealCodex } from "../lib/codexbin.ts";
10
3
  import { codexIdentityOf, readLiveCodexAuth, writeParkedCodexAuth } from "../lib/codexauth.ts";
11
4
  import { CodexUsageReadError, fetchCodexUsage } from "../lib/codexusage.ts";
12
5
  import { loadCodexAccounts, saveCodexAccounts } from "../lib/codexstate.ts";
13
6
  import { loadConfig, pinBinOverride } from "../lib/state.ts";
14
- import { installCodexSupervisor, codexSupervisorLink, ensurePathInRc, shellRcPath } from "../lib/install.ts";
7
+ import { installCodexSupervisor, codexSupervisorLink, ensurePathInRc, managedShellRcSkipLines, shellRcPath } from "../lib/install.ts";
15
8
  import { withLock } from "../lib/lock.ts";
16
9
  import { presentCodexAccountIds } from "../lib/codexpresence.ts";
17
10
  import { codexCredItemFor, codexPaths } from "../lib/paths.ts";
18
11
  import type { CodexAccount, CodexUsage } from "../lib/types.ts";
19
12
  import { c } from "./render.ts";
20
13
 
21
- /** Fail fast when config.toml pins the credential store away from the plain
22
- * file tokenmaxxing swaps (structural line scan: the repo ships no TOML
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
14
  function storePinnedAwayFromFile(): boolean {
32
15
  const configToml = `${codexPaths.home}/config.toml`;
33
16
  if (!existsSync(configToml)) return false;
@@ -36,8 +19,6 @@ function storePinnedAwayFromFile(): boolean {
36
19
  if (!line.startsWith("cli_auth_credentials_store")) continue;
37
20
  const rest = line.slice("cli_auth_credentials_store".length).trimStart();
38
21
  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
22
  const beforeComment = rest.slice(1).split("#", 1)[0]!;
42
23
  const value = beforeComment.replaceAll('"', "").replaceAll("'", "").trim();
43
24
  return value !== "file";
@@ -46,7 +27,6 @@ function storePinnedAwayFromFile(): boolean {
46
27
  }
47
28
 
48
29
  export async function cmdCodexInit(): Promise<number> {
49
- // Fail fast on a broken merged config before installing (see cmdInit).
50
30
  loadConfig();
51
31
  const real = resolveRealCodex();
52
32
  const fail = verifyRealCodex({ bin: real });
@@ -54,7 +34,6 @@ export async function cmdCodexInit(): Promise<number> {
54
34
  console.error(c.red(`codex binary failed verification: ${real}: ${fail}`));
55
35
  return 1;
56
36
  }
57
- // Sparse write: only the pin lands in the file, never the merged config.
58
37
  pinBinOverride({ key: "codexBin", bin: real });
59
38
 
60
39
  if (storePinnedAwayFromFile()) {
@@ -81,13 +60,6 @@ export async function cmdCodexInit(): Promise<number> {
81
60
 
82
61
  const credFile = codexCredItemFor(identity.accountId);
83
62
 
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
63
  if (presentCodexAccountIds().has(identity.accountId)) {
92
64
  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
65
  console.error(c.dim("close that codex session (or let it exit) and re-run `tokenmaxxing init --codex`."));
@@ -95,21 +67,9 @@ export async function cmdCodexInit(): Promise<number> {
95
67
  }
96
68
 
97
69
  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
70
  if (presentCodexAccountIds().has(identity.accountId)) {
104
71
  throw new Error("a live supervised codex session started running this account mid-init - close it and re-run `tokenmaxxing init --codex`");
105
72
  }
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
73
  const fresh2 = readLiveCodexAuth();
114
74
  if (!fresh2 || codexIdentityOf({ auth: fresh2 }).accountId !== identity.accountId) {
115
75
  throw new Error("the live codex login changed while init was running (a concurrent swap?) - re-run `tokenmaxxing init --codex`");
@@ -137,7 +97,16 @@ export async function cmdCodexInit(): Promise<number> {
137
97
 
138
98
  installCodexSupervisor();
139
99
  const rc = shellRcPath();
140
- if (rc) ensurePathInRc(rc);
100
+ if (rc) {
101
+ const pathOutcome = ensurePathInRc(rc);
102
+ if (pathOutcome === "skipped") {
103
+ const hint = managedShellRcSkipLines();
104
+ console.log();
105
+ console.log(c.yellow(`⚠ ${hint.headline}`));
106
+ console.log(c.yellow(` ${hint.detail}`));
107
+ console.log(c.yellow(` ${hint.exportLine}`));
108
+ }
109
+ }
141
110
 
142
111
  console.log();
143
112
  console.log(`${c.green("✓")} imported codex account ${c.bold(account.label)} (${account.planType ?? "?"})`);
@@ -1,8 +1,3 @@
1
- // `tokenmaxxing rm --codex <selector>` - remove a pooled codex account (not
2
- // the live one). Until this existed the uninstall message pointed users at
3
- // `xx rm` for every parked credential while codex blobs were unremovable
4
- // (adversarial-review catch).
5
-
6
1
  import { withLock } from "../lib/lock.ts";
7
2
  import { codexPaths } from "../lib/paths.ts";
8
3
  import { loadCodexAccounts, saveCodexAccounts } from "../lib/codexstate.ts";
@@ -17,7 +12,6 @@ export async function cmdCodexRm(selector?: string): Promise<number> {
17
12
  console.error("usage: tokenmaxxing rm --codex <email|label|id>");
18
13
  return 2;
19
14
  }
20
- // under the codex flock: a concurrent swap's index write must not be clobbered.
21
15
  return withLock(codexPaths.lockFile, async () => {
22
16
  const index = loadCodexAccounts();
23
17
  const account = findCodexAccount(index.accounts, selector);
@@ -25,10 +19,6 @@ export async function cmdCodexRm(selector?: string): Promise<number> {
25
19
  console.error(c.red(`no codex account matches "${selector}"`));
26
20
  return 1;
27
21
  }
28
- // The live identity is decoded from auth.json itself (id_token claims),
29
- // offline ground truth - labels drift, the blob cannot lie. An unreadable
30
- // live blob THROWS out of liveCodexAccountId (the codex loaders' contract),
31
- // which fails this destructive command loudly rather than trusting a label.
32
22
  if (liveCodexAccountId() === account.accountId) {
33
23
  console.error(c.red(`${account.label} is the LIVE codex account - run \`tokenmaxxing switch --codex\` to move off it first.`));
34
24
  return 1;
@@ -37,9 +27,6 @@ export async function cmdCodexRm(selector?: string): Promise<number> {
37
27
  console.error(c.red(`${account.label} is running in a live codex session - close that session before removing it.`));
38
28
  return 1;
39
29
  }
40
- // Parked codex blobs are plain 0600 files; hard delete on purpose (the
41
- // credential-dir cleanup exception - trashing would move a credential
42
- // into the Trash folder).
43
30
  deleteParkedCodexAuth({ credFile: account.credFile });
44
31
  index.accounts = index.accounts.filter((x) => x.accountId !== account.accountId);
45
32
  saveCodexAccounts({ index });
@@ -1,12 +1,3 @@
1
- // `tokenmaxxing switch --codex [sel]`. Bare form is GREEDY and IDEMPOTENT off
2
- // cached windows, exactly like the claude switch: rank every codex account
3
- // (current included) by pace pressure among those under their screening bars;
4
- // when the current account already wins, do nothing. The current account is
5
- // the LIVE auth.json's own identity, never the stored label. The swap takes
6
- // effect for the NEXT codex start: a running codex refuses a different
7
- // account's credential (restart is the switch; the Stop hook + supervisor
8
- // automate that for supervised sessions).
9
-
10
1
  import { withLock } from "../lib/lock.ts";
11
2
  import { codexPaths } from "../lib/paths.ts";
12
3
  import { loadConfig } from "../lib/state.ts";
@@ -32,10 +23,6 @@ export async function cmdCodexSwitch(sel?: string): Promise<number> {
32
23
  }
33
24
  const currentId = liveCodexAccountId();
34
25
 
35
- // truthiness on purpose, mirroring the claude switch: an EMPTY selector
36
- // must mean "no selector" - `startsWith("")` matches every account, so
37
- // `sel !== undefined` let `xx switch --codex ""` swap onto the first
38
- // account (adversarial-review catch)
39
26
  if (sel) {
40
27
  const target = index.accounts.find(
41
28
  (account) => account.label === sel || account.email === sel || account.accountId.startsWith(sel),
@@ -60,8 +47,6 @@ export async function cmdCodexSwitch(sel?: string): Promise<number> {
60
47
  try {
61
48
  await performCodexSwap({ target });
62
49
  } catch (e) {
63
- // a dead grant is an expected operational state, not a stack trace
64
- // (closing-review catch; mirrors the claude selector path).
65
50
  if (e instanceof CodexInvalidGrantError) {
66
51
  console.error(c.red(`${target.label}'s refresh token is dead - re-add it with \`tokenmaxxing add --codex\``));
67
52
  return 1;
package/src/cli/config.ts CHANGED
@@ -1,10 +1,3 @@
1
- // `tokenmaxxing config` - inspect, edit, and housekeep config.json (user ask
2
- // 2026-07-16). Operates on the SPARSE file: config.json holds only overrides
3
- // and loadConfig merges defaults at read time, so baking defaults into the
4
- // file would freeze future default changes. `tidy` is the housekeeper: it
5
- // drops keys the schema no longer knows (e.g. the pre-0.7 flat `threshold`)
6
- // and normalizes switchModels casing; get/set/unset only ever report them.
7
-
8
1
  import { existsSync, readFileSync } from "node:fs";
9
2
  import { isPlainObject } from "es-toolkit";
10
3
  import { get, set, unset } from "es-toolkit/compat";
@@ -14,9 +7,6 @@ import { ConfigFileSchema, loadConfig, mergeConfigFile } from "../lib/state.ts";
14
7
  import { writeFileAtomic } from "../lib/atomic.ts";
15
8
  import { c } from "./render.ts";
16
9
 
17
- /** Hand-maintained mirror of ConfigFileSchema's dotted keys; an invariant test
18
- * (test/config.test.ts) pins the two together so a new schema field cannot
19
- * silently become invisible to get/set/tidy. */
20
10
  export const KNOWN_KEYS = [
21
11
  "thresholds.session",
22
12
  "thresholds.weekly",
@@ -42,7 +32,6 @@ function writeRawFile(input: { raw: Record<string, unknown> }): void {
42
32
  writeFileAtomic(paths.configJson, JSON.stringify(input.raw, null, 2) + "\n");
43
33
  }
44
34
 
45
- /** Dotted keys in `obj` (two levels: this config nests exactly once). */
46
35
  function dottedKeys(obj: Record<string, unknown>): string[] {
47
36
  const keys: string[] = [];
48
37
  for (const [key, value] of Object.entries(obj)) {
@@ -94,7 +83,6 @@ function cmdGet(key: string): number {
94
83
  return 0;
95
84
  }
96
85
 
97
- /** JSON when it parses (numbers, booleans, arrays), else the literal string. */
98
86
  function parseValue(text: string): unknown {
99
87
  try {
100
88
  return JSON.parse(text);
@@ -117,19 +105,12 @@ function cmdSet(key: string, valueText: string): number {
117
105
  console.error(c.red(`rejected: ${validated.error.issues.map((issue) => `${issue.path.join(".")}: ${issue.message}`).join("; ")}`));
118
106
  return 1;
119
107
  }
120
- // The per-field gate passed; the MERGED whole must too, or this write makes
121
- // every later loadConfig throw (the projectionMargin-vs-thresholds refine),
122
- // silently disabling status/switch/hooks/statusline until the file is
123
- // hand-repaired (closing-review catch).
124
108
  const mergedCheck = mergeConfigFile(validated.data);
125
109
  if (!mergedCheck.ok) {
126
110
  console.error(c.red(`rejected: ${mergedCheck.detail}`));
127
111
  return 1;
128
112
  }
129
113
  writeRawFile({ raw: next });
130
- // Report the FILE-level change: with an env override in place, the effective
131
- // value would not move, and an unchanged-looking arrow would misrepresent
132
- // the write that just happened.
133
114
  const beforeFile = get(raw, key);
134
115
  console.log(
135
116
  `${key}: ${beforeFile === undefined ? "(default)" : JSON.stringify(beforeFile)} -> ${JSON.stringify(get(next, key))}`,
@@ -139,8 +120,6 @@ function cmdSet(key: string, valueText: string): number {
139
120
  return 0;
140
121
  }
141
122
 
142
- /** Remove parents emptied by a deletion or a strip: the sparse file must not
143
- * accumulate `{}` husks. */
144
123
  function pruneEmptyParents(raw: Record<string, unknown>): void {
145
124
  for (const [topKey, value] of Object.entries(raw)) {
146
125
  if (isPlainObject(value) && Object.keys(value).length === 0) {
@@ -162,11 +141,6 @@ function cmdUnset(key: string): number {
162
141
  const next = structuredClone(raw);
163
142
  unset(next, key);
164
143
  pruneEmptyParents(next);
165
- // Same merged-whole gate as `set` (adversarial-review catch): removing one
166
- // override shifts the merged value back to its default, and the
167
- // projectionMargin-vs-thresholds refine can fail on the RESULT even though
168
- // every remaining field is individually valid - writing that file would make
169
- // every later loadConfig throw, bricking status/switch/hooks/statusline.
170
144
  const validated = ConfigFileSchema.safeParse(next);
171
145
  if (!validated.success) {
172
146
  console.error(c.red(`rejected: ${validated.error.issues.map((issue) => `${issue.path.join(".")}: ${issue.message}`).join("; ")}`));
@@ -185,8 +159,6 @@ function cmdUnset(key: string): number {
185
159
  function cmdTidy(): number {
186
160
  const raw = readRawFile();
187
161
  const dropped = unknownFileKeys(raw);
188
- // ConfigFileSchema strips unknown keys at both levels; switchModels casing
189
- // normalizes to what the loader would use anyway.
190
162
  const parsed = ConfigFileSchema.parse(raw);
191
163
  const next = RawFileSchema.parse(JSON.parse(JSON.stringify(parsed)));
192
164
  const models = get(next, "policy.switchModels");
@@ -195,7 +167,6 @@ function cmdTidy(): number {
195
167
  if (normalized != null) set(next, "policy.switchModels", normalized);
196
168
  pruneEmptyParents(next);
197
169
 
198
- // Honest housekeeping: say exactly what changed, write only when something did.
199
170
  if (JSON.stringify(next) === JSON.stringify(raw)) {
200
171
  console.log(c.dim("nothing to tidy"));
201
172
  return 0;
@@ -216,7 +187,6 @@ export function cmdConfig(args: string[]): number {
216
187
  if (sub === "unset" && key !== undefined) return cmdUnset(key);
217
188
  if (sub === "tidy") return cmdTidy();
218
189
  } catch (e) {
219
- // A corrupt config.json fails fast with a recovery step, not a stack trace.
220
190
  console.error(c.red(`config.json is unreadable: ${e instanceof Error ? e.message : String(e)}`));
221
191
  console.error(c.dim(`fix or delete ${paths.configJson} (defaults apply when it is absent), then re-run`));
222
192
  return 1;
package/src/cli/doctor.ts CHANGED
@@ -1,6 +1,3 @@
1
- // `tokenmaxxing doctor` - verify the supervisor + four settings entries survived
2
- // and the pool is healthy.
3
-
4
1
  import { existsSync, readFileSync } from "node:fs";
5
2
  import { verifyRealClaude } from "../lib/claudebin.ts";
6
3
  import { checkSettings, installedBin } from "../lib/settings.ts";
@@ -12,8 +9,6 @@ import { isAccessTokenExpiring, fetchTokenOrg } from "../lib/oauth.ts";
12
9
  import { CredentialBlobSchema, type RolesResponse } from "../lib/types.ts";
13
10
  import { c } from "./render.ts";
14
11
 
15
- /** The org a stored blob's token truly belongs to; null = expired (unverifiable
16
- * read-only - doctor never refreshes). Throws on unreadable blob / API failure. */
17
12
  async function blobOrg(raw: string): Promise<RolesResponse | null> {
18
13
  const creds = CredentialBlobSchema.parse(JSON.parse(raw)).claudeAiOauth;
19
14
  if (isAccessTokenExpiring(creds)) return null;
@@ -35,6 +30,7 @@ export async function cmdDoctor(): Promise<number> {
35
30
  check(s.statusLineOk, "statusLine shim installed in settings.json", "run `tokenmaxxing init`");
36
31
  check(s.subagentStatusLineOk, "subagentStatusLine shim installed in settings.json", "run `tokenmaxxing init`");
37
32
  check(s.stopOk, "Stop hook installed in settings.json", "run `tokenmaxxing init`");
33
+ check(s.stopFailureOk, "StopFailure hook installed in settings.json", "run `tokenmaxxing init`");
38
34
  check(s.sessionStartOk, "SessionStart hook installed in settings.json", "run `tokenmaxxing init`");
39
35
  check(checkTimerHealthy(), "periodic check timer active", timerActivationHint());
40
36
 
@@ -45,9 +41,6 @@ export async function cmdDoctor(): Promise<number> {
45
41
  const live = await readItem(liveTarget());
46
42
  check(!!live, "live credential readable");
47
43
 
48
- // Identity agreement: a stored credential must belong to the account it is
49
- // filed under - a mislabeled blob once made every consumer of a backup
50
- // (sampling, swap) silently act on another account.
51
44
  const active = idx.accounts.find((a) => a.accountUuid === idx.activeAccountUuid);
52
45
  if (live && active) {
53
46
  try {
@@ -77,16 +70,10 @@ export async function cmdDoctor(): Promise<number> {
77
70
  const cfg = loadConfig();
78
71
  check(!!cfg.claudeBin && existsSync(cfg.claudeBin), "real claude binary resolved", "set claudeBin in config.json");
79
72
  if (cfg.claudeBin && existsSync(cfg.claudeBin)) {
80
- // Behavioral: the pin must answer --version without re-entering the wrapper.
81
- // Catches a poisoned pin (a shim that resolves `claude` back to us) that
82
- // existence checks cannot - the 2026-07-12 recursive-spawn incident.
83
73
  const fail = verifyRealClaude(cfg.claudeBin);
84
74
  check(fail === null, "claudeBin launches the real claude", fail ?? undefined);
85
75
  }
86
76
 
87
- // Warnings only: an interactive alias/function can shadow or bypass the
88
- // wrapper in ways PATH checks cannot see (`alias claude=...`, or a `cc`-style
89
- // alias hardcoding an absolute path to the real binary).
90
77
  const rc = shellRcPath();
91
78
  if (rc && existsSync(rc)) {
92
79
  for (const s of findClaudeShadowers(readFileSync(rc, "utf8"))) {