tokenmaxxing 1.8.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 (85) hide show
  1. package/DESIGN.md +2 -4
  2. package/README.md +1 -1
  3. package/agent-plugin/plugin.json +1 -1
  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 +0 -40
  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 +1 -33
  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 -73
  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 -34
  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 +6 -127
  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 -48
  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 -40
  67. package/src/sdk.ts +0 -59
  68. package/agent-plugin/agents/tokenmaxxing-claude.md +0 -43
  69. package/agent-plugin/agents/tokenmaxxing-codex.md +0 -40
  70. package/agent-plugin/hooks/cursor-relay.json +0 -14
  71. package/agent-plugin/skills/relay-session/SKILL.md +0 -118
  72. package/agent-plugin/skills/relay-session/references/ipc.md +0 -23
  73. package/src/cli/relay.ts +0 -323
  74. package/src/entries/relaypermission.ts +0 -105
  75. package/src/lib/relay/config.ts +0 -84
  76. package/src/lib/relay/decide.ts +0 -75
  77. package/src/lib/relay/gc.ts +0 -80
  78. package/src/lib/relay/install.ts +0 -143
  79. package/src/lib/relay/markers.ts +0 -148
  80. package/src/lib/relay/modes.ts +0 -82
  81. package/src/lib/relay/protocol.ts +0 -61
  82. package/src/lib/relay/registry.ts +0 -175
  83. package/src/lib/relay/tmux.ts +0 -109
  84. package/src/lib/relay/turn.ts +0 -137
  85. package/src/lib/relay/worker.ts +0 -141
@@ -1,10 +1,3 @@
1
- // One isolated Claude login, harvested. Shared by `add` (register whatever
2
- // account the login lands on) and `auth` (require it to match an existing
3
- // account). Drives the login in a throwaway CLAUDE_CONFIG_DIR - the ONLY use
4
- // of CLAUDE_CONFIG_DIR in the tool - auto-exits the moment the login lands,
5
- // samples that account's usage, then returns the credential + identity. The
6
- // temp dir and isolated credential are always destroyed before returning.
7
-
8
1
  import { existsSync, mkdirSync, readFileSync, rmSync } from "node:fs";
9
2
  import { join } from "node:path";
10
3
  import { z } from "zod";
@@ -17,7 +10,6 @@ import { CredentialBlobSchema, OAuthAccountSchema } from "../lib/types.ts";
17
10
  import { c } from "./render.ts";
18
11
 
19
12
  const HarvestedLoginSchema = z.object({
20
- /** the raw isolated credential blob (park it via claudeAiOauthOnly). */
21
13
  blobRaw: z.string(),
22
14
  blob: CredentialBlobSchema,
23
15
  oauthAccount: OAuthAccountSchema,
@@ -25,7 +17,6 @@ const HarvestedLoginSchema = z.object({
25
17
  });
26
18
  export type HarvestedLogin = z.infer<typeof HarvestedLoginSchema>;
27
19
 
28
- /** True once `/login` has written a usable identity into the onboard dir. */
29
20
  function identityReady(cjPath: string): boolean {
30
21
  if (!existsSync(cjPath)) return false;
31
22
  try {
@@ -36,32 +27,16 @@ function identityReady(cjPath: string): boolean {
36
27
  }
37
28
  }
38
29
 
39
- /**
40
- * Run one isolated login session and harvest it. The caller prints its own
41
- * instructions (which account to sign in as) BEFORE calling. Prints progress
42
- * and failure diagnostics; returns null when no usable login landed.
43
- */
44
30
  export async function harvestIsolatedLogin(): Promise<HarvestedLogin | null> {
45
31
  const onboardDir = paths.onboardDir;
46
32
  rmSync(onboardDir, { recursive: true, force: true });
47
33
  mkdirSync(onboardDir, { recursive: true });
48
34
  const iso = isolatedTarget(onboardDir);
49
- // Reap a PREVIOUS run's stranded isolated credential too: on macOS the
50
- // isolated store is a namespaced KEYCHAIN item, not a file in onboardDir,
51
- // so the rmSync above never touched it - a signal-killed harvest (Ctrl-C
52
- // after /login, before the ~400ms poll) left a live credential in the
53
- // keychain that the next run's spawned claude would silently reuse as an
54
- // already-authenticated session (closing-review catch). deleteItem on a
55
- // missing item is a no-op; on Linux iso is a file inside onboardDir and the
56
- // rmSync already covered it.
57
35
  await deleteItem(iso);
58
36
  const cjPath = join(onboardDir, ".claude.json");
59
37
  const real = resolveRealClaude();
60
38
 
61
39
  const savedTermios = saveTermios();
62
- // Scrub the ambient credential/identity overrides claude honors BEFORE its
63
- // keychain lookup (verified 2.1.205) - the onboard session must authenticate
64
- // only via the /login the user performs inside it.
65
40
  const env: Record<string, string> = { ...process.env, CLAUDE_CONFIG_DIR: onboardDir, TOKENMAXXING_PROBE: "1", TOKENMAXXING_SUPERVISED: "" };
66
41
  for (const key of CRED_ENV_OVERRIDES) delete env[key];
67
42
  const p = Bun.spawn([real], {
@@ -71,15 +46,7 @@ export async function harvestIsolatedLogin(): Promise<HarvestedLogin | null> {
71
46
  env,
72
47
  });
73
48
 
74
- // The finally makes the "always destroyed before returning" header true for
75
- // every non-signal exit - INCLUDING a failure while the login session is
76
- // still being polled (review catch, PR #31): an exception must not strand a
77
- // plaintext credential on disk or leave the spawned claude running. (An
78
- // interactive Ctrl-C is reaped by the next run's cleanup-first, which
79
- // deletes the isolated keychain item as well as the dir.)
80
49
  try {
81
- // Auto-exit (#17): watch for a completed login - identity written AND the
82
- // isolated credential present - then SIGTERM claude. No manual /exit.
83
50
  let exited = false;
84
51
  const onExit = p.exited.then(() => { exited = true; });
85
52
  while (!exited) {
@@ -91,9 +58,6 @@ export async function harvestIsolatedLogin(): Promise<HarvestedLogin | null> {
91
58
  }
92
59
  await p.exited;
93
60
  await onExit;
94
- // restore promptly so the harvest's own output renders on a sane terminal;
95
- // the finally's second restore is an idempotent stty and covers the
96
- // thrown-mid-poll path.
97
61
  restoreTermios(savedTermios);
98
62
 
99
63
  const blobRaw = await readItem(iso);
@@ -111,7 +75,6 @@ export async function harvestIsolatedLogin(): Promise<HarvestedLogin | null> {
111
75
  return null;
112
76
  }
113
77
 
114
- // Sample usage now (#16) so the account isn't "not sampled yet" in status/ls.
115
78
  console.log(c.dim("sampling usage..."));
116
79
  const sampled = await probeUsage(onboardDir);
117
80
  if (!sampled) console.log(c.yellow("could not sample usage now - it will fill in on first use."));
package/src/cli/rename.ts CHANGED
@@ -1,8 +1,3 @@
1
- // `tokenmaxxing rename [--codex] <selector> <new-label>` - relabel a pooled
2
- // account. The pools are separate namespaces and one email can hold both a
3
- // claude and a codex account, so the codex pool is targeted explicitly via
4
- // `--codex` (mirroring `switch --codex`), never by searching both pools.
5
-
6
1
  import { withLock } from "../lib/lock.ts";
7
2
  import { codexPaths, paths } from "../lib/paths.ts";
8
3
  import { loadAccounts, saveAccounts } from "../lib/state.ts";
@@ -10,7 +5,6 @@ import { loadCodexAccounts, saveCodexAccounts } from "../lib/codexstate.ts";
10
5
  import { c } from "./render.ts";
11
6
  import type { Account, CodexAccount } from "../lib/types.ts";
12
7
 
13
- /** Resolve a claude account by email, label, or accountUuid prefix. */
14
8
  export function findAccount(accounts: Account[], selector: string): Account | undefined {
15
9
  const s = selector.toLowerCase();
16
10
  return (
@@ -20,7 +14,6 @@ export function findAccount(accounts: Account[], selector: string): Account | un
20
14
  );
21
15
  }
22
16
 
23
- /** Resolve a codex account by email, label, or accountId prefix. */
24
17
  export function findCodexAccount(accounts: CodexAccount[], selector: string): CodexAccount | undefined {
25
18
  const s = selector.toLowerCase();
26
19
  return (
@@ -31,7 +24,6 @@ export function findCodexAccount(accounts: CodexAccount[], selector: string): Co
31
24
  }
32
25
 
33
26
  async function renameCodexAccount(input: { selector: string; newLabel: string }): Promise<number> {
34
- // under the codex flock: a concurrent codex swap's index write must not be clobbered.
35
27
  return withLock(codexPaths.lockFile, async () => {
36
28
  const index = loadCodexAccounts();
37
29
  const account = findCodexAccount(index.accounts, input.selector);
@@ -39,11 +31,6 @@ async function renameCodexAccount(input: { selector: string; newLabel: string })
39
31
  console.error(c.red(`no codex account matches "${input.selector}"`));
40
32
  return 1;
41
33
  }
42
- // labels resolve selectors first-match: a duplicate would make the other
43
- // account unreachable by label and misdirect destructive commands like
44
- // `rm` onto the wrong one (adversarial-review catch)
45
- // case-insensitive, matching how findCodexAccount resolves selectors (PR
46
- // #37 review catch: a casing-only duplicate slipped the === guard)
47
34
  const taken = index.accounts.find((x) => x.accountId !== account.accountId && x.label.toLowerCase() === input.newLabel.toLowerCase());
48
35
  if (taken) {
49
36
  console.error(c.red(`label "${input.newLabel}" is already used by ${taken.accountId.slice(0, 8)} - labels must be unique within the pool`));
@@ -65,7 +52,6 @@ export async function cmdRename(argv: string[]): Promise<number> {
65
52
  return 2;
66
53
  }
67
54
  if (codex) return renameCodexAccount({ selector, newLabel });
68
- // under the flock: a concurrent swap's index write must not be clobbered.
69
55
  return withLock(paths.lockFile, async () => {
70
56
  const idx = loadAccounts();
71
57
  const a = findAccount(idx.accounts, selector);
@@ -73,11 +59,6 @@ export async function cmdRename(argv: string[]): Promise<number> {
73
59
  console.error(c.red(`no claude account matches "${selector}" (codex accounts rename via --codex)`));
74
60
  return 1;
75
61
  }
76
- // labels resolve selectors first-match: a duplicate would make the other
77
- // account unreachable by label and misdirect destructive commands like
78
- // `rm` onto the wrong one (adversarial-review catch)
79
- // case-insensitive, matching how findAccount resolves selectors (PR #37
80
- // review catch: a casing-only duplicate slipped the === guard)
81
62
  const taken = idx.accounts.find((x) => x.accountUuid !== a.accountUuid && x.label.toLowerCase() === newLabel.toLowerCase());
82
63
  if (taken) {
83
64
  console.error(c.red(`label "${newLabel}" is already used by ${taken.email} - labels must be unique within the pool`));
package/src/cli/render.ts CHANGED
@@ -1,9 +1,5 @@
1
- // Terminal rendering helpers for the CLI (bars, colors, relative times).
2
-
3
1
  import { clamp } from "es-toolkit";
4
2
 
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
3
  export function makeColors(enabled: boolean) {
8
4
  const paint = (code: string) => (s: string) => (enabled ? `\x1b[${code}m${s}\x1b[0m` : s);
9
5
  return {
@@ -18,9 +14,6 @@ export function makeColors(enabled: boolean) {
18
14
 
19
15
  export const c = makeColors(!process.env.NO_COLOR && !!process.stdout.isTTY);
20
16
 
21
- /** Used% -> RGB on a continuous green->yellow->red ramp, anchored at the
22
- * semantic severity bands (pure green at 0, yellow at 75, red from 95) so the
23
- * band boundaries read exactly like the old 3-color scheme. Blue stays 0. */
24
17
  function rampRgb(usedPct: number): { r: number; g: number } {
25
18
  const u = clamp(usedPct, 0, 100);
26
19
  if (u >= 95) return { r: 255, g: 0 };
@@ -28,11 +21,6 @@ function rampRgb(usedPct: number): { r: number; g: number } {
28
21
  return { r: Math.round((255 * u) / 75), g: 255 };
29
22
  }
30
23
 
31
- /** Usage-severity painter for the statusLine (user decision 2026-07-18: colors
32
- * carry quota status instead of numbers). Truecolor when the terminal
33
- * advertises it, else the nearest 256-color cube step; disabled -> identity,
34
- * and callers must then fall back to numeric rendering (without color a
35
- * drained window must not look fresh). */
36
24
  export function makeUsagePaint(input: { enabled: boolean; truecolor: boolean }) {
37
25
  return (usedPct: number) =>
38
26
  (s: string): string => {
@@ -45,12 +33,6 @@ export function makeUsagePaint(input: { enabled: boolean; truecolor: boolean })
45
33
  };
46
34
  }
47
35
 
48
- /** Plan label for a claude account, e.g. "max 20x": subscription name plus the
49
- * multiplier segment of the rate-limit tier id ("default_claude_max_20x").
50
- * Structural, never exact-string: the multiplier is any <digits>x segment, so
51
- * tiers without one (e.g. "default_claude_zero") fall back to the bare
52
- * subscription name, and an absent blob field falls back to null (unmeasured
53
- * must not look like a tier). */
54
36
  export function claudeTierLabel(input: { subscriptionType?: string; rateLimitTier?: string }): string | null {
55
37
  const segments = input.rateLimitTier?.split("_") ?? [];
56
38
  const multiplier = segments.find((seg) => seg.length > 1 && seg.endsWith("x") && Number.isInteger(Number(seg.slice(0, -1))));
@@ -58,12 +40,10 @@ export function claudeTierLabel(input: { subscriptionType?: string; rateLimitTie
58
40
  return multiplier ? `${input.subscriptionType} ${multiplier}` : input.subscriptionType;
59
41
  }
60
42
 
61
- /** "1 account" / "3 accounts": counted nouns always pluralize properly. */
62
43
  export function count(input: { n: number; noun: string }): string {
63
44
  return `${input.n} ${input.noun}${input.n === 1 ? "" : "s"}`;
64
45
  }
65
46
 
66
- /** A fixed-width usage bar, colored by fill. */
67
47
  export function bar(pct: number, width = 16): string {
68
48
  const clamped = clamp(pct, 0, 100);
69
49
  const filled = Math.round((clamped / 100) * width);
@@ -73,7 +53,6 @@ export function bar(pct: number, width = 16): string {
73
53
  return `${paint(body)} ${label}`;
74
54
  }
75
55
 
76
- /** Relative-time string for a reset epoch, e.g. "resets in 2h13m". */
77
56
  export function fmtReset(epochMs: number | null | undefined, now = Date.now()): string {
78
57
  if (epochMs == null) return "";
79
58
  const dsec = Math.round((epochMs - now) / 1000);
@@ -85,8 +64,6 @@ export function fmtReset(epochMs: number | null | undefined, now = Date.now()):
85
64
  return `resets in ${m}m`;
86
65
  }
87
66
 
88
- /** Age of a cached sample, largest unit only: "just now", "3m ago", "2h ago",
89
- * "5d ago". */
90
67
  export function fmtAgo(epochMs: number, now = Date.now()): string {
91
68
  const dsec = Math.max(0, Math.round((now - epochMs) / 1000));
92
69
  if (dsec < 60) return "just now";
package/src/cli/rm.ts CHANGED
@@ -1,5 +1,3 @@
1
- // `tokenmaxxing rm <selector>` - remove a pooled account (not the active one).
2
-
3
1
  import { rmSync } from "node:fs";
4
2
  import { join } from "node:path";
5
3
  import { deleteItem, isolatedTarget, liveTarget, parkedTarget, readItem } from "../lib/credstore.ts";
@@ -16,7 +14,6 @@ export async function cmdRm(selector?: string): Promise<number> {
16
14
  console.error("usage: tokenmaxxing rm <email|label|uuid>");
17
15
  return 2;
18
16
  }
19
- // under the flock: a concurrent swap's index write must not be clobbered.
20
17
  return withLock(paths.lockFile, async () => {
21
18
  const idx = loadAccounts();
22
19
  const a = findAccount(idx.accounts, selector);
@@ -28,13 +25,6 @@ export async function cmdRm(selector?: string): Promise<number> {
28
25
  console.error(c.red(`${a.email} is the ACTIVE account - switch away before removing it.`));
29
26
  return 1;
30
27
  }
31
- // The label drifts (manual /login); the token cannot lie. Removing the
32
- // account whose credential is actually LIVE would destroy its only backup
33
- // and leave the next swap refusing over an unpooled credential. Fail
34
- // CLOSED (review catch, PR #31): when the live owner cannot be verified -
35
- // unparsable blob, expired token, roles outage - refuse rather than trust
36
- // the stale label; rm is destructive and can wait. The check is read-only:
37
- // an expired bearer simply fails the roles call, nothing is ever rotated.
38
28
  const live = await readItem(liveTarget());
39
29
  if (live != null) {
40
30
  let liveOrg: string;
@@ -51,15 +41,6 @@ export async function cmdRm(selector?: string): Promise<number> {
51
41
  }
52
42
  }
53
43
  await deleteItem(parkedTarget(a.keychainItem));
54
- // Sweep sample-probe residue too: a probe killed mid-run strands the
55
- // account's isolated credential (macOS: a namespaced keychain item), and
56
- // once the account leaves the pool nothing would ever probe-and-heal it
57
- // again (closing-review critic gap). deleteItem on a missing item is a
58
- // no-op. Hard delete, not trash, on purpose: the sample dir is a
59
- // throwaway CLAUDE_CONFIG_DIR that can hold PLAINTEXT credential material
60
- // on Linux - the credential-dir cleanup exception (owner 2026-07-16, same
61
- // rule sample.ts and onboard.ts follow); trashing would move credentials
62
- // into the Trash folder.
63
44
  const sampleDir = join(paths.sampleDir, credItemFor(a.accountUuid));
64
45
  await deleteItem(isolatedTarget(sampleDir));
65
46
  rmSync(sampleDir, { recursive: true, force: true });
package/src/cli/status.ts CHANGED
@@ -1,17 +1,3 @@
1
- // `tokenmaxxing status`: accounts with 5h / weekly / per-model usage bars.
2
- // Parked accounts are live-sampled in isolation (`claude -p /usage`); the active
3
- // account is read off the free statusLine feed (usage.json) so we never poll its
4
- // own busy token. A sample that fails falls back to the last-known values with a
5
- // visible "(cached)" note - never a silent stale number. Fresh figures are
6
- // persisted onto each account for the picker/switch logic.
7
- //
8
- // `--force` additionally PINGS every account (one minimal haiku request each)
9
- // before sampling, so every account's 5h session window starts ticking NOW
10
- // instead of lying dormant until first real use, and every bar is a live probe
11
- // taken after the ping. The active account still prefers the tee only as a
12
- // fallback: its own `/usage` fail-silents exactly when a live session is
13
- // running it, and that session's tee is fresher than any cache.
14
-
15
1
  import { sortBy } from "es-toolkit";
16
2
  import { loadAccounts, loadConfig, loadUsage, loadModelUsage, saveAccounts } from "../lib/state.ts";
17
3
  import { readOAuthAccount } from "../lib/claudejson.ts";
@@ -27,17 +13,11 @@ import { bar, c, claudeTierLabel, count, fmtAgo, fmtReset } from "./render.ts";
27
13
  import type { FullUsage } from "../lib/usage.ts";
28
14
  import type { Account, Config, CodexWindow, UsageWindow } from "../lib/types.ts";
29
15
 
30
- /** `preRender` runs after sampling, right before the first output line: `watch`
31
- * keeps the previous frame on screen through the multi-second sample and
32
- * clears only when the fresh frame is ready to paint (watch(1) semantics). */
33
16
  export async function cmdStatus(force = false, preRender?: () => void): Promise<number> {
34
17
  let idx = loadAccounts();
35
18
  const cfg = loadConfig();
36
19
  const now = Date.now();
37
20
 
38
- // A window whose cached reset has passed is empty again; weekly windows recur
39
- // on a fixed per-account anchor, so a stale weekly reset extrapolates forward.
40
- // Fresh samples pass through unchanged (their resets are in the future).
41
21
  const row = (name: string, w: UsageWindow, weekly: boolean) => {
42
22
  const passed = w.resetsAt != null && w.resetsAt <= now;
43
23
  const pct = passed ? 0 : w.usedPercentage;
@@ -46,10 +26,6 @@ export async function cmdStatus(force = false, preRender?: () => void): Promise<
46
26
  };
47
27
 
48
28
  if (idx.accounts.length === 0) {
49
- // A codex-only pool is a documented standalone flow: the empty-claude
50
- // early return must still render it, and only a fully empty install gets
51
- // the init hint (closing-review catch: bare `xx` claimed "no accounts"
52
- // over a populated codex pool and pointed at the wrong init).
53
29
  if (loadCodexAccounts().accounts.length > 0) {
54
30
  console.log(c.dim("no claude accounts (run `tokenmaxxing init` to pool claude too)"));
55
31
  console.log();
@@ -60,9 +36,6 @@ export async function cmdStatus(force = false, preRender?: () => void): Promise<
60
36
  return 0;
61
37
  }
62
38
 
63
- // Load, sample, and save entirely under the flock: parked refreshes must not
64
- // collide with an in-flight swap, and a save of an index loaded before a
65
- // concurrent swap would clobber the swap's activeAccountUuid.
66
39
  console.error(c.dim(force ? "pinging every account (starts each 5h session timer) + sampling live usage..." : "sampling live usage..."));
67
40
  const outcomes = new Map<string, SampleOutcome>();
68
41
  await withLock(paths.lockFile, async () => {
@@ -72,19 +45,8 @@ export async function cmdStatus(force = false, preRender?: () => void): Promise<
72
45
  const liveOAuth = readOAuthAccount();
73
46
  const activeOrg = liveOAuth?.organizationUuid ?? null;
74
47
  const probeOne = async (a: Account) => {
75
- // Active = the org claude's own oauthAccount names, NOT the stored
76
- // label conjunction: after a manual /login the label lags, and routing
77
- // the genuinely-live account through the parked prober would refresh
78
- // its stale parked grant and could flag a healthy account needs-reauth.
79
- // probeActiveUsage's own roles check still fail-fasts on residual drift.
80
48
  const isActive = activeOrg != null && activeOrg === a.organizationUuid;
81
- // The tee path never opens the credential blob, so the active account's
82
- // tier comes from the live oauthAccount instead - it names this very org
83
- // (uuid-matched above), so the tier is attributed to its own identity.
84
49
  if (isActive && liveOAuth?.organizationRateLimitTier != null) a.rateLimitTier = liveOAuth.organizationRateLimitTier;
85
- // Active account: prefer the free statusLine push (usage.json) so we never
86
- // poll its own token, which is busy exactly when it matters. per-model
87
- // comes from model-usage.json (also statusLine-driven).
88
50
  const fromStatusLine: FullUsage | null =
89
51
  isActive && live && live.org === a.organizationUuid
90
52
  ? {
@@ -96,13 +58,6 @@ export async function cmdStatus(force = false, preRender?: () => void): Promise<
96
58
  let viaTee = false;
97
59
  let outcome: SampleOutcome;
98
60
  if (force) {
99
- // Force: ping + live probe for everyone. The tee predates the ping,
100
- // so it serves ONLY as the fallback for the fail-silent `/usage`
101
- // case (the probe got past every guard and claude printed no limit
102
- // lines). Any other failure - identity mismatch, dead refresh, a
103
- // refused ping - must stay VISIBLE: the old unconditional fallback
104
- // masked pre-ping failures as healthy tee data, silently skipping
105
- // the ping with no warning (closing-review catch).
106
61
  outcome = isActive ? await probeActiveUsage(a, { ping: true }) : await probeParkedUsage(a, { ping: true });
107
62
  if (!outcome.ok && fromStatusLine && outcome.reason.includes("no limit data")) {
108
63
  const failed = outcome;
@@ -121,35 +76,17 @@ export async function cmdStatus(force = false, preRender?: () => void): Promise<
121
76
  outcomes.set(a.accountUuid, outcome);
122
77
  if (!outcome.ok) return;
123
78
  a.lastUsage = { fiveHour: outcome.usage.session, sevenDay: outcome.usage.weekAll };
124
- // stamp when the figures were actually measured: the statusLine tee's
125
- // own write time for the push-fed active account, else the probe time.
126
79
  a.lastUsageAt = viaTee && live ? live.ts : Date.now();
127
80
  if (Object.keys(outcome.usage.perModel).length > 0) {
128
81
  a.lastPerModel = outcome.usage.perModel;
129
- // via the tee, the per-model rows come from model-usage.json whose
130
- // own sample time may be DAYS older than the aggregate tee - dating
131
- // them by the tee's timestamp inflated the null-reset self-bound
132
- // (closing-review catch). A fresh probe's rows date by the probe.
133
82
  a.lastPerModelAt = viaTee && modelUsage ? (modelUsage.sampledAt ?? modelUsage.ts) : a.lastUsageAt;
134
83
  }
135
84
  };
136
- // The ACTIVE probe runs FIRST, alone: it refreshes an expired live access
137
- // token under claude's refresh lock, and every parked probe's fail-closed
138
- // live-owner check reads that same live token - run concurrently they
139
- // raced the refresh and, after the machine idled past the token lifetime,
140
- // every parked sample 401'd on the first `xx status` (closing-review
141
- // catch). Parked probes then run concurrently as before.
142
85
  const activeAccount = idx.accounts.find((a) => activeOrg != null && activeOrg === a.organizationUuid) ?? null;
143
86
  if (activeAccount) await probeOne(activeAccount);
144
- // even when the active account's usage came from the TEE (no active
145
- // probe, no refresh side effect), the parked probes still need a fresh
146
- // live token for their fail-closed live-owner checks (cubic review
147
- // catch, PR #35). A dead live grant is deliberately not fatal here: each
148
- // parked probe reports its own honest failure reason.
149
87
  try {
150
88
  await ensureLiveTokenFresh();
151
89
  } catch {
152
- // per-account guards surface the failure per probe
153
90
  }
154
91
  await Promise.all(idx.accounts.filter((a) => a !== activeAccount).map(probeOne));
155
92
  saveAccounts(idx);
@@ -159,18 +96,12 @@ export async function cmdStatus(force = false, preRender?: () => void): Promise<
159
96
  console.log(c.dim(`thresholds 5h ${cfg.thresholds.session}% weekly ${cfg.thresholds.weekly}% (${count({ n: idx.accounts.length, noun: "claude account" })})`));
160
97
  console.log();
161
98
 
162
- // Display order (user decision 2026-07-18): earliest upcoming reset first
163
- // (5h or extrapolated weekly, from the just-refreshed samples), needs-reauth
164
- // last; the ● marker identifies the active account wherever it sorts.
165
99
  const displayAccounts = sortBy(idx.accounts, [(a) => (a.needsReauth ? 1 : 0), (a) => earliestReset(a, now)]);
166
- // The marker matches the probe routing above: active = the org claude's own
167
- // oauthAccount names (the swap label can lag after a manual /login).
168
100
  const displayActiveOrg = readOAuthAccount()?.organizationUuid ?? null;
169
101
  for (const a of displayAccounts) {
170
102
  const active = displayActiveOrg != null && a.organizationUuid === displayActiveOrg;
171
103
  const outcome = outcomes.get(a.accountUuid);
172
104
  const failed = outcome ? !outcome.ok : false;
173
- // On a failed sample, fall back to the last-known values (with a note below).
174
105
  const usage = outcome?.ok ? outcome.usage : undefined;
175
106
  const aggregate = usage ? { fiveHour: usage.session, sevenDay: usage.weekAll } : a.lastUsage;
176
107
  const perModel = usage ? usage.perModel : a.lastPerModel;
@@ -184,8 +115,6 @@ export async function cmdStatus(force = false, preRender?: () => void): Promise<
184
115
 
185
116
  const tier = claudeTierLabel(a);
186
117
  console.log(`${marker} ${c.bold(a.label || a.email)}${tier ? ` ${c.dim(tier)}` : ""}${badges.length ? ` ${badges.join(" ")}` : ""}`);
187
- // Chart label convention (user rule 2026-07-17): everything lowercase,
188
- // model names short ("fable", "spark").
189
118
  if (aggregate) {
190
119
  row("5h", aggregate.fiveHour, false);
191
120
  row("week", aggregate.sevenDay, true);
@@ -198,10 +127,6 @@ export async function cmdStatus(force = false, preRender?: () => void): Promise<
198
127
  if (outcome?.pingError != null) {
199
128
  console.log(` ${c.yellow("ping failed (5h timer may not have started)")}: ${c.dim(outcome.pingError)}`);
200
129
  }
201
- // A successful ping ALWAYS opens the 5h window (live-verified 2026-07-16),
202
- // but the server's usage feed reflects it with a lag of up to a few
203
- // minutes, so a probe taken seconds later can still show a dormant window.
204
- // Say so rather than looking like the ping did nothing.
205
130
  if (force && outcome?.ok && outcome.pingError == null && aggregate && aggregate.fiveHour.resetsAt == null) {
206
131
  console.log(` ${c.dim("pinged - 5h timer started this run; the usage feed lags, re-run status shortly for the fresh window")}`);
207
132
  }
@@ -212,9 +137,6 @@ export async function cmdStatus(force = false, preRender?: () => void): Promise<
212
137
  return 0;
213
138
  }
214
139
 
215
- /** The codex pool, appended when it is non-empty. Every account samples via
216
- * the free usage GET (nothing metered, no window started): the live account
217
- * through the live auth.json, parked accounts through their parked blobs. */
218
140
  async function renderCodexSection(input: {
219
141
  cfg: Config;
220
142
  now: number;
@@ -251,8 +173,6 @@ async function renderCodexSection(input: {
251
173
  console.log();
252
174
  const windowLabel = (window: CodexWindow) =>
253
175
  isSessionWindow({ window }) ? `${Math.round((window.windowSeconds ?? 0) / 3600)}h` : "week";
254
- // Same display order as the claude pool: earliest upcoming reset first
255
- // across every cached window, needs-reauth last.
256
176
  const displayAccounts = sortBy(index.accounts, [
257
177
  (a) => (a.needsReauth ? 1 : 0),
258
178
  (a) => {
package/src/cli/switch.ts CHANGED
@@ -1,19 +1,3 @@
1
- // `tokenmaxxing switch [selector]`.
2
- // No selector → greedy: rank EVERY account (current included) by pace pressure
3
- // (furthest behind its own weekly pace first, see picker.ts) among those with
4
- // session/week under threshold, off the cached windows.
5
- // When the current account already wins (or ties - swapping between equals buys
6
- // nothing), do nothing: the command is idempotent, so running it periodically
7
- // converges on the right account. With a selector → switch to that one. Runs
8
- // under the flock. When everything is depleted it stays on / switches to
9
- // whichever account recovers soonest.
10
- //
11
- // The active LABEL can drift from the live login (manual /login is the
12
- // surviving drift source). A no-op would freeze that drift forever, so when
13
- // ~/.claude.json names a different account than accounts.json we swap for real
14
- // instead of trusting the label - performSwap resolves the live credential's
15
- // true owner, harvesting the drifted login correctly.
16
-
17
1
  import { withLock } from "../lib/lock.ts";
18
2
  import { paths } from "../lib/paths.ts";
19
3
  import { loadAccounts, loadConfig } from "../lib/state.ts";
@@ -36,10 +20,6 @@ export async function cmdSwitch(selector?: string): Promise<number> {
36
20
 
37
21
  return withLock(paths.lockFile, async () => {
38
22
  const idx = loadAccounts();
39
- // ONE claude.json read for both identity fields: two reads could straddle
40
- // a concurrent /login and describe two different live identities - the
41
- // drift check and the seat must come from the same snapshot (cubic review
42
- // catch, PR #33).
43
23
  const liveClaim = readOAuthAccount();
44
24
  const claimed = liveClaim?.accountUuid ?? null;
45
25
  const claimedOrg = liveClaim?.organizationUuid ?? null;
@@ -64,26 +44,9 @@ export async function cmdSwitch(selector?: string): Promise<number> {
64
44
  return swapTo(target);
65
45
  }
66
46
 
67
- // auto: greedy over everyone, current included - a no-op when current wins.
68
- // No session context here, so every configured per-model family gates.
69
- // Dead-token fallback mirrors decide.ts's greedy loop, NOT chooseAndSwap:
70
- // its fallback lands on the next usable candidate unconditionally, which
71
- // is right when over a bar but wrong here - a dead grant on the pace
72
- // winner must re-check the seat, or a healthy current account gets a real
73
- // swap onto a pace-WORSE account and the next periodic check bounces it
74
- // straight back (closing-review catch). performSwap persists needs-reauth
75
- // before throwing, so each reload shrinks the candidate set and the loop
76
- // terminates.
77
47
  const everyone: PickCtx = { now, thresholds: effectiveBars(cfg), currentAccountUuid: null, switchFamilies: cfg.policy.switchModels };
78
48
  while (true) {
79
49
  const cur = loadAccounts();
80
- // The seat is the LIVE login's pooled account when resolvable, the
81
- // stored label only as fallback - the same identity rule AND the same
82
- // identity KEY as decide.ts's seatOf: the organizationUuid, since quota
83
- // is metered per org and the org is what the roles endpoint verifies
84
- // (bugbot review catches, PR #33). Under drift this also makes ties
85
- // favor the live account: the realign swap then keeps the same
86
- // credential instead of hopping accounts on a tie.
87
50
  const active =
88
51
  (claimedOrg != null ? cur.accounts.find((a) => a.organizationUuid === claimedOrg) : null) ??
89
52
  cur.accounts.find((a) => a.accountUuid === cur.activeAccountUuid) ??
@@ -96,7 +59,7 @@ export async function cmdSwitch(selector?: string): Promise<number> {
96
59
  return 0;
97
60
  }
98
61
  const best = pickBest(cur.accounts, { ...everyone, currentAccountUuid: active?.accountUuid ?? null });
99
- if (!best) break; // no usable target: the depleted path below decides
62
+ if (!best) break;
100
63
  try {
101
64
  await performSwap(best);
102
65
  } catch (e) {
@@ -110,20 +73,11 @@ export async function cmdSwitch(selector?: string): Promise<number> {
110
73
  return 0;
111
74
  }
112
75
 
113
- // No usable target swapped in: everything is depleted, or the remaining
114
- // candidates' refresh tokens just died (performSwap persists needs-reauth
115
- // before throwing, hence the reload). Stay on / switch to whichever
116
- // recovers soonest. (Layer 2 - the wall squeeze - is deliberately confined
117
- // to the automatic decision path in decide.ts, which decides off the live
118
- // statusLine tee; bare `xx switch` stays cache-only and simply parks here.)
119
76
  const fresh = loadAccounts();
120
77
  const earliest = pickEarliestReset(fresh.accounts, everyone);
121
78
  if (!earliest) {
122
- // Either every account needs re-auth, or every account is blocked with no
123
- // recoverable bound (unparsed reset clocks AND no sample time - see log).
124
79
  const reauth = fresh.accounts.filter((a) => a.needsReauth).map((a) => a.label);
125
80
  if (reauth.length > 0) { console.error(c.yellow(`no switchable account - reauth needed (run \`tokenmaxxing auth --all\`): ${reauth.join(", ")}`)); return 1; }
126
- // never freeze a label drift behind a no-op (see header).
127
81
  const freshActive = fresh.accounts.find((a) => a.accountUuid === fresh.activeAccountUuid) ?? null;
128
82
  if (drifted && freshActive) return swapTo(freshActive);
129
83
  console.log(c.yellow("all accounts at their limit with unknown reset times (unparsed reset clocks? see tokenmaxxing.log) - staying put"));
@@ -132,8 +86,6 @@ export async function cmdSwitch(selector?: string): Promise<number> {
132
86
  const reauth = fresh.accounts.filter((a) => a.needsReauth).map((a) => a.label);
133
87
  const reauthNote = reauth.length ? ` - re-auth needed: ${reauth.join(", ")}` : "";
134
88
  if (earliest.account.accountUuid === fresh.activeAccountUuid && !drifted) {
135
- // availableAt in the past means the current account is fine and the
136
- // others are simply unusable - do not claim a limit that is not there.
137
89
  const msg = earliest.availableAt <= now
138
90
  ? `staying on ${c.bold(earliest.account.label)} - no usable switch target${reauthNote}`
139
91
  : `all accounts at limit - staying on ${c.bold(earliest.account.label)} (${fmtReset(earliest.availableAt, now)})${reauthNote}`;
package/src/cli/watch.ts CHANGED
@@ -1,23 +1,12 @@
1
- // `tokenmaxxing watch [seconds]`: native live status (issue #3) - re-render
2
- // `status` on an interval instead of `watch -n 120 'tokenmaxxing status'`.
3
- // Plain `status` per tick, never `--force`: a ping meters real quota and starts
4
- // 5h session windows, so watching must stay free; each refresh costs only the
5
- // parked accounts' `/usage` probes the one-shot status already does.
6
-
7
1
  import { clamp, delay } from "es-toolkit";
8
2
  import { loadAccounts } from "../lib/state.ts";
9
3
  import { cmdStatus } from "./status.ts";
10
4
  import { c } from "./render.ts";
11
5
 
12
6
  const DEFAULT_INTERVAL_S = 120;
13
- /** Floor: each tick spawns a `/usage` probe per parked account (seconds each,
14
- * under the flock); anything faster than this just queues probes. */
15
7
  const MIN_INTERVAL_S = 30;
16
- /** Cap: past ~24.8 days the setTimeout ms overflow collapses the sleep to ~1ms
17
- * and the loop runs hot; a day is already beyond any sane watch cadence. */
18
8
  const MAX_INTERVAL_S = 86_400;
19
9
 
20
- /** Seconds between refreshes, clamped; null when the argument is not a positive number. */
21
10
  export function resolveWatchInterval(arg?: string): number | null {
22
11
  if (arg === undefined) return DEFAULT_INTERVAL_S;
23
12
  const n = Number(arg);
@@ -25,9 +14,6 @@ export function resolveWatchInterval(arg?: string): number | null {
25
14
  return clamp(n, MIN_INTERVAL_S, MAX_INTERVAL_S);
26
15
  }
27
16
 
28
- /** Home + clear screen + clear scrollback. Written only once the fresh frame is
29
- * ready to paint (cmdStatus preRender), so the previous frame stays readable
30
- * through the multi-second sample instead of blanking - watch(1) semantics. */
31
17
  const CLEAR = "\x1b[H\x1b[2J\x1b[3J";
32
18
 
33
19
  export async function cmdWatch(intervalArg?: string): Promise<number> {
@@ -43,9 +29,6 @@ export async function cmdWatch(intervalArg?: string): Promise<number> {
43
29
  console.log(c.dim(`watch: every ${intervalS}s, ${new Date().toLocaleTimeString()}, ctrl-c to quit`));
44
30
  };
45
31
  while (true) {
46
- // One failed tick (a mid-write ~/.claude.json read, a transient probe or
47
- // lock error) must not kill an hours-long monitor: report it and keep the
48
- // cadence, exactly like watch(1) showing a failing command's output.
49
32
  try {
50
33
  await cmdStatus(false, paintHeader);
51
34
  } catch (e) {