tokenmaxxing 0.3.1 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/DESIGN.md CHANGED
@@ -4,7 +4,7 @@ Automatic Claude Code account switching. You run `claude` exactly as always; whe
4
4
 
5
5
  > Scope: **Claude Code only, macOS first.** Codex and other CLIs deferred (see `.memory/cc-codex-auth-mechanics.md`).
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 → 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.
8
8
 
9
9
  ---
10
10
 
@@ -57,7 +57,7 @@ The supervisor's `claude` call returns; it sees `respawn/<sid>`, deletes it, res
57
57
  ### 3.4 Swap sequence (under the lock)
58
58
  1. **Harvest the live credential into its TRUE owner's backup** - read the current `Claude Code-credentials` blob and resolve which account it actually belongs to via the roles endpoint (`GET /api/oauth/claude_cli/roles`), NOT the `accounts.json` active label. The label drifts from the live blob (a kill mid-swap, a manual `/login`), and harvesting by label once overwrote another account's backup and destroyed its only credential. Mandatory anyway: Claude rotates the refresh token in place, so older backups are dead. Refuse the swap if the live credential belongs to no pooled account.
59
59
  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
- 3. **Install B** - `security add-generic-password -U 'Claude Code-credentials' …` with B's fresh (non-expired) `claudeAiOauth` JSON.
60
+ 3. **Install B** - `security add-generic-password -U ... 'Claude Code-credentials' ...` with B's fresh (non-expired) `claudeAiOauth` JSON.
61
61
  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
62
  5. Do steps 1, 3, 4 inside Claude's own `~/.claude.lock` so the writes can't collide with a token refresh.
63
63
 
@@ -114,7 +114,7 @@ TypeScript on Bun, shipped as source: one multi-call entry (`src/main.ts`, `#!/u
114
114
 
115
115
  Unit suite: **32 pass**. Hermetic swap+concurrency+model-aware E2E: **all pass**. CLI init/doctor/uninstall: **pass** (re-verified init/doctor 2026-07-09 through the npm-installed bun shim on linux-arm64 after the switch to source packaging; full suite 47 pass / 0 fail there).
116
116
 
117
- 1. **Transcript continuity across a process boundary - ✅ proven on real claude/real account.** `claude --session-id X -p …` committed a clean 12-line transcript; `claude --resume X -p …` recalled the earlier turn's codeword. The supervisor's kill→restore-termios→respawn→`--resume` loop is proven with a mock claude. The one step not run live is the abrupt SIGTERM of an *idle interactive* real claude (structurally safe - the transcript is fully committed+fsynced before idle and nothing writes while idle).
117
+ 1. **Transcript continuity across a process boundary - ✅ proven on real claude/real account.** `claude --session-id X -p ...` committed a clean 12-line transcript; `claude --resume X -p ...` recalled the earlier turn's codeword. The supervisor's kill→restore-termios→respawn→`--resume` loop is proven with a mock claude. The one step not run live is the abrupt SIGTERM of an *idle interactive* real claude (structurally safe - the transcript is fully committed+fsynced before idle and nothing writes while idle).
118
118
  2. **Terminal restoration - ✅ mechanism proven.** The supervisor saves `stty -g` and restores it between kill and respawn; the path executes in the mock-supervisor run. Visual raw-mode/​resize confirmation wants a live interactive terminal.
119
119
  3. **SessionStart swap - ✅ swap logic proven; ⚠️ "adopted by first turn" needs a 2nd real account.** The hook's decision+swap path is covered by the E2E; the claude-internal timing (SessionStart before first `getToken`) needs a second subscription to observe end-to-end.
120
120
  4. **Keychain writes from a headless hook - ✅ proven.** The swap E2E's 4 concurrent subprocesses each wrote the live item via `security -i` (secret on stdin, never argv) with no prompt/hang; `init` parked backups headlessly too. (Residual: the live `Claude Code-credentials` ACL for the *fresh resumed claude* is mitigated by onboarding touching `security` interactively.)
package/README.md CHANGED
@@ -6,9 +6,9 @@
6
6
 
7
7
  ```
8
8
  $ claude
9
- you work normally
10
- ↻ tokenmaxxing: switched to work@acme.com - resuming
11
- same conversation, fresh quota
9
+ ...you work normally...
10
+ ↻ tokenmaxxing: switched to work@acme.com - resuming...
11
+ ...same conversation, fresh quota...
12
12
  ```
13
13
 
14
14
  ## Why
@@ -24,7 +24,7 @@ bun add -g tokenmaxxing
24
24
  tokenmaxxing init
25
25
  ```
26
26
 
27
- `init` imports the account you're already on, installs the `claude` supervisor + three `settings.json` entries (a statusLine shim, 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
+ `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:
28
28
 
29
29
  ```sh
30
30
  tokenmaxxing add # logs one in, in isolation, and pools it
@@ -47,7 +47,7 @@ claude # use claude as always
47
47
 
48
48
  Switching triggers at **95%** (configurable) on any of:
49
49
 
50
- - **Session** (5-hour) or **week (all models)** - the aggregate windows, fed free/push-based by a statusLine shim.
50
+ - **Session** (5-hour) or **week (all models)** - the aggregate windows, fed free/push-based by the statusLine.
51
51
  - **Per-model weekly cap** - the capable models (Fable, Opus) have their own tighter weekly limit that binds *before* the aggregate. tokenmaxxing reads it from `claude -p '/usage'` (free, 0 tokens, TTL-cached) whenever the active model is one of `policy.switchModels`, so a Fable session switches on the Fable cap while a Sonnet session rides the aggregate.
52
52
 
53
53
  The 5% headroom is deliberate: it's the budget to reach a clean turn boundary and respawn before the wall.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tokenmaxxing",
3
- "version": "0.3.1",
3
+ "version": "0.5.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
@@ -11,6 +11,7 @@ import { readItem, writeItem, deleteItem, parkedTarget, isolatedTarget, claudeAi
11
11
  import { resolveRealClaude } from "../lib/claudebin.ts";
12
12
  import { probeUsage } from "../lib/usage.ts";
13
13
  import { saveTermios, restoreTermios } from "../lib/tty.ts";
14
+ import { withLock } from "../lib/lock.ts";
14
15
  import { loadAccounts, saveAccounts } from "../lib/state.ts";
15
16
  import { credItemFor, paths } from "../lib/paths.ts";
16
17
  import { CredentialBlobSchema, OAuthAccountSchema, type Account } from "../lib/types.ts";
@@ -93,7 +94,7 @@ export async function cmdAdd(): Promise<number> {
93
94
  }
94
95
 
95
96
  // Sample usage now (#16) so the account isn't "not sampled yet" in status/ls.
96
- console.log(c.dim("sampling usage"));
97
+ console.log(c.dim("sampling usage..."));
97
98
  const sampled = await probeUsage(onboardDir);
98
99
  if (!sampled) console.log(c.yellow("could not sample usage now - it will fill in on first use."));
99
100
 
@@ -101,29 +102,33 @@ export async function cmdAdd(): Promise<number> {
101
102
  const keychainItem = credItemFor(uuid);
102
103
  await writeItem(parkedTarget(keychainItem), claudeAiOauthOnly(blobRaw)); // park a small backup
103
104
 
104
- const idx = loadAccounts();
105
- const existing = idx.accounts.find((a) => a.accountUuid === uuid);
106
- const account: Account = {
107
- accountUuid: uuid,
108
- email: oauthAccount.emailAddress,
109
- organizationUuid: oauthAccount.organizationUuid,
110
- label: existing?.label ?? oauthAccount.emailAddress,
111
- keychainItem,
112
- oauthAccount,
113
- addedAt: existing?.addedAt ?? new Date().toISOString(),
114
- subscriptionType: blob.claudeAiOauth.subscriptionType,
115
- needsReauth: false,
116
- lastUsage: sampled ? { fiveHour: sampled.session, sevenDay: sampled.weekAll } : existing?.lastUsage,
117
- lastPerModel: sampled && Object.keys(sampled.perModel).length > 0 ? sampled.perModel : existing?.lastPerModel,
118
- };
119
- if (existing) Object.assign(existing, account);
120
- else idx.accounts.push(account);
121
- saveAccounts(idx);
105
+ // under the flock: a concurrent swap's index write must not be clobbered.
106
+ const { account, poolSize } = await withLock(paths.lockFile, async () => {
107
+ const idx = loadAccounts();
108
+ const existing = idx.accounts.find((a) => a.accountUuid === uuid);
109
+ const fresh: Account = {
110
+ accountUuid: uuid,
111
+ email: oauthAccount.emailAddress,
112
+ organizationUuid: oauthAccount.organizationUuid,
113
+ label: existing?.label ?? oauthAccount.emailAddress,
114
+ keychainItem,
115
+ oauthAccount,
116
+ addedAt: existing?.addedAt ?? new Date().toISOString(),
117
+ subscriptionType: blob.claudeAiOauth.subscriptionType,
118
+ needsReauth: false,
119
+ lastUsage: sampled ? { fiveHour: sampled.session, sevenDay: sampled.weekAll } : existing?.lastUsage,
120
+ lastPerModel: sampled && Object.keys(sampled.perModel).length > 0 ? sampled.perModel : existing?.lastPerModel,
121
+ };
122
+ if (existing) Object.assign(existing, fresh);
123
+ else idx.accounts.push(fresh);
124
+ saveAccounts(idx);
125
+ return { account: fresh, poolSize: idx.accounts.length };
126
+ });
122
127
 
123
128
  await cleanup();
124
129
 
125
130
  console.log();
126
131
  const usageNote = sampled ? ` · session ${sampled.session.usedPercentage}% / week ${sampled.weekAll.usedPercentage}%` : "";
127
- console.log(`${c.green("✓")} added ${c.bold(account.email)} (${account.subscriptionType ?? "?"})${usageNote} → pool now has ${idx.accounts.length} account(s)`);
132
+ console.log(`${c.green("✓")} added ${c.bold(account.email)} (${account.subscriptionType ?? "?"})${usageNote} → pool now has ${poolSize} account(s)`);
128
133
  return 0;
129
134
  }
@@ -0,0 +1,29 @@
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";
8
+ import { log } from "../lib/log.ts";
9
+ import { c, fmtReset } from "./render.ts";
10
+
11
+ export async function cmdCheck(): Promise<number> {
12
+ let d;
13
+ try {
14
+ d = await evaluateAndMaybeSwap();
15
+ } catch (e) {
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)}`));
19
+ return 1;
20
+ }
21
+ if (d.swapped && d.account) {
22
+ console.log(`${c.green("↻")} switched to ${c.bold(d.account.label)}`);
23
+ } else if (d.waitUntil !== undefined && d.account) {
24
+ console.log(c.yellow(`all accounts at limit - staying on ${c.bold(d.account.label)} (${fmtReset(d.waitUntil)})`));
25
+ } else {
26
+ console.log(c.dim(`no switch (${d.reason})`));
27
+ }
28
+ return 0;
29
+ }
package/src/cli/doctor.ts CHANGED
@@ -3,7 +3,7 @@
3
3
 
4
4
  import { existsSync } from "node:fs";
5
5
  import { checkSettings, installedBin } from "../lib/settings.ts";
6
- import { isBinDirAhead } from "../lib/install.ts";
6
+ import { checkTimerHealthy, isBinDirAhead, timerActivationHint } from "../lib/install.ts";
7
7
  import { paths } from "../lib/paths.ts";
8
8
  import { loadAccounts, loadConfig } from "../lib/state.ts";
9
9
  import { readItem, liveTarget, parkedTarget } from "../lib/credstore.ts";
@@ -34,6 +34,7 @@ export async function cmdDoctor(): Promise<number> {
34
34
  check(s.statusLineOk, "statusLine shim installed in settings.json", "run `tokenmaxxing init`");
35
35
  check(s.stopOk, "Stop hook installed in settings.json", "run `tokenmaxxing init`");
36
36
  check(s.sessionStartOk, "SessionStart hook installed in settings.json", "run `tokenmaxxing init`");
37
+ check(checkTimerHealthy(), "periodic check timer active", timerActivationHint());
37
38
 
38
39
  const idx = loadAccounts();
39
40
  check(idx.accounts.length > 0, "at least one account in the pool", "run `tokenmaxxing init`");
package/src/cli/init.ts CHANGED
@@ -6,7 +6,7 @@ import { isApiKeyMode, readOAuthAccount } from "../lib/claudejson.ts";
6
6
  import { readItem, writeItem, liveTarget, parkedTarget, mergeIntoLive } from "../lib/credstore.ts";
7
7
  import { refreshCredential, isAccessTokenExpiring, fetchTokenOrg } from "../lib/oauth.ts";
8
8
  import { loadAccounts, saveAccounts, loadConfig, saveConfig } from "../lib/state.ts";
9
- import { installSupervisor, shellRcPath, ensurePathInRc } from "../lib/install.ts";
9
+ import { installSupervisor, shellRcPath, ensurePathInRc, timerActivationHint, type InstallOutcome } from "../lib/install.ts";
10
10
  import { resolveRealClaude } from "../lib/claudebin.ts";
11
11
  import { credItemFor, paths } from "../lib/paths.ts";
12
12
  import { CredentialBlobSchema, type Account } from "../lib/types.ts";
@@ -25,6 +25,11 @@ function ensurePathAhead(): void {
25
25
  else console.log(c.yellow(`⚠ PATH line already in ${rc} - restart your shell to pick it up`));
26
26
  }
27
27
 
28
+ function reportTimer(out: InstallOutcome): void {
29
+ if (out.timerLoaded) console.log(`${c.green("✓")} periodic check timer active (every 3m)`);
30
+ else console.log(c.yellow(`⚠ check timer written but not activated - run: ${timerActivationHint()}`));
31
+ }
32
+
28
33
  export async function cmdInit(): Promise<number> {
29
34
  mkdirSync(paths.home, { recursive: true });
30
35
 
@@ -41,6 +46,7 @@ export async function cmdInit(): Promise<number> {
41
46
  saveConfig(cfg);
42
47
  const active = existingIdx.accounts.find((a) => a.accountUuid === existingIdx.activeAccountUuid);
43
48
  console.log(`${c.green("✓")} re-installed supervisor + hooks (pool already has ${existingIdx.accounts.length} account${existingIdx.accounts.length === 1 ? "" : "s"} - not re-importing)`);
49
+ reportTimer(out);
44
50
  if (!out.pathAhead) ensurePathAhead();
45
51
  console.log(` active: ${c.bold(active?.label ?? "unknown")} · run ${c.cyan("tokenmaxxing add")} for more, ${c.cyan("tokenmaxxing status")} to check`);
46
52
  return 0;
@@ -113,7 +119,7 @@ export async function cmdInit(): Promise<number> {
113
119
 
114
120
  console.log(`${c.green("✓")} imported current account → ${c.bold(account.email)} (${account.subscriptionType ?? "?"})`);
115
121
  console.log(`${c.green("✓")} installed ${c.bold("claude")} supervisor + statusLine/Stop/SessionStart hooks`);
116
- if (out.priorStatusLine) console.log(`${c.green("✓")} wrapped your existing statusLine (preserved)`);
122
+ reportTimer(out);
117
123
  if (!out.pathAhead) {
118
124
  console.log();
119
125
  ensurePathAhead();
package/src/cli/rename.ts CHANGED
@@ -1,5 +1,7 @@
1
1
  // `tokenmaxxing rename <selector> <new-label>` - relabel a pooled account.
2
2
 
3
+ import { withLock } from "../lib/lock.ts";
4
+ import { paths } from "../lib/paths.ts";
3
5
  import { loadAccounts, saveAccounts } from "../lib/state.ts";
4
6
  import { c } from "./render.ts";
5
7
  import type { Account } from "../lib/types.ts";
@@ -14,20 +16,23 @@ export function findAccount(accounts: Account[], selector: string): Account | un
14
16
  );
15
17
  }
16
18
 
17
- export function cmdRename(selector?: string, newLabel?: string): number {
19
+ export async function cmdRename(selector?: string, newLabel?: string): Promise<number> {
18
20
  if (!selector || !newLabel) {
19
21
  console.error("usage: tokenmaxxing rename <email|label|uuid> <new-label>");
20
22
  return 2;
21
23
  }
22
- const idx = loadAccounts();
23
- const a = findAccount(idx.accounts, selector);
24
- if (!a) {
25
- console.error(c.red(`no account matches "${selector}"`));
26
- return 1;
27
- }
28
- const old = a.label;
29
- a.label = newLabel;
30
- saveAccounts(idx);
31
- console.log(`renamed ${c.dim(old)} → ${c.bold(newLabel)}`);
32
- return 0;
24
+ // under the flock: a concurrent swap's index write must not be clobbered.
25
+ return withLock(paths.lockFile, async () => {
26
+ const idx = loadAccounts();
27
+ const a = findAccount(idx.accounts, selector);
28
+ if (!a) {
29
+ console.error(c.red(`no account matches "${selector}"`));
30
+ return 1;
31
+ }
32
+ const old = a.label;
33
+ a.label = newLabel;
34
+ saveAccounts(idx);
35
+ console.log(`renamed ${c.dim(old)} → ${c.bold(newLabel)}`);
36
+ return 0;
37
+ });
33
38
  }
package/src/cli/render.ts CHANGED
@@ -2,16 +2,21 @@
2
2
 
3
3
  import { clamp } from "es-toolkit";
4
4
 
5
- const useColor = !process.env.NO_COLOR && process.stdout.isTTY;
5
+ /** ANSI painters, no-ops when disabled. The statusLine renders through a pipe
6
+ * (never a TTY), so it needs its own enable gate; the CLI gates on stdout. */
7
+ export function makeColors(enabled: boolean) {
8
+ const paint = (code: string) => (s: string) => (enabled ? `\x1b[${code}m${s}\x1b[0m` : s);
9
+ return {
10
+ dim: paint("2"),
11
+ bold: paint("1"),
12
+ green: paint("32"),
13
+ yellow: paint("33"),
14
+ red: paint("31"),
15
+ cyan: paint("36"),
16
+ };
17
+ }
6
18
 
7
- export const c = {
8
- dim: (s: string) => (useColor ? `\x1b[2m${s}\x1b[0m` : s),
9
- bold: (s: string) => (useColor ? `\x1b[1m${s}\x1b[0m` : s),
10
- green: (s: string) => (useColor ? `\x1b[32m${s}\x1b[0m` : s),
11
- yellow: (s: string) => (useColor ? `\x1b[33m${s}\x1b[0m` : s),
12
- red: (s: string) => (useColor ? `\x1b[31m${s}\x1b[0m` : s),
13
- cyan: (s: string) => (useColor ? `\x1b[36m${s}\x1b[0m` : s),
14
- };
19
+ export const c = makeColors(!process.env.NO_COLOR && !!process.stdout.isTTY);
15
20
 
16
21
  /** A fixed-width usage bar, colored by fill. */
17
22
  export function bar(pct: number, width = 16): string {
@@ -34,3 +39,20 @@ export function fmtReset(epochMs: number | null | undefined, now = Date.now()):
34
39
  if (h > 0) return `resets in ${h}h${m}m`;
35
40
  return `resets in ${m}m`;
36
41
  }
42
+
43
+ /** Compact time-until-reset for the statusLine: the largest unit only ("6d",
44
+ * "2h", "45m"), floored to "1m" so a live window never reads as zero, and ""
45
+ * once the reset has passed (the window is simply empty again). Non-empty
46
+ * output always ends in a unit letter, so the digit-leading used-percent glued
47
+ * after it stays parseable. */
48
+ export function fmtResetShort(epochMs: number | null | undefined, now = Date.now()): string {
49
+ if (epochMs == null) return "";
50
+ const dsec = Math.round((epochMs - now) / 1000);
51
+ if (dsec <= 0) return "";
52
+ const d = Math.floor(dsec / 86400);
53
+ const h = Math.floor((dsec % 86400) / 3600);
54
+ const m = Math.floor((dsec % 3600) / 60);
55
+ if (d > 0) return `${d}d`;
56
+ if (h > 0) return `${h}h`;
57
+ return `${Math.max(m, 1)}m`;
58
+ }
package/src/cli/rm.ts CHANGED
@@ -1,6 +1,8 @@
1
1
  // `tokenmaxxing rm <selector>` - remove a pooled account (not the active one).
2
2
 
3
3
  import { deleteItem, parkedTarget } from "../lib/credstore.ts";
4
+ import { withLock } from "../lib/lock.ts";
5
+ import { paths } from "../lib/paths.ts";
4
6
  import { loadAccounts, saveAccounts } from "../lib/state.ts";
5
7
  import { findAccount } from "./rename.ts";
6
8
  import { c } from "./render.ts";
@@ -10,19 +12,22 @@ export async function cmdRm(selector?: string): Promise<number> {
10
12
  console.error("usage: tokenmaxxing rm <email|label|uuid>");
11
13
  return 2;
12
14
  }
13
- const idx = loadAccounts();
14
- const a = findAccount(idx.accounts, selector);
15
- if (!a) {
16
- console.error(c.red(`no account matches "${selector}"`));
17
- return 1;
18
- }
19
- if (a.accountUuid === idx.activeAccountUuid) {
20
- console.error(c.red(`${a.email} is the ACTIVE account - switch away before removing it.`));
21
- return 1;
22
- }
23
- await deleteItem(parkedTarget(a.keychainItem));
24
- idx.accounts = idx.accounts.filter((x) => x.accountUuid !== a.accountUuid);
25
- saveAccounts(idx);
26
- console.log(`removed ${c.bold(a.label)} from the pool (${idx.accounts.length} left)`);
27
- return 0;
15
+ // under the flock: a concurrent swap's index write must not be clobbered.
16
+ return withLock(paths.lockFile, async () => {
17
+ const idx = loadAccounts();
18
+ const a = findAccount(idx.accounts, selector);
19
+ if (!a) {
20
+ console.error(c.red(`no account matches "${selector}"`));
21
+ return 1;
22
+ }
23
+ if (a.accountUuid === idx.activeAccountUuid) {
24
+ console.error(c.red(`${a.email} is the ACTIVE account - switch away before removing it.`));
25
+ return 1;
26
+ }
27
+ await deleteItem(parkedTarget(a.keychainItem));
28
+ idx.accounts = idx.accounts.filter((x) => x.accountUuid !== a.accountUuid);
29
+ saveAccounts(idx);
30
+ console.log(`removed ${c.bold(a.label)} from the pool (${idx.accounts.length} left)`);
31
+ return 0;
32
+ });
28
33
  }
package/src/cli/status.ts CHANGED
@@ -16,10 +16,8 @@ import type { FullUsage } from "../lib/usage.ts";
16
16
  import type { UsageWindow } from "../lib/types.ts";
17
17
 
18
18
  export async function cmdStatus(): Promise<number> {
19
- const idx = loadAccounts();
19
+ let idx = loadAccounts();
20
20
  const cfg = loadConfig();
21
- const live = loadUsage();
22
- const modelUsage = loadModelUsage();
23
21
  const now = Date.now();
24
22
 
25
23
  if (idx.accounts.length === 0) {
@@ -27,13 +25,17 @@ export async function cmdStatus(): Promise<number> {
27
25
  return 0;
28
26
  }
29
27
 
30
- const activeOrg = readOAuthAccount()?.organizationUuid ?? null;
31
-
32
- // Sample under the flock so parked refreshes can't collide with an in-flight swap.
33
- console.error(c.dim("sampling live usage"));
28
+ // Load, sample, and save entirely under the flock: parked refreshes must not
29
+ // collide with an in-flight swap, and a save of an index loaded before a
30
+ // concurrent swap would clobber the swap's activeAccountUuid.
31
+ console.error(c.dim("sampling live usage..."));
34
32
  const outcomes = new Map<string, SampleOutcome>();
35
- await withLock(paths.lockFile, () =>
36
- Promise.all(
33
+ await withLock(paths.lockFile, async () => {
34
+ idx = loadAccounts();
35
+ const live = loadUsage();
36
+ const modelUsage = loadModelUsage();
37
+ const activeOrg = readOAuthAccount()?.organizationUuid ?? null;
38
+ await Promise.all(
37
39
  idx.accounts.map(async (a) => {
38
40
  const isActive = a.accountUuid === idx.activeAccountUuid && activeOrg === a.organizationUuid;
39
41
  // Active account: prefer the free statusLine push (usage.json) so we never
@@ -57,9 +59,9 @@ export async function cmdStatus(): Promise<number> {
57
59
  a.lastUsage = { fiveHour: outcome.usage.session, sevenDay: outcome.usage.weekAll };
58
60
  if (Object.keys(outcome.usage.perModel).length > 0) a.lastPerModel = outcome.usage.perModel;
59
61
  }),
60
- ),
61
- );
62
- saveAccounts(idx);
62
+ );
63
+ saveAccounts(idx);
64
+ });
63
65
 
64
66
  console.log(c.dim(`threshold ${cfg.threshold}% · ${idx.accounts.length} account(s)`));
65
67
  console.log();
@@ -1,13 +1,53 @@
1
- // statusLine shim. Reads Claude's statusLine stdin, tees the rate-limit data to
2
- // usage.json (write-on-change, O(ms)), then transparently delegates to the user's
3
- // prior statusLine command (same stdin) and passes its stdout through unchanged.
4
- // Must NEVER break the user's status line: every step is best-effort.
1
+ // Native statusLine. Reads Claude's statusLine stdin, tees the rate-limit data
2
+ // to usage.json (write-on-change, O(ms)), then renders ONE line:
3
+ // worktree name (linked worktrees only), model (effort), ctx used,
4
+ // +added/-removed, then quota as USED percent (bold, severity-colored)
5
+ // glued after its time-to-reset: "◆ F26 2h5 1d38 ◇ F67 2d40 ◇ 2 full"
6
+ // ("2h5" = resets in 2h, 5 used) - the active account's windows after a
7
+ // green ◆ (per-model by initial first, "F?" when the cap applies but is
8
+ // unmeasured, then session/5h, then week), then each parked account's week
9
+ // after a cyan ◇, in swap-preference order so the first usable ◇ is where
10
+ // the next swap lands. Adjacent untouched (or unsampled) parked accounts
11
+ // collapse into one counted token. Blocks are joined by TWO spaces, tokens
12
+ // within a block by one. Per-model resets are omitted (they match the
13
+ // weekly reset).
14
+ // Must NEVER break the status line: render what parses, skip what doesn't.
5
15
 
16
+ import { sortBy } from "es-toolkit";
17
+ import { z } from "zod";
6
18
  import { readOAuthAccount } from "../lib/claudejson.ts";
7
- import { readPriorStatusLine } from "../lib/settings.ts";
8
- import { writeUsage } from "../lib/state.ts";
9
- import { parseStatusLineStdin, parseStatusLineModel } from "../lib/usage.ts";
10
- import type { UsageState } from "../lib/types.ts";
19
+ import { loadAccounts, loadConfig, loadLastSwapAt, loadModelUsage, writeUsage } from "../lib/state.ts";
20
+ import { familyTokens, matchedFamily, parseStatusLineStdin, parseStatusLineModel } from "../lib/usage.ts";
21
+ import { isExhausted, swapPreference, weeklyExpiry } from "../lib/picker.ts";
22
+ import { worktreeName } from "../lib/worktree.ts";
23
+ import { fmtResetShort, makeColors } from "../cli/render.ts";
24
+ import {
25
+ AccountsIndexSchema,
26
+ StatusLineStdinSchema,
27
+ UsageWindowSchema,
28
+ type Account,
29
+ type UsageState,
30
+ type UsageWindow,
31
+ } from "../lib/types.ts";
32
+
33
+ /** A session that hasn't adopted a fresh swap yet (<=30s keychain cache) pushes
34
+ * the OLD account's windows while ~/.claude.json already names the NEW org.
35
+ * Suppress the tee for this long after a swap so that mislabel never lands. */
36
+ const ADOPTION_GRACE_MS = 45_000;
37
+
38
+ const RenderCtxSchema = z.object({
39
+ accounts: AccountsIndexSchema,
40
+ /** active account's per-model weekly windows ({} unless fresh for the live org). */
41
+ perModel: z.record(z.string(), UsageWindowSchema),
42
+ /** families (lowercased) whose per-model weekly cap gates a switch. */
43
+ switchModels: z.array(z.string()),
44
+ threshold: z.number(),
45
+ /** linked-worktree basename, null in a main checkout. */
46
+ worktree: z.string().nullable(),
47
+ now: z.number(),
48
+ color: z.boolean(),
49
+ });
50
+ export type RenderCtx = z.infer<typeof RenderCtxSchema>;
11
51
 
12
52
  async function readStdin(): Promise<string> {
13
53
  const chunks: Uint8Array[] = [];
@@ -15,38 +55,132 @@ async function readStdin(): Promise<string> {
15
55
  return Buffer.concat(chunks).toString("utf8");
16
56
  }
17
57
 
58
+ /** Pure renderer: statusLine stdin + tokenmaxxing state → the emitted line. */
59
+ export function renderStatusline(stdinObj: unknown, ctx: RenderCtx): string {
60
+ const col = makeColors(ctx.color);
61
+ const severity = (u: number) => (u >= 95 ? col.red : u >= 75 ? col.yellow : col.green);
62
+ const gauge = (u: number) => col.bold(severity(u)(`${Math.round(u)}`));
63
+ // Used quota in a window; one whose reset has passed is empty again.
64
+ const used = (w: UsageWindow) => (w.resetsAt != null && w.resetsAt <= ctx.now ? 0 : w.usedPercentage);
65
+ const reset = (epochMs: number | null) => fmtResetShort(epochMs, ctx.now);
66
+
67
+ const parsed = StatusLineStdinSchema.safeParse(stdinObj);
68
+ const d = parsed.success ? parsed.data : null;
69
+
70
+ // ---- info block: worktree, model, ctx, diff
71
+ const info: string[] = [];
72
+ if (ctx.worktree != null) info.push(ctx.worktree);
73
+ const modelName = d?.model?.display_name ?? d?.model?.id;
74
+ if (modelName) {
75
+ const effort = d?.effort?.level;
76
+ info.push(col.bold(modelName) + (effort ? ` (${effort})` : ""));
77
+ }
78
+ const ctxUsed = d?.context_window?.used_percentage;
79
+ if (ctxUsed != null) info.push(`ctx ${gauge(ctxUsed)}`);
80
+ const added = d?.cost?.total_lines_added ?? 0;
81
+ const removed = d?.cost?.total_lines_removed ?? 0;
82
+ // -removed stays unpainted: red means quota alarm and nothing else.
83
+ if (added > 0 || removed > 0) info.push(`${col.green(`+${added}`)}/-${removed}`);
84
+
85
+ // ---- active account block
86
+ const seg = (label: string, w: UsageWindow, resetAt: number | null) =>
87
+ `${label}${reset(resetAt)}${gauge(used(w))}`;
88
+ const wins = parseStatusLineStdin(stdinObj);
89
+ const windows: string[] = [];
90
+ // A capacity-constrained model whose cap is unmeasured must not look safe.
91
+ const family = matchedFamily(parseStatusLineModel(stdinObj), ctx.switchModels);
92
+ if (family && !Object.keys(ctx.perModel).some((k) => familyTokens(k).includes(family))) {
93
+ windows.push(`${family[0]!.toUpperCase()}?`);
94
+ }
95
+ for (const [name, w] of Object.entries(ctx.perModel)) windows.push(seg(name.slice(0, 1), w, null));
96
+ if (wins) {
97
+ windows.push(seg("", wins.fiveHour, wins.fiveHour.resetsAt));
98
+ windows.push(seg("", wins.sevenDay, wins.sevenDay.resetsAt));
99
+ }
100
+ const active =
101
+ windows.length > 0
102
+ ? `${col.green("◆")} ${windows.join(" ")}`
103
+ : ctx.accounts.activeAccountUuid != null
104
+ ? `${col.green("◆")} ?`
105
+ : "";
106
+
107
+ // ---- parked accounts, in swap order: the first usable ◇ is the next target
108
+ const pickCtx = { now: ctx.now, threshold: ctx.threshold, currentAccountUuid: ctx.accounts.activeAccountUuid };
109
+ const parked = sortBy(
110
+ ctx.accounts.accounts.filter((a) => a.accountUuid !== ctx.accounts.activeAccountUuid),
111
+ [(a) => (a.needsReauth || isExhausted(a, pickCtx) ? 1 : 0), ...swapPreference(ctx.now)],
112
+ );
113
+ const poolSeg = (a: Account): { kind: "full" | "unknown" | "other"; text: string } => {
114
+ const marker = a.needsReauth ? col.red("✗") : col.cyan("◇");
115
+ const mergeable = !a.needsReauth;
116
+
117
+ const week = a.lastUsage?.sevenDay;
118
+ if (week == null) return { kind: mergeable ? "unknown" : "other", text: `${marker} ?` };
119
+ const weekUsed = used(week);
120
+ if (Math.round(weekUsed) <= 0) return { kind: mergeable ? "full" : "other", text: `${marker} ${col.green("full")}` };
121
+
122
+ const parts: string[] = [];
123
+ // A per-model weekly cap with more used than the aggregate is the binding constraint - surface it.
124
+ for (const [name, w] of Object.entries(a.lastPerModel ?? {})) {
125
+ if (used(w) > weekUsed) parts.push(seg(name.slice(0, 1), w, null));
126
+ }
127
+ const expiry = weeklyExpiry(a, ctx.now);
128
+ parts.push(seg("", week, Number.isFinite(expiry) ? expiry : null));
129
+ return { kind: "other", text: `${marker} ${parts.join(" ")}` };
130
+ };
131
+
132
+ // Adjacent identical bare tokens collapse into one counted token ("◇ 3 full").
133
+ const pool: string[] = [];
134
+ const segs = parked.map(poolSeg);
135
+ for (let i = 0; i < segs.length; ) {
136
+ const s = segs[i]!;
137
+ let j = i + 1;
138
+ while (s.kind !== "other" && j < segs.length && segs[j]!.kind === s.kind) j++;
139
+ if (j - i >= 2) pool.push(`${col.cyan("◇")} ${j - i} ${s.kind === "full" ? col.green("full") : "?"}`);
140
+ else pool.push(s.text);
141
+ i = j;
142
+ }
143
+
144
+ return [info.join(" "), active, ...pool].filter((l) => l !== "").join(" ");
145
+ }
146
+
18
147
  export async function runStatusline(): Promise<number> {
19
148
  const raw = await readStdin();
149
+ let obj: unknown = null;
150
+ try {
151
+ obj = JSON.parse(raw);
152
+ } catch {
153
+ // malformed stdin - render from state alone
154
+ }
155
+ const now = Date.now();
20
156
 
21
- // 1) tee usage - best effort, never throws out.
157
+ // tee usage for the Stop hook / status - best effort, never blocks rendering.
158
+ let org: string | null = null;
22
159
  try {
23
- const obj = JSON.parse(raw);
24
- const windows = parseStatusLineStdin(obj);
25
- if (windows) {
26
- const org = readOAuthAccount()?.organizationUuid ?? null;
27
- const state: UsageState = { ...windows, org, ts: Date.now(), model: parseStatusLineModel(obj) };
160
+ org = readOAuthAccount()?.organizationUuid ?? null;
161
+ const windows = obj == null ? null : parseStatusLineStdin(obj);
162
+ const lastSwapAt = loadLastSwapAt();
163
+ if (windows && (lastSwapAt == null || now - lastSwapAt >= ADOPTION_GRACE_MS)) {
164
+ const state: UsageState = { ...windows, org, ts: now, model: parseStatusLineModel(obj) };
28
165
  writeUsage(state);
29
166
  }
30
167
  } catch {
31
- // malformed stdin - skip usage, still delegate below
168
+ // skip the tee, still render below
32
169
  }
33
170
 
34
- // 2) delegate to the prior statusLine, feeding it the same stdin.
35
- const prior = readPriorStatusLine();
36
- if (prior) {
37
- try {
38
- const p = Bun.spawn(["/bin/sh", "-c", prior], {
39
- stdin: new TextEncoder().encode(raw),
40
- stdout: "pipe",
41
- stderr: "inherit",
42
- });
43
- const out = await new Response(p.stdout).text();
44
- await p.exited;
45
- if (out) process.stdout.write(out);
46
- return p.exitCode ?? 0;
47
- } catch {
48
- // fall through to no-op
49
- }
50
- }
171
+ const cfg = loadConfig();
172
+ const modelUsage = loadModelUsage();
173
+ const stdin = StatusLineStdinSchema.safeParse(obj);
174
+ const dir = stdin.success ? (stdin.data.workspace?.current_dir ?? stdin.data.workspace?.project_dir ?? null) : null;
175
+ const ctx: RenderCtx = {
176
+ accounts: loadAccounts(),
177
+ perModel: modelUsage && modelUsage.org === org ? modelUsage.perModel : {},
178
+ switchModels: cfg.policy.switchModels,
179
+ threshold: cfg.threshold,
180
+ worktree: dir == null ? null : worktreeName(dir),
181
+ now,
182
+ color: !process.env.NO_COLOR,
183
+ };
184
+ process.stdout.write(renderStatusline(obj, ctx) + "\n");
51
185
  return 0;
52
186
  }