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 +4 -5
- package/README.md +10 -4
- package/package.json +2 -2
- package/src/cli/check.ts +5 -1
- package/src/cli/cloudrun.ts +117 -0
- package/src/cli/cursorinit.ts +81 -0
- package/src/cli/doctor.ts +11 -1
- package/src/cli/rm.ts +10 -3
- package/src/cli/setuptoken.ts +160 -0
- package/src/lib/cloud.ts +176 -0
- package/src/lib/decide.ts +2 -46
- package/src/lib/oauth.ts +2 -1
- package/src/lib/paths.ts +5 -0
- package/src/lib/sample.ts +117 -35
- package/src/lib/setuptokens.ts +40 -0
- package/src/lib/swap.ts +0 -4
- package/src/lib/types.ts +1 -0
- package/src/lib/usage.ts +14 -18
- package/src/main.ts +18 -1
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)
|
|
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
|
-
-
|
|
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
|
-
|
|
|
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
|
-
-
|
|
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
|
|
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.
|
|
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.
|
|
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(
|
|
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 {
|
|
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
|
|
48
|
-
|
|
49
|
-
|
|
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
|
+
}
|
package/src/lib/cloud.ts
ADDED
|
@@ -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
|
|
8
|
-
import {
|
|
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
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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,
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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
|
|
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,
|
|
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
|
|
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(
|
|
172
|
+
await writeItem(isolatedTarget(dir), installed);
|
|
167
173
|
writeFileSync(join(dir, ".claude.json"), JSON.stringify({ oauthAccount: account.oauthAccount, hasCompletedOnboarding: true }));
|
|
168
|
-
|
|
169
|
-
|
|
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
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
351
|
+
const full = await probeUsageOnce(env, now);
|
|
356
352
|
if (full) return full;
|
|
357
|
-
if (attempt >= (opts.retries ?? PROBE_RETRY_DELAYS_MS.length)
|
|
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 = [
|