tokenmaxxing 1.27.0 → 1.29.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
@@ -50,7 +50,7 @@ The Stop hook's stdin has no usage data, but the **statusLine does** (`rate_limi
50
50
 
51
51
  ### 3.2 Detect + swap + signal (Stop hook, per turn)
52
52
  1. Read `usage.json` and exit while the live account is under both bars, as defined in the [switching policy](docs/content/docs/switching.mdx).
53
- 2. Take the pool lock, re-check state, select a candidate under the [switching policy](docs/content/docs/switching.mdx), and attempt bounded verification of stale usage before swapping the credential.
53
+ 2. Take the pool lock, re-check state, select a candidate under the [switching policy](docs/content/docs/switching.mdx) from the cached usage figures, and swap the credential.
54
54
  3. Done - the running session adopts the new credential on its own within a request or two. Only when the pool is depleted (the decision returned a `waitUntil`: pre-parked on the soonest-recovering account, or staying on the current one when it recovers first) does the hook write `respawn/<session_id>` (atomic temp+rename).
55
55
 
56
56
  ### 3.3 Depleted-pool pause (supervisor)
@@ -82,10 +82,7 @@ Each terminal ran the supervisor, so each has its own child `claude` and its own
82
82
  The [switching policy](docs/content/docs/switching.mdx) is one rule: under both bars, hold; at or over a bar, move to the usable account with the highest pace pressure; nothing usable, wait for the soonest reset.
83
83
 
84
84
  - Automatic and manual switching rank by pace pressure alone; organization membership is not an input.
85
- - Successful candidate probes persist their results and restart selection.
86
- - Verification attempts at most two candidates with a shared 12-second deadline per candidate for identity reads and CLI execution.
87
- - Expiring access tokens are not refreshed for verification, and the isolated CLI receives no refresh grant.
88
- - Failed or budget-limited verification uses the existing cached data, so stale-target handoffs remain possible.
85
+ - Selection reads the cached figures only. Each periodic check tick samples the parked account whose last `/usage` attempt is oldest, skipping any attempted within `policy.usagePollTtlMs`, so every parked account is attempted about once per tick per pooled account, or once per `policy.usagePollTtlMs` plus a tick when that is longer, and its cached figure is as fresh as its last successful attempt. No sample runs inside the post-swap cooldown. The pool lock covers only the reservation (its owner lookups and refresh under one 20-second deadline) and the result write, never the `/usage` child.
89
86
  - The depleted pause happens at the bar, not at 100; Codex has no pause and rides its account until the server refuses it, because a running sibling cannot adopt another account without restarting.
90
87
 
91
88
  The [profile](docs/content/docs/switching-profile.mdx) records observed cache rewrites across swaps and does not infer subscription quota savings.
@@ -118,6 +115,8 @@ The [profile](docs/content/docs/switching-profile.mdx) records observed cache re
118
115
 
119
116
  **Shipped since (0.13.0):** Codex as a second pool, parallel state (`codex-accounts.json`, `codex-creds/`, own flock), same pace-pressure policy. Codex differences that shaped it: restart IS the switch (a running codex refuses another account's credential), usage is a free direct GET with epoch resets and a duration-classified window set (the weekly window is primary on current plans), the refresh token rotates with reuse punished (harvest-by-true-owner, persist every rotation), and codex's new Stop-hook system drives the auto-swap through a codex supervisor shim that respawns `codex resume <session-id>` (hooks must be trusted once via `/hooks`). Sibling sessions left on ANY non-live account are reconciled cross-session (owner decisions 2026-07-20; a non-live session cannot refresh cross-account and would wedge at token expiry, healthy or not): the deciding actor drops a reconcile marker addressed to the sibling's supervisor, and the sibling's own Stop hook promotes it into a respawn onto the live account at its next turn boundary - the only safe respawn point - while a blocked live seat never receives a signal.
120
117
 
118
+ **Shipped since (1.29.0):** a Cursor Cloud relay. A cloud VM has no keychain, no pool state, and no hot swap, so the path uses what it does have (environment variables, a shell, repo files) and a different credential primitive, one-year inference-only setup tokens, minted once per pooled account by `tokenmaxxing setup-token` on the owner's machine and carried to the VM as one user-scoped Runtime Secret (`TOKENMAXXING_TOKENS`). `tokenmaxxing cursor init` writes a `claude` project subagent (`.cursor/agents/claude.md`) that forwards each task to `tokenmaxxing cloud run`, which spawns `claude -p --dangerously-skip-permissions --output-format json` under `CLAUDE_CODE_OAUTH_TOKEN` and pins the thread to that token (the API caches by exact prefix, so a rotation busts one thread, not all). A setup token carries no usage signal, so rotation is reactive. A run whose transcript gained a non-transient rate-limit row during that run (the structured row the StopFailure hook reads) walls the label until the reset the row names (five hours without one) and resumes the same session on the next token; the result's `api_error_status` alone never walls, so a transient 429 or an older row from a previous token does not cascade across the pool. The local switching engine never reads a setup token; `/usage` prints no percentages under one. Details in [Cursor Cloud](docs/content/docs/cursor-cloud.mdx).
119
+
121
120
  **Non-goals:** an API/MITM proxy; reimplementing OAuth beyond the single refresh-grant call in the swap; Slack as a message bus; soft concurrency caps.
122
121
 
123
122
  ---
package/README.md CHANGED
@@ -75,7 +75,10 @@ claude # use claude as always
75
75
  | `tokenmaxxing doctor` | verify the supervisor + settings entries survived |
76
76
  | `tokenmaxxing rename [--codex] <sel> <label>` / `rm [--codex] <sel>` | manage the pool (`--codex` targets the codex pool: one email can hold both a claude and a codex account) |
77
77
  | `tokenmaxxing uninstall` | remove supervisor + settings entries (accounts/credentials kept) |
78
- | `--json` | machine-readable output: one JSON document on stdout for `status`, `ls`, `config`, `doctor`, `check`, `switch`, `rename`, `rm`, `uninstall`, and one per tick for `watch` (`ok` mirrors the exit code, failures add `error`) |
78
+ | `tokenmaxxing setup-token [--print \| rm <label\|uuid>]` | Cursor Cloud only: mint one `claude setup-token` per pooled account (a browser sign-in each) and print the `TOKENMAXXING_TOKENS` secret value; `--print` prints the stored set, `rm` drops one |
79
+ | `tokenmaxxing cursor init [dir]` | write the Claude relay subagent and `.cursor/environment.json` into a repo |
80
+ | `tokenmaxxing cloud run [--session <id>] [--max-turns <n>] "<prompt>"` | on a Cursor Cloud VM: run `claude -p` on a setup token, rotate to the next token on a usage limit |
81
+ | `--json` | machine-readable output: one JSON document on stdout for `status`, `ls`, `config`, `doctor`, `check`, `switch`, `rename`, `rm`, `uninstall`, `setup-token --print`, `cursor init`, `cloud run`, and one per tick for `watch` (`ok` mirrors the exit code, failures add `error`) |
79
82
 
80
83
  ## How switching decides
81
84
 
@@ -88,8 +91,7 @@ The bars' headroom is deliberate: it's the budget to reach a clean turn boundary
88
91
 
89
92
  Selection ranks usable candidates by remaining weekly percentage divided by time to reset, highest first; organization membership is not an input, and manual `tokenmaxxing switch` uses the same ranking.
90
93
 
91
- - Candidate verification makes at most two bounded `/usage` attempts per evaluation and re-ranks successful samples before switching.
92
- - Failed verification leaves cached figures in force, so it cannot guarantee a fresh target.
94
+ - Every periodic check tick samples the parked account whose last `/usage` attempt is oldest, skipping any attempted within `policy.usagePollTtlMs`, so every parked account is attempted about once per tick per pooled account, or once per `usagePollTtlMs` plus a tick when that is longer; the swap reads the cached figures, which are as fresh as each account's last successful attempt. No sample runs inside the 45-second post-swap cooldown.
93
95
  - When no account is usable, the session pauses until the soonest reset if that lands within `policy.maxWaitMs`, and otherwise stays put.
94
96
 
95
97
  See [How switching decides](docs/content/docs/switching.mdx) for the policy and the [cache-cost profile](docs/content/docs/switching-profile.mdx) for measurements and their limits.
@@ -110,7 +112,7 @@ See [How switching decides](docs/content/docs/switching.mdx) for the policy and
110
112
  }
111
113
  ```
112
114
 
113
- `thresholds.session` and `thresholds.weekly` are the two bars, one number each (an array `thresholds.session`, the former ladder, fails config loading with a message naming the field); `projectionMargin` is a fixed safety margin subtracted from each threshold bar (effective bar = threshold - margin), so a large turn is less likely to blow past a bar between checks; `switchModels` names the models whose per-model cap triggers a switch; `usagePollTtlMs` is how long a `/usage` per-model poll stays fresh; `maxWaitMs` bounds the depleted-pool countdown - a soonest reset further out than this does not pause the session (no respawn marker is written and the session simply keeps hitting its limit until an account recovers); `checkIntervalMs` is the periodic check tick (default 60s), which `init` writes into the timer - re-run `tokenmaxxing init` after changing it so the timer unit picks up the new tick.
115
+ `thresholds.session` and `thresholds.weekly` are the two bars, one number each (an array `thresholds.session`, the former ladder, fails config loading with a message naming the field); `projectionMargin` is a fixed safety margin subtracted from each threshold bar (effective bar = threshold - margin), so a large turn is less likely to blow past a bar between checks; `switchModels` names the models whose per-model cap triggers a switch; `usagePollTtlMs` is how long a `/usage` attempt stays fresh, for the live per-model poll and for the parked account each check tick samples; `maxWaitMs` bounds the depleted-pool countdown - a soonest reset further out than this does not pause the session (no respawn marker is written and the session simply keeps hitting its limit until an account recovers); `checkIntervalMs` is the periodic check tick (default 60s), which `init` writes into the timer - re-run `tokenmaxxing init` after changing it so the timer unit picks up the new tick.
114
116
 
115
117
  State lives entirely in `~/.config/tokenmaxxing/`. Per-account credentials follow the platform's Claude Code store: the login keychain on macOS (`tokenmaxxing-cred-<uuid8>` items, never plaintext on disk), 0600 files under `~/.config/tokenmaxxing/creds/` on Linux (the same plaintext model claude itself uses for `~/.claude/.credentials.json`).
116
118
 
@@ -128,6 +130,10 @@ Codex mechanics differ from Claude Code in one hard way: a running codex process
128
130
 
129
131
  Two codex-specific facts worth knowing: codex does not run hooks it has not been told to trust, so after `init --codex` you must open codex once and trust the tokenmaxxing Stop hook via `/hooks` (auto-switching is inert until then); and codex has no cross-process lock on `auth.json`, so tokenmaxxing serializes all of its own credential writes behind its own lock and swaps only at idle turn boundaries.
130
132
 
133
+ ## Cursor Cloud
134
+
135
+ A Cursor Cloud Agent can hand substantial work to Claude Code running on your own accounts. The VM has no keychain and no hot swap, so this path uses setup tokens instead. `tokenmaxxing setup-token` mints one `claude setup-token` per pooled account on your machine and prints the value for a user-scoped Runtime Secret named `TOKENMAXXING_TOKENS`; `tokenmaxxing cursor init` writes a `claude` project subagent and an `environment.json` install line into the repo; on the VM, the subagent forwards each task to `tokenmaxxing cloud run`, which runs `claude -p` on one token per thread and moves the thread to the next token when a run ends on a usage limit. Setup tokens are inference-only and report no usage percentages, so cloud rotation is reactive and the local switching engine never reads one. Details and limits in [Cursor Cloud](docs/content/docs/cursor-cloud.mdx).
136
+
131
137
  ## Honest limitations
132
138
 
133
139
  - **One cold turn.** The first turn on a new account re-uploads context once (prompt cache is org-scoped).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tokenmaxxing",
3
- "version": "1.27.0",
3
+ "version": "1.29.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",
@@ -19,7 +19,7 @@
19
19
  "DESIGN.md"
20
20
  ],
21
21
  "engines": {
22
- "bun": ">=1.2.6"
22
+ "bun": ">=1.3.6"
23
23
  },
24
24
  "os": [
25
25
  "darwin",
package/src/cli/check.ts CHANGED
@@ -1,11 +1,15 @@
1
1
  import { evaluateAndMaybeSwap } from "../lib/decide.ts";
2
+ import { sampleOldestParked } from "../lib/sample.ts";
3
+ import { loadConfig } from "../lib/state.ts";
2
4
  import { log } from "../lib/log.ts";
3
5
  import { c, emitError, emitJson, fmtReset } from "./render.ts";
4
6
 
5
7
  export async function cmdCheck(json = false): Promise<number> {
8
+ const now = Date.now();
6
9
  let d;
7
10
  try {
8
- d = await evaluateAndMaybeSwap(Date.now());
11
+ d = await evaluateAndMaybeSwap(now);
12
+ await sampleOldestParked(loadConfig());
9
13
  } catch (e) {
10
14
  const detail = e instanceof Error ? e.message : String(e);
11
15
  log("check.error", { err: detail });
@@ -0,0 +1,117 @@
1
+ import { withLock } from "../lib/lock.ts";
2
+ import { paths } from "../lib/paths.ts";
3
+ import {
4
+ limitWallUntil,
5
+ loadCloudSessions,
6
+ loadCloudWalled,
7
+ newReservationKey,
8
+ pickToken,
9
+ readCloudTokens,
10
+ saveCloudSessions,
11
+ saveCloudWalled,
12
+ spawnCloudClaude,
13
+ transcriptBytes,
14
+ } from "../lib/cloud.ts";
15
+ import { c, emitError, emitJson, fmtReset } from "./render.ts";
16
+
17
+ const USAGE = "usage: tokenmaxxing cloud run [--session <id>] [--max-turns <n>] \"<prompt>\" (the prompt is also read from stdin)";
18
+
19
+ function parseFlags(args: string[]): { session: string | null; maxTurns: number | null; prompt: string | null } | { error: string } {
20
+ let session: string | null = null;
21
+ let maxTurns: number | null = null;
22
+ const positional: string[] = [];
23
+ for (let i = 0; i < args.length; i++) {
24
+ const arg = args[i]!;
25
+ if (arg === "--session") {
26
+ const raw = args[++i];
27
+ if (raw == null || raw === "") return { error: "--session needs a session id" };
28
+ session = raw;
29
+ continue;
30
+ }
31
+ if (arg === "--max-turns") {
32
+ const raw = args[++i];
33
+ maxTurns = Number(raw);
34
+ if (!Number.isInteger(maxTurns) || maxTurns < 1) return { error: `--max-turns needs a positive whole number, got: ${raw ?? "nothing"}` };
35
+ continue;
36
+ }
37
+ positional.push(arg);
38
+ }
39
+ return { session, maxTurns, prompt: positional.length > 0 ? positional.join(" ") : null };
40
+ }
41
+
42
+ export async function cmdCloudRun(args: string[], json = false): Promise<number> {
43
+ const flags = parseFlags(args);
44
+ if ("error" in flags) {
45
+ emitError({ json, message: `${flags.error} - ${USAGE}` });
46
+ return 2;
47
+ }
48
+ const prompt = flags.prompt ?? (process.stdin.isTTY ? null : await Bun.stdin.text());
49
+ if (prompt == null || prompt.trim() === "") {
50
+ emitError({ json, message: USAGE });
51
+ return 2;
52
+ }
53
+ const tokens = readCloudTokens();
54
+ let sessionId = flags.session;
55
+ while (true) {
56
+ const reservation = newReservationKey();
57
+ const pick = await withLock(paths.cloudLockFile, () => {
58
+ const sessions = loadCloudSessions();
59
+ const picked = pickToken({ tokens, sessions, walled: loadCloudWalled(), sessionId, now: Date.now() });
60
+ if (picked.ok) saveCloudSessions({ ...sessions, [reservation]: picked.token.label });
61
+ return picked;
62
+ });
63
+ if (!pick.ok) {
64
+ emitError({
65
+ json,
66
+ message: `every token is at its limit - earliest ${fmtReset(pick.earliestReset)}`,
67
+ extra: { earliestReset: pick.earliestReset, session_id: sessionId },
68
+ });
69
+ return 1;
70
+ }
71
+ const label = pick.token.label;
72
+ console.error(c.dim(`cloud run: ${label}${sessionId ? ` (resuming ${sessionId})` : ""}`));
73
+ const transcriptBefore = transcriptBytes(sessionId);
74
+ let spawned: Awaited<ReturnType<typeof spawnCloudClaude>>;
75
+ try {
76
+ spawned = await spawnCloudClaude({ token: pick.token.token, prompt, resume: sessionId, maxTurns: flags.maxTurns });
77
+ } catch (e) {
78
+ await withLock(paths.cloudLockFile, () => {
79
+ const sessions = loadCloudSessions();
80
+ delete sessions[reservation];
81
+ saveCloudSessions(sessions);
82
+ });
83
+ throw e;
84
+ }
85
+ const { exitCode, result } = spawned;
86
+ sessionId = result.session_id;
87
+ const failed = result.is_error || result.subtype !== "success";
88
+ const until = failed ? limitWallUntil({ sessionId: result.session_id, sinceBytes: transcriptBefore, now: Date.now() }) : null;
89
+ await withLock(paths.cloudLockFile, () => {
90
+ const sessions = loadCloudSessions();
91
+ delete sessions[reservation];
92
+ saveCloudSessions({ ...sessions, [result.session_id]: label });
93
+ if (until != null) saveCloudWalled({ ...loadCloudWalled(), [label]: until });
94
+ });
95
+ if (failed) {
96
+ if (until != null) {
97
+ console.error(c.yellow(`cloud run: ${label} hit its limit (${fmtReset(until)}) - resuming the session on the next token`));
98
+ continue;
99
+ }
100
+ const errors = result.subtype === "success" ? [result.result] : result.errors;
101
+ emitError({
102
+ json,
103
+ message: `claude exited ${exitCode ?? "on signal"} (${result.subtype}): ${errors.join("; ")}`,
104
+ notes: [`session ${result.session_id}`],
105
+ extra: { session_id: result.session_id, subtype: result.subtype, errors },
106
+ });
107
+ return 1;
108
+ }
109
+ if (json) {
110
+ emitJson({ ok: true, session_id: result.session_id, result: result.result, num_turns: result.num_turns, total_cost_usd: result.total_cost_usd });
111
+ return 0;
112
+ }
113
+ console.log(result.result);
114
+ console.log(`session ${result.session_id}`);
115
+ return 0;
116
+ }
117
+ }
@@ -0,0 +1,81 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { join, resolve } from "node:path";
3
+ import { z } from "zod";
4
+ import { writeFileAtomic } from "../lib/atomic.ts";
5
+ import { c, emitError, emitJson } from "./render.ts";
6
+
7
+ export const CLOUD_INSTALL_LINE =
8
+ 'curl -fsSL https://bun.sh/install | bash && curl -fsSL https://claude.ai/install.sh | bash && "$HOME/.bun/bin/bun" add -g tokenmaxxing && test -x "$HOME/.bun/bin/bun" && test -x "$HOME/.local/bin/claude" && test -e "$HOME/.bun/bin/tokenmaxxing"';
9
+
10
+ export const CLOUD_RUN_COMMAND = 'TOKENMAXXING_CLAUDE_BIN="$HOME/.local/bin/claude" "$HOME/.bun/bin/bun" "$HOME/.bun/bin/tokenmaxxing" cloud run';
11
+
12
+ export const RELAY_AGENT = `---
13
+ name: claude
14
+ description: Runs a task in Claude Code on the owner's subscription accounts. Use proactively for substantial implementation, debugging, or research work that should not consume the parent's context.
15
+ model: inherit
16
+ readonly: false
17
+ ---
18
+
19
+ You are a thin forwarding wrapper around Claude Code.
20
+
21
+ Your only job is to forward the task to the runner and return its output. Do not do anything else.
22
+
23
+ Rules:
24
+
25
+ - Use exactly one shell call. Pass the task on stdin through a quoted heredoc. Pick the delimiter fresh for every call as \`TASK_\` followed by eight random letters and digits, and check that no line of the task equals it; a quoted heredoc ends only on a line that matches the delimiter exactly, so the task text can never become shell commands.
26
+
27
+ \`\`\`sh
28
+ ${CLOUD_RUN_COMMAND} <<'TASK_k3v9qx2m'
29
+ <task>
30
+ TASK_k3v9qx2m
31
+ \`\`\`
32
+
33
+ - When the task continues earlier Claude Code work, add \`--session <id>\` after \`cloud run\` with the session id from the previous output.
34
+ - Preserve the task text as given. Do not inspect the repository, read files, run other commands, or summarize.
35
+ - Return the command's stdout exactly as-is, including the trailing \`session <id>\` line.
36
+ - If the command fails, return its stderr as-is.
37
+ `;
38
+
39
+ const ENVIRONMENT_JSON = JSON.stringify({ install: CLOUD_INSTALL_LINE }, null, 2) + "\n";
40
+ const ExistingEnvironmentSchema = z.looseObject({ user: z.string().optional() });
41
+
42
+ const REPO_FILE_MODE = 0o644;
43
+ const USAGE = "usage: tokenmaxxing cursor init [dir]";
44
+
45
+ export function cmdCursorInit(args: string[], json = false): number {
46
+ if (args.length > 1) {
47
+ emitError({ json, message: USAGE });
48
+ return 2;
49
+ }
50
+ const dir = resolve(args[0] ?? ".");
51
+ if (!existsSync(dir)) {
52
+ emitError({ json, message: `no such directory: ${dir}` });
53
+ return 1;
54
+ }
55
+ const agentFile = join(dir, ".cursor", "agents", "claude.md");
56
+ const environmentFile = join(dir, ".cursor", "environment.json");
57
+ const existing = existsSync(environmentFile) ? ExistingEnvironmentSchema.parse(Bun.JSONC.parse(readFileSync(environmentFile, "utf8"))) : null;
58
+ const agent = existsSync(agentFile) && readFileSync(agentFile, "utf8") === RELAY_AGENT ? "unchanged" : "written";
59
+ if (agent === "written") writeFileAtomic(agentFile, RELAY_AGENT, REPO_FILE_MODE);
60
+ const environment = existing == null ? "written" : "exists";
61
+ const warnings: string[] = [];
62
+ if (existing == null) {
63
+ writeFileAtomic(environmentFile, ENVIRONMENT_JSON, REPO_FILE_MODE);
64
+ } else if (existing.user === "root" || existing.user === "0") {
65
+ warnings.push(".cursor/environment.json sets user to root; Claude Code refuses --dangerously-skip-permissions under root, so every relay call fails until user is a non-root account");
66
+ }
67
+ if (json) {
68
+ emitJson({ ok: true, dir, agent, environment, install: CLOUD_INSTALL_LINE, warnings });
69
+ return 0;
70
+ }
71
+ console.log(`${c.green("✓")} .cursor/agents/claude.md ${agent}`);
72
+ if (environment === "written") {
73
+ console.log(`${c.green("✓")} .cursor/environment.json written`);
74
+ } else {
75
+ console.log(c.yellow("⚠ .cursor/environment.json exists - left untouched; add this to its install command:"));
76
+ console.log(` ${CLOUD_INSTALL_LINE}`);
77
+ }
78
+ for (const warning of warnings) console.log(c.yellow(`⚠ ${warning}`));
79
+ console.log(c.dim("next: commit both files, add the TOKENMAXXING_TOKENS user-scoped Runtime Secret at cursor.com/dashboard/cloud-agents, and start a cloud agent on the repo"));
80
+ return 0;
81
+ }
package/src/cli/doctor.ts CHANGED
@@ -7,7 +7,8 @@ import { loadAccounts, loadConfig } from "../lib/state.ts";
7
7
  import { readItem, liveTarget, parkedTarget } from "../lib/credstore.ts";
8
8
  import { isAccessTokenExpiring, fetchTokenIdentity, describeIdentity } from "../lib/oauth.ts";
9
9
  import { CredentialBlobSchema, type TokenIdentity } from "../lib/types.ts";
10
- import { c, emitJson } from "./render.ts";
10
+ import { SETUP_TOKEN_STALE_MS, loadSetupTokens } from "../lib/setuptokens.ts";
11
+ import { c, emitJson, fmtAgo } from "./render.ts";
11
12
 
12
13
  async function blobIdentity(raw: string): Promise<TokenIdentity | null> {
13
14
  const creds = CredentialBlobSchema.parse(JSON.parse(raw)).claudeAiOauth;
@@ -77,6 +78,15 @@ export async function cmdDoctor(json = false): Promise<number> {
77
78
  if (a.needsReauth) check(false, `${a.email} needs re-auth`, `run \`tokenmaxxing auth ${a.label}\` to re-login`);
78
79
  }
79
80
 
81
+ if (existsSync(paths.setupTokensJson)) {
82
+ const setupTokens = loadSetupTokens().tokens;
83
+ for (const a of idx.accounts) {
84
+ const token = setupTokens.find((t) => t.accountUuid === a.accountUuid);
85
+ if (!token) warn(`no setup token stored for ${a.label} - run \`tokenmaxxing setup-token\` to mint one for Cursor Cloud`);
86
+ else if (Date.now() - token.mintedAt > SETUP_TOKEN_STALE_MS) warn(`the setup token for ${a.label} was minted ${fmtAgo(token.mintedAt)} and expires a year after minting - \`tokenmaxxing setup-token rm ${a.label}\` then \`tokenmaxxing setup-token\` re-mints it`);
87
+ }
88
+ }
89
+
80
90
  const cfg = loadConfig();
81
91
  check(!!cfg.claudeBin && existsSync(cfg.claudeBin), "real claude binary resolved", "set claudeBin in config.json");
82
92
  if (cfg.claudeBin && existsSync(cfg.claudeBin)) {
package/src/cli/rm.ts CHANGED
@@ -4,6 +4,7 @@ import { deleteItem, isolatedTarget, liveTarget, parkedTarget, readItem } from "
4
4
  import { fetchTokenIdentity } from "../lib/oauth.ts";
5
5
  import { withLock } from "../lib/lock.ts";
6
6
  import { credItemFor, paths } from "../lib/paths.ts";
7
+ import { loadSetupTokens, saveSetupTokens } from "../lib/setuptokens.ts";
7
8
  import { loadAccounts, saveAccounts } from "../lib/state.ts";
8
9
  import { CredentialBlobSchema } from "../lib/types.ts";
9
10
  import { findAccount } from "./rename.ts";
@@ -43,12 +44,18 @@ export async function cmdRm(selector?: string, json = false): Promise<number> {
43
44
  return 1;
44
45
  }
45
46
  }
47
+ const setupTokens = loadSetupTokens();
46
48
  await deleteItem(parkedTarget(a.keychainItem));
47
- const sampleDir = join(paths.sampleDir, credItemFor(a.accountUuid));
48
- await deleteItem(isolatedTarget(sampleDir));
49
- rmSync(sampleDir, { recursive: true, force: true });
49
+ const item = credItemFor(a.accountUuid);
50
+ for (const sampleDir of [join(paths.sampleDir, item), join(paths.sampleDir, `${item}-tick`)]) {
51
+ await deleteItem(isolatedTarget(sampleDir));
52
+ rmSync(sampleDir, { recursive: true, force: true });
53
+ }
50
54
  idx.accounts = idx.accounts.filter((x) => x.accountUuid !== a.accountUuid);
51
55
  saveAccounts(idx);
56
+ if (setupTokens.tokens.some((t) => t.accountUuid === a.accountUuid)) {
57
+ saveSetupTokens({ ...setupTokens, tokens: setupTokens.tokens.filter((t) => t.accountUuid !== a.accountUuid) });
58
+ }
52
59
  if (json) emitJson({ ok: true, pool: "claude", removed: a.label, remaining: idx.accounts.length });
53
60
  else console.log(`removed ${c.bold(a.label)} from the pool (${idx.accounts.length} left)`);
54
61
  return 0;
@@ -0,0 +1,160 @@
1
+ import { mkdirSync, rmSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { resolveRealClaude } from "../lib/claudebin.ts";
4
+ import { withLock } from "../lib/lock.ts";
5
+ import { paths } from "../lib/paths.ts";
6
+ import { loadAccounts } from "../lib/state.ts";
7
+ import { scrubCredentialEnv } from "../lib/usage.ts";
8
+ import { CURSOR_SECRET_VALUE_CAP_BYTES, cursorSecretValue, loadSetupTokens, saveSetupTokens, type SetupToken } from "../lib/setuptokens.ts";
9
+ import type { Account } from "../lib/types.ts";
10
+ import { c, emitError, emitJson } from "./render.ts";
11
+
12
+ const TOKEN_BEGIN_MARKER = "Your OAuth token (valid for";
13
+ const TOKEN_END_MARKER = "Store this token securely";
14
+ const USAGE = "usage: tokenmaxxing setup-token [--print] | tokenmaxxing setup-token rm <label|uuid>";
15
+
16
+ export function extractSetupToken(output: string): string | null {
17
+ const lines = Bun.stripANSI(output).split("\n").map((line) => line.trim());
18
+ const end = lines.findLastIndex((line) => line.includes(TOKEN_END_MARKER));
19
+ if (end < 0) return null;
20
+ const begin = lines.slice(0, end).findLastIndex((line) => line.includes(TOKEN_BEGIN_MARKER));
21
+ if (begin < 0) return null;
22
+ const token = lines.slice(begin + 1, end).join("");
23
+ return token === "" ? null : token;
24
+ }
25
+
26
+ async function mint(input: { real: string; item: string }): Promise<string | null> {
27
+ const configDir = join(paths.setupTokenDir, input.item);
28
+ rmSync(configDir, { recursive: true, force: true });
29
+ mkdirSync(configDir, { recursive: true });
30
+ const env = scrubCredentialEnv({ ...process.env, CLAUDE_CONFIG_DIR: configDir });
31
+ const child = Bun.spawn([input.real, "setup-token"], { env, stdin: "inherit", stdout: "pipe", stderr: "inherit" });
32
+ const decoder = new TextDecoder();
33
+ let captured = "";
34
+ for await (const chunk of child.stdout) {
35
+ process.stdout.write(chunk);
36
+ captured += decoder.decode(chunk, { stream: true });
37
+ }
38
+ await child.exited;
39
+ rmSync(configDir, { recursive: true, force: true });
40
+ if (child.exitCode !== 0) {
41
+ console.error(c.red(`claude setup-token exited ${child.exitCode ?? "on signal"} - nothing stored`));
42
+ return null;
43
+ }
44
+ const captureToken = extractSetupToken(captured);
45
+ if (captureToken) return captureToken;
46
+ const pasted = prompt("the token line was not captured from the output above - paste the token here:")?.trim() ?? "";
47
+ if (pasted === "") {
48
+ console.error(c.red("no token pasted - nothing stored"));
49
+ return null;
50
+ }
51
+ return pasted;
52
+ }
53
+
54
+ function currentTokens(tokens: SetupToken[], accounts: Account[]): { label: string; token: string }[] {
55
+ return accounts.flatMap((a) => {
56
+ const stored = tokens.find((t) => t.accountUuid === a.accountUuid);
57
+ return stored ? [{ label: a.label, token: stored.token }] : [];
58
+ });
59
+ }
60
+
61
+ async function rmToken(selector: string, json: boolean): Promise<number> {
62
+ const outcome = await withLock(paths.lockFile, async (): Promise<{ error: string } | { kept: SetupToken[] }> => {
63
+ const store = loadSetupTokens();
64
+ const accounts = loadAccounts().accounts;
65
+ const pooled = accounts.find((a) => a.label === selector);
66
+ const orphan = (t: SetupToken) => !accounts.some((a) => a.accountUuid === t.accountUuid);
67
+ const targets = store.tokens.filter((t) =>
68
+ t.accountUuid === selector || (pooled ? t.accountUuid === pooled.accountUuid : t.label === selector && orphan(t)),
69
+ );
70
+ if (targets.length === 0) return { error: `no setup token stored for "${selector}"` };
71
+ if (targets.length > 1) return { error: `"${selector}" matches ${targets.length} stored tokens (${targets.map((t) => `${t.label} ${t.accountUuid}`).join(", ")}) - nothing removed` };
72
+ const kept = store.tokens.filter((t) => t !== targets[0]);
73
+ saveSetupTokens({ ...store, tokens: kept });
74
+ return { kept };
75
+ });
76
+ if ("error" in outcome) {
77
+ emitError({ json, message: outcome.error });
78
+ return 1;
79
+ }
80
+ const remaining = outcome.kept;
81
+ if (json) {
82
+ emitJson({ ok: true, removed: selector, remaining: remaining.map((t) => t.label) });
83
+ return 0;
84
+ }
85
+ console.log(`removed the setup token for ${selector} (local only: Claude Code documents no revocation for setup tokens)`);
86
+ return 0;
87
+ }
88
+
89
+ export async function cmdSetupToken(args: string[], json = false): Promise<number> {
90
+ const [sub, label] = args;
91
+ if (sub === "rm" && label !== undefined && args.length === 2) return rmToken(label, json);
92
+ const print = sub === "--print" && args.length === 1;
93
+ if (args.length > 0 && !print) {
94
+ emitError({ json, message: USAGE });
95
+ return 2;
96
+ }
97
+ if (json && !print) {
98
+ emitError({ json, message: "setup-token mints through a browser login flow and has no --json form; `setup-token --print --json` prints the stored set" });
99
+ return 2;
100
+ }
101
+ let store = loadSetupTokens();
102
+ let idx = loadAccounts();
103
+ if (!print) {
104
+ if (idx.accounts.length === 0) {
105
+ emitError({ json, message: "no accounts in the pool - run `tokenmaxxing init` first" });
106
+ return 1;
107
+ }
108
+ const real = resolveRealClaude();
109
+ const missing = idx.accounts.filter((a) => !store.tokens.some((t) => t.accountUuid === a.accountUuid));
110
+ if (missing.length === 0) console.log(c.dim("every pooled account already has a stored setup token (`setup-token rm <label>` drops one so it can be re-minted)"));
111
+ for (const a of missing) {
112
+ console.log();
113
+ console.log(`${c.bold(a.label)} - sign into this account in the browser (${a.email})`);
114
+ const token = await mint({ real, item: a.keychainItem });
115
+ if (!token) return 1;
116
+ const stored = await withLock(paths.lockFile, async () => {
117
+ if (!loadAccounts().accounts.some((x) => x.accountUuid === a.accountUuid)) return false;
118
+ const fresh = loadSetupTokens();
119
+ const tokens = fresh.tokens.filter((t) => t.accountUuid !== a.accountUuid);
120
+ saveSetupTokens({ ...fresh, tokens: [...tokens, { accountUuid: a.accountUuid, label: a.label, token, mintedAt: Date.now() }] });
121
+ return true;
122
+ });
123
+ if (!stored) {
124
+ console.log(c.yellow(`${a.label} left the pool during the login - token discarded`));
125
+ continue;
126
+ }
127
+ console.log(`${c.green("✓")} stored the setup token for ${c.bold(a.label)} ${c.dim("(ownership not verified: an inference-only token cannot read the profile endpoint, so the label is the sign-in you chose)")}`);
128
+ }
129
+ store = loadSetupTokens();
130
+ idx = loadAccounts();
131
+ }
132
+ if (store.tokens.length === 0) {
133
+ emitError({ json, message: "no setup tokens stored - run `tokenmaxxing setup-token` to mint them" });
134
+ return 1;
135
+ }
136
+ const tokens = currentTokens(store.tokens, idx.accounts);
137
+ const orphaned = store.tokens
138
+ .filter((t) => !idx.accounts.some((a) => a.accountUuid === t.accountUuid))
139
+ .map((t) => ({ label: t.label, accountUuid: t.accountUuid }));
140
+ if (tokens.length === 0) {
141
+ emitError({ json, message: "no stored setup token belongs to a pooled account - run `tokenmaxxing setup-token` to mint them" });
142
+ return 1;
143
+ }
144
+ const secret = cursorSecretValue(tokens);
145
+ const bytes = Buffer.byteLength(secret);
146
+ if (json) {
147
+ emitJson({ ok: true, tokens, orphaned, bytes, cap: CURSOR_SECRET_VALUE_CAP_BYTES });
148
+ return 0;
149
+ }
150
+ console.log();
151
+ if (orphaned.length > 0) {
152
+ console.log(c.dim(`left out of the secret (no longer in the pool): ${orphaned.map((t) => `${t.label} (${t.accountUuid})`).join(", ")}; \`setup-token rm <label|uuid>\` drops one`));
153
+ }
154
+ console.log(`${c.bold("TOKENMAXXING_TOKENS")} ${c.dim("(user-scoped Runtime Secret at cursor.com/dashboard/cloud-agents)")}`);
155
+ console.log(secret);
156
+ if (bytes > CURSOR_SECRET_VALUE_CAP_BYTES) {
157
+ console.log(c.yellow(`⚠ ${bytes} bytes: over the ${CURSOR_SECRET_VALUE_CAP_BYTES}-byte value cap Cursor documents for API-created environment variables`));
158
+ }
159
+ return 0;
160
+ }
@@ -0,0 +1,176 @@
1
+ import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { minBy } from "es-toolkit";
4
+ import { z } from "zod";
5
+ import { paths } from "./paths.ts";
6
+ import { writeFileAtomic } from "./atomic.ts";
7
+ import { UNMANAGED_ENV, resolveRealClaude } from "./claudebin.ts";
8
+ import { pidExists } from "./proc.ts";
9
+ import { CloudTokenSchema, CloudTokensSchema, type CloudToken } from "./setuptokens.ts";
10
+ import { normalizeResetsAt, parseErrorBody, readTranscriptTail, scrubCredentialEnv } from "./usage.ts";
11
+
12
+ export const TOKENS_ENV = "TOKENMAXXING_TOKENS";
13
+ export const FALLBACK_WALL_MS = 5 * 3_600_000;
14
+ const RESERVATION_PREFIX = "pending:";
15
+
16
+ export function readCloudTokens(): CloudToken[] {
17
+ const raw = process.env[TOKENS_ENV];
18
+ if (raw == null || raw === "") {
19
+ throw new Error(`${TOKENS_ENV} is not set - add it as a user-scoped Runtime Secret with the value \`tokenmaxxing setup-token --print\` prints`);
20
+ }
21
+ let json: unknown;
22
+ try {
23
+ json = JSON.parse(raw);
24
+ } catch {
25
+ throw new Error(`${TOKENS_ENV} is not valid JSON`);
26
+ }
27
+ const parsed = CloudTokensSchema.safeParse(json);
28
+ if (!parsed.success) throw new Error(`${TOKENS_ENV} must be a non-empty JSON array of {label, token} objects`);
29
+ const labels = parsed.data.map((t) => t.label);
30
+ if (new Set(labels).size !== labels.length) throw new Error(`${TOKENS_ENV} carries a duplicate label`);
31
+ const reserved = labels.find((label) => label in {});
32
+ if (reserved != null) throw new Error(`${TOKENS_ENV} carries the label "${reserved}", which is a reserved object property name`);
33
+ return parsed.data;
34
+ }
35
+
36
+ const SessionsSchema = z.record(z.string(), z.string());
37
+ const WalledSchema = z.record(z.string(), z.number());
38
+
39
+ function loadState<T>(file: string, schema: z.ZodType<T>, empty: T): T {
40
+ if (!existsSync(file)) return empty;
41
+ let json: unknown;
42
+ try {
43
+ json = JSON.parse(readFileSync(file, "utf8"));
44
+ } catch {
45
+ throw new Error(`${file} is corrupt (unparsable JSON) - repair or remove the file`);
46
+ }
47
+ return schema.parse(json);
48
+ }
49
+
50
+ export function loadCloudSessions(): Record<string, string> {
51
+ const sessions = loadState(paths.cloudSessionsJson, SessionsSchema, {});
52
+ for (const key of Object.keys(sessions)) {
53
+ if (!key.startsWith(RESERVATION_PREFIX)) continue;
54
+ const pid = Number(key.slice(RESERVATION_PREFIX.length).split(":")[0]);
55
+ if (!Number.isInteger(pid) || !pidExists(pid)) delete sessions[key];
56
+ }
57
+ return sessions;
58
+ }
59
+
60
+ export function saveCloudSessions(sessions: Record<string, string>): void {
61
+ writeFileAtomic(paths.cloudSessionsJson, JSON.stringify(SessionsSchema.parse(sessions), null, 2) + "\n");
62
+ }
63
+
64
+ export function newReservationKey(): string {
65
+ return `${RESERVATION_PREFIX}${process.pid}:${crypto.randomUUID().slice(0, 8)}`;
66
+ }
67
+
68
+ export function loadCloudWalled(): Record<string, number> {
69
+ return loadState(paths.cloudWalledJson, WalledSchema, {});
70
+ }
71
+
72
+ export function saveCloudWalled(walled: Record<string, number>): void {
73
+ writeFileAtomic(paths.cloudWalledJson, JSON.stringify(WalledSchema.parse(walled), null, 2) + "\n");
74
+ }
75
+
76
+ const PickSchema = z.discriminatedUnion("ok", [
77
+ z.object({ ok: z.literal(true), token: CloudTokenSchema }),
78
+ z.object({ ok: z.literal(false), earliestReset: z.number() }),
79
+ ]);
80
+ export type Pick = z.infer<typeof PickSchema>;
81
+
82
+ export function pickToken(input: {
83
+ tokens: CloudToken[];
84
+ sessions: Record<string, string>;
85
+ walled: Record<string, number>;
86
+ sessionId: string | null;
87
+ now: number;
88
+ }): Pick {
89
+ const walled = new Map(Object.entries(input.walled));
90
+ const sessions = new Map(Object.entries(input.sessions));
91
+ const open = (label: string) => (walled.get(label) ?? 0) <= input.now;
92
+ if (input.sessionId != null) {
93
+ const pinned = sessions.get(input.sessionId);
94
+ const token = input.tokens.find((t) => t.label === pinned);
95
+ if (token && open(token.label)) return { ok: true, token };
96
+ }
97
+ const candidates = input.tokens.filter((t) => open(t.label));
98
+ if (candidates.length === 0) {
99
+ return { ok: false, earliestReset: Math.min(...input.tokens.map((t) => walled.get(t.label) ?? input.now)) };
100
+ }
101
+ const load = new Map<string, number>();
102
+ for (const label of sessions.values()) load.set(label, (load.get(label) ?? 0) + 1);
103
+ return { ok: true, token: minBy(candidates, (t) => load.get(t.label) ?? 0) ?? candidates[0]! };
104
+ }
105
+
106
+ const ResultBase = {
107
+ type: z.literal("result"),
108
+ session_id: z.string(),
109
+ is_error: z.boolean(),
110
+ num_turns: z.number(),
111
+ total_cost_usd: z.number(),
112
+ };
113
+ const CloudResultSchema = z.discriminatedUnion("subtype", [
114
+ z.looseObject({ ...ResultBase, subtype: z.literal("success"), result: z.string(), api_error_status: z.number().nullish() }),
115
+ z.looseObject({
116
+ ...ResultBase,
117
+ subtype: z.enum(["error_max_turns", "error_during_execution", "error_max_budget_usd", "error_max_structured_output_retries"]),
118
+ errors: z.array(z.string()),
119
+ }),
120
+ ]);
121
+ export type CloudResult = z.infer<typeof CloudResultSchema>;
122
+
123
+ export async function spawnCloudClaude(input: {
124
+ token: string;
125
+ prompt: string;
126
+ resume: string | null;
127
+ maxTurns: number | null;
128
+ }): Promise<{ exitCode: number | null; result: CloudResult }> {
129
+ const args = [
130
+ "-p", "--output-format", "json", "--dangerously-skip-permissions",
131
+ ...(input.resume ? ["--resume", input.resume] : []),
132
+ ...(input.maxTurns != null ? ["--max-turns", String(input.maxTurns)] : []),
133
+ "--", input.prompt,
134
+ ];
135
+ const env: Record<string, string> = { ...scrubCredentialEnv({ ...process.env, [UNMANAGED_ENV]: "1" }), CLAUDE_CODE_OAUTH_TOKEN: input.token };
136
+ delete env[TOKENS_ENV];
137
+ const child = Bun.spawn([resolveRealClaude(), ...args], { env, stdin: "ignore", stdout: "pipe", stderr: "inherit" });
138
+ const stdout = await new Response(child.stdout).text();
139
+ await child.exited;
140
+ let json: unknown;
141
+ try {
142
+ json = JSON.parse(stdout);
143
+ } catch {
144
+ throw new Error(`claude exited ${child.exitCode ?? "on signal"} without a JSON result: ${stdout.trim().slice(0, 200)}`);
145
+ }
146
+ const parsed = CloudResultSchema.safeParse(json);
147
+ if (!parsed.success) {
148
+ throw new Error(`claude exited ${child.exitCode ?? "on signal"} with a result that does not match the documented result message: ${stdout.trim().slice(0, 200)}`);
149
+ }
150
+ return { exitCode: child.exitCode, result: parsed.data };
151
+ }
152
+
153
+ export function cloudTranscriptPath(sessionId: string): string | null {
154
+ const projects = join(paths.claudeDir, "projects");
155
+ if (!existsSync(projects)) return null;
156
+ for (const dir of readdirSync(projects)) {
157
+ const candidate = join(projects, dir, `${sessionId}.jsonl`);
158
+ if (existsSync(candidate)) return candidate;
159
+ }
160
+ return null;
161
+ }
162
+
163
+ export function transcriptBytes(sessionId: string | null): number {
164
+ const transcript = sessionId ? cloudTranscriptPath(sessionId) : null;
165
+ return transcript ? statSync(transcript).size : 0;
166
+ }
167
+
168
+ export function limitWallUntil(input: { sessionId: string; sinceBytes: number; now: number }): number | null {
169
+ const transcript = cloudTranscriptPath(input.sessionId);
170
+ const rows = transcript ? readTranscriptTail(transcript, statSync(transcript).size - input.sinceBytes + 1) : [];
171
+ const row = rows.findLast((r) => r.isApiErrorMessage === true);
172
+ if (!row || row.error !== "rate_limit" || row.apiErrorIsTransient === true) return null;
173
+ if ((row.quotaLimits?.rateLimitType ?? "") === "" && parseErrorBody(row.errorDetails)?.error?.type !== "rate_limit_error") return null;
174
+ const resetsAt = row.quotaLimits?.resetsAt != null ? normalizeResetsAt(row.quotaLimits.resetsAt) : null;
175
+ return resetsAt != null && resetsAt > input.now ? resetsAt : input.now + FALLBACK_WALL_MS;
176
+ }
package/src/lib/decide.ts CHANGED
@@ -4,9 +4,8 @@ import { withLock } from "./lock.ts";
4
4
  import { paths } from "./paths.ts";
5
5
  import { POST_SWAP_COOLDOWN_MS, loadAccounts, loadConfig, loadDepletedWait, loadLastSwapAt, loadUsage, loadUsageSnapshot, loadModelUsage, saveAccounts, saveDepletedWait, saveModelUsage, writeUsage } from "./state.ts";
6
6
  import { readOAuthAccount } from "./claudejson.ts";
7
- import { isSkippableSwapError, performSwap, type VerifyVerdict } from "./swap.ts";
8
- import { probeParkedUsage } from "./sample.ts";
9
- import { isExhausted, nextWeeklyReset, pickBest, pickEarliestReset, thresholdBars, usableAt, type PickCtx } from "./picker.ts";
7
+ import { isSkippableSwapError, performSwap } from "./swap.ts";
8
+ import { nextWeeklyReset, pickBest, pickEarliestReset, thresholdBars, usableAt } from "./picker.ts";
10
9
  import { familyTokens, gatedFamilies, probeUsage, type EnforcedClass } from "./usage.ts";
11
10
  import { log } from "./log.ts";
12
11
  import { AccountSchema, ModelUsageStateSchema, UsageStateSchema, type Account, type Config, type EnforcedLimit, type ModelUsageState, type Thresholds, type UsageState, type UsageWindow } from "./types.ts";
@@ -21,8 +20,6 @@ export type SwapDecision = z.infer<typeof SwapDecisionSchema>;
21
20
 
22
21
  const WEEK_MS = 7 * 24 * 60 * 60 * 1000;
23
22
  const FIVE_HOURS_MS = 5 * 60 * 60 * 1000;
24
- const VERIFY_PROBE_BUDGET = 2;
25
- const VERIFY_PROBE_KILL_MS = 12_000;
26
23
 
27
24
  function liveUsed(input: { window: UsageWindow; windowMs: number; sampledAt: number; now: number }): number {
28
25
  const { window: w, windowMs, sampledAt, now } = input;
@@ -186,53 +183,12 @@ export async function evaluateAndMaybeSwap(now = Date.now(), anticipatory = fals
186
183
  rejected.add(candidate.accountUuid);
187
184
  log("decide.candidate_rejected", { account: candidate.accountUuid.slice(0, 8), error: e instanceof Error ? e.message : String(e) });
188
185
  };
189
- let probeBudget = VERIFY_PROBE_BUDGET;
190
- const verified = async (candidate: Account, ctx: PickCtx): Promise<VerifyVerdict> => {
191
- if (candidate.lastUsageAt != null && now - candidate.lastUsageAt <= cfg.policy.usagePollTtlMs) return "go";
192
- const short = candidate.accountUuid.slice(0, 8);
193
- const idx2 = loadAccounts();
194
- const stored = idx2.accounts.find((a) => a.accountUuid === candidate.accountUuid);
195
- if (!stored) return "go";
196
- if (probeBudget <= 0) {
197
- log("decide.verify_budget_spent", { account: short });
198
- return "go";
199
- }
200
- probeBudget -= 1;
201
- const sample = await probeParkedUsage(stored, { retries: 0, refreshParked: false, killMs: VERIFY_PROBE_KILL_MS, signal: AbortSignal.timeout(VERIFY_PROBE_KILL_MS) });
202
- if (sample.ok) {
203
- const at = Date.now();
204
- stored.lastUsage = { fiveHour: sample.usage.session, sevenDay: sample.usage.weekAll };
205
- stored.lastUsageAt = at;
206
- if (Object.keys(sample.usage.perModel).length > 0) {
207
- stored.lastPerModel = sample.usage.perModel;
208
- stored.lastPerModelAt = at;
209
- }
210
- }
211
- if (sample.ok || stored.needsReauth === true) saveAccounts(idx2);
212
- if (stored.needsReauth === true) {
213
- rejected.add(candidate.accountUuid);
214
- log("decide.candidate_rejected", { account: short, error: "needs reauth" });
215
- return "skip";
216
- }
217
- if (!sample.ok) {
218
- log("decide.candidate_unverified", { account: short });
219
- return "go";
220
- }
221
- log(isExhausted(stored, ctx) ? "decide.candidate_walled" : "decide.candidate_verified", {
222
- account: short,
223
- session: sample.usage.session.usedPercentage,
224
- weekly: sample.usage.weekAll.usedPercentage,
225
- });
226
- return "rerank";
227
- };
228
-
229
186
  while (true) {
230
187
  const cur = loadAccounts();
231
188
  const seat = seatOf(cur);
232
189
  const ctx = { now, thresholds: bars, currentAccountUuid: seat?.accountUuid ?? null, switchFamilies };
233
190
  const best = pickBest(usable(cur.accounts), ctx);
234
191
  if (!best) break;
235
- if ((await verified(best, ctx)) !== "go") continue;
236
192
  try {
237
193
  await performSwap(best);
238
194
  } catch (e) {
package/src/lib/oauth.ts CHANGED
@@ -40,7 +40,7 @@ export function isDeadCredential(creds: OAuthCreds): boolean {
40
40
  return creds.refreshToken === "" || creds.accessToken === "";
41
41
  }
42
42
 
43
- export async function refreshCredential(creds: OAuthCreds, now = Date.now()): Promise<OAuthCreds> {
43
+ export async function refreshCredential(creds: OAuthCreds, now = Date.now(), signal?: AbortSignal): Promise<OAuthCreds> {
44
44
  if (isDeadCredential(creds)) throw new InvalidGrantError("credential was cleared after a failed refresh");
45
45
  const scope = (creds.scopes?.length ? creds.scopes : DEFAULT_SCOPES).join(" ");
46
46
  const body = {
@@ -55,6 +55,7 @@ export async function refreshCredential(creds: OAuthCreds, now = Date.now()): Pr
55
55
  res = await http.post(TOKEN_URL, {
56
56
  headers: { "Content-Type": "application/json" },
57
57
  body: JSON.stringify(body),
58
+ signal,
58
59
  });
59
60
  } catch (e) {
60
61
  throw new Error(`token endpoint unreachable: ${e instanceof Error ? e.message : String(e)}`);
package/src/lib/paths.ts CHANGED
@@ -28,6 +28,11 @@ export const paths = {
28
28
  onboardDir: join(TM_HOME, "onboard"),
29
29
  sampleDir: join(TM_HOME, "sample"),
30
30
  credsDir: join(TM_HOME, "creds"),
31
+ setupTokensJson: join(TM_HOME, "setup-tokens.json"),
32
+ setupTokenDir: join(TM_HOME, "setup-token"),
33
+ cloudSessionsJson: join(TM_HOME, "cloud", "sessions.json"),
34
+ cloudWalledJson: join(TM_HOME, "cloud", "walled.json"),
35
+ cloudLockFile: join(TM_HOME, "cloud", "lock"),
31
36
 
32
37
  claudeJson: env("TOKENMAXXING_CLAUDE_JSON", join(HOME, ".claude.json")),
33
38
  claudeSettings: env(
package/src/lib/sample.ts CHANGED
@@ -1,15 +1,18 @@
1
1
  import { mkdirSync, rmSync, writeFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
+ import { minBy } from "es-toolkit";
3
4
  import { z } from "zod";
4
5
  import { readItem, writeItem, deleteItem, liveTarget, parkedTarget, isolatedTarget, claudeAiOauthOnly, mergeIntoLive } from "./credstore.ts";
5
6
  import { credItemFor, paths } from "./paths.ts";
6
7
  import { withClaudeRefreshLock } from "./claudelock.ts";
8
+ import { withLock } from "./lock.ts";
9
+ import { readOAuthAccount } from "./claudejson.ts";
7
10
  import { refreshCredential, isAccessTokenExpiring, isDeadCredential, fetchTokenIdentity, describeIdentity, IdentityUnavailableError, InvalidGrantError } from "./oauth.ts";
8
11
  import { FullUsageSchema, probeUsage } from "./usage.ts";
9
- import { loadAccounts } from "./state.ts";
12
+ import { POST_SWAP_COOLDOWN_MS, loadAccounts, loadLastSwapAt, saveAccounts } from "./state.ts";
10
13
  import { keepRotatedPair } from "./swap.ts";
11
14
  import { log } from "./log.ts";
12
- import { CredentialBlobSchema, TokenIdentitySchema, type Account, type OAuthCreds, type TokenIdentity } from "./types.ts";
15
+ import { CredentialBlobSchema, TokenIdentitySchema, type Account, type Config, type OAuthCreds, type TokenIdentity } from "./types.ts";
13
16
 
14
17
  const SampleOutcomeSchema = z.discriminatedUnion("ok", [
15
18
  z.object({ ok: z.literal(true), usage: FullUsageSchema }),
@@ -44,10 +47,12 @@ function refreshPlanFields(account: Account, creds: OAuthCreds): void {
44
47
  if (creds.rateLimitTier != null) account.rateLimitTier = creds.rateLimitTier;
45
48
  }
46
49
 
47
- export async function probeParkedUsage(
48
- account: Account,
49
- opts: { retries?: number; refreshParked?: boolean; killMs?: number; signal?: AbortSignal } = {},
50
- ): Promise<SampleOutcome> {
50
+ const ProbeFailureSchema = z.object({ ok: z.literal(false), reason: z.string() });
51
+ type ProbeFailure = z.infer<typeof ProbeFailureSchema>;
52
+ const PreparedProbeSchema = z.discriminatedUnion("ok", [z.object({ ok: z.literal(true), dir: z.string(), installed: z.string() }), ProbeFailureSchema]);
53
+ type PreparedProbe = z.infer<typeof PreparedProbeSchema>;
54
+
55
+ async function prepareParkedProbe(account: Account, dir: string, signal?: AbortSignal): Promise<PreparedProbe> {
51
56
  const backup = parkedTarget(account.keychainItem);
52
57
  const parkedRaw = await readItem(backup);
53
58
  if (!parkedRaw) return { ok: false, reason: "no parked credential - run `tokenmaxxing auth`" };
@@ -62,13 +67,11 @@ export async function probeParkedUsage(
62
67
  account.needsReauth = true;
63
68
  return { ok: false, reason: "parked credential was cleared after a failed refresh - re-auth with `tokenmaxxing auth`" };
64
69
  }
65
- if (opts.refreshParked === false && isAccessTokenExpiring(creds, 300_000)) {
66
- return { ok: false, reason: "parked access token is near expiry - verification requires an unexpired token" };
67
- }
68
70
 
69
71
  const liveRaw = await readItem(liveTarget());
70
72
  let liveAccount: string | null = null;
71
73
  let liveToken: string | null = null;
74
+ let liveRefresh: string | null = null;
72
75
  if (liveRaw != null) {
73
76
  let liveCreds: OAuthCreds;
74
77
  try {
@@ -78,8 +81,9 @@ export async function probeParkedUsage(
78
81
  }
79
82
  if (!isDeadCredential(liveCreds)) {
80
83
  liveToken = liveCreds.accessToken;
84
+ liveRefresh = liveCreds.refreshToken;
81
85
  try {
82
- liveAccount = (await fetchTokenIdentity(liveCreds.accessToken, opts.signal)).accountUuid;
86
+ liveAccount = (await fetchTokenIdentity(liveCreds.accessToken, signal)).accountUuid;
83
87
  } catch (e) {
84
88
  return { ok: false, reason: `cannot verify the live credential's owner (${(e instanceof Error ? e.message : String(e)).slice(0, 80)}) - refusing to sample a possibly-live account` };
85
89
  }
@@ -98,10 +102,7 @@ export async function probeParkedUsage(
98
102
  };
99
103
 
100
104
  if (isAccessTokenExpiring(creds, 300_000)) {
101
- if (opts.refreshParked === false) {
102
- return { ok: false, reason: "parked access token is near expiry - verification requires an unexpired token" };
103
- }
104
- const owner = await checkIdentity(creds, account);
105
+ const owner = await checkIdentity(creds, account, signal);
105
106
  if (owner.status === "mismatch") {
106
107
  const kept = await relocate(owner.owner);
107
108
  account.needsReauth = true;
@@ -110,9 +111,9 @@ export async function probeParkedUsage(
110
111
  if (owner.status === "unavailable" && !owner.stale) {
111
112
  return { ok: false, reason: `${owner.reason} - refusing to refresh a parked credential whose owner cannot be verified` };
112
113
  }
113
- const rotate = async (): Promise<{ fresh: OAuthCreds } | { failed: SampleOutcome }> => {
114
+ const rotate = async (): Promise<{ fresh: OAuthCreds } | { failed: ProbeFailure }> => {
114
115
  try {
115
- return { fresh: await refreshCredential(creds) };
116
+ return { fresh: await refreshCredential(creds, Date.now(), signal) };
116
117
  } catch (e) {
117
118
  if (e instanceof InvalidGrantError) {
118
119
  account.needsReauth = true;
@@ -121,16 +122,24 @@ export async function probeParkedUsage(
121
122
  return { failed: { ok: false, reason: `token refresh failed: ${e instanceof Error ? e.message : String(e)}` } };
122
123
  }
123
124
  };
124
- let result: { fresh: OAuthCreds } | { failed: SampleOutcome };
125
+ let result: { fresh: OAuthCreds } | { failed: ProbeFailure };
125
126
  if (owner.status === "match") {
126
127
  result = await rotate();
127
128
  } else {
129
+ if (liveRefresh != null && creds.refreshToken === liveRefresh) {
130
+ return { ok: false, reason: "this slot holds a copy of the LIVE login's grant - refusing to rotate it from a parked slot; run `tokenmaxxing switch` to reconcile" };
131
+ }
128
132
  try {
133
+ signal?.throwIfAborted();
129
134
  result = await withClaudeRefreshLock(async (lock) => {
130
135
  const rotated = await rotate();
131
136
  if ("failed" in rotated) return rotated;
132
- const verified = await checkIdentity(rotated.fresh, account);
133
- if (verified.status !== "mismatch") return rotated;
137
+ const verified = await checkIdentity(rotated.fresh, account, signal);
138
+ if (verified.status === "match") return rotated;
139
+ if (verified.status === "unavailable") {
140
+ await writeItem(backup, JSON.stringify({ claudeAiOauth: rotated.fresh }));
141
+ return { failed: { ok: false, reason: `${verified.reason} - the rotated pair stays in this slot unverified until the next pass` } };
142
+ }
134
143
  const trueOwner = loadAccounts().accounts.find((a) => a.accountUuid === verified.owner.accountUuid) ?? null;
135
144
  const kept = await keepRotatedPair({ fresh: rotated.fresh, owner: trueOwner, fallback: account, liveOwnerUuid: liveAccount, expectedLiveToken: liveToken, lock });
136
145
  account.needsReauth = true;
@@ -145,7 +154,7 @@ export async function probeParkedUsage(
145
154
  await writeItem(backup, JSON.stringify({ claudeAiOauth: creds }));
146
155
  }
147
156
 
148
- const identity = await checkIdentity(creds, account, opts.signal);
157
+ const identity = await checkIdentity(creds, account, signal);
149
158
  if (identity.status === "mismatch") {
150
159
  const kept = await relocate(identity.owner);
151
160
  account.needsReauth = true;
@@ -156,27 +165,100 @@ export async function probeParkedUsage(
156
165
  }
157
166
  refreshPlanFields(account, creds);
158
167
 
159
- const dir = join(paths.sampleDir, credItemFor(account.accountUuid));
160
168
  rmSync(dir, { recursive: true, force: true });
161
169
  mkdirSync(dir, { recursive: true });
162
- const isoTarget = isolatedTarget(dir);
163
- const installed = JSON.stringify({ claudeAiOauth: opts.refreshParked === false ? { ...creds, refreshToken: "" } : creds });
164
-
170
+ const installed = JSON.stringify({ claudeAiOauth: creds });
165
171
  try {
166
- await writeItem(isoTarget, installed);
172
+ await writeItem(isolatedTarget(dir), installed);
167
173
  writeFileSync(join(dir, ".claude.json"), JSON.stringify({ oauthAccount: account.oauthAccount, hasCompletedOnboarding: true }));
168
- const usage = await probeUsage(dir, Date.now(), opts);
169
- return usage ? { ok: true, usage } : { ok: false, reason: "`/usage` returned no limit data (see log)" };
174
+ } catch (e) {
175
+ await finishParkedProbe(account, { dir, installed });
176
+ throw e;
177
+ }
178
+ return { ok: true, dir, installed };
179
+ }
180
+
181
+ async function runParkedProbe(dir: string, opts: { retries?: number }): Promise<SampleOutcome> {
182
+ const usage = await probeUsage(dir, Date.now(), { retries: opts.retries });
183
+ return usage ? { ok: true, usage } : { ok: false, reason: "`/usage` returned no limit data (see log)" };
184
+ }
185
+
186
+ async function finishParkedProbe(account: Account, prepared: { dir: string; installed: string }): Promise<void> {
187
+ const isoTarget = isolatedTarget(prepared.dir);
188
+ const backup = parkedTarget(account.keychainItem);
189
+ try {
190
+ const afterIso = await readItem(isoTarget);
191
+ if (afterIso && afterIso !== prepared.installed) {
192
+ const parkedNow = await readItem(backup);
193
+ const installedToken = CredentialBlobSchema.parse(JSON.parse(prepared.installed)).claudeAiOauth.accessToken;
194
+ const parkedToken = parkedNow == null ? null : CredentialBlobSchema.parse(JSON.parse(parkedNow)).claudeAiOauth.accessToken;
195
+ if (parkedToken === installedToken) await writeItem(backup, claudeAiOauthOnly(afterIso));
196
+ else log("sample.harvest_skipped", { account: account.accountUuid.slice(0, 8) });
197
+ }
170
198
  } finally {
171
- try {
172
- if (opts.refreshParked !== false) {
173
- const afterIso = await readItem(isoTarget);
174
- if (afterIso && afterIso !== installed) await writeItem(backup, claudeAiOauthOnly(afterIso));
175
- }
176
- } finally {
177
- await deleteItem(isoTarget);
178
- rmSync(dir, { recursive: true, force: true });
199
+ await deleteItem(isoTarget);
200
+ rmSync(prepared.dir, { recursive: true, force: true });
201
+ }
202
+ }
203
+
204
+ export async function probeParkedUsage(account: Account): Promise<SampleOutcome> {
205
+ const prepared = await prepareParkedProbe(account, join(paths.sampleDir, credItemFor(account.accountUuid)));
206
+ if (!prepared.ok) return prepared;
207
+ try {
208
+ return await runParkedProbe(prepared.dir, {});
209
+ } finally {
210
+ await finishParkedProbe(account, prepared);
211
+ }
212
+ }
213
+
214
+ const PREPARE_DEADLINE_MS = 20_000;
215
+
216
+ export async function sampleOldestParked(cfg: Config): Promise<void> {
217
+ const reserved = await withLock(paths.lockFile, async () => {
218
+ const now = Date.now();
219
+ const lastSwapAt = loadLastSwapAt();
220
+ if (lastSwapAt != null && now - lastSwapAt < POST_SWAP_COOLDOWN_MS) return null;
221
+ const idx = loadAccounts();
222
+ const live = readOAuthAccount()?.accountUuid ?? null;
223
+ const sampledAt = (a: Account) => Math.max(a.lastUsageAt ?? 0, a.lastProbeAt ?? 0);
224
+ const stale = idx.accounts.filter((a) => a.accountUuid !== live && a.needsReauth !== true && now - sampledAt(a) > cfg.policy.usagePollTtlMs);
225
+ const target = minBy(stale, sampledAt);
226
+ if (!target) return null;
227
+ target.lastProbeAt = now;
228
+ const prepared = await prepareParkedProbe(target, join(paths.sampleDir, `${credItemFor(target.accountUuid)}-tick`), AbortSignal.timeout(PREPARE_DEADLINE_MS));
229
+ saveAccounts(idx);
230
+ if (!prepared.ok) {
231
+ log("sample.parked_failed", { account: target.accountUuid.slice(0, 8), reason: prepared.reason.slice(0, 200) });
232
+ return null;
179
233
  }
234
+ return { account: target, prepared };
235
+ });
236
+ if (!reserved) return;
237
+ const { account, prepared } = reserved;
238
+ const startedAt = Date.now();
239
+ let outcome: SampleOutcome | null = null;
240
+ try {
241
+ outcome = await runParkedProbe(prepared.dir, { retries: 0 });
242
+ } finally {
243
+ await withLock(paths.lockFile, async () => {
244
+ await finishParkedProbe(account, prepared);
245
+ if (outcome == null) return;
246
+ const idx = loadAccounts();
247
+ const stored = idx.accounts.find((a) => a.accountUuid === account.accountUuid);
248
+ if (stored && outcome.ok && (stored.lastUsageAt == null || startedAt > stored.lastUsageAt)) {
249
+ stored.lastUsage = { fiveHour: outcome.usage.session, sevenDay: outcome.usage.weekAll };
250
+ stored.lastUsageAt = startedAt;
251
+ if (Object.keys(outcome.usage.perModel).length > 0) {
252
+ stored.lastPerModel = outcome.usage.perModel;
253
+ stored.lastPerModelAt = startedAt;
254
+ }
255
+ saveAccounts(idx);
256
+ }
257
+ log(outcome.ok ? "sample.parked_ok" : "sample.parked_failed", {
258
+ account: account.accountUuid.slice(0, 8),
259
+ ...(outcome.ok ? {} : { reason: outcome.reason.slice(0, 200) }),
260
+ });
261
+ });
180
262
  }
181
263
  }
182
264
 
@@ -0,0 +1,40 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { z } from "zod";
3
+ import { paths } from "./paths.ts";
4
+ import { writeFileAtomic } from "./atomic.ts";
5
+
6
+ export const CURSOR_SECRET_VALUE_CAP_BYTES = 4096;
7
+ export const SETUP_TOKEN_STALE_MS = 335 * 86_400_000;
8
+
9
+ const SetupTokenSchema = z.object({ accountUuid: z.string().min(1), label: z.string().min(1), token: z.string().min(1), mintedAt: z.number() });
10
+ export type SetupToken = z.infer<typeof SetupTokenSchema>;
11
+
12
+ const SetupTokensFileSchema = z.object({ version: z.literal(1), tokens: z.array(SetupTokenSchema).default([]) });
13
+ export type SetupTokensFile = z.infer<typeof SetupTokensFileSchema>;
14
+
15
+ export const CloudTokenSchema = z.object({ label: z.string().min(1), token: z.string().min(1) });
16
+ export type CloudToken = z.infer<typeof CloudTokenSchema>;
17
+ export const CloudTokensSchema = z.array(CloudTokenSchema).min(1);
18
+
19
+ export function loadSetupTokens(): SetupTokensFile {
20
+ if (!existsSync(paths.setupTokensJson)) return { version: 1, tokens: [] };
21
+ let json: unknown;
22
+ try {
23
+ json = JSON.parse(readFileSync(paths.setupTokensJson, "utf8"));
24
+ } catch {
25
+ throw new Error(`${paths.setupTokensJson} is corrupt (unparsable JSON) - refusing to treat a damaged token store as empty; repair or remove the file`);
26
+ }
27
+ const parsed = SetupTokensFileSchema.safeParse(json);
28
+ if (!parsed.success) {
29
+ throw new Error(`${paths.setupTokensJson} does not match the setup-token schema - repair or remove the file`);
30
+ }
31
+ return parsed.data;
32
+ }
33
+
34
+ export function saveSetupTokens(file: SetupTokensFile): void {
35
+ writeFileAtomic(paths.setupTokensJson, JSON.stringify(SetupTokensFileSchema.parse(file), null, 2) + "\n");
36
+ }
37
+
38
+ export function cursorSecretValue(tokens: CloudToken[]): string {
39
+ return JSON.stringify(CloudTokensSchema.parse(tokens));
40
+ }
package/src/lib/swap.ts CHANGED
@@ -1,4 +1,3 @@
1
- import { z } from "zod";
2
1
  import { clearDepletedWait, clearUsageSnapshots, loadAccounts, saveAccounts, saveLastSwapAt } from "./state.ts";
3
2
  import { readItem, writeItem, liveTarget, parkedTarget, claudeAiOauthOnly, mergeIntoLive } from "./credstore.ts";
4
3
  import { refreshCredential, isAccessTokenExpiring, isDeadCredential, fetchTokenIdentity, describeIdentity, IdentityUnavailableError, InvalidGrantError, RefreshRejectedError } from "./oauth.ts";
@@ -11,9 +10,6 @@ function parseBlob(raw: string) {
11
10
  return CredentialBlobSchema.parse(JSON.parse(raw));
12
11
  }
13
12
 
14
- const VerifyVerdictSchema = z.enum(["go", "rerank", "skip"]);
15
- export type VerifyVerdict = z.infer<typeof VerifyVerdictSchema>;
16
-
17
13
  export async function performSwap(target: Account): Promise<void> {
18
14
  const idx = loadAccounts();
19
15
 
package/src/lib/types.ts CHANGED
@@ -68,6 +68,7 @@ export const AccountSchema = z.object({
68
68
  lastPerModel: z.record(z.string(), UsageWindowSchema).optional(),
69
69
  lastPerModelAt: z.number().optional(),
70
70
  lastUsageAt: z.number().optional(),
71
+ lastProbeAt: z.number().optional(),
71
72
  enforcedUntil: z.number().optional(),
72
73
  needsReauth: z.boolean().optional(),
73
74
  subscriptionType: z.string().optional(),
package/src/lib/usage.ts CHANGED
@@ -280,17 +280,9 @@ const SpawnResultSchema = z.object({
280
280
  });
281
281
  type SpawnResult = z.infer<typeof SpawnResultSchema>;
282
282
 
283
- async function spawnClaudeBounded(
284
- cmd: string[],
285
- env: Record<string, string>,
286
- killMs = PROBE_KILL_MS,
287
- signal?: AbortSignal,
288
- ): Promise<SpawnResult | null> {
289
- signal?.throwIfAborted();
283
+ async function spawnClaudeBounded(cmd: string[], env: Record<string, string>): Promise<SpawnResult | null> {
290
284
  const p = Bun.spawn(cmd, { env, stdout: "pipe", stderr: "pipe" });
291
- const abort = () => p.kill("SIGKILL");
292
- signal?.addEventListener("abort", abort, { once: true });
293
- const killer = setTimeout(() => p.kill("SIGKILL"), killMs);
285
+ const killer = setTimeout(() => p.kill("SIGKILL"), PROBE_KILL_MS);
294
286
  try {
295
287
  const reads = Promise.all([new Response(p.stdout).text(), new Response(p.stderr).text()]);
296
288
  const settled = await Promise.race([
@@ -303,14 +295,13 @@ async function spawnClaudeBounded(
303
295
  return { exitCode: p.exitCode, stdout, stderr };
304
296
  } finally {
305
297
  clearTimeout(killer);
306
- signal?.removeEventListener("abort", abort);
307
298
  }
308
299
  }
309
300
 
310
- async function probeUsageOnce(env: Record<string, string>, now: number, killMs: number, signal?: AbortSignal): Promise<FullUsage | null> {
301
+ async function probeUsageOnce(env: Record<string, string>, now: number): Promise<FullUsage | null> {
311
302
  let out: string;
312
303
  try {
313
- const r = await spawnClaudeBounded([resolveRealClaude(), "-p", "/usage", "--output-format", "json"], env, killMs, signal);
304
+ const r = await spawnClaudeBounded([resolveRealClaude(), "-p", "/usage", "--output-format", "json"], env);
314
305
  if (r === null) {
315
306
  log("usage.probe_failed", { err: "output pipes still open after child exit (leaked descendant)" });
316
307
  return null;
@@ -341,20 +332,25 @@ async function probeUsageOnce(env: Record<string, string>, now: number, killMs:
341
332
 
342
333
  const PROBE_RETRY_DELAYS_MS = [2000, 5000];
343
334
 
335
+ export function scrubCredentialEnv(env: Record<string, string>): Record<string, string> {
336
+ const scrubbed = { ...env };
337
+ for (const k of CRED_ENV_OVERRIDES) delete scrubbed[k];
338
+ return scrubbed;
339
+ }
340
+
344
341
  function probeEnv(configDir?: string): Record<string, string> {
345
- const env: Record<string, string> = { ...process.env, TOKENMAXXING_PROBE: "1", [WRAP_DEPTH_ENV]: String(MAX_WRAP_DEPTH) };
346
- for (const k of CRED_ENV_OVERRIDES) delete env[k];
342
+ const env = scrubCredentialEnv({ ...process.env, TOKENMAXXING_PROBE: "1", [WRAP_DEPTH_ENV]: String(MAX_WRAP_DEPTH) });
347
343
  if (configDir) env.CLAUDE_CONFIG_DIR = configDir;
348
344
  return env;
349
345
  }
350
346
 
351
- export async function probeUsage(configDir?: string, now = Date.now(), opts: { retries?: number; killMs?: number; signal?: AbortSignal } = {}): Promise<FullUsage | null> {
347
+ export async function probeUsage(configDir?: string, now = Date.now(), opts: { retries?: number } = {}): Promise<FullUsage | null> {
352
348
  const env = probeEnv(configDir);
353
349
 
354
350
  for (let attempt = 0; ; attempt++) {
355
- const full = await probeUsageOnce(env, now, opts.killMs ?? PROBE_KILL_MS, opts.signal);
351
+ const full = await probeUsageOnce(env, now);
356
352
  if (full) return full;
357
- if (attempt >= (opts.retries ?? PROBE_RETRY_DELAYS_MS.length) || opts.signal?.aborted) {
353
+ if (attempt >= (opts.retries ?? PROBE_RETRY_DELAYS_MS.length)) {
358
354
  log("usage.probe_gave_up", { attempts: attempt + 1 });
359
355
  return null;
360
356
  }
package/src/main.ts CHANGED
@@ -25,6 +25,9 @@ import { cmdRename } from "./cli/rename.ts";
25
25
  import { cmdSwitch } from "./cli/switch.ts";
26
26
  import { cmdCheck } from "./cli/check.ts";
27
27
  import { cmdConfig } from "./cli/config.ts";
28
+ import { cmdSetupToken } from "./cli/setuptoken.ts";
29
+ import { cmdCloudRun } from "./cli/cloudrun.ts";
30
+ import { cmdCursorInit } from "./cli/cursorinit.ts";
28
31
  import { timerDeactivationHint, uninstallSupervisor } from "./lib/install.ts";
29
32
  import { c, emitError, emitJson } from "./cli/render.ts";
30
33
 
@@ -51,8 +54,11 @@ function printHelp(): void {
51
54
  ${c.cyan("tokenmaxxing rename")} [--codex] <sel> <label>
52
55
  ${c.cyan("tokenmaxxing rm")} [--codex] <sel>
53
56
  ${c.cyan("tokenmaxxing uninstall")} remove supervisor + settings entries
57
+ ${c.cyan("tokenmaxxing setup-token")} [--print | rm <label|uuid>] Cursor Cloud only: mint one \`claude setup-token\` per pooled account (browser sign-in each) and print the TOKENMAXXING_TOKENS secret value; ${c.cyan("--print")} prints the stored set, ${c.cyan("rm")} drops one
58
+ ${c.cyan("tokenmaxxing cursor init")} [dir] write the Claude relay subagent (.cursor/agents/claude.md) and .cursor/environment.json into a repo
59
+ ${c.cyan("tokenmaxxing cloud run")} [--session <id>] [--max-turns <n>] "<prompt>" on a Cursor Cloud VM: run claude -p on a setup token from TOKENMAXXING_TOKENS, rotate to the next token on a usage limit
54
60
 
55
- ${c.cyan("--json")} print one JSON document on stdout instead of text (status, ls, config, doctor, check, switch, rename, rm, uninstall; one per tick for watch); every document carries ${c.bold("ok")}, failures add ${c.bold("error")}
61
+ ${c.cyan("--json")} print one JSON document on stdout instead of text (status, ls, config, doctor, check, switch, rename, rm, uninstall, setup-token --print, cursor init, cloud run; one per tick for watch); every document carries ${c.bold("ok")}, failures add ${c.bold("error")}
56
62
 
57
63
  ${c.dim("(aliased as")} ${c.cyan("xx")}${c.dim(")")} - then just run ${c.bold("claude")} as always; it switches accounts near quota automatically.`);
58
64
  }
@@ -134,6 +140,17 @@ async function main(): Promise<number> {
134
140
  return args.includes("--codex") ? cmdCodexRm(rest[0], json) : cmdRm(rest[0], json);
135
141
  }
136
142
  case "rename": return cmdRename(args.slice(1), json);
143
+ case "setup-token": return cmdSetupToken(args.slice(1), json);
144
+ case "cursor": {
145
+ if (args[1] === "init") return cmdCursorInit(args.slice(2), json);
146
+ emitError({ json, message: "usage: tokenmaxxing cursor init [dir]" });
147
+ return 2;
148
+ }
149
+ case "cloud": {
150
+ if (args[1] === "run") return cmdCloudRun(args.slice(2), json);
151
+ emitError({ json, message: 'usage: tokenmaxxing cloud run [--session <id>] [--max-turns <n>] "<prompt>"' });
152
+ return 2;
153
+ }
137
154
  case "uninstall": {
138
155
  const out = uninstallSupervisor();
139
156
  const removed = [