tokenmaxxing 0.16.0 → 0.18.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
@@ -28,7 +28,7 @@ It is a process/PTY manager only - spawn, forward the terminal, wait, restore te
28
28
  ## 2. What tokenmaxxing installs
29
29
 
30
30
  - A `claude` **supervisor** on your PATH ahead of the real binary (`~/.config/tokenmaxxing/bin/claude`), or a shell function - you invoke it identically.
31
- - Three `~/.claude/settings.json` entries (merged, preserving anything you already have): a transparent `statusLine` shim, a `Stop` hook, a `SessionStart` hook.
31
+ - Four `~/.claude/settings.json` entries (merged, preserving anything you already have): a transparent `statusLine` shim, a `subagentStatusLine` shim (per-subagent rows in the agents panel), a `Stop` hook, a `SessionStart` hook.
32
32
  - **`~/.config/tokenmaxxing/`** - the single home for config and state:
33
33
  - `config.json` - threshold, account order/policy.
34
34
  - `accounts.json` - non-secret index `{email, organizationUuid, accountUuid, lastUsage, resetsAt, needs_reauth}`.
@@ -71,6 +71,7 @@ Each terminal ran the supervisor, so each has its own child `claude` and its own
71
71
  - **`tokenmaxxing init` imports the account you're already on - automatically, no prompts, no re-login.** It reads the live `Claude Code-credentials` keychain blob plus the `oauthAccount` object in `~/.claude.json` (email, `organizationUuid`, `accountUuid`, plan tier) and writes them as **account #1** into tokenmaxxing's store (`tokenmaxxing-cred-<accountUuid[:8]>` + an `accounts.json` index entry). Nothing about your current session changes - that account stays active; it's now just also a registered pool member. After this one command you already have a working (single-account) pool. `init` also installs the supervisor + the three settings entries.
72
72
  - If the current auth is API-key mode (`ANTHROPIC_API_KEY`/`apiKeyHelper`) rather than a subscription `/login`, there's no quota-poolable subscription credential to import - `init` says so and points you to `/login` first (per-token API billing isn't what tokenmaxxing pools).
73
73
  - **`tokenmaxxing add`** - registers *additional* accounts: logs one in via a throwaway `CLAUDE_CONFIG_DIR=~/.config/tokenmaxxing/onboard` (your primary login untouched), harvests it into the store, deletes the temp dir + its namespaced item. This is the **only** time `CLAUDE_CONFIG_DIR` is ever used.
74
+ - **`tokenmaxxing auth [sel | --all]`** - reauthenticates an *existing* pool member whose refresh token died (a needs-reauth account can never heal through a swap: the dead token is exactly what a swap would need). Same isolated-login harvest as `add`, but it states which email to sign in with and **requires the login to land on the target account** (harvested `accountUuid` must match, else nothing changes) - the credential write and the needs-reauth clear happen in one flock critical section so a concurrent swap's harvest cannot clobber the fresh backup. Bare `auth` lists the pool with emails and asks which; `--all` walks every flagged account one by one.
74
75
  - Both commands exercise `security` reads/writes interactively (where a macOS keychain ACL prompt is acceptable), so the first access never happens cold inside a headless hook.
75
76
 
76
77
  ---
@@ -82,6 +83,18 @@ The decision engages at `five_hour >= 50%` (policy.greedySessionFloor): from the
82
83
 
83
84
  ---
84
85
 
86
+ ## 5b. The Slack bridge (`xx serve`, 0.18.0)
87
+
88
+ A local Socket Mode daemon (no public URL) that turns Slack threads into Claude Code sessions on the pooled accounts. Stack (user decision 2026-07-18): Vercel's Chat SDK (`chat` + `@chat-adapter/slack`) for the Slack side; the Claude Agent SDK driven through `src/sdk.ts`'s pooled surface for the claude side - `xx serve` is that surface's first in-repo consumer. EVE (Vercel's agent framework) was researched and explicitly dropped: it owns its own model loop via AI Gateway, so it would replace Claude Code rather than drive it.
89
+
90
+ - **Config**: `slack.json` (0600 - it holds the xoxb-/xapp- tokens) with per-channel links `{channel, repo, worktree, permissionMode, model?}`. `serve setup` prints the app manifest (minimal scopes: app_mentions:read, channels:history, groups:history, chat:write, files:write, users:read + socket mode) and prompts for the tokens; `serve link <channel-id> <repo>` manages links (channel IDs only - names drift, ids don't).
91
+ - **Thread = session**: a bot mention in a linked channel subscribes the thread, creates `slack-worktrees/<threadKey>` (branch `tm-slack-<threadKey>` cut from the repo's HEAD; `--no-worktree` links run in the repo itself), and records `{threadId, cwd, sessionId}` under `slack-threads/`. Resume is cwd-keyed in claude, so the cwd stays byte-stable for the thread's life; worktrees are never auto-deleted (they hold the thread's work).
92
+ - **Turn = spawn**: each thread message runs ONE `query()` with `resume: sessionId` (never a persistent streaming query - the SDK subprocess reads credentials at spawn, so per-turn spawns are what let `ensureBestAccount()` land each turn on the freshest account, and the daemon can restart without losing threads). `stopHookCheck` rides along as the SDK Stop hook. Streamed `text_delta`s feed `thread.post(AsyncIterable)` (the adapter debounces edits); tool-only turns post the final result text.
93
+ - **Safety posture**: per-link `permissionMode`, default `acceptEdits`; `--dangerous` opts a link into `bypassPermissions`. Turn failures post a trimmed message-only diagnostic (never a raw error body). The socket loop aborts visibly after 3 consecutive connection failures.
94
+ - **Verified hermetically** (2026-07-18): schema/link management, worktree creation + idempotency, arg parsing, daemon fail-fast paths. **Not yet live-verified**: a real Socket Mode connection under Bun (needs real tokens; the underlying ws/undici primitives tested clean) and a real relayed turn (meters an account). Run one live smoke test before relying on it.
95
+
96
+ ---
97
+
85
98
  ## 6. Honest papercuts
86
99
  - **Respawn hiccup (depleted pause only).** Plain swaps never restart the session. When the whole pool is depleted you see `claude` stop, a countdown, and a resume; anything typed in the split second before the SIGTERM is lost, and the supervisor resets terminal mode so nothing is left garbled.
87
100
  - **Adoption lag.** macOS reads the keychain through a raw 30s cache, so at most the first turn after a swap can still meter the old account. The bars' headroom absorbs it.
package/README.md CHANGED
@@ -38,12 +38,14 @@ claude # use claude as always
38
38
  | `tokenmaxxing init --codex` | same for codex: import login, install codex supervisor + Stop hook |
39
39
  | `tokenmaxxing add` | register an additional account (isolated login, harvested into the pool) |
40
40
  | `tokenmaxxing add --codex` | register an additional codex account (isolated login) |
41
+ | `tokenmaxxing auth [sel \| --all]` | reauthenticate a pooled account in place: bare lists the pool (emails shown) and asks which; a selector targets one account and tells you the email to sign in with; `--all` walks every needs-reauth account one by one |
41
42
  | `tokenmaxxing switch --codex [sel]` | switch the codex pool (takes effect on the next codex start) |
42
43
  | `tokenmaxxing ls` | list pooled accounts |
43
44
  | `tokenmaxxing status` | accounts with 5h / weekly usage bars, active + exhausted-until-reset |
44
45
  | `tokenmaxxing status --force` | additionally ping every account (one tiny haiku request each) so all 5h session timers start now, then sample fresh |
45
46
  | `tokenmaxxing watch [seconds]` | live status: re-render every N seconds (default 120, floor 30; never pings) |
46
47
  | `tokenmaxxing config` | effective config with sources; `get`/`set`/`unset` dotted keys, `tidy` prunes unknown keys |
48
+ | `tokenmaxxing serve` | Slack bridge daemon (Socket Mode, no public URL): `setup` prints the app manifest and stores the two tokens, `link <channel-id> <repo>` ties a channel to a repo, then mentioning the bot in that channel opens a Claude Code session per thread (own git worktree by default) and thread messages relay in and out |
47
49
  | `tokenmaxxing doctor` | verify the supervisor + settings entries survived |
48
50
  | `tokenmaxxing rename [--codex] <sel> <label>` / `rm <sel>` | manage the pool (`--codex` targets the codex pool: one email can hold both a claude and a codex account) |
49
51
  | `tokenmaxxing uninstall` | remove supervisor + settings entries (accounts/credentials kept) |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tokenmaxxing",
3
- "version": "0.16.0",
3
+ "version": "0.18.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",
@@ -40,6 +40,10 @@
40
40
  "typescript": "^5.6.0"
41
41
  },
42
42
  "dependencies": {
43
+ "@anthropic-ai/claude-agent-sdk": "^0.3.214",
44
+ "@chat-adapter/slack": "^4.34.0",
45
+ "@chat-adapter/state-memory": "^4.34.0",
46
+ "chat": "^4.34.0",
43
47
  "es-toolkit": "^1.49.0",
44
48
  "ky": "^2.0.2",
45
49
  "zod": "^4.4.3"
package/src/cli/add.ts CHANGED
@@ -1,109 +1,32 @@
1
- // `tokenmaxxing add` - register an ADDITIONAL account. Logs one in inside a
2
- // throwaway CLAUDE_CONFIG_DIR (the ONLY use of CLAUDE_CONFIG_DIR), auto-exits the
3
- // moment the login lands, samples that account's usage, then harvests its
4
- // credential + identity into the pool and deletes the temp dir + isolated
5
- // credential. Your primary login is never touched.
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.
6
4
 
7
- import { existsSync, mkdirSync, readFileSync, rmSync } from "node:fs";
8
- import { join } from "node:path";
9
- import { z } from "zod";
10
- import { readItem, writeItem, deleteItem, parkedTarget, isolatedTarget, claudeAiOauthOnly } from "../lib/credstore.ts";
11
- import { resolveRealClaude } from "../lib/claudebin.ts";
12
- import { probeUsage } from "../lib/usage.ts";
13
- import { saveTermios, restoreTermios } from "../lib/tty.ts";
14
5
  import { withLock } from "../lib/lock.ts";
15
6
  import { loadAccounts, saveAccounts } from "../lib/state.ts";
16
7
  import { credItemFor, paths } from "../lib/paths.ts";
17
- import { CredentialBlobSchema, OAuthAccountSchema, type Account } from "../lib/types.ts";
8
+ import { writeItem, parkedTarget, claudeAiOauthOnly } from "../lib/credstore.ts";
9
+ import { harvestIsolatedLogin } from "./onboard.ts";
10
+ import { type Account } from "../lib/types.ts";
18
11
  import { c, claudeTierLabel, count } from "./render.ts";
19
12
 
20
- /** True once `/login` has written a usable identity into the onboard dir. */
21
- function identityReady(cjPath: string): boolean {
22
- if (!existsSync(cjPath)) return false;
23
- try {
24
- const oauthAccount = JSON.parse(readFileSync(cjPath, "utf8")).oauthAccount;
25
- return z.object({ accountUuid: z.string().min(1) }).safeParse(oauthAccount).success;
26
- } catch {
27
- return false;
28
- }
29
- }
30
-
31
13
  export async function cmdAdd(): Promise<number> {
32
- const onboardDir = paths.onboardDir;
33
- rmSync(onboardDir, { recursive: true, force: true });
34
- mkdirSync(onboardDir, { recursive: true });
35
- const iso = isolatedTarget(onboardDir);
36
- const cjPath = join(onboardDir, ".claude.json");
37
- const real = resolveRealClaude();
38
-
39
14
  console.log(c.cyan("Opening an isolated Claude login - your primary login is untouched."));
40
15
  console.log(c.dim(`In the session that opens, run ${c.bold("/login")} with the account to add. It closes itself once you're in.`));
41
16
  console.log();
42
17
 
43
- const savedTermios = saveTermios();
44
- // Scrub the ambient credential/identity overrides claude honors BEFORE its
45
- // keychain lookup (verified 2.1.205) - the onboard session must authenticate
46
- // only via the /login the user performs inside it.
47
- const env: Record<string, string> = { ...process.env, CLAUDE_CONFIG_DIR: onboardDir, TOKENMAXXING_PROBE: "1", TOKENMAXXING_SUPERVISED: "" };
48
- delete env.ANTHROPIC_API_KEY;
49
- delete env.ANTHROPIC_AUTH_TOKEN;
50
- delete env.CLAUDE_CODE_OAUTH_TOKEN;
51
- delete env.CLAUDE_SECURESTORAGE_CONFIG_DIR;
52
- const p = Bun.spawn([real], {
53
- stdin: "inherit",
54
- stdout: "inherit",
55
- stderr: "inherit",
56
- env,
57
- });
58
-
59
- // Auto-exit (#17): watch for a completed login - identity written AND the
60
- // isolated credential present - then SIGTERM claude. No manual /exit.
61
- let exited = false;
62
- const onExit = p.exited.then(() => { exited = true; });
63
- while (!exited) {
64
- await Bun.sleep(400);
65
- if (identityReady(cjPath) && (await readItem(iso))) {
66
- p.kill();
67
- break;
68
- }
69
- }
70
- await p.exited;
71
- await onExit;
72
- restoreTermios(savedTermios);
73
-
74
- const cleanup = async () => {
75
- await deleteItem(iso);
76
- rmSync(onboardDir, { recursive: true, force: true });
77
- };
78
-
79
- const blobRaw = await readItem(iso);
80
- if (!blobRaw || !identityReady(cjPath)) {
81
- console.error(c.red("no login detected in the isolated session - nothing added."));
82
- await cleanup();
83
- return 1;
84
- }
85
-
86
- let blob, oauthAccount;
87
- try {
88
- blob = CredentialBlobSchema.parse(JSON.parse(blobRaw));
89
- oauthAccount = OAuthAccountSchema.parse(JSON.parse(readFileSync(cjPath, "utf8")).oauthAccount);
90
- } catch {
91
- console.error(c.red("could not parse the onboarded account's credential/identity."));
92
- await cleanup();
93
- return 1;
94
- }
95
-
96
- // Sample usage now (#16) so the account isn't "not sampled yet" in status/ls.
97
- console.log(c.dim("sampling usage..."));
98
- const sampled = await probeUsage(onboardDir);
99
- if (!sampled) console.log(c.yellow("could not sample usage now - it will fill in on first use."));
18
+ const harvested = await harvestIsolatedLogin();
19
+ if (!harvested) return 1;
20
+ const { blobRaw, blob, oauthAccount, sampled } = harvested;
100
21
 
101
22
  const uuid = oauthAccount.accountUuid;
102
23
  const keychainItem = credItemFor(uuid);
103
- await writeItem(parkedTarget(keychainItem), claudeAiOauthOnly(blobRaw)); // park a small backup
104
24
 
105
- // under the flock: a concurrent swap's index write must not be clobbered.
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.
106
28
  const { account, poolSize } = await withLock(paths.lockFile, async () => {
29
+ await writeItem(parkedTarget(keychainItem), claudeAiOauthOnly(blobRaw)); // park a small backup
107
30
  const idx = loadAccounts();
108
31
  const existing = idx.accounts.find((a) => a.accountUuid === uuid);
109
32
  const fresh: Account = {
@@ -127,8 +50,6 @@ export async function cmdAdd(): Promise<number> {
127
50
  return { account: fresh, poolSize: idx.accounts.length };
128
51
  });
129
52
 
130
- await cleanup();
131
-
132
53
  console.log();
133
54
  const usageNote = sampled ? ` (session ${sampled.session.usedPercentage}% / week ${sampled.weekAll.usedPercentage}%)` : "";
134
55
  console.log(`${c.green("✓")} added ${c.bold(account.email)} (${claudeTierLabel(account) ?? "?"})${usageNote} → pool now has ${count({ n: poolSize, noun: "account" })}`);
@@ -0,0 +1,177 @@
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
+ import { partition } from "es-toolkit";
10
+ import { z } from "zod";
11
+ import { withLock } from "../lib/lock.ts";
12
+ import { loadAccounts, saveAccounts } from "../lib/state.ts";
13
+ import { paths } from "../lib/paths.ts";
14
+ import { writeItem, parkedTarget, claudeAiOauthOnly } from "../lib/credstore.ts";
15
+ import { findAccount } from "./rename.ts";
16
+ import { harvestIsolatedLogin } from "./onboard.ts";
17
+ import { c, claudeTierLabel, count } from "./render.ts";
18
+ import type { Account, AccountsIndex } from "../lib/types.ts";
19
+
20
+ const AUTH_USAGE = "usage: tokenmaxxing auth [<email|label|id> | --all]";
21
+
22
+ export const AuthPlanSchema = z.discriminatedUnion("kind", [
23
+ /** malformed argv: print AUTH_USAGE, exit 2. */
24
+ z.object({ kind: z.literal("usage") }),
25
+ /** unresolvable state (empty pool, unknown selector): exit 1. */
26
+ z.object({ kind: z.literal("error"), message: z.string() }),
27
+ /** bare `auth`: ask interactively. */
28
+ z.object({ kind: z.literal("pick") }),
29
+ z.object({ kind: z.literal("targets"), uuids: z.array(z.string()) }),
30
+ ]);
31
+ export type AuthPlan = z.infer<typeof AuthPlanSchema>;
32
+
33
+ /** Resolve argv into a reauth plan (pure - unit-tested). Argv shape is
34
+ * validated before any pool state is consulted. */
35
+ export function planAuth(input: { accounts: Account[]; argv: string[] }): AuthPlan {
36
+ const all = input.argv.includes("--all");
37
+ const rest = input.argv.filter((a) => a !== "--all");
38
+ if ((all && rest.length > 0) || rest.length > 1) return { kind: "usage" };
39
+ if (input.accounts.length === 0) return { kind: "error", message: "no accounts in the pool - run `tokenmaxxing init` first" };
40
+ if (all) {
41
+ const flagged = input.accounts.filter((a) => a.needsReauth === true);
42
+ return { kind: "targets", uuids: flagged.map((a) => a.accountUuid) };
43
+ }
44
+ const selector = rest[0];
45
+ if (selector !== undefined) {
46
+ const found = findAccount(input.accounts, selector);
47
+ if (!found) return { kind: "error", message: `no claude account matches "${selector}"` };
48
+ return { kind: "targets", uuids: [found.accountUuid] };
49
+ }
50
+ return { kind: "pick" };
51
+ }
52
+
53
+ /** Needs-reauth accounts first (they are why the user is here), pool order within. */
54
+ export function pickerOrder(accounts: Account[]): Account[] {
55
+ const [flagged, healthy] = partition(accounts, (a) => a.needsReauth === true);
56
+ return [...flagged, ...healthy];
57
+ }
58
+
59
+ function askWhichAccount(idx: AccountsIndex): Account | null {
60
+ const ordered = pickerOrder(idx.accounts);
61
+ console.log("which account do you want to reauthenticate?");
62
+ for (const [i, a] of ordered.entries()) {
63
+ const flags: string[] = [];
64
+ if (a.accountUuid === idx.activeAccountUuid) flags.push(c.green("active"));
65
+ if (a.needsReauth) flags.push(c.red("needs-reauth"));
66
+ const labelNote = a.label && a.label !== a.email ? ` (${a.label})` : "";
67
+ const tag = flags.length ? ` ${flags.join(" ")}` : "";
68
+ console.log(` ${i + 1}. ${c.bold(a.email)}${labelNote}${tag}`);
69
+ }
70
+ const answer = prompt("account (number, email, or label):")?.trim();
71
+ if (!answer) {
72
+ console.error(c.red("nothing selected"));
73
+ return null;
74
+ }
75
+ const n = Number(answer);
76
+ const byNumber = Number.isInteger(n) && n >= 1 && n <= ordered.length ? ordered[n - 1] : undefined;
77
+ const chosen = byNumber ?? findAccount(ordered, answer);
78
+ if (!chosen) {
79
+ console.error(c.red(`no account matches "${answer}"`));
80
+ return null;
81
+ }
82
+ return chosen;
83
+ }
84
+
85
+ async function reauthOne(target: Account): Promise<boolean> {
86
+ console.log(c.cyan(`Reauthenticating ${c.bold(target.label)} - sign in as ${c.bold(target.email)}.`));
87
+ console.log(c.dim(`In the session that opens, run ${c.bold("/login")} with that account. It closes itself once you're in.`));
88
+ console.log();
89
+
90
+ const harvested = await harvestIsolatedLogin();
91
+ if (!harvested) return false;
92
+ const { blobRaw, blob, oauthAccount, sampled } = harvested;
93
+
94
+ if (oauthAccount.accountUuid !== target.accountUuid) {
95
+ console.error(
96
+ c.red(
97
+ `that login is ${c.bold(oauthAccount.emailAddress)}, but ${target.label} is ${c.bold(target.email)} - nothing changed. To pool it as its own account, run \`tokenmaxxing add\`.`,
98
+ ),
99
+ );
100
+ return false;
101
+ }
102
+
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
+ const isActive = await withLock(paths.lockFile, async () => {
108
+ await writeItem(parkedTarget(target.keychainItem), claudeAiOauthOnly(blobRaw));
109
+ const idx = loadAccounts();
110
+ const account = idx.accounts.find((a) => a.accountUuid === target.accountUuid);
111
+ if (account) {
112
+ account.email = oauthAccount.emailAddress;
113
+ account.organizationUuid = oauthAccount.organizationUuid;
114
+ account.oauthAccount = oauthAccount;
115
+ account.subscriptionType = blob.claudeAiOauth.subscriptionType;
116
+ account.rateLimitTier = blob.claudeAiOauth.rateLimitTier;
117
+ account.needsReauth = false;
118
+ if (sampled) {
119
+ account.lastUsage = { fiveHour: sampled.session, sevenDay: sampled.weekAll };
120
+ if (Object.keys(sampled.perModel).length > 0) account.lastPerModel = sampled.perModel;
121
+ account.lastUsageAt = Date.now();
122
+ }
123
+ saveAccounts(idx);
124
+ }
125
+ return idx.activeAccountUuid === target.accountUuid;
126
+ });
127
+
128
+ const usageNote = sampled ? ` (session ${sampled.session.usedPercentage}% / week ${sampled.weekAll.usedPercentage}%)` : "";
129
+ const tier = claudeTierLabel(blob.claudeAiOauth) ?? "?";
130
+ console.log(`${c.green("✓")} reauthed ${c.bold(oauthAccount.emailAddress)} (${tier})${usageNote}`);
131
+ if (isActive) {
132
+ console.log(c.dim("this account is the active one: the fresh credential is parked as its backup; the live session keeps its current token until the next swap."));
133
+ }
134
+ return true;
135
+ }
136
+
137
+ export async function cmdAuth(argv: string[]): Promise<number> {
138
+ const idx = loadAccounts();
139
+ const plan = planAuth({ accounts: idx.accounts, argv });
140
+ if (plan.kind === "usage") {
141
+ console.error(AUTH_USAGE);
142
+ return 2;
143
+ }
144
+ if (plan.kind === "error") {
145
+ console.error(c.red(plan.message));
146
+ return 1;
147
+ }
148
+
149
+ const targets: Account[] = [];
150
+ if (plan.kind === "pick") {
151
+ const picked = askWhichAccount(idx);
152
+ if (!picked) return 1;
153
+ targets.push(picked);
154
+ } else {
155
+ for (const uuid of plan.uuids) {
156
+ const account = idx.accounts.find((a) => a.accountUuid === uuid);
157
+ if (account) targets.push(account);
158
+ }
159
+ if (targets.length === 0) {
160
+ console.log(`${c.green("✓")} no account needs reauth`);
161
+ return 0;
162
+ }
163
+ }
164
+
165
+ let ok = 0;
166
+ for (const [i, target] of targets.entries()) {
167
+ console.log();
168
+ if (targets.length > 1) console.log(c.bold(`[${i + 1}/${targets.length}]`));
169
+ if (await reauthOne(target)) ok += 1;
170
+ }
171
+
172
+ if (targets.length > 1) {
173
+ console.log();
174
+ console.log(`reauthed ${count({ n: ok, noun: "account" })} of ${targets.length}`);
175
+ }
176
+ return ok === targets.length ? 0 : 1;
177
+ }
package/src/cli/doctor.ts CHANGED
@@ -33,6 +33,7 @@ export async function cmdDoctor(): Promise<number> {
33
33
 
34
34
  const s = checkSettings();
35
35
  check(s.statusLineOk, "statusLine shim installed in settings.json", "run `tokenmaxxing init`");
36
+ check(s.subagentStatusLineOk, "subagentStatusLine shim installed in settings.json", "run `tokenmaxxing init`");
36
37
  check(s.stopOk, "Stop hook installed in settings.json", "run `tokenmaxxing init`");
37
38
  check(s.sessionStartOk, "SessionStart hook installed in settings.json", "run `tokenmaxxing init`");
38
39
  check(checkTimerHealthy(), "periodic check timer active", timerActivationHint());
@@ -60,17 +61,17 @@ export async function cmdDoctor(): Promise<number> {
60
61
 
61
62
  for (const a of idx.accounts) {
62
63
  const parked = await readItem(parkedTarget(a.keychainItem));
63
- check(!!parked, `parked credential present for ${a.email}`, "re-run `tokenmaxxing init`/`add`");
64
+ check(!!parked, `parked credential present for ${a.email}`, `run \`tokenmaxxing auth ${a.label}\``);
64
65
  if (parked) {
65
66
  try {
66
67
  const org = await blobOrg(parked);
67
- if (org) check(org.organization_uuid === a.organizationUuid, `parked credential identity matches ${a.email}`, `token belongs to ${org.organization_name} - re-auth with \`tokenmaxxing add\``);
68
+ if (org) check(org.organization_uuid === a.organizationUuid, `parked credential identity matches ${a.email}`, `token belongs to ${org.organization_name} - run \`tokenmaxxing auth ${a.label}\``);
68
69
  else console.log(c.dim(` - ${a.email} identity unverifiable (access token expired)`));
69
70
  } catch (e) {
70
71
  check(false, `parked credential identity matches ${a.email}`, String((e as Error).message ?? e).slice(0, 100));
71
72
  }
72
73
  }
73
- if (a.needsReauth) check(false, `${a.email} needs re-auth`, "run `tokenmaxxing add` to re-login");
74
+ if (a.needsReauth) check(false, `${a.email} needs re-auth`, `run \`tokenmaxxing auth ${a.label}\` to re-login`);
74
75
  }
75
76
 
76
77
  const cfg = loadConfig();
@@ -0,0 +1,112 @@
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
+ import { existsSync, mkdirSync, readFileSync, rmSync } from "node:fs";
9
+ import { join } from "node:path";
10
+ import { z } from "zod";
11
+ import { readItem, deleteItem, isolatedTarget } from "../lib/credstore.ts";
12
+ import { resolveRealClaude } from "../lib/claudebin.ts";
13
+ import { probeUsage, FullUsageSchema } from "../lib/usage.ts";
14
+ import { saveTermios, restoreTermios } from "../lib/tty.ts";
15
+ import { paths } from "../lib/paths.ts";
16
+ import { CredentialBlobSchema, OAuthAccountSchema } from "../lib/types.ts";
17
+ import { c } from "./render.ts";
18
+
19
+ export const HarvestedLoginSchema = z.object({
20
+ /** the raw isolated credential blob (park it via claudeAiOauthOnly). */
21
+ blobRaw: z.string(),
22
+ blob: CredentialBlobSchema,
23
+ oauthAccount: OAuthAccountSchema,
24
+ sampled: FullUsageSchema.nullable(),
25
+ });
26
+ export type HarvestedLogin = z.infer<typeof HarvestedLoginSchema>;
27
+
28
+ /** True once `/login` has written a usable identity into the onboard dir. */
29
+ function identityReady(cjPath: string): boolean {
30
+ if (!existsSync(cjPath)) return false;
31
+ try {
32
+ const oauthAccount = JSON.parse(readFileSync(cjPath, "utf8")).oauthAccount;
33
+ return z.object({ accountUuid: z.string().min(1) }).safeParse(oauthAccount).success;
34
+ } catch {
35
+ return false;
36
+ }
37
+ }
38
+
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
+ export async function harvestIsolatedLogin(): Promise<HarvestedLogin | null> {
45
+ const onboardDir = paths.onboardDir;
46
+ rmSync(onboardDir, { recursive: true, force: true });
47
+ mkdirSync(onboardDir, { recursive: true });
48
+ const iso = isolatedTarget(onboardDir);
49
+ const cjPath = join(onboardDir, ".claude.json");
50
+ const real = resolveRealClaude();
51
+
52
+ const savedTermios = saveTermios();
53
+ // Scrub the ambient credential/identity overrides claude honors BEFORE its
54
+ // keychain lookup (verified 2.1.205) - the onboard session must authenticate
55
+ // only via the /login the user performs inside it.
56
+ const env: Record<string, string> = { ...process.env, CLAUDE_CONFIG_DIR: onboardDir, TOKENMAXXING_PROBE: "1", TOKENMAXXING_SUPERVISED: "" };
57
+ delete env.ANTHROPIC_API_KEY;
58
+ delete env.ANTHROPIC_AUTH_TOKEN;
59
+ delete env.CLAUDE_CODE_OAUTH_TOKEN;
60
+ delete env.CLAUDE_SECURESTORAGE_CONFIG_DIR;
61
+ const p = Bun.spawn([real], {
62
+ stdin: "inherit",
63
+ stdout: "inherit",
64
+ stderr: "inherit",
65
+ env,
66
+ });
67
+
68
+ // Auto-exit (#17): watch for a completed login - identity written AND the
69
+ // isolated credential present - then SIGTERM claude. No manual /exit.
70
+ let exited = false;
71
+ const onExit = p.exited.then(() => { exited = true; });
72
+ while (!exited) {
73
+ await Bun.sleep(400);
74
+ if (identityReady(cjPath) && (await readItem(iso))) {
75
+ p.kill();
76
+ break;
77
+ }
78
+ }
79
+ await p.exited;
80
+ await onExit;
81
+ restoreTermios(savedTermios);
82
+
83
+ const cleanup = async () => {
84
+ await deleteItem(iso);
85
+ rmSync(onboardDir, { recursive: true, force: true });
86
+ };
87
+
88
+ const blobRaw = await readItem(iso);
89
+ if (!blobRaw || !identityReady(cjPath)) {
90
+ console.error(c.red("no login detected in the isolated session - nothing changed."));
91
+ await cleanup();
92
+ return null;
93
+ }
94
+
95
+ let blob, oauthAccount;
96
+ try {
97
+ blob = CredentialBlobSchema.parse(JSON.parse(blobRaw));
98
+ oauthAccount = OAuthAccountSchema.parse(JSON.parse(readFileSync(cjPath, "utf8")).oauthAccount);
99
+ } catch {
100
+ console.error(c.red("could not parse the onboarded account's credential/identity."));
101
+ await cleanup();
102
+ return null;
103
+ }
104
+
105
+ // Sample usage now (#16) so the account isn't "not sampled yet" in status/ls.
106
+ console.log(c.dim("sampling usage..."));
107
+ const sampled = await probeUsage(onboardDir);
108
+ if (!sampled) console.log(c.yellow("could not sample usage now - it will fill in on first use."));
109
+
110
+ await cleanup();
111
+ return { blobRaw, blob, oauthAccount, sampled };
112
+ }
package/src/cli/render.ts CHANGED
@@ -18,6 +18,33 @@ export function makeColors(enabled: boolean) {
18
18
 
19
19
  export const c = makeColors(!process.env.NO_COLOR && !!process.stdout.isTTY);
20
20
 
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
+ function rampRgb(usedPct: number): { r: number; g: number } {
25
+ const u = clamp(usedPct, 0, 100);
26
+ if (u >= 95) return { r: 255, g: 0 };
27
+ if (u >= 75) return { r: 255, g: Math.round(255 * (1 - (u - 75) / 20)) };
28
+ return { r: Math.round((255 * u) / 75), g: 255 };
29
+ }
30
+
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
+ export function makeUsagePaint(input: { enabled: boolean; truecolor: boolean }) {
37
+ return (usedPct: number) =>
38
+ (s: string): string => {
39
+ if (!input.enabled) return s;
40
+ const { r, g } = rampRgb(usedPct);
41
+ const code = input.truecolor
42
+ ? `38;2;${r};${g};0`
43
+ : `38;5;${16 + 36 * Math.round(r / 51) + 6 * Math.round(g / 51)}`;
44
+ return `\x1b[${code}m${s}\x1b[0m`;
45
+ };
46
+ }
47
+
21
48
  /** Plan label for a claude account, e.g. "max 20x": subscription name plus the
22
49
  * multiplier segment of the rate-limit tier id ("default_claude_max_20x").
23
50
  * Structural, never exact-string: the multiplier is any <digits>x segment, so