tokenmaxxing 0.10.0 → 0.12.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 +2 -0
- package/README.md +26 -0
- package/package.json +5 -1
- package/src/cli/status.ts +5 -1
- package/src/cli/watch.ts +57 -0
- package/src/lib/usage.ts +1 -1
- package/src/main.ts +3 -0
- package/src/sdk.ts +110 -0
package/DESIGN.md
CHANGED
|
@@ -99,6 +99,8 @@ The decision engages at `five_hour >= 50%` (policy.greedySessionFloor): from the
|
|
|
99
99
|
|
|
100
100
|
**v2:** projected-threshold pre-emption; a `UserPromptSubmit` guard that respawns *before* a turn starts when already over; Windows.
|
|
101
101
|
|
|
102
|
+
**Shipped since (0.11.0):** a programmatic SDK surface (`src/sdk.ts`, the package's `exports["."]`) for pairing with the Claude Agent SDK - personal use across the owner's own pooled accounts. The Agent SDK reads credentials per subprocess spawn and has no statusLine, so the surface is boundary-driven: run the shared switch decision before a spawn (`ensureBestAccount`) and at Stop-hook turn boundaries (`stopHookCheck`), and hand the SDK a pinned real-claude path plus a full replacement env scrubbed of credential overrides (`pooledOptions`).
|
|
103
|
+
|
|
102
104
|
**Later:** Codex as a second pool; tool-agnostic picker.
|
|
103
105
|
|
|
104
106
|
**Non-goals:** an API/MITM proxy; reimplementing OAuth beyond the single refresh-grant call in the swap.
|
package/README.md
CHANGED
|
@@ -40,6 +40,7 @@ claude # use claude as always
|
|
|
40
40
|
| `tokenmaxxing ls` | list pooled accounts |
|
|
41
41
|
| `tokenmaxxing status` | accounts with 5h / weekly usage bars, active + exhausted-until-reset |
|
|
42
42
|
| `tokenmaxxing status --force` | additionally ping every account (one tiny haiku request each) so all 5h session timers start now, then sample fresh |
|
|
43
|
+
| `tokenmaxxing watch [seconds]` | live status: re-render every N seconds (default 120, floor 30; never pings) |
|
|
43
44
|
| `tokenmaxxing doctor` | verify the supervisor + settings entries survived |
|
|
44
45
|
| `tokenmaxxing rename <sel> <label>` · `rm <sel>` | manage the pool |
|
|
45
46
|
| `tokenmaxxing uninstall` | remove supervisor + settings entries (accounts/credentials kept) |
|
|
@@ -75,6 +76,31 @@ The **target** is chosen greedily off each account's cached windows: among usabl
|
|
|
75
76
|
|
|
76
77
|
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`).
|
|
77
78
|
|
|
79
|
+
## Pairing with the Claude Agent SDK
|
|
80
|
+
|
|
81
|
+
For agents you build on the [Claude Agent SDK](https://docs.claude.com/en/api/agent-sdk/overview) against **your own** pooled accounts, `tokenmaxxing` is importable as a library (your agent app must run under Bun: tokenmaxxing ships TypeScript source and uses `bun:ffi`):
|
|
82
|
+
|
|
83
|
+
```ts
|
|
84
|
+
import { query } from "@anthropic-ai/claude-agent-sdk";
|
|
85
|
+
import { ensureBestAccount, pooledOptions, stopHookCheck } from "tokenmaxxing";
|
|
86
|
+
|
|
87
|
+
await ensureBestAccount(); // run the switch decision before the spawn (swaps once it engages - see below)
|
|
88
|
+
|
|
89
|
+
for await (const message of query({
|
|
90
|
+
prompt: "...",
|
|
91
|
+
options: {
|
|
92
|
+
...pooledOptions(), // pinned real claude + scrubbed env -> the pooled live credential
|
|
93
|
+
hooks: { Stop: [{ hooks: [stopHookCheck] }] }, // re-decide at every turn boundary
|
|
94
|
+
},
|
|
95
|
+
})) {
|
|
96
|
+
// capture the session id from the init message if you want `resume` across swaps
|
|
97
|
+
}
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
The SDK reads credentials when it spawns the claude subprocess and has no statusLine, so none of the CLI-side supervisor machinery applies; the integration is boundary-driven instead. `ensureBestAccount()` runs the exact greedy decision the CLI hooks and timer run (screening bars, pace-pressure target, post-swap cooldown - all shared code); like them, it deliberately does nothing until the decision engages (the active session past `policy.greedySessionFloor`, or a bar crossed), so a fresh account rides instead of churning. `pooledOptions()` pins `pathToClaudeCodeExecutable` to the real claude binary and supplies a full replacement `env` with every ambient credential override (`ANTHROPIC_API_KEY`, `CLAUDE_CODE_OAUTH_TOKEN`, ...) scrubbed, so the subprocess resolves the pool's live credential and nothing else. The pooled surface requires the default Claude Code credential store: it fails fast if `CLAUDE_CONFIG_DIR` or `CLAUDE_SECURESTORAGE_CONFIG_DIR` is set in your app's environment, because a swap would write the live credential where those point while the spawned subprocess reads the default store. `stopHookCheck` re-runs the decision at turn boundaries; a swap it lands takes effect on the next subprocess spawn (it never yanks a mid-query token). If your app loads user settings (see the SDK's `settingSources`), the Stop hook `tokenmaxxing init` installed may already fire in SDK sessions too - `stopHookCheck` makes the check explicit and works when settings are restricted.
|
|
101
|
+
|
|
102
|
+
This is for pooling **your own** subscription accounts in agents you run yourself - the same personal-use posture as the CLI. Anthropic does not allow third-party products to offer claude.ai login or rate limits, including agents built on the Agent SDK; don't ship this surface to third parties.
|
|
103
|
+
|
|
78
104
|
## Honest limitations
|
|
79
105
|
|
|
80
106
|
- **One cold turn.** The first turn after resuming 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": "0.
|
|
3
|
+
"version": "0.12.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",
|
|
@@ -13,6 +13,10 @@
|
|
|
13
13
|
"bin": {
|
|
14
14
|
"tokenmaxxing": "./src/main.ts"
|
|
15
15
|
},
|
|
16
|
+
"exports": {
|
|
17
|
+
".": "./src/sdk.ts",
|
|
18
|
+
"./package.json": "./package.json"
|
|
19
|
+
},
|
|
16
20
|
"files": [
|
|
17
21
|
"src",
|
|
18
22
|
"README.md",
|
package/src/cli/status.ts
CHANGED
|
@@ -22,7 +22,10 @@ import { bar, c, fmtAgo, fmtReset } from "./render.ts";
|
|
|
22
22
|
import type { FullUsage } from "../lib/usage.ts";
|
|
23
23
|
import type { UsageWindow } from "../lib/types.ts";
|
|
24
24
|
|
|
25
|
-
|
|
25
|
+
/** `preRender` runs after sampling, right before the first output line: `watch`
|
|
26
|
+
* keeps the previous frame on screen through the multi-second sample and
|
|
27
|
+
* clears only when the fresh frame is ready to paint (watch(1) semantics). */
|
|
28
|
+
export async function cmdStatus(force = false, preRender?: () => void): Promise<number> {
|
|
26
29
|
let idx = loadAccounts();
|
|
27
30
|
const cfg = loadConfig();
|
|
28
31
|
const now = Date.now();
|
|
@@ -89,6 +92,7 @@ export async function cmdStatus(force = false): Promise<number> {
|
|
|
89
92
|
saveAccounts(idx);
|
|
90
93
|
});
|
|
91
94
|
|
|
95
|
+
preRender?.();
|
|
92
96
|
console.log(c.dim(`threshold 5h ${cfg.thresholds.session}% · week ${cfg.thresholds.weekly}% · ${idx.accounts.length} account(s)`));
|
|
93
97
|
console.log();
|
|
94
98
|
|
package/src/cli/watch.ts
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// `tokenmaxxing watch [seconds]`: native live status (issue #3) - re-render
|
|
2
|
+
// `status` on an interval instead of `watch -n 120 'tokenmaxxing status'`.
|
|
3
|
+
// Plain `status` per tick, never `--force`: a ping meters real quota and starts
|
|
4
|
+
// 5h session windows, so watching must stay free; each refresh costs only the
|
|
5
|
+
// parked accounts' `/usage` probes the one-shot status already does.
|
|
6
|
+
|
|
7
|
+
import { delay } from "es-toolkit";
|
|
8
|
+
import { loadAccounts } from "../lib/state.ts";
|
|
9
|
+
import { cmdStatus } from "./status.ts";
|
|
10
|
+
import { c } from "./render.ts";
|
|
11
|
+
|
|
12
|
+
const DEFAULT_INTERVAL_S = 120;
|
|
13
|
+
/** Floor: each tick spawns a `/usage` probe per parked account (seconds each,
|
|
14
|
+
* under the flock); anything faster than this just queues probes. */
|
|
15
|
+
const MIN_INTERVAL_S = 30;
|
|
16
|
+
/** Cap: past ~24.8 days the setTimeout ms overflow collapses the sleep to ~1ms
|
|
17
|
+
* and the loop runs hot; a day is already beyond any sane watch cadence. */
|
|
18
|
+
const MAX_INTERVAL_S = 86_400;
|
|
19
|
+
|
|
20
|
+
/** Seconds between refreshes, clamped; null when the argument is not a positive number. */
|
|
21
|
+
export function resolveWatchInterval(arg?: string): number | null {
|
|
22
|
+
if (arg === undefined) return DEFAULT_INTERVAL_S;
|
|
23
|
+
const n = Number(arg);
|
|
24
|
+
if (!Number.isFinite(n) || n <= 0) return null;
|
|
25
|
+
return Math.min(Math.max(n, MIN_INTERVAL_S), MAX_INTERVAL_S);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Home + clear screen + clear scrollback. Written only once the fresh frame is
|
|
29
|
+
* ready to paint (cmdStatus preRender), so the previous frame stays readable
|
|
30
|
+
* through the multi-second sample instead of blanking - watch(1) semantics. */
|
|
31
|
+
const CLEAR = "\x1b[H\x1b[2J\x1b[3J";
|
|
32
|
+
|
|
33
|
+
export async function cmdWatch(intervalArg?: string): Promise<number> {
|
|
34
|
+
const intervalS = resolveWatchInterval(intervalArg);
|
|
35
|
+
if (intervalS === null) {
|
|
36
|
+
console.error(c.red(`watch interval must be a positive number of seconds, got: ${intervalArg}`));
|
|
37
|
+
return 2;
|
|
38
|
+
}
|
|
39
|
+
if (loadAccounts().accounts.length === 0) return cmdStatus();
|
|
40
|
+
|
|
41
|
+
const paintHeader = () => {
|
|
42
|
+
process.stdout.write(process.stdout.isTTY ? CLEAR : "\n");
|
|
43
|
+
console.log(c.dim(`watch · every ${intervalS}s · ${new Date().toLocaleTimeString()} · ctrl-c to quit`));
|
|
44
|
+
};
|
|
45
|
+
while (true) {
|
|
46
|
+
// One failed tick (a mid-write ~/.claude.json read, a transient probe or
|
|
47
|
+
// lock error) must not kill an hours-long monitor: report it and keep the
|
|
48
|
+
// cadence, exactly like watch(1) showing a failing command's output.
|
|
49
|
+
try {
|
|
50
|
+
await cmdStatus(false, paintHeader);
|
|
51
|
+
} catch (e) {
|
|
52
|
+
paintHeader();
|
|
53
|
+
console.error(c.red(`status failed this tick: ${String((e as Error).message ?? e)}`));
|
|
54
|
+
}
|
|
55
|
+
await delay(intervalS * 1000);
|
|
56
|
+
}
|
|
57
|
+
}
|
package/src/lib/usage.ts
CHANGED
|
@@ -197,7 +197,7 @@ export function parseUsageText(text: string, now = Date.now()): UsageWindows | n
|
|
|
197
197
|
/** Env-var identity/credential overrides the claude binary honors BEFORE its
|
|
198
198
|
* keychain lookup (verified 2.1.205). A probe MUST scrub every one of these or
|
|
199
199
|
* an ambient value silently meters the wrong account. */
|
|
200
|
-
const CRED_ENV_OVERRIDES = [
|
|
200
|
+
export const CRED_ENV_OVERRIDES = [
|
|
201
201
|
"ANTHROPIC_API_KEY",
|
|
202
202
|
"ANTHROPIC_AUTH_TOKEN",
|
|
203
203
|
"CLAUDE_CODE_OAUTH_TOKEN",
|
package/src/main.ts
CHANGED
|
@@ -12,6 +12,7 @@ import { cmdInit } from "./cli/init.ts";
|
|
|
12
12
|
import { cmdAdd } from "./cli/add.ts";
|
|
13
13
|
import { cmdLs } from "./cli/ls.ts";
|
|
14
14
|
import { cmdStatus } from "./cli/status.ts";
|
|
15
|
+
import { cmdWatch } from "./cli/watch.ts";
|
|
15
16
|
import { cmdDoctor } from "./cli/doctor.ts";
|
|
16
17
|
import { cmdRm } from "./cli/rm.ts";
|
|
17
18
|
import { cmdRename } from "./cli/rename.ts";
|
|
@@ -31,6 +32,7 @@ function printHelp(): void {
|
|
|
31
32
|
${c.cyan("tokenmaxxing ls")} list pooled accounts
|
|
32
33
|
${c.cyan("tokenmaxxing status")} accounts with 5h / weekly / per-model usage bars
|
|
33
34
|
${c.cyan("tokenmaxxing status --force")} ping every account (one tiny haiku request each) so all 5h session timers start now, then sample fresh; ${c.cyan("xx --force")} works too
|
|
35
|
+
${c.cyan("tokenmaxxing watch")} [seconds] live status: re-render every N seconds (default 120, never pings)
|
|
34
36
|
${c.cyan("tokenmaxxing doctor")} verify the install is intact
|
|
35
37
|
${c.cyan("tokenmaxxing rename")} <sel> <label>
|
|
36
38
|
${c.cyan("tokenmaxxing rm")} <sel>
|
|
@@ -65,6 +67,7 @@ async function main(): Promise<number> {
|
|
|
65
67
|
case "add": return cmdAdd();
|
|
66
68
|
case "ls": return cmdLs();
|
|
67
69
|
case "status": return cmdStatus(args.includes("--force"));
|
|
70
|
+
case "watch": return cmdWatch(args[1]);
|
|
68
71
|
case "doctor": return cmdDoctor();
|
|
69
72
|
case "rm": return cmdRm(args[1]);
|
|
70
73
|
case "rename": return cmdRename(args[1], args[2]);
|
package/src/sdk.ts
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
// Programmatic surface for pairing tokenmaxxing with the Claude Code Agent SDK
|
|
2
|
+
// (personal use across your own pooled accounts - user decision 2026-07-16).
|
|
3
|
+
//
|
|
4
|
+
// The Agent SDK spawns a claude CLI subprocess per query() and that subprocess
|
|
5
|
+
// reads credentials at spawn time: no statusLine tee, no supervisor, no
|
|
6
|
+
// mid-query hot-swap. So the integration is boundary-driven - run the switch
|
|
7
|
+
// decision BEFORE a spawn so it lands on the best account, and again at
|
|
8
|
+
// Stop-hook turn boundaries so the NEXT spawn does; a running subprocess keeps
|
|
9
|
+
// its snapshotted token either way, which is exactly the clean-boundary
|
|
10
|
+
// semantics the CLI supervisor enforces with markers.
|
|
11
|
+
//
|
|
12
|
+
// Nothing here imports the Agent SDK: the helpers return plain values that
|
|
13
|
+
// spread structurally into its Options, so tokenmaxxing keeps its exact
|
|
14
|
+
// dependency set (zod, es-toolkit, ky).
|
|
15
|
+
|
|
16
|
+
import { z } from "zod";
|
|
17
|
+
import { MAX_WRAP_DEPTH, WRAP_DEPTH_ENV, resolveRealClaude } from "./lib/claudebin.ts";
|
|
18
|
+
import { evaluateAndMaybeSwap, type SwapDecision } from "./lib/decide.ts";
|
|
19
|
+
import { CRED_ENV_OVERRIDES } from "./lib/usage.ts";
|
|
20
|
+
import { log } from "./lib/log.ts";
|
|
21
|
+
|
|
22
|
+
export { evaluateAndMaybeSwap };
|
|
23
|
+
export type { SwapDecision };
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Run the same greedy pace-pressure decision the hooks and check timer run
|
|
27
|
+
* (never anticipatory: there is no supervisor to pause an SDK session, so a
|
|
28
|
+
* depleted pre-park would yank it onto a known-blocked account for nothing).
|
|
29
|
+
* Call it right before query() so the subprocess spawns on the best account.
|
|
30
|
+
*/
|
|
31
|
+
export async function ensureBestAccount(now = Date.now()): Promise<SwapDecision> {
|
|
32
|
+
return evaluateAndMaybeSwap(now, false);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** The pinned real claude binary, for Options.pathToClaudeCodeExecutable.
|
|
36
|
+
* Never the supervisor wrapper: an SDK subprocess is headless print mode, so
|
|
37
|
+
* the wrapper's respawn machinery buys nothing and only adds recursion risk. */
|
|
38
|
+
export function claudeExecutablePath(): string {
|
|
39
|
+
return resolveRealClaude();
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** The pooled surface requires the DEFAULT Claude Code credential store. An
|
|
43
|
+
* ambient config-dir override desyncs the two sides of a swap on Linux: the
|
|
44
|
+
* swap (running in THIS process) writes the live credential where these vars
|
|
45
|
+
* point (credDir() honors them), while the scrubbed subprocess reads the
|
|
46
|
+
* default store - so the subprocess silently runs on a stale or absent
|
|
47
|
+
* credential. Fail fast on both platforms rather than platform-split the
|
|
48
|
+
* behavior (adversarial review catch, 2026-07-16). */
|
|
49
|
+
const AMBIENT_STORE_VARS = ["CLAUDE_SECURESTORAGE_CONFIG_DIR", "CLAUDE_CONFIG_DIR"] as const;
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The env an SDK-spawned claude must run under to meter the POOLED live
|
|
53
|
+
* credential: every ambient credential override is scrubbed (claude honors
|
|
54
|
+
* them BEFORE its keychain/file lookup, so one inherited ANTHROPIC_API_KEY
|
|
55
|
+
* silently meters the wrong account), and the wrap depth is preset to the cap
|
|
56
|
+
* so a poisoned claudeBin pin that leads back into the tokenmaxxing wrapper
|
|
57
|
+
* aborts on first entry instead of fork-bombing.
|
|
58
|
+
*
|
|
59
|
+
* Returns a FULL environment, not a patch: the Agent SDK's Options.env
|
|
60
|
+
* REPLACES the subprocess env rather than merging over process.env (verified
|
|
61
|
+
* against the official TS reference 2026-07-16), which is what makes deleting
|
|
62
|
+
* keys from this copy effective.
|
|
63
|
+
*/
|
|
64
|
+
export function pooledSpawnEnv(): Record<string, string> {
|
|
65
|
+
for (const k of AMBIENT_STORE_VARS) {
|
|
66
|
+
if (process.env[k]) {
|
|
67
|
+
throw new Error(
|
|
68
|
+
`${k} is set: the pooled SDK surface requires the default Claude Code credential store (a swap writes the live credential where ${k} points, while the spawned subprocess reads the default store). Unset it in the process running tokenmaxxing.`,
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
const env: Record<string, string> = { ...process.env, [WRAP_DEPTH_ENV]: String(MAX_WRAP_DEPTH) };
|
|
73
|
+
for (const k of CRED_ENV_OVERRIDES) delete env[k];
|
|
74
|
+
return env;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const PooledOptionsSchema = z.object({
|
|
78
|
+
pathToClaudeCodeExecutable: z.string(),
|
|
79
|
+
env: z.record(z.string(), z.string()),
|
|
80
|
+
});
|
|
81
|
+
export type PooledOptions = z.infer<typeof PooledOptionsSchema>;
|
|
82
|
+
|
|
83
|
+
/** Options fragment to spread into the Agent SDK's Options. */
|
|
84
|
+
export function pooledOptions(): PooledOptions {
|
|
85
|
+
return PooledOptionsSchema.parse({
|
|
86
|
+
pathToClaudeCodeExecutable: claudeExecutablePath(),
|
|
87
|
+
env: pooledSpawnEnv(),
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Agent SDK Stop-hook callback (structurally matches HookCallback; the args
|
|
93
|
+
* are irrelevant here; `{}` is the documented no-op output). Runs the switch
|
|
94
|
+
* decision at the turn boundary; a swap landed here takes effect on the next
|
|
95
|
+
* subprocess spawn. Errors are caught LOUDLY (stderr + log), not rethrown:
|
|
96
|
+
* the SDK hooks reference states an unhandled exception can interrupt the
|
|
97
|
+
* agent (verified 2026-07-16), and aborting the caller's turn because a
|
|
98
|
+
* switch check failed costs more than riding out the current account. Call
|
|
99
|
+
* ensureBestAccount() directly where a broken pool should throw.
|
|
100
|
+
*/
|
|
101
|
+
export async function stopHookCheck(): Promise<Record<string, never>> {
|
|
102
|
+
try {
|
|
103
|
+
await evaluateAndMaybeSwap(Date.now(), false);
|
|
104
|
+
} catch (e) {
|
|
105
|
+
const err = String((e as Error).message ?? e);
|
|
106
|
+
console.error(`tokenmaxxing: switch check failed at turn boundary: ${err}`);
|
|
107
|
+
log("sdk.stop_error", { err });
|
|
108
|
+
}
|
|
109
|
+
return {};
|
|
110
|
+
}
|