tokenmaxxing 1.21.0 → 1.24.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/DESIGN.md CHANGED
@@ -37,7 +37,7 @@ It is a process manager only - spawn with inherited stdio plus saved `stty -g` t
37
37
  - `bin/claude` - the supervisor.
38
38
  - Per-account **credentials** follow the platform's Claude Code store: macOS = login-keychain items `tokenmaxxing-cred-<accountUuid[:8]>` (never plaintext on disk); Linux = 0600 files `creds/tokenmaxxing-cred-<accountUuid[:8]>.json` (the same plaintext model claude itself uses - its Linux build has no keyring path at all, binary-verified 2.1.205). One `credstore` facade dispatches on a `{kind: keychain|file}` target; call sites never branch on platform.
39
39
 
40
- - A periodic check job (`com.tokenmaxxing.check` launchd agent on macOS, `tokenmaxxing-check.timer` systemd user timer on Linux) running `tokenmaxxing check --if-due` once per tick (`policy.checkIntervalMs`, 60s by default), self-paced up to five ticks from the live account's headroom: hooks alone miss long agentic turns, so the timer is the backstop that keeps switching engaged mid-turn.
40
+ - A periodic check job (`com.tokenmaxxing.check` launchd agent on macOS, `tokenmaxxing-check.timer` systemd user timer on Linux) running `tokenmaxxing check` once per tick (`policy.checkIntervalMs`, 60s by default): hooks alone miss long agentic turns, so the timer is the backstop that keeps switching engaged mid-turn.
41
41
 
42
42
  The switching path runs no long-lived daemon. The statusline pushes usage. Hooks and the supervisor react. The periodic check job above is the only recurring process. The `claude` binary and `~/.claude` layout are untouched.
43
43
 
@@ -72,7 +72,7 @@ Each terminal ran the supervisor, so each has its own child `claude` and its own
72
72
 
73
73
  - **`tokenmaxxing init` imports the account you're already on - automatically, no prompts, no re-login.** It reads the live `Claude Code-credentials` keychain blob plus the `oauthAccount` object in `~/.claude.json` (email, `organizationUuid`, `accountUuid`, plan tier) and writes them as **account #1** into tokenmaxxing's store (`tokenmaxxing-cred-<accountUuid[:8]>` + an `accounts.json` index entry). Nothing about your current session changes - that account stays active; it's now just also a registered pool member. After this one command you already have a working (single-account) pool. `init` also installs the supervisor + the four settings entries.
74
74
  - If the current auth is API-key mode (`ANTHROPIC_API_KEY`/`apiKeyHelper`) rather than a subscription `/login`, there's no quota-poolable subscription credential to import - `init` says so and points you to `/login` first (per-token API billing isn't what tokenmaxxing pools).
75
- - **`tokenmaxxing add`** - registers *additional* accounts: logs one in via a throwaway `CLAUDE_CONFIG_DIR=~/.config/tokenmaxxing/onboard` (your primary login untouched), harvests it into the store, deletes the temp dir + its namespaced item. `CLAUDE_CONFIG_DIR` is SET by tokenmaxxing only for throwaway isolated stores like this one: the same harvest serves `auth`, and parked-account `/usage` sampling probes under `sample/<item>` the same way; an AMBIENT `CLAUDE_CONFIG_DIR` is refused by every CLI command and by the SDK's pooledSpawnEnv.
75
+ - **`tokenmaxxing add`** - registers *additional* accounts: logs one in via a throwaway `CLAUDE_CONFIG_DIR=~/.config/tokenmaxxing/onboard` (your primary login untouched), harvests it into the store, deletes the temp dir + its namespaced item. `CLAUDE_CONFIG_DIR` is SET by tokenmaxxing only for throwaway isolated stores like this one: the same harvest serves `auth`, and parked-account `/usage` sampling probes under `sample/<item>` the same way; an AMBIENT `CLAUDE_CONFIG_DIR` is refused by every CLI command.
76
76
  - **`tokenmaxxing auth [sel | --all]`** - reauthenticates an *existing* pool member whose refresh token died (a needs-reauth account can never heal through a swap: the dead token is exactly what a swap would need). Same isolated-login harvest as `add`, but it states which email to sign in with and **requires the login to land on the target account** (harvested `accountUuid` must match, else nothing changes) - the credential write and the needs-reauth clear happen in one flock critical section so a concurrent swap's harvest cannot clobber the fresh backup. Bare `auth` lists the pool with emails and asks which; `--all` walks every flagged account one by one.
77
77
  - Both commands exercise `security` reads/writes interactively (where a macOS keychain ACL prompt is acceptable), so the first access never happens cold inside a headless hook.
78
78
 
@@ -118,7 +118,7 @@ The [wall policy](docs/content/docs/switching.mdx#the-wall) defines Layer 2 reco
118
118
 
119
119
  **v2:** projected-threshold pre-emption; a `UserPromptSubmit` guard that respawns *before* a turn starts when already over; Windows.
120
120
 
121
- **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`).
121
+ **Removed (1.23.0):** the programmatic SDK surface, the stdio MCP server, and the Agent Plugin that shipped from 0.11.0 through 1.22.0. The CLI, the hooks, and the supervisor are the only surfaces.
122
122
 
123
123
  **Later:** tool-agnostic picker.
124
124
 
package/README.md CHANGED
@@ -113,36 +113,10 @@ See [How switching decides](docs/content/docs/switching.mdx) for the policy and
113
113
  }
114
114
  ```
115
115
 
116
- `thresholds.session` is a ladder: the bar is the lowest rung some pooled account still clears, so the default `[90]` is one bar, while with `[50, 80, 95]` a seat past 50 hands off while a sibling is under 50, and the bar climbs to 80 and then 95 as the whole pool fills; `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; `greedySessionFloor` is the session-used % at which the greedy convergence engages; `switchModels` names the models whose per-model cap triggers a switch; `usagePollTtlMs` is how long a `/usage` per-model poll stays fresh; `maxWaitMs` bounds the depleted-pool countdown - a soonest reset further out than this does not pause the session (no respawn marker is written and the session simply keeps hitting its limit until an account recovers); `checkIntervalMs` is the periodic check tick (default 60s), which `init` writes into the timer and which every headroom band is a multiple of, one tick near a bar up to five with plenty of headroom (a depleted wait and the post-swap cooldown sleep to their own deadlines instead) - re-run `tokenmaxxing init` after changing it so the timer unit picks up the new tick.
116
+ `thresholds.session` is a ladder: the bar is the lowest rung some pooled account still clears, so the default `[90]` is one bar, while with `[50, 80, 95]` a seat past 50 hands off while a sibling is under 50, and the bar climbs to 80 and then 95 as the whole pool fills; `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; `greedySessionFloor` is the session-used % at which the greedy convergence engages; `switchModels` names the models whose per-model cap triggers a switch; `usagePollTtlMs` is how long a `/usage` per-model poll stays fresh; `maxWaitMs` bounds the depleted-pool countdown - a soonest reset further out than this does not pause the session (no respawn marker is written and the session simply keeps hitting its limit until an account recovers); `checkIntervalMs` is the periodic check tick (default 60s), which `init` writes into the timer - re-run `tokenmaxxing init` after changing it so the timer unit picks up the new tick.
117
117
 
118
118
  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`).
119
119
 
120
- ## Pairing with the Claude Agent SDK
121
-
122
- 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`):
123
-
124
- ```ts
125
- import { query } from "@anthropic-ai/claude-agent-sdk";
126
- import { ensureBestAccount, pooledOptions, stopHookCheck } from "tokenmaxxing";
127
-
128
- await ensureBestAccount();
129
-
130
- for await (const message of query({
131
- prompt: "...",
132
- options: {
133
- ...pooledOptions(),
134
- hooks: { Stop: [{ hooks: [stopHookCheck] }] },
135
- },
136
- })) {
137
- }
138
- ```
139
-
140
- The loop body receives the message stream; keep the session id from the init message if you want `resume` across swaps.
141
-
142
- 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.
143
-
144
- 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.
145
-
146
120
  ## Codex support
147
121
 
148
122
  The same pooling works for OpenAI's Codex CLI (your own ChatGPT-subscription accounts):
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tokenmaxxing",
3
- "version": "1.21.0",
3
+ "version": "1.24.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,13 +13,8 @@
13
13
  "bin": {
14
14
  "tokenmaxxing": "./src/main.ts"
15
15
  },
16
- "exports": {
17
- ".": "./src/sdk.ts",
18
- "./package.json": "./package.json"
19
- },
20
16
  "files": [
21
17
  "src",
22
- "agent-plugin",
23
18
  "README.md",
24
19
  "DESIGN.md"
25
20
  ],
@@ -41,7 +36,6 @@
41
36
  "typescript": "^5.6.0"
42
37
  },
43
38
  "dependencies": {
44
- "@modelcontextprotocol/sdk": "1.29.0",
45
39
  "es-toolkit": "^1.49.0",
46
40
  "ky": "^2.0.2",
47
41
  "zod": "^4.4.3"
package/src/cli/check.ts CHANGED
@@ -1,49 +1,33 @@
1
- import { checkDelayMs, evaluateAndMaybeSwap } from "../lib/decide.ts";
2
- import { readOAuthAccount } from "../lib/claudejson.ts";
1
+ import { evaluateAndMaybeSwap } from "../lib/decide.ts";
3
2
  import { log } from "../lib/log.ts";
4
- import { loadConfig, loadNextCheckDueAt, saveNextCheckDueAt } from "../lib/state.ts";
5
3
  import { c, emitError, emitJson, fmtReset } from "./render.ts";
6
4
 
7
- export async function cmdCheck(args: string[] = [], json = false): Promise<number> {
8
- const now = Date.now();
9
- let cfg;
5
+ export async function cmdCheck(json = false): Promise<number> {
10
6
  let d;
11
7
  try {
12
- cfg = loadConfig();
13
- const dueAt = args.includes("--if-due") ? loadNextCheckDueAt({ now, cfg }) : null;
14
- if (dueAt != null && now + cfg.policy.checkIntervalMs / 2 < dueAt) {
15
- if (json) emitJson({ ok: true, due: false, nextCheckAt: dueAt });
16
- else console.log(c.dim(`not due (${Math.ceil((dueAt - now) / 1000)}s)`));
17
- return 0;
18
- }
19
- d = await evaluateAndMaybeSwap(now);
8
+ d = await evaluateAndMaybeSwap(Date.now());
20
9
  } catch (e) {
21
10
  const detail = e instanceof Error ? e.message : String(e);
22
11
  log("check.error", { err: detail });
23
12
  emitError({ json, message: `check failed: ${detail}` });
24
13
  return 1;
25
14
  }
26
- const delayMs = checkDelayMs({ cfg, account: readOAuthAccount()?.accountUuid ?? null, now, decision: d });
27
- saveNextCheckDueAt({ dueAt: now + delayMs, ts: now });
28
15
  if (json) {
29
16
  emitJson({
30
17
  ok: true,
31
- due: true,
32
18
  swapped: d.swapped,
33
19
  account: d.account?.label ?? null,
34
20
  reason: d.reason,
35
21
  waitUntil: d.waitUntil ?? null,
36
- nextCheckAt: now + delayMs,
37
22
  });
38
23
  return 0;
39
24
  }
40
- const next = c.dim(`next in ${Math.round(delayMs / 1000)}s`);
41
25
  if (d.swapped && d.account) {
42
- console.log(`${c.green("↻")} switched to ${c.bold(d.account.label)} ${next}`);
26
+ console.log(`${c.green("↻")} switched to ${c.bold(d.account.label)}`);
43
27
  } else if (d.waitUntil !== undefined && d.account) {
44
- console.log(c.yellow(`all accounts at limit - staying on ${c.bold(d.account.label)} (${fmtReset(d.waitUntil)})`) + ` ${next}`);
28
+ console.log(c.yellow(`all accounts at limit - staying on ${c.bold(d.account.label)} (${fmtReset(d.waitUntil)})`));
45
29
  } else {
46
- console.log(c.dim(`no switch (${d.reason}) `) + next);
30
+ console.log(c.dim(`no switch (${d.reason})`));
47
31
  }
48
32
  return 0;
49
33
  }
package/src/lib/decide.ts CHANGED
@@ -2,11 +2,11 @@ import { maxBy } from "es-toolkit";
2
2
  import { z } from "zod";
3
3
  import { withLock } from "./lock.ts";
4
4
  import { paths } from "./paths.ts";
5
- import { MAX_CHECK_DELAY_TICKS, POST_SWAP_COOLDOWN_MS, maxCheckDelayMs, loadAccounts, loadConfig, loadDepletedWait, loadLastSwapAt, loadUsage, loadUsageSnapshot, loadModelUsage, saveAccounts, saveDepletedWait, saveModelUsage, writeUsage } from "./state.ts";
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
7
  import { chooseAndSwap, isSkippableSwapError, performSwap, type VerifyVerdict } from "./swap.ts";
8
8
  import { probeParkedUsage } from "./sample.ts";
9
- import { currentWins, effectiveBars, hardBars, isExhausted, nextWeeklyReset, pickBest, pickEarliestReset, sessionLadder, usableAt, type PickCtx } from "./picker.ts";
9
+ import { currentWins, effectiveBars, hardBars, isExhausted, nextWeeklyReset, pickBest, pickEarliestReset, usableAt, type PickCtx } from "./picker.ts";
10
10
  import { familyTokens, gatedFamilies, probeUsage, type EnforcedClass } from "./usage.ts";
11
11
  import { log } from "./log.ts";
12
12
  import { AccountSchema, ModelUsageStateSchema, UsageStateSchema, type Account, type Config, type EnforcedLimit, type ModelUsageState, type Thresholds, type UsageState, type UsageWindow } from "./types.ts";
@@ -413,38 +413,3 @@ export async function recordEnforcedLimit(input: { limit: EnforcedClass; account
413
413
  return { outcome: "stamped", resetsAt: limit.resetsAt };
414
414
  });
415
415
  }
416
-
417
- const STAGE_CEILING_TICKS = [MAX_CHECK_DELAY_TICKS, 3, 2];
418
-
419
- export function checkDelayMs(input: { cfg: Config; account: string | null; now: number; decision: SwapDecision }): number {
420
- const { cfg, account, now, decision } = input;
421
- const tick = cfg.policy.checkIntervalMs;
422
- const max = maxCheckDelayMs(cfg);
423
- if (decision.waitUntil !== undefined) return Math.min(max, Math.max(tick, decision.waitUntil - now));
424
- const swapAt = loadLastSwapAt();
425
- if (swapAt != null && now - swapAt < POST_SWAP_COOLDOWN_MS) return swapAt + POST_SWAP_COOLDOWN_MS - now;
426
- const snap = loadUsageSnapshot();
427
- if (!account || !snap || !usageFresh(snap.state, snap.at, account, cfg.policy.usagePollTtlMs, now)) return 3 * tick;
428
- const accounts = loadAccounts().accounts;
429
- const u = freshest(snap.state, snap.at, accounts.find((a) => a.accountUuid === account)) ?? { ...snap.state, ts: snap.at };
430
- const mu = loadModelUsage();
431
- const muSame = mu && mu.account === account ? mu : null;
432
- const families = gatedFamilies(u.model, cfg.policy.switchModels);
433
- const bars = effectiveBars(cfg, { accounts: overlayLive(accounts, u, muSame, account), now, switchFamilies: families });
434
- const heads = [
435
- bars.session - liveUsed({ window: u.fiveHour, windowMs: FIVE_HOURS_MS, sampledAt: u.ts, now }),
436
- bars.weekly - liveUsed({ window: u.sevenDay, windowMs: WEEK_MS, sampledAt: u.ts, now }),
437
- ];
438
- let capMissing = false;
439
- const capFresh = muSame != null && now - (muSame.sampledAt ?? muSame.ts) <= cfg.policy.usagePollTtlMs;
440
- for (const family of families) {
441
- const cap = muSame && capFresh ? capForFamily(muSame, family, now) : undefined;
442
- if (cap && muSame) heads.push(bars.weekly - liveUsed({ window: cap, windowMs: WEEK_MS, sampledAt: muSame.sampledAt ?? muSame.ts, now }));
443
- else capMissing = true;
444
- }
445
- const headroom = Math.min(...heads);
446
- const banded = headroom >= 40 ? max : headroom >= 20 ? 3 * tick : headroom >= 8 ? 2 * tick : tick;
447
- const stage = sessionLadder(cfg).indexOf(bars.session);
448
- const staged = Math.min(banded, (STAGE_CEILING_TICKS[stage] ?? 1) * tick);
449
- return capMissing ? Math.min(staged, 2 * tick) : staged;
450
- }
@@ -214,7 +214,7 @@ function installCheckTimer(intervalS: number): boolean {
214
214
  <plist version="1.0">
215
215
  <dict>
216
216
  <key>Label</key><string>${LAUNCHD_LABEL}</string>
217
- <key>ProgramArguments</key><array><string>${escape(installedBin())}</string><string>check</string><string>--if-due</string></array>
217
+ <key>ProgramArguments</key><array><string>${escape(installedBin())}</string><string>check</string></array>
218
218
  <key>StartInterval</key><integer>${intervalS}</integer>
219
219
  <key>StandardOutPath</key><string>/dev/null</string>
220
220
  <key>StandardErrorPath</key><string>${escape(join(paths.home, "check.stderr.log"))}</string>
@@ -229,7 +229,7 @@ function installCheckTimer(intervalS: number): boolean {
229
229
  return run(["launchctl", "bootstrap", domain, plist]) || checkTimerHealthy();
230
230
  }
231
231
 
232
- const exec = `"${installedBin().replaceAll("%", "%%")}" check --if-due`;
232
+ const exec = `"${installedBin().replaceAll("%", "%%")}" check`;
233
233
  writeFileAtomic(
234
234
  join(paths.systemdUserDir, "tokenmaxxing-check.service"),
235
235
  `[Unit]
package/src/lib/paths.ts CHANGED
@@ -20,7 +20,6 @@ export const paths = {
20
20
  modelUsageJson: join(TM_HOME, "model-usage.json"),
21
21
  lastSwapJson: join(TM_HOME, "lastswap.json"),
22
22
  depletedJson: join(TM_HOME, "depleted.json"),
23
- nextCheckJson: join(TM_HOME, "nextcheck.json"),
24
23
  respawnDir: join(TM_HOME, "respawn"),
25
24
  binDir: join(TM_HOME, "bin"),
26
25
  supervisorLink: join(TM_HOME, "bin", "claude"),
package/src/lib/state.ts CHANGED
@@ -8,7 +8,6 @@ import {
8
8
  ConfigSchema,
9
9
  LastSwapSchema,
10
10
  ModelUsageStateSchema,
11
- NextCheckSchema,
12
11
  SessionLadderSchema,
13
12
  UsageStateSchema,
14
13
  type AccountsIndex,
@@ -209,33 +208,8 @@ export function clearDepletedWait(): void {
209
208
  rmSync(paths.depletedJson, { force: true });
210
209
  }
211
210
 
212
- export const MAX_CHECK_DELAY_TICKS = 5;
213
211
  export const POST_SWAP_COOLDOWN_MS = 45_000;
214
212
 
215
- export function maxCheckDelayMs(cfg: Config): number {
216
- return MAX_CHECK_DELAY_TICKS * cfg.policy.checkIntervalMs;
217
- }
218
-
219
- export function loadNextCheckDueAt(input: { now: number; cfg: Config }): number | null {
220
- if (!existsSync(paths.nextCheckJson)) return null;
221
- let parsed;
222
- try {
223
- parsed = NextCheckSchema.safeParse(JSON.parse(readFileSync(paths.nextCheckJson, "utf8")));
224
- } catch {
225
- return null;
226
- }
227
- if (!parsed.success) return null;
228
- return parsed.data.dueAt - input.now > Math.max(maxCheckDelayMs(input.cfg), POST_SWAP_COOLDOWN_MS) ? null : parsed.data.dueAt;
229
- }
230
-
231
- export function saveNextCheckDueAt(input: { dueAt: number; ts: number }): void {
232
- writeFileAtomic(paths.nextCheckJson, JSON.stringify(NextCheckSchema.parse(input)));
233
- }
234
-
235
- export function clearNextCheck(): void {
236
- rmSync(paths.nextCheckJson, { force: true });
237
- }
238
-
239
213
  const USAGE_TS_REFRESH_MS = 10 * 60_000;
240
214
  const SAMPLED_AT_REFRESH_MS = 30_000;
241
215
 
package/src/lib/swap.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { z } from "zod";
2
- import { clearDepletedWait, clearNextCheck, clearUsageSnapshots, loadAccounts, saveAccounts, saveLastSwapAt } from "./state.ts";
2
+ import { clearDepletedWait, clearUsageSnapshots, loadAccounts, saveAccounts, saveLastSwapAt } from "./state.ts";
3
3
  import { readItem, writeItem, liveTarget, parkedTarget, claudeAiOauthOnly, mergeIntoLive } from "./credstore.ts";
4
4
  import { refreshCredential, isAccessTokenExpiring, isDeadCredential, fetchTokenIdentity, describeIdentity, IdentityUnavailableError, InvalidGrantError, RefreshRejectedError } from "./oauth.ts";
5
5
  import { swapOAuthAccount } from "./claudejson.ts";
@@ -155,7 +155,6 @@ export async function performSwap(target: Account): Promise<void> {
155
155
  saveAccounts(idx);
156
156
  clearUsageSnapshots();
157
157
  clearDepletedWait();
158
- clearNextCheck();
159
158
  saveLastSwapAt(Date.now());
160
159
  });
161
160
  log("swap.done", { account: target.accountUuid.slice(0, 8), email: target.email });
package/src/lib/types.ts CHANGED
@@ -85,8 +85,6 @@ export const AccountsIndexSchema = z.object({
85
85
  export const LastSwapSchema = z.object({ ts: z.number() });
86
86
  export type AccountsIndex = z.infer<typeof AccountsIndexSchema>;
87
87
 
88
- export const NextCheckSchema = z.object({ dueAt: z.number(), ts: z.number() });
89
-
90
88
  export const EnforcedLimitSchema = z.object({
91
89
  account: z.string(),
92
90
  family: z.string().nullable(),
package/src/main.ts CHANGED
@@ -140,7 +140,13 @@ async function main(): Promise<number> {
140
140
  const rest = args.slice(1).filter((a) => a !== "--codex");
141
141
  return args.includes("--codex") ? cmdCodexSwitch(rest[0], json) : cmdSwitch(rest[0], json);
142
142
  }
143
- case "check": return cmdCheck(args.slice(1), json);
143
+ case "check": {
144
+ if (args.length > 1) {
145
+ emitError({ json, message: `unknown check option: ${args[1]} (check takes no options; the timer runs a plain check every tick)` });
146
+ return 2;
147
+ }
148
+ return cmdCheck(json);
149
+ }
144
150
  case "config": return cmdConfig(args.slice(1), json);
145
151
  case "init": return args.includes("--codex") ? cmdCodexInit() : cmdInit();
146
152
  case "add": return args.includes("--codex") ? cmdCodexAdd() : cmdAdd();
@@ -1,7 +0,0 @@
1
- #!/usr/bin/env bun
2
- import { main } from "../../src/entries/mcp.ts";
3
-
4
- main().catch((e: unknown) => {
5
- console.error(e instanceof Error ? e.message : String(e));
6
- process.exit(1);
7
- });
@@ -1,10 +0,0 @@
1
- {
2
- "$schema": "https://agent-plugins.org/schemas/1.0.0/mcp.schema.json",
3
- "mcpServers": {
4
- "tokenmaxxing": {
5
- "type": "stdio",
6
- "command": "./bin/tokenmaxxing-mcp",
7
- "cwd": "${PLUGIN_ROOT}"
8
- }
9
- }
10
- }
@@ -1,20 +0,0 @@
1
- {
2
- "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
3
- "name": "tokenmaxxing",
4
- "version": "1.21.0",
5
- "description": "Pool Claude Code and Codex logins, switch on pace pressure, and expose safe ops tools to agent clients.",
6
- "author": {
7
- "name": "anaclumos",
8
- "url": "https://github.com/anaclumos"
9
- },
10
- "homepage": "https://github.com/anaclumos/tokenmaxxing",
11
- "repository": "https://github.com/anaclumos/tokenmaxxing",
12
- "license": "MIT",
13
- "keywords": [
14
- "claude",
15
- "codex",
16
- "quota",
17
- "account-switching",
18
- "mcp"
19
- ]
20
- }
@@ -1,23 +0,0 @@
1
- ---
2
- name: codex-pool
3
- description: Operate the Codex side of the pool (restart-is-switch, no Layer 2, never target a running sibling). Use for codex init/switch/status questions or MCP pool_switch with codex=true.
4
- ---
5
-
6
- # Codex pool
7
-
8
- ## Mechanics
9
-
10
- - No hot-swap: restart IS the switch (`codex resume <sid>`). A credential write takes effect on the next codex start.
11
- - Auto-switching needs trusted hooks (`/hooks`). Do not clobber the user's `notify` key in `config.toml`.
12
- - Refresh-token reuse is punished; a superseded token can kill the grant family. Persist every rotation immediately.
13
- - An account running in another supervised session is never a swap target and never sampler-refreshed. Parked does not mean idle.
14
- - Classify windows by DURATION, never by position. Some plans have no 5h window.
15
- - Layer 2 wall squeeze is Claude-only. Do not extend it to Codex.
16
-
17
- ## Agent actions
18
-
19
- - Read: `pool_ls` / `pool_status` (Codex section included when present).
20
- - Mutate: MCP `pool_switch` with `codex=true`, `confirm=true`, and `TOKENMAXXING_AGENT_MUTATIONS=1` after user approval.
21
- - Interactive `init --codex` / `add --codex` / reauth stay human-driven.
22
-
23
- See [references/codex.md](references/codex.md).
@@ -1,5 +0,0 @@
1
- # Codex references
2
-
3
- - `docs/content/docs/codex.mdx`
4
- - `src/lib/codexdecide.ts`, `src/lib/codexpick.ts`, `src/lib/codexswap.ts`
5
- - `.memory/cc-codex-auth-mechanics.md`
@@ -1,27 +0,0 @@
1
- ---
2
- name: credentials-hygiene
3
- description: Credential and identity rules for tokenmaxxing (fetchTokenIdentity account-uuid identity, flock, no blob compare, never print secrets). Use before any auth, swap, or credential-touching change.
4
- ---
5
-
6
- # Credentials hygiene
7
-
8
- ## Identity
9
-
10
- - Identity is the `accountUuid` that `fetchTokenIdentity` reports for a token (`GET /api/oauth/profile`), never the organization, never a stored label, and never a blob byte compare. Every seat of a Team plan shares one `organizationUuid`, so an org-keyed lookup collapses the seats onto one account. Two rotations of one account differ byte-for-byte.
11
- - Park a credential under its token's real owner. Commit the active label in the same critical section as credential writes.
12
- - Every live-store write goes through `withClaudeRefreshLock` (Claude). Near-expiry sessions can rotate into the live store at any moment.
13
- - A refresh rotation revokes the previous access token at once, so refreshing any copy of a credential that is live elsewhere kills those sessions' token.
14
-
15
- ## Isolation
16
-
17
- - Refuse ambient `CLAUDE_CONFIG_DIR` / `CLAUDE_SECURESTORAGE_CONFIG_DIR` in CLI, SDK, and MCP.
18
- - A missing namespaced keychain item never falls back to the live one.
19
- - Same accounts pooled on several hosts race on refresh (documented limitation).
20
-
21
- ## Agent hard stops
22
-
23
- - No tool may return keychain blobs, `.credentials.json`, `auth.json`, or OAuth access/refresh tokens.
24
- - Do not bypass Claude `/usage` with direct `GET /api/oauth/usage` (owner-rejected; 429s on the active session).
25
- - Prefer MCP read tools; never `cat` credential paths into the transcript.
26
-
27
- See [references/credentials.md](references/credentials.md).
@@ -1,6 +0,0 @@
1
- # Credential references
2
-
3
- - `docs/content/docs/credentials.mdx`
4
- - `DESIGN.md` sections on install and swap
5
- - `.memory/cc-codex-auth-mechanics.md`
6
- - `src/lib/credstore.ts`, `src/lib/oauth.ts`
@@ -1,26 +0,0 @@
1
- ---
2
- name: doctor-diagnostics
3
- description: Diagnose a broken or incomplete tokenmaxxing install (supervisor, hooks, timer, credential identity). Use when doctor fails, PATH looks wrong, or identity drift is suspected. Prefer the MCP doctor tool.
4
- ---
5
-
6
- # Doctor and diagnostics
7
-
8
- ## Prefer MCP
9
-
10
- Call `doctor`. It checks the supervisor wrapper, settings hooks, periodic timer, live/parked credential presence, and org identity match. Output is pass/fail plus labels.
11
-
12
- Also useful: `config_get` (no key) for effective config sources, and `pool_ls` for needs-reauth.
13
-
14
- ## Hard stops
15
-
16
- - Never dump keychain items, `.credentials.json`, `auth.json`, or OAuth tokens into chat.
17
- - Doctor never refreshes tokens. Expired access tokens show as unverifiable identity, not as a prompt to paste secrets.
18
- - Do not run `init` / `add` / `auth` from an agent session without the user present for interactive login.
19
-
20
- ## Common repairs (ask before mutating)
21
-
22
- - Missing wrapper or hooks: user runs `tokenmaxxing init` (or `init --codex`).
23
- - needs-reauth: user runs `tokenmaxxing auth <label>` (or `--all`).
24
- - PATH: `binDir` must precede the real claude.
25
-
26
- See [references/troubleshooting.md](references/troubleshooting.md).
@@ -1,5 +0,0 @@
1
- # Troubleshooting pointers
2
-
3
- - `docs/content/docs/troubleshooting.mdx`
4
- - `docs/content/docs/state-directory.mdx` for where state lives under `~/.config/tokenmaxxing` (or `TOKENMAXXING_HOME`)
5
- - Ambient `CLAUDE_CONFIG_DIR` / `CLAUDE_SECURESTORAGE_CONFIG_DIR` must be unset for CLI, SDK, and this MCP server
@@ -1,27 +0,0 @@
1
- ---
2
- name: pool-status
3
- description: Read the Claude and Codex account pool safely (list, usage bars, watch). Use when checking quota, which account is active, needs-reauth, or before any switch. Prefer MCP pool_ls and pool_status over raw shell.
4
- ---
5
-
6
- # Pool status (safe reads)
7
-
8
- ## Prefer MCP
9
-
10
- - `pool_ls` for labels, active marker, needs-reauth
11
- - `pool_status` for 5h / weekly / per-model bars (free `/usage` path)
12
-
13
- If MCP is unavailable, run `tokenmaxxing ls --json` or `tokenmaxxing status --json` (alias `xx`) and parse the document. Never add `--ping`.
14
-
15
- ## Hard stops
16
-
17
- - Do not run `status --ping` or bare `xx --ping`. That pings every account with a real request and opens each 5h window.
18
- - Do not print credential files, keychain blobs, or OAuth tokens. Labels and status only.
19
- - Do not kill sessions or supervisors to "free" an account.
20
-
21
- ## Notes
22
-
23
- - Active Claude usage often comes from the statusLine tee; parked accounts are probed in isolation.
24
- - `watch` re-renders status on an interval and never pings.
25
- - Hermetic agents: set `TOKENMAXXING_HOME` to a throwaway directory. That isolates state files only: `init`, `uninstall`, and the codex hook install still write settings.json, codex `hooks.json`, the shell rc, and the timer unit under `HOME`, so never run them from an agent.
26
-
27
- See [references/commands.md](references/commands.md).
@@ -1,9 +0,0 @@
1
- # Status and list commands
2
-
3
- - `tokenmaxxing` / `tokenmaxxing status`: sample and show bars; persists cached usage for the picker.
4
- - `tokenmaxxing status --ping [--count N]`: DENIED for agents without explicit user approval. Meters every account, or N random ones under `--count`.
5
- - `tokenmaxxing ls`: compact list.
6
- - `tokenmaxxing watch [seconds]`: live re-render (default 120), never `--ping`.
7
- - `--json` on any of these: one JSON document on stdout (`ok` mirrors the exit code); parse it instead of scraping the bars. `watch --json` prints one document per tick and never exits on its own.
8
-
9
- Docs: `docs/content/docs/commands.mdx`.
@@ -1,27 +0,0 @@
1
- ---
2
- name: safe-contribution
3
- description: Safely change and ship tokenmaxxing (verification without a test suite, PR window, Mac vs Linux skew, public-repo hygiene). Use when editing this repo, opening PRs, or verifying decision-path changes.
4
- ---
5
-
6
- # Safe contribution
7
-
8
- ## Verify
9
-
10
- - The repo carries no test code and no comments (owner ruling 2026-09-02). Never add tests, a test script, or comments.
11
- - Verify with `bun run typecheck`, then hermetic CLI runs: `TOKENMAXXING_HOME=/tmp/xx-test bun run src/main.ts ...` (for decision-path changes, write accounts.json, usage.json, config.json, and a claude.json under that root and run `check`; point `TOKENMAXXING_KEYCHAIN_SERVICE` and `TOKENMAXXING_KEYCHAIN_ACCOUNT` at throwaway names and `TOKENMAXXING_CLAUDE_JSON` at that claude.json, because the CLI resolves claude.json from HOME, not from `TOKENMAXXING_HOME`).
12
- - Ask before any run that meters real quota (`status --ping`, live-pool inference). Free `/usage` / plain status is fine.
13
-
14
- ## Ship
15
-
16
- - Work on a branch; bump `package.json` and `agent-plugin/plugin.json` to the same version in the same PR; open PR; wait the full review window; handle every review; merge; `gh release create v<version>`; verify npm publish.
17
- - A PR that changes no packed file skips the bump and the release, and merging it is shipping it. Check membership with `npm pack --dry-run`, not the `files` array: the tarball is `src`, `agent-plugin`, `README.md`, `DESIGN.md`, `LICENSE`, and `package.json`, the last two always packed though `files` never names them. So a `docs/`, `.memory/`, or AGENTS.md change is exempt, while a LICENSE or metadata change is not. Everything else, the full review window included, still applies.
18
- - Never push directly to main. npm trusted publishing is bound to workflow filename `ci.yml`.
19
-
20
- ## Machine gotchas
21
-
22
- - The owner's Mac may run this working tree live. Do not kill sessions or supervisors without asking.
23
- - Linux boxes often run an older npm global: compare installed version before "works on Mac not Linux" debugging.
24
- - Repo is PUBLIC: no Slack IDs, device info, or secrets in commits, PRs, docs, or `.memory`.
25
- - Do not reintroduce banned patterns listed in `AGENTS.md` (Stop text-sniff failsafe, rejected statusline formats, old-state shims).
26
-
27
- See [references/ship.md](references/ship.md).
@@ -1,5 +0,0 @@
1
- # Ship and testing references
2
-
3
- - `AGENTS.md` (Release and CI, Testing, Do not reintroduce)
4
- - `.memory/shipping-pr-based.md`
5
- - `.memory/review-window-is-mandatory-full-length.md`
@@ -1,34 +0,0 @@
1
- ---
2
- name: sdk-pairing
3
- description: Pair tokenmaxxing with the Claude Agent SDK (ensureBestAccount, pooledOptions, stopHookCheck). Use when building or debugging Bun agents that spawn Claude through the pooled credential store.
4
- ---
5
-
6
- # SDK pairing
7
-
8
- Import from the package root (`tokenmaxxing` → `src/sdk.ts`). Bun only.
9
-
10
- ```ts
11
- import { query } from "@anthropic-ai/claude-agent-sdk";
12
- import { ensureBestAccount, pooledOptions, stopHookCheck } from "tokenmaxxing";
13
-
14
- await ensureBestAccount();
15
- for await (const message of query({
16
- prompt: "...",
17
- options: {
18
- ...pooledOptions(),
19
- hooks: { Stop: [{ hooks: [stopHookCheck] }] },
20
- },
21
- })) {
22
- }
23
- ```
24
-
25
- The loop body receives the message stream; keep the session id from the init message for `resume` across swaps.
26
-
27
- ## Rules
28
-
29
- - Call `ensureBestAccount()` before each `query()` spawn (no mid-query hot-swap).
30
- - `pooledOptions()` pins the real claude binary and a full replacement env with credential overrides scrubbed. It throws if `CLAUDE_CONFIG_DIR` or `CLAUDE_SECURESTORAGE_CONFIG_DIR` is set.
31
- - `stopHookCheck` re-decides at turn boundaries; errors are swallowed so a broken check does not abort the turn.
32
- - Own-accounts only (see terms docs). Offering pooled subscription logins to third parties is a ToS problem.
33
-
34
- See [references/sdk.md](references/sdk.md).
@@ -1,6 +0,0 @@
1
- # SDK references
2
-
3
- - `src/sdk.ts` (self-documenting header)
4
- - `docs/content/docs/sdk.mdx`
5
- - `docs/content/docs/terms.mdx`
6
- - `.memory/agent-sdk-auth-surface.md`
@@ -1,34 +0,0 @@
1
- ---
2
- name: switching-policy
3
- description: Explain and apply tokenmaxxing switch policy (greedy vs hard path, pace pressure, Layer 2 wall squeeze Claude-only, model-family matching). Use before pool_switch, pool_check, or when changing thresholds.
4
- ---
5
-
6
- # Switching policy
7
-
8
- ## Vocabulary
9
-
10
- - **Engaged**: session used >= `policy.greedySessionFloor` (default 80) or any screening bar crossed.
11
- - **GREEDY path** permits lateral moves only within the incumbent organization and retains `greedySwapMargin` hysteresis.
12
- - **HARD path** starts at a crossed screening bar and may cross organizations, with Layer 2 as its fallback.
13
- - **Organization preference** requires measured headroom in session, aggregate weekly and gated model windows under the [policy](references/policy.md).
14
- - **Verification** attempts at most two stale candidates per evaluation, with a 12-second deadline covering identity reads and the CLI for each candidate.
15
- - **Probe results** are persisted and re-ranked, while screening exhaustion remains eligible for the wall squeeze and reset selection.
16
- - **Unavailable verification** leaves cached figures in force, and verification never spends a parked refresh grant.
17
- - **Manual switch** retains pure pace ranking with no organization preference or incumbent margin.
18
- - **Pace pressure**: remaining weekly percent / time to weekly reset (highest first). Not most-remaining.
19
- - **Effective bars**: `effectiveBars(cfg, pool)` = the active rung of the 5h ladder (`thresholds.session`, default `[90]`, a single rung: the lowest rung some pooled account, the current one included, still clears) and the weekly bar, each minus `policy.projectionMargin`. Trigger and screening must share these bars or swaps ping-pong. Codex reads `terminalBars(cfg)`, the top rung only. The check cadence is capped one band per rung climbed, and every band is a multiple of the tick `policy.checkIntervalMs` (default 60000).
20
-
21
- ## Layer 2 (Claude only)
22
-
23
- When the hard path finds no usable target, judge against the wall (`hardThresholds` minus margin). Under-wall seat HOLDS; walled seat swaps to best under-wall sibling. Codex has no Layer 2 last-drop swap (cannot hot-adopt).
24
-
25
- ## Model matching
26
-
27
- Match model families by exact token after splitting id/display on spaces, dots, and hyphens (`familyTokens` / `matchedFamily` in `src/lib/usage.ts`). Never exact full display strings (names drift: "Fable" / "Fable 5"). Unmeasured usage is unknown and ranks last, never 0 / first. Per-model weekly caps: Sonnet and Fable exist; only Fable gates a switch by default (`policy.switchModels`).
28
-
29
- ## Agent actions
30
-
31
- - Explain with this skill; mutate only via MCP `pool_switch` / `pool_check` with user approval, `confirm=true`, and `TOKENMAXXING_AGENT_MUTATIONS=1`.
32
- - Do not reintroduce Stop-hook text-sniffing limit failsafes.
33
-
34
- See [references/policy.md](references/policy.md).
@@ -1,9 +0,0 @@
1
- # Policy sources
2
-
3
- - `docs/content/docs/switching.mdx`
4
- - `src/lib/decide.ts`, `src/lib/picker.ts`
5
- - `.cursor/rules/switch-verification.md`
6
-
7
- Automatic organization preference requires session usage below `greedySessionFloor` and more than `max(1, session bar - greedySessionFloor)` points below every applicable bar.
8
-
9
- The exact policy, defaults, verification limits and manual-switch behavior live in `docs/content/docs/switching.mdx`.
@@ -1,277 +0,0 @@
1
- import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
- import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
3
- import { z } from "zod";
4
- import { readFileSync } from "node:fs";
5
- import { join } from "node:path";
6
- import { cmdLs } from "../cli/ls.ts";
7
- import { cmdStatus } from "../cli/status.ts";
8
- import { cmdDoctor } from "../cli/doctor.ts";
9
- import { cmdConfig } from "../cli/config.ts";
10
- import { cmdSwitch } from "../cli/switch.ts";
11
- import { cmdCodexSwitch } from "../cli/codexswitch.ts";
12
- import { cmdCheck } from "../cli/check.ts";
13
-
14
- const MUTATIONS_ENV = "TOKENMAXXING_AGENT_MUTATIONS";
15
- const PACKAGE_ROOT = join(import.meta.dir, "../..");
16
-
17
- function packageVersion(): string {
18
- try {
19
- const raw = JSON.parse(readFileSync(join(PACKAGE_ROOT, "package.json"), "utf8")) as { version?: string };
20
- return raw.version ?? "0.0.0";
21
- } catch {
22
- return "0.0.0";
23
- }
24
- }
25
-
26
- export function refuseAmbientStoreEnv(): string | null {
27
- const nonEmpty = (v: string | undefined) => (v != null && v !== "" ? v : null);
28
- return nonEmpty(process.env.CLAUDE_SECURESTORAGE_CONFIG_DIR) ?? nonEmpty(process.env.CLAUDE_CONFIG_DIR);
29
- }
30
-
31
- export function scrubSecrets(text: string): string {
32
- return text
33
- .replace(/\b(Bearer\s+)[A-Za-z0-9._\-+/=]+/gi, "$1[redacted]")
34
- .replace(/\b(sk-ant-[A-Za-z0-9_-]+)\b/g, "[redacted]")
35
- .replace(/\b(accessToken|refreshToken|claudeAiOauth)\b\s*[:=]\s*["']?[^"'}\s,]+/gi, "$1=[redacted]");
36
- }
37
-
38
- type CaptureResult = { code: number; stdout: string; stderr: string };
39
-
40
- let captureChain: Promise<unknown> = Promise.resolve();
41
-
42
- export async function captureCli(run: () => number | Promise<number>): Promise<CaptureResult> {
43
- const job = async (): Promise<CaptureResult> => {
44
- const out: string[] = [];
45
- const err: string[] = [];
46
- const joinArgs = (args: unknown[]) => args.map((a) => (typeof a === "string" ? a : String(a))).join(" ");
47
- const log = console.log;
48
- const error = console.error;
49
- console.log = (...args: unknown[]) => { out.push(joinArgs(args)); };
50
- console.error = (...args: unknown[]) => { err.push(joinArgs(args)); };
51
- try {
52
- const code = await run();
53
- return { code, stdout: out.join("\n"), stderr: err.join("\n") };
54
- } finally {
55
- console.log = log;
56
- console.error = error;
57
- }
58
- };
59
- const next = captureChain.then(job, job);
60
- captureChain = next.then(
61
- () => undefined,
62
- () => undefined,
63
- );
64
- return next;
65
- }
66
-
67
- function textResult(input: { text: string; isError?: boolean }) {
68
- return {
69
- content: [{ type: "text" as const, text: scrubSecrets(input.text) }],
70
- ...(input.isError ? { isError: true } : {}),
71
- };
72
- }
73
-
74
- function formatCapture(cap: CaptureResult): string {
75
- const parts: string[] = [];
76
- if (cap.stdout) parts.push(cap.stdout);
77
- if (cap.stderr) parts.push(cap.stderr);
78
- parts.push(`exit ${cap.code}`);
79
- return parts.join("\n").trimEnd();
80
- }
81
-
82
- export function mutationsEnabled(): boolean {
83
- return process.env[MUTATIONS_ENV] === "1";
84
- }
85
-
86
- function mutationDenied(confirm: boolean): string | null {
87
- if (!confirm) {
88
- return `Mutating tools require confirm=true. Also set ${MUTATIONS_ENV}=1 in the MCP server environment after the user approves.`;
89
- }
90
- if (!mutationsEnabled()) {
91
- return `Mutations are disabled. Set ${MUTATIONS_ENV}=1 in the MCP server environment only after the user explicitly approves a pool mutation.`;
92
- }
93
- return null;
94
- }
95
-
96
- const HELP_TEXT = `tokenmaxxing agent MCP
97
-
98
- Read tools (always available):
99
- pool_ls list pooled Claude and Codex accounts (labels/status only)
100
- pool_status sample usage bars (free /usage path; never --ping)
101
- doctor verify install health (labels/status only)
102
- config_get read effective config, or one key when provided
103
- help this catalog
104
-
105
- Mutating tools (confirm=true AND ${MUTATIONS_ENV}=1):
106
- pool_switch Claude greedy/forced switch, or Codex when codex=true
107
- pool_check one evaluate-and-maybe-swap pass
108
- config_set write a config.json override
109
- config_unset remove a config.json override
110
-
111
- Hard deny (no tools):
112
- status --ping / metered pings
113
- init / add / auth / rm / uninstall
114
- credential blobs or token values
115
- killing sessions or supervisors
116
-
117
- Prefer these tools over raw shell for pool ops. Honor TOKENMAXXING_HOME for hermetic use.
118
- `;
119
-
120
- export function createTokenmaxxingMcpServer(): McpServer {
121
- const server = new McpServer({
122
- name: "tokenmaxxing",
123
- version: packageVersion(),
124
- });
125
-
126
- server.registerTool(
127
- "help",
128
- {
129
- description: "Catalog of tokenmaxxing MCP tools and hard safety rules. Use when deciding which pool tool to call.",
130
- inputSchema: {},
131
- },
132
- async () => textResult({ text: HELP_TEXT }),
133
- );
134
-
135
- server.registerTool(
136
- "pool_ls",
137
- {
138
- description: "List pooled Claude and Codex accounts with active and needs-reauth flags. Labels and status only; never credential material.",
139
- inputSchema: {},
140
- },
141
- async () => {
142
- const cap = await captureCli(() => cmdLs());
143
- return textResult({ text: formatCapture(cap), isError: cap.code !== 0 });
144
- },
145
- );
146
-
147
- server.registerTool(
148
- "pool_status",
149
- {
150
- description: "Show pool usage bars via the free /usage path. Never opens 5h windows. Do not request --ping; that tool does not exist.",
151
- inputSchema: {},
152
- },
153
- async () => {
154
- const cap = await captureCli(() => cmdStatus());
155
- return textResult({ text: formatCapture(cap), isError: cap.code !== 0 });
156
- },
157
- );
158
-
159
- server.registerTool(
160
- "doctor",
161
- {
162
- description: "Verify supervisor, hooks, timer, and credential identity health. Reports labels and pass/fail only; never returns credential blobs.",
163
- inputSchema: {},
164
- },
165
- async () => {
166
- const cap = await captureCli(() => cmdDoctor());
167
- return textResult({ text: formatCapture(cap), isError: cap.code !== 0 });
168
- },
169
- );
170
-
171
- server.registerTool(
172
- "config_get",
173
- {
174
- description: "Read effective config with sources, or one dotted key when key is set (e.g. thresholds.session).",
175
- inputSchema: {
176
- key: z.string().optional().describe("Optional dotted config key; omit for the full effective table"),
177
- },
178
- },
179
- async ({ key }) => {
180
- const args = key ? ["get", key] : [];
181
- const cap = await captureCli(() => cmdConfig(args));
182
- return textResult({ text: formatCapture(cap), isError: cap.code !== 0 });
183
- },
184
- );
185
-
186
- server.registerTool(
187
- "pool_switch",
188
- {
189
- description: "Switch the Claude pool (or Codex when codex=true). Requires confirm=true and TOKENMAXXING_AGENT_MUTATIONS=1. Hot-swaps live Claude; Codex takes effect on next start.",
190
- inputSchema: {
191
- confirm: z.boolean().describe("Must be true after the user approves the mutation"),
192
- selector: z.string().optional().describe("Optional account selector; omit for greedy best"),
193
- codex: z.boolean().optional().describe("When true, run the Codex pool switch instead"),
194
- },
195
- },
196
- async ({ confirm, selector, codex }) => {
197
- const denied = mutationDenied(confirm);
198
- if (denied) return textResult({ text: denied, isError: true });
199
- const cap = await captureCli(() => (codex ? cmdCodexSwitch(selector) : cmdSwitch(selector)));
200
- return textResult({ text: formatCapture(cap), isError: cap.code !== 0 });
201
- },
202
- );
203
-
204
- server.registerTool(
205
- "pool_check",
206
- {
207
- description: "Run one evaluate-and-maybe-swap pass (the periodic timer path). Requires confirm=true and TOKENMAXXING_AGENT_MUTATIONS=1.",
208
- inputSchema: {
209
- confirm: z.boolean().describe("Must be true after the user approves the mutation"),
210
- },
211
- },
212
- async ({ confirm }) => {
213
- const denied = mutationDenied(confirm);
214
- if (denied) return textResult({ text: denied, isError: true });
215
- const cap = await captureCli(() => cmdCheck());
216
- return textResult({ text: formatCapture(cap), isError: cap.code !== 0 });
217
- },
218
- );
219
-
220
- server.registerTool(
221
- "config_set",
222
- {
223
- description: "Write a config.json override. Requires confirm=true and TOKENMAXXING_AGENT_MUTATIONS=1.",
224
- inputSchema: {
225
- confirm: z.boolean().describe("Must be true after the user approves the mutation"),
226
- key: z.string().describe("Dotted config key"),
227
- value: z.string().describe("JSON or literal string value"),
228
- },
229
- },
230
- async ({ confirm, key, value }) => {
231
- const denied = mutationDenied(confirm);
232
- if (denied) return textResult({ text: denied, isError: true });
233
- const cap = await captureCli(() => cmdConfig(["set", key, value]));
234
- return textResult({ text: formatCapture(cap), isError: cap.code !== 0 });
235
- },
236
- );
237
-
238
- server.registerTool(
239
- "config_unset",
240
- {
241
- description: "Remove a config.json override. Requires confirm=true and TOKENMAXXING_AGENT_MUTATIONS=1.",
242
- inputSchema: {
243
- confirm: z.boolean().describe("Must be true after the user approves the mutation"),
244
- key: z.string().describe("Dotted config key"),
245
- },
246
- },
247
- async ({ confirm, key }) => {
248
- const denied = mutationDenied(confirm);
249
- if (denied) return textResult({ text: denied, isError: true });
250
- const cap = await captureCli(() => cmdConfig(["unset", key]));
251
- return textResult({ text: formatCapture(cap), isError: cap.code !== 0 });
252
- },
253
- );
254
-
255
- return server;
256
- }
257
-
258
- export async function main(): Promise<void> {
259
- const ambient = refuseAmbientStoreEnv();
260
- if (ambient != null) {
261
- console.error(
262
- `CLAUDE_CONFIG_DIR / CLAUDE_SECURESTORAGE_CONFIG_DIR is set (${ambient}): the pooled MCP surface requires the default Claude Code credential store. Unset it and retry.`,
263
- );
264
- process.exit(1);
265
- }
266
- const server = createTokenmaxxingMcpServer();
267
- const transport = new StdioServerTransport();
268
- await server.connect(transport);
269
- console.error("tokenmaxxing MCP server running on stdio");
270
- }
271
-
272
- if (import.meta.main) {
273
- main().catch((e) => {
274
- console.error(e instanceof Error ? e.message : String(e));
275
- process.exit(1);
276
- });
277
- }
package/src/sdk.ts DELETED
@@ -1,55 +0,0 @@
1
- import { z } from "zod";
2
- import { UNMANAGED_ENV, resolveRealClaude } from "./lib/claudebin.ts";
3
- import { evaluateAndMaybeSwap, type SwapDecision } from "./lib/decide.ts";
4
- import { CRED_ENV_OVERRIDES } from "./lib/usage.ts";
5
- import { log } from "./lib/log.ts";
6
-
7
- export { evaluateAndMaybeSwap };
8
- export type { SwapDecision };
9
-
10
- export async function ensureBestAccount(now = Date.now()): Promise<SwapDecision> {
11
- return evaluateAndMaybeSwap(now, false);
12
- }
13
-
14
- export function claudeExecutablePath(): string {
15
- return resolveRealClaude();
16
- }
17
-
18
- const AMBIENT_STORE_VARS = ["CLAUDE_SECURESTORAGE_CONFIG_DIR", "CLAUDE_CONFIG_DIR"] as const;
19
-
20
- export function pooledSpawnEnv(): Record<string, string> {
21
- for (const k of AMBIENT_STORE_VARS) {
22
- if (process.env[k]) {
23
- throw new Error(
24
- `${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.`,
25
- );
26
- }
27
- }
28
- const env: Record<string, string> = { ...process.env, [UNMANAGED_ENV]: "1" };
29
- for (const k of CRED_ENV_OVERRIDES) delete env[k];
30
- return env;
31
- }
32
-
33
- const PooledOptionsSchema = z.object({
34
- pathToClaudeCodeExecutable: z.string(),
35
- env: z.record(z.string(), z.string()),
36
- });
37
- export type PooledOptions = z.infer<typeof PooledOptionsSchema>;
38
-
39
- export function pooledOptions(): PooledOptions {
40
- return PooledOptionsSchema.parse({
41
- pathToClaudeCodeExecutable: claudeExecutablePath(),
42
- env: pooledSpawnEnv(),
43
- });
44
- }
45
-
46
- export async function stopHookCheck(): Promise<Record<string, never>> {
47
- try {
48
- await evaluateAndMaybeSwap(Date.now(), false);
49
- } catch (e) {
50
- const err = e instanceof Error ? e.message : String(e);
51
- console.error(`tokenmaxxing: switch check failed at turn boundary: ${err}`);
52
- log("sdk.stop_error", { err });
53
- }
54
- return {};
55
- }