tokenmaxxing 1.13.1 → 1.15.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` every 60s, self-paced up to 300s 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 --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.
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
 
package/README.md CHANGED
@@ -70,7 +70,7 @@ claude # use claude as always
70
70
  | `tokenmaxxing switch --codex [sel]` | switch the codex pool (takes effect on the next codex start) |
71
71
  | `tokenmaxxing ls` | list pooled accounts |
72
72
  | `tokenmaxxing status` | accounts with 5h / weekly usage bars, active + exhausted-until-reset |
73
- | `tokenmaxxing status --force` | additionally ping every account (one tiny haiku request each) so all 5h session timers start now, then sample fresh |
73
+ | `tokenmaxxing status --ping [--count N]` | additionally ping every account (one tiny haiku request each) so all 5h session timers start now, then sample fresh; `--count N` pings N randomly picked accounts instead, to stagger the resets |
74
74
  | `tokenmaxxing watch [seconds]` | live status: re-render every N seconds (default 120, floor 30; never pings) |
75
75
  | `tokenmaxxing config` | effective config with sources; `get`/`set`/`unset` dotted keys, `tidy` prunes unknown keys |
76
76
  | `tokenmaxxing doctor` | verify the supervisor + settings entries survived |
@@ -100,12 +100,13 @@ The **target** is chosen greedily off each account's cached windows: among usabl
100
100
  "projectionMargin": 0,
101
101
  "greedySessionFloor": 80,
102
102
  "switchModels": ["fable"],
103
- "usagePollTtlMs": 90000
103
+ "usagePollTtlMs": 90000,
104
+ "checkIntervalMs": 60000
104
105
  }
105
106
  }
106
107
  ```
107
108
 
108
- `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).
109
+ `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.
109
110
 
110
111
  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`).
111
112
 
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
3
3
  "name": "tokenmaxxing",
4
- "version": "1.13.1",
4
+ "version": "1.15.0",
5
5
  "description": "Pool Claude Code and Codex logins, switch on pace pressure, and expose safe ops tools to agent clients.",
6
6
  "author": {
7
7
  "name": "anaclumos",
@@ -10,18 +10,18 @@ description: Read the Claude and Codex account pool safely (list, usage bars, wa
10
10
  - `pool_ls` for labels, active marker, needs-reauth
11
11
  - `pool_status` for 5h / weekly / per-model bars (free `/usage` path)
12
12
 
13
- If MCP is unavailable, run `tokenmaxxing ls --json` or `tokenmaxxing status --json` (alias `xx`) and parse the document. Never add `--force`.
13
+ If MCP is unavailable, run `tokenmaxxing ls --json` or `tokenmaxxing status --json` (alias `xx`) and parse the document. Never add `--ping`.
14
14
 
15
15
  ## Hard stops
16
16
 
17
- - Do not run `status --force` or bare `xx --force`. That pings every account with a real request and opens each 5h window.
17
+ - Do not run `status --ping` or bare `xx --ping`. That pings every account with a real request and opens each 5h window.
18
18
  - Do not print credential files, keychain blobs, or OAuth tokens. Labels and status only.
19
19
  - Do not kill sessions or supervisors to "free" an account.
20
20
 
21
21
  ## Notes
22
22
 
23
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 force-pings.
24
+ - `watch` re-renders status on an interval and never pings.
25
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
26
 
27
27
  See [references/commands.md](references/commands.md).
@@ -1,9 +1,9 @@
1
1
  # Status and list commands
2
2
 
3
3
  - `tokenmaxxing` / `tokenmaxxing status`: sample and show bars; persists cached usage for the picker.
4
- - `tokenmaxxing status --force`: DENIED for agents without explicit user approval. Meters every account.
4
+ - `tokenmaxxing status --ping [--count N]`: DENIED for agents without explicit user approval. Meters every account, or N random ones under `--count`.
5
5
  - `tokenmaxxing ls`: compact list.
6
- - `tokenmaxxing watch [seconds]`: live re-render (default 120), never `--force`.
6
+ - `tokenmaxxing watch [seconds]`: live re-render (default 120), never `--ping`.
7
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
8
 
9
9
  Docs: `docs/content/docs/commands.mdx`.
@@ -9,11 +9,11 @@ description: Safely change and ship tokenmaxxing (verification without a test su
9
9
 
10
10
  - The repo carries no test code and no comments (owner ruling 2026-09-02). Never add tests, a test script, or comments.
11
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 --force`, live-pool inference). Free `/usage` / plain status is fine.
12
+ - Ask before any run that meters real quota (`status --ping`, live-pool inference). Free `/usage` / plain status is fine.
13
13
 
14
14
  ## Ship
15
15
 
16
- - Work on a branch; bump `package.json` in the same PR; open PR; wait the full review window; handle every review; merge; `gh release create v<version>`; verify npm publish.
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
17
  - Never push directly to main. npm trusted publishing is bound to workflow filename `ci.yml`.
18
18
 
19
19
  ## Machine gotchas
@@ -11,7 +11,7 @@ description: Explain and apply tokenmaxxing switch policy (greedy vs hard path,
11
11
  - **GREEDY path**: engaged but under every bar. Rank all accounts by pace pressure; keep seat on best-or-tie (`currentWins`); else swap to strictly better. Never depleted-waits or pre-parks. The automatic path adds incumbent hysteresis (`policy.greedySwapMargin`, default 0.15): hold the seat unless a parked account's pace pressure exceeds the current one's by more than that fraction, so near-tied accounts stop trading the seat and busting the per-org prompt cache. The margin gates lateral under-bar swaps only, never a crossed bar or the wall; bare `tokenmaxxing switch` uses margin 0 and always takes the strictly best account.
12
12
  - **HARD path**: a screening bar crossed. Swap to best usable target; if none, Layer 2 wall logic (Claude only).
13
13
  - **Pace pressure**: remaining weekly percent / time to weekly reset (highest first). Not most-remaining.
14
- - **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.
14
+ - **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).
15
15
 
16
16
  ## Layer 2 (Claude only)
17
17
 
@@ -4,4 +4,4 @@
4
4
  - `src/lib/decide.ts`, `src/lib/picker.ts`
5
5
  - `.memory/switch-policy-pace-pressure.md`
6
6
 
7
- Default screening bars: session ladder [90] (a single rung; add lower rungs such as [50, 80, 95] to hand off earlier) with the active rung resolved per pool, weekly 98. Engagement floor 80. Wall defaults 100. Cooldown 45s on the automatic path after a swap. Check cadence ceiling per rung: 300s, 180s, 120s, then the 60s floor.
7
+ Default screening bars: session ladder [90] (a single rung; add lower rungs such as [50, 80, 95] to hand off earlier) with the active rung resolved per pool, weekly 98. Engagement floor 80. Wall defaults 100. Cooldown 45s on the automatic path after a swap. Check tick `policy.checkIntervalMs` (default 60000, minimum 10000); `init` writes it into the timer unit. Check cadence ceiling per rung: five ticks, three, two, then the one-tick floor, with the headroom bands at the same multiples (40 points sleeps five ticks, 20 three, 8 two, less every tick).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tokenmaxxing",
3
- "version": "1.13.1",
3
+ "version": "1.15.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",
package/src/cli/check.ts CHANGED
@@ -1,21 +1,21 @@
1
- import { CHECK_DELAY_FLOOR_MS, checkDelayMs, evaluateAndMaybeSwap } from "../lib/decide.ts";
1
+ import { checkDelayMs, evaluateAndMaybeSwap } from "../lib/decide.ts";
2
2
  import { readOAuthAccount } from "../lib/claudejson.ts";
3
3
  import { log } from "../lib/log.ts";
4
4
  import { loadConfig, loadNextCheckDueAt, saveNextCheckDueAt } from "../lib/state.ts";
5
5
  import { c, emitError, emitJson, fmtReset } from "./render.ts";
6
6
 
7
- const TICK_SLACK_MS = CHECK_DELAY_FLOOR_MS / 2;
8
-
9
7
  export async function cmdCheck(args: string[] = [], json = false): Promise<number> {
10
8
  const now = Date.now();
11
- const dueAt = args.includes("--if-due") ? loadNextCheckDueAt(now) : null;
12
- if (dueAt != null && now + TICK_SLACK_MS < dueAt) {
13
- if (json) emitJson({ ok: true, due: false, nextCheckAt: dueAt });
14
- else console.log(c.dim(`not due (${Math.ceil((dueAt - now) / 1000)}s)`));
15
- return 0;
16
- }
9
+ let cfg;
17
10
  let d;
18
11
  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
19
  d = await evaluateAndMaybeSwap(now);
20
20
  } catch (e) {
21
21
  const detail = e instanceof Error ? e.message : String(e);
@@ -23,7 +23,7 @@ export async function cmdCheck(args: string[] = [], json = false): Promise<numbe
23
23
  emitError({ json, message: `check failed: ${detail}` });
24
24
  return 1;
25
25
  }
26
- const delayMs = checkDelayMs({ cfg: loadConfig(), org: readOAuthAccount()?.organizationUuid ?? null, now, decision: d });
26
+ const delayMs = checkDelayMs({ cfg, org: readOAuthAccount()?.organizationUuid ?? null, now, decision: d });
27
27
  saveNextCheckDueAt({ dueAt: now + delayMs, ts: now });
28
28
  if (json) {
29
29
  emitJson({
package/src/cli/config.ts CHANGED
@@ -20,8 +20,11 @@ export const KNOWN_KEYS = [
20
20
  "policy.switchModels",
21
21
  "policy.usagePollTtlMs",
22
22
  "policy.maxWaitMs",
23
+ "policy.checkIntervalMs",
23
24
  ] as const;
24
25
 
26
+ const TIMER_REWRITE_NOTE = "note: run `tokenmaxxing init` to rewrite the periodic timer at the new tick";
27
+
25
28
  const RawFileSchema = z.record(z.string(), z.unknown());
26
29
 
27
30
  function readRawFile(): Record<string, unknown> {
@@ -150,6 +153,7 @@ function cmdSet(key: string, valueText: string, json: boolean): number {
150
153
  `${key}: ${beforeFile === undefined ? "(default)" : JSON.stringify(beforeFile)} -> ${JSON.stringify(get(next, key))}`,
151
154
  );
152
155
  if (env) console.log(c.yellow(`note: ${env} is set and overrides the file value in this environment`));
156
+ if (key === "policy.checkIntervalMs") console.log(c.yellow(TIMER_REWRITE_NOTE));
153
157
  return 0;
154
158
  }
155
159
 
@@ -189,6 +193,7 @@ function cmdUnset(key: string, json: boolean): number {
189
193
  return 0;
190
194
  }
191
195
  console.log(`${key} unset -> ${JSON.stringify(value)} (default)`);
196
+ if (key === "policy.checkIntervalMs") console.log(c.yellow(TIMER_REWRITE_NOTE));
192
197
  return 0;
193
198
  }
194
199
 
package/src/cli/init.ts CHANGED
@@ -28,7 +28,7 @@ function ensurePathAhead(): void {
28
28
  }
29
29
 
30
30
  function reportTimer(out: InstallOutcome): void {
31
- if (out.timerLoaded) console.log(`${c.green("✓")} periodic check timer active (every 3m)`);
31
+ if (out.timerLoaded) console.log(`${c.green("✓")} periodic check timer active (every ${out.checkIntervalS}s)`);
32
32
  else console.log(c.yellow(`⚠ check timer written but not activated - run: ${timerActivationHint()}`));
33
33
  }
34
34
 
@@ -37,7 +37,7 @@ export function printUsage(): void {
37
37
  console.log(` ${c.bold("how to use")} - ${c.cyan("xx")} is shorthand for ${c.cyan("tokenmaxxing")}:`);
38
38
  console.log(` ${c.cyan("claude")} use claude as always; it switches accounts near quota automatically`);
39
39
  console.log(` ${c.cyan("xx")} show the pool with usage bars (same as ${c.cyan("xx status")})`);
40
- console.log(` ${c.cyan("xx status --force")} ping every account (one tiny haiku request each) so all 5h timers start now, then sample fresh`);
40
+ console.log(` ${c.cyan("xx status --ping")} ping every account (one tiny haiku request each) so all 5h timers start now, then sample fresh; ${c.cyan("--count N")} pings N random accounts instead`);
41
41
  console.log(` ${c.cyan("xx add")} log in and pool another account`);
42
42
  console.log(` ${c.cyan("xx switch")} hop to the best account right now (the automatic switching needs no command)`);
43
43
  console.log(` ${c.cyan("xx help")} everything else`);
package/src/cli/status.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { sortBy } from "es-toolkit";
1
+ import { sampleSize, sortBy } from "es-toolkit";
2
2
  import { z } from "zod";
3
3
  import { loadAccounts, loadConfig, loadUsage, loadUsageSnapshot, loadModelUsage, saveAccounts } from "../lib/state.ts";
4
4
  import { readOAuthAccount } from "../lib/claudejson.ts";
@@ -86,14 +86,31 @@ function currentCodexWindow(w: CodexWindow, now: number): CodexWindow {
86
86
  return { ...currentWindow(w, !isSessionWindow({ window: w }), now), windowSeconds: w.windowSeconds };
87
87
  }
88
88
 
89
- async function collectClaude(input: { cfg: Config; force: boolean; now: number }): Promise<StatusReport["claude"]> {
90
- const { cfg, force, now } = input;
89
+ function pickForPing(accounts: Account[], pingCount: number | undefined): Account[] {
90
+ if (pingCount == null) return accounts;
91
+ const usable = accounts.filter((a) => a.needsReauth !== true);
92
+ return sampleSize(usable, Math.min(pingCount, usable.length));
93
+ }
94
+
95
+ function progressNote(input: { ping: boolean; pingCount: number | undefined; picked: Account[]; total: number }): string {
96
+ const { ping, pingCount, picked, total } = input;
97
+ if (!ping) return "sampling live usage...";
98
+ if (pingCount == null) return "pinging every account (starts each 5h session timer) + sampling live usage...";
99
+ if (picked.length === 0) return "no account to ping (every account needs reauth), sampling live usage...";
100
+ return `pinging ${picked.length} of ${count({ n: total, noun: "account" })} (${picked.map((a) => a.label || a.email).join(", ")}) so their 5h session timers start now + sampling live usage...`;
101
+ }
102
+
103
+ async function collectClaude(input: { cfg: Config; ping: boolean; pingCount: number | undefined; now: number }): Promise<StatusReport["claude"]> {
104
+ const { cfg, ping, pingCount, now } = input;
91
105
  let idx = loadAccounts();
92
106
  const samples = new Map<string, { outcome: SampleOutcome; viaTee: boolean }>();
107
+ let pings = new Set<string>();
93
108
  if (idx.accounts.length > 0) {
94
- console.error(c.dim(force ? "pinging every account (starts each 5h session timer) + sampling live usage..." : "sampling live usage..."));
95
109
  await withLock(paths.lockFile, async () => {
96
110
  idx = loadAccounts();
111
+ const picked = ping ? pickForPing(idx.accounts, pingCount) : [];
112
+ pings = new Set(picked.map((a) => a.accountUuid));
113
+ console.error(c.dim(progressNote({ ping, pingCount, picked, total: idx.accounts.length })));
97
114
  const tee = loadUsageSnapshot();
98
115
  const live = tee?.state ?? null;
99
116
  const teeAt = tee?.at ?? null;
@@ -114,7 +131,7 @@ async function collectClaude(input: { cfg: Config; force: boolean; now: number }
114
131
  : null;
115
132
  let viaTee = false;
116
133
  let outcome: SampleOutcome;
117
- if (force) {
134
+ if (pings.has(a.accountUuid)) {
118
135
  outcome = isActive ? await probeActiveUsage(a, { ping: true }) : await probeParkedUsage(a, { ping: true });
119
136
  if (!outcome.ok && fromStatusLine && outcome.reason.includes("no limit data")) {
120
137
  const failed = outcome;
@@ -177,7 +194,7 @@ async function collectClaude(input: { cfg: Config; force: boolean; now: number }
177
194
  sample: sampled.outcome.ok ? { ok: true, source: sampled.viaTee ? "statusline" : "probe" } : { ok: false, reason: sampled.outcome.reason },
178
195
  pingError: sampled.outcome.pingError ?? null,
179
196
  pingRejected: sampled.outcome.pingRejected === true,
180
- pinged: force && sampled.outcome.ok && sampled.outcome.pingError == null && aggregate != null && aggregate.fiveHour.resetsAt == null,
197
+ pinged: pings.has(a.accountUuid) && sampled.outcome.ok && sampled.outcome.pingError == null && aggregate != null && aggregate.fiveHour.resetsAt == null,
181
198
  };
182
199
  });
183
200
  return {
@@ -341,11 +358,11 @@ function renderClaude(input: {
341
358
  }
342
359
  }
343
360
 
344
- export async function cmdStatus(opts: { force?: boolean; json?: boolean; preRender?: () => void } = {}): Promise<number> {
345
- const { force = false, json = false } = opts;
361
+ export async function cmdStatus(opts: { ping?: boolean; pingCount?: number; json?: boolean; preRender?: () => void } = {}): Promise<number> {
362
+ const { ping = false, pingCount, json = false } = opts;
346
363
  const cfg = loadConfig();
347
364
  const now = Date.now();
348
- const claude = await collectClaude({ cfg, force, now });
365
+ const claude = await collectClaude({ cfg, ping, pingCount, now });
349
366
  if (!json) {
350
367
  opts.preRender?.();
351
368
  const at = Date.now();
@@ -97,7 +97,7 @@ const HELP_TEXT = `tokenmaxxing agent MCP
97
97
 
98
98
  Read tools (always available):
99
99
  pool_ls list pooled Claude and Codex accounts (labels/status only)
100
- pool_status sample usage bars (free /usage path; never --force)
100
+ pool_status sample usage bars (free /usage path; never --ping)
101
101
  doctor verify install health (labels/status only)
102
102
  config_get read effective config, or one key when provided
103
103
  help this catalog
@@ -109,7 +109,7 @@ Mutating tools (confirm=true AND ${MUTATIONS_ENV}=1):
109
109
  config_unset remove a config.json override
110
110
 
111
111
  Hard deny (no tools):
112
- status --force / metered pings
112
+ status --ping / metered pings
113
113
  init / add / auth / rm / uninstall
114
114
  credential blobs or token values
115
115
  killing sessions or supervisors
@@ -147,7 +147,7 @@ export function createTokenmaxxingMcpServer(): McpServer {
147
147
  server.registerTool(
148
148
  "pool_status",
149
149
  {
150
- description: "Show pool usage bars via the free /usage path. Never opens 5h windows. Do not request --force; that tool does not exist.",
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
151
  inputSchema: {},
152
152
  },
153
153
  async () => {
package/src/lib/decide.ts CHANGED
@@ -2,7 +2,7 @@ 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_MS, loadAccounts, loadConfig, loadDepletedWait, loadLastSwapAt, loadUsage, loadUsageSnapshot, loadModelUsage, saveAccounts, saveDepletedWait, saveModelUsage, writeUsage } from "./state.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";
6
6
  import { readOAuthAccount } from "./claudejson.ts";
7
7
  import { chooseAndSwap, performSwap } from "./swap.ts";
8
8
  import { currentWins, effectiveBars, hardBars, isExhausted, nextWeeklyReset, pickBest, pickEarliestReset, sessionLadder, usableAt } from "./picker.ts";
@@ -126,8 +126,6 @@ async function loadFreshSnapshots(cfg: Config, org: string | null, now: number):
126
126
  return { u, mu, uAt };
127
127
  }
128
128
 
129
- export const POST_SWAP_COOLDOWN_MS = 45_000;
130
-
131
129
  export async function evaluateAndMaybeSwap(now = Date.now(), anticipatory = false, enforced: EnforcedLimit | null = null): Promise<SwapDecision> {
132
130
  const activeOrg = readOAuthAccount()?.organizationUuid ?? null;
133
131
  const enforced0 = enforced && enforced.org === activeOrg ? enforced : null;
@@ -357,17 +355,17 @@ export async function recordEnforcedLimit(input: { limit: EnforcedClass; org: st
357
355
  });
358
356
  }
359
357
 
360
- export const CHECK_DELAY_FLOOR_MS = 60_000;
361
- const CHECK_DELAY_UNKNOWN_MS = 180_000;
362
- const STAGE_CEILING_MS = [MAX_CHECK_DELAY_MS, 180_000, 120_000];
358
+ const STAGE_CEILING_TICKS = [MAX_CHECK_DELAY_TICKS, 3, 2];
363
359
 
364
360
  export function checkDelayMs(input: { cfg: Config; org: string | null; now: number; decision: SwapDecision }): number {
365
361
  const { cfg, org, now, decision } = input;
366
- if (decision.waitUntil !== undefined) return Math.min(MAX_CHECK_DELAY_MS, Math.max(CHECK_DELAY_FLOOR_MS, decision.waitUntil - now));
362
+ const tick = cfg.policy.checkIntervalMs;
363
+ const max = maxCheckDelayMs(cfg);
364
+ if (decision.waitUntil !== undefined) return Math.min(max, Math.max(tick, decision.waitUntil - now));
367
365
  const swapAt = loadLastSwapAt();
368
366
  if (swapAt != null && now - swapAt < POST_SWAP_COOLDOWN_MS) return swapAt + POST_SWAP_COOLDOWN_MS - now;
369
367
  const snap = loadUsageSnapshot();
370
- if (!org || !snap || !usageFresh(snap.state, snap.at, org, cfg.policy.usagePollTtlMs, now)) return CHECK_DELAY_UNKNOWN_MS;
368
+ if (!org || !snap || !usageFresh(snap.state, snap.at, org, cfg.policy.usagePollTtlMs, now)) return 3 * tick;
371
369
  const accounts = loadAccounts().accounts;
372
370
  const u = freshest(snap.state, snap.at, accounts.find((a) => a.organizationUuid === org)) ?? { ...snap.state, ts: snap.at };
373
371
  const mu = loadModelUsage();
@@ -386,8 +384,8 @@ export function checkDelayMs(input: { cfg: Config; org: string | null; now: numb
386
384
  else capMissing = true;
387
385
  }
388
386
  const headroom = Math.min(...heads);
389
- const banded = headroom >= 40 ? MAX_CHECK_DELAY_MS : headroom >= 20 ? 180_000 : headroom >= 8 ? 120_000 : CHECK_DELAY_FLOOR_MS;
387
+ const banded = headroom >= 40 ? max : headroom >= 20 ? 3 * tick : headroom >= 8 ? 2 * tick : tick;
390
388
  const stage = sessionLadder(cfg).indexOf(bars.session);
391
- const staged = Math.min(banded, STAGE_CEILING_MS[stage] ?? CHECK_DELAY_FLOOR_MS);
392
- return capMissing ? Math.min(staged, 120_000) : staged;
389
+ const staged = Math.min(banded, (STAGE_CEILING_TICKS[stage] ?? 1) * tick);
390
+ return capMissing ? Math.min(staged, 2 * tick) : staged;
393
391
  }
@@ -6,12 +6,14 @@ import { codexPaths, HOME, paths } from "./paths.ts";
6
6
  import { writeFileAtomic } from "./atomic.ts";
7
7
  import { installedBin, installSettings, isOurHookCommand, uninstallSettings } from "./settings.ts";
8
8
  import { resolveRealClaude } from "./claudebin.ts";
9
+ import { loadConfig } from "./state.ts";
9
10
 
10
11
  const InstallOutcomeSchema = z.object({
11
12
  claudeWrapper: z.string(),
12
13
  installedBin: z.string(),
13
14
  pathAhead: z.boolean(),
14
15
  timerLoaded: z.boolean(),
16
+ checkIntervalS: z.number().int().positive(),
15
17
  });
16
18
  export type InstallOutcome = z.infer<typeof InstallOutcomeSchema>;
17
19
 
@@ -105,11 +107,13 @@ export function installSupervisor(): InstallOutcome {
105
107
  writeFileAtomic(join(paths.binDir, "xx"), `#!/bin/sh\nexec ${JSON.stringify(target)} "$@"\n`, 0o755);
106
108
 
107
109
  installSettings();
110
+ const checkIntervalS = Math.ceil(loadConfig().policy.checkIntervalMs / 1000);
108
111
  return {
109
112
  claudeWrapper: paths.supervisorLink,
110
113
  installedBin: target,
111
114
  pathAhead: isBinDirAhead(),
112
- timerLoaded: installCheckTimer(),
115
+ timerLoaded: installCheckTimer(checkIntervalS),
116
+ checkIntervalS,
113
117
  };
114
118
  }
115
119
 
@@ -179,7 +183,6 @@ export function uninstallCodexSupervisor(): void {
179
183
  if (existsSync(codexSupervisorLink())) rmSync(codexSupervisorLink(), { force: true });
180
184
  }
181
185
 
182
- const CHECK_INTERVAL_S = 60;
183
186
  const LAUNCHD_LABEL = "com.tokenmaxxing.check";
184
187
 
185
188
  function launchdPlist(): string {
@@ -199,7 +202,7 @@ function run(cmd: string[]): boolean {
199
202
  }
200
203
  }
201
204
 
202
- function installCheckTimer(): boolean {
205
+ function installCheckTimer(intervalS: number): boolean {
203
206
  if (skipImperativeTimer()) return true;
204
207
 
205
208
  if (process.platform === "darwin") {
@@ -212,7 +215,7 @@ function installCheckTimer(): boolean {
212
215
  <dict>
213
216
  <key>Label</key><string>${LAUNCHD_LABEL}</string>
214
217
  <key>ProgramArguments</key><array><string>${escape(installedBin())}</string><string>check</string><string>--if-due</string></array>
215
- <key>StartInterval</key><integer>${CHECK_INTERVAL_S}</integer>
218
+ <key>StartInterval</key><integer>${intervalS}</integer>
216
219
  <key>StandardOutPath</key><string>/dev/null</string>
217
220
  <key>StandardErrorPath</key><string>${escape(join(paths.home, "check.stderr.log"))}</string>
218
221
  </dict>
@@ -244,9 +247,9 @@ ExecStart=${exec}
244
247
  Description=tokenmaxxing periodic account-switch check
245
248
 
246
249
  [Timer]
247
- OnBootSec=60
248
- OnUnitActiveSec=${CHECK_INTERVAL_S}
249
- AccuracySec=5
250
+ OnBootSec=${intervalS}
251
+ OnUnitActiveSec=${intervalS}
252
+ AccuracySec=${Math.max(1, Math.floor(intervalS / 12))}
250
253
 
251
254
  [Install]
252
255
  WantedBy=timers.target
package/src/lib/state.ts CHANGED
@@ -22,7 +22,15 @@ const DEFAULT_CONFIG: Config = {
22
22
  hardThresholds: { session: 100, weekly: 100 },
23
23
  claudeBin: "",
24
24
  codexBin: "",
25
- policy: { projectionMargin: 0, greedySessionFloor: 80, greedySwapMargin: 0.15, switchModels: ["fable"], usagePollTtlMs: 90_000, maxWaitMs: 3_600_000 },
25
+ policy: {
26
+ projectionMargin: 0,
27
+ greedySessionFloor: 80,
28
+ greedySwapMargin: 0.15,
29
+ switchModels: ["fable"],
30
+ usagePollTtlMs: 90_000,
31
+ maxWaitMs: 3_600_000,
32
+ checkIntervalMs: 60_000,
33
+ },
26
34
  };
27
35
 
28
36
  const PercentSchema = z.number().min(0).max(100);
@@ -41,6 +49,7 @@ export const ConfigFileSchema = z
41
49
  switchModels: z.array(z.string()),
42
50
  usagePollTtlMs: z.number().int().positive(),
43
51
  maxWaitMs: z.number().int().positive(),
52
+ checkIntervalMs: z.number().int().min(10_000),
44
53
  })
45
54
  .partial(),
46
55
  })
@@ -70,6 +79,7 @@ export function mergeConfigFile(p: z.infer<typeof ConfigFileSchema>): MergeOutco
70
79
  cfg.policy.greedySwapMargin = p.policy?.greedySwapMargin ?? cfg.policy.greedySwapMargin;
71
80
  cfg.policy.usagePollTtlMs = p.policy?.usagePollTtlMs ?? cfg.policy.usagePollTtlMs;
72
81
  cfg.policy.maxWaitMs = p.policy?.maxWaitMs ?? cfg.policy.maxWaitMs;
82
+ cfg.policy.checkIntervalMs = p.policy?.checkIntervalMs ?? cfg.policy.checkIntervalMs;
73
83
  if (p.policy?.switchModels) {
74
84
  cfg.policy.switchModels = p.policy.switchModels.map((s) => s.toLowerCase());
75
85
  }
@@ -199,9 +209,14 @@ export function clearDepletedWait(): void {
199
209
  rmSync(paths.depletedJson, { force: true });
200
210
  }
201
211
 
202
- export const MAX_CHECK_DELAY_MS = 300_000;
212
+ export const MAX_CHECK_DELAY_TICKS = 5;
213
+ export const POST_SWAP_COOLDOWN_MS = 45_000;
203
214
 
204
- export function loadNextCheckDueAt(now: number): number | null {
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 {
205
220
  if (!existsSync(paths.nextCheckJson)) return null;
206
221
  let parsed;
207
222
  try {
@@ -210,7 +225,7 @@ export function loadNextCheckDueAt(now: number): number | null {
210
225
  return null;
211
226
  }
212
227
  if (!parsed.success) return null;
213
- return parsed.data.dueAt - now > MAX_CHECK_DELAY_MS ? null : parsed.data.dueAt;
228
+ return parsed.data.dueAt - input.now > Math.max(maxCheckDelayMs(input.cfg), POST_SWAP_COOLDOWN_MS) ? null : parsed.data.dueAt;
214
229
  }
215
230
 
216
231
  export function saveNextCheckDueAt(input: { dueAt: number; ts: number }): void {
package/src/lib/types.ts CHANGED
@@ -125,6 +125,7 @@ export const ConfigSchema = z
125
125
  switchModels: z.array(z.string()),
126
126
  usagePollTtlMs: z.number().int().positive(),
127
127
  maxWaitMs: z.number().int().positive(),
128
+ checkIntervalMs: z.number().int().min(10_000),
128
129
  }),
129
130
  })
130
131
  .refine((cfg) => cfg.policy.projectionMargin < Math.min(...cfg.thresholds.session, cfg.thresholds.weekly), {
package/src/main.ts CHANGED
@@ -45,7 +45,7 @@ function printHelp(): void {
45
45
  ${c.cyan("tokenmaxxing switch --codex")} [sel] switch the codex pool (takes effect on next codex start)
46
46
  ${c.cyan("tokenmaxxing ls")} list pooled accounts
47
47
  ${c.cyan("tokenmaxxing status")} accounts with 5h / weekly / per-model usage bars
48
- ${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
48
+ ${c.cyan("tokenmaxxing status --ping")} [--count N] ping every account (one tiny haiku request each) so all 5h session timers start now, then sample fresh; ${c.cyan("--count N")} pings N randomly picked accounts instead, to stagger the resets; ${c.cyan("xx --ping")} works too
49
49
  ${c.cyan("tokenmaxxing watch")} [seconds] live status: re-render every N seconds (default 120, never pings)
50
50
  ${c.cyan("tokenmaxxing config")} [get|set|unset|tidy] inspect and edit config.json (bare = effective config with sources)
51
51
  ${c.cyan("tokenmaxxing doctor")} verify the install is intact
@@ -60,6 +60,28 @@ function printHelp(): void {
60
60
 
61
61
  let jsonMode = false;
62
62
 
63
+ function statusFlags(rest: string[]): { ping: boolean; pingCount: number | undefined } | { error: string } {
64
+ let ping = false;
65
+ let pingCount: number | undefined;
66
+ for (let i = 0; i < rest.length; i++) {
67
+ const arg = rest[i]!;
68
+ if (arg === "--ping") {
69
+ ping = true;
70
+ continue;
71
+ }
72
+ if (arg === "--count") {
73
+ if (pingCount != null) return { error: "--count may only be given once" };
74
+ const raw = rest[++i];
75
+ pingCount = Number(raw);
76
+ if (!Number.isInteger(pingCount) || pingCount < 1) return { error: `--count needs a positive whole number of accounts, got: ${raw ?? "nothing"}` };
77
+ continue;
78
+ }
79
+ return { error: `unknown status option: ${arg} (status takes --ping and --count N)` };
80
+ }
81
+ if (pingCount != null && !ping) return { error: "--count only applies together with --ping" };
82
+ return { ping, pingCount };
83
+ }
84
+
63
85
  async function main(): Promise<number> {
64
86
  if (process.platform !== "darwin" && process.platform !== "linux") {
65
87
  console.error(`tokenmaxxing supports macOS and Linux only (this is ${process.platform})`);
@@ -103,8 +125,17 @@ async function main(): Promise<number> {
103
125
  case "__stop-failure-hook": return runStopFailureHook();
104
126
  case "__session-start": return runSessionStart();
105
127
  case "__codex-stop-hook": return runCodexStopHook();
106
- case undefined: return cmdStatus({ json });
107
- case "--force": return cmdStatus({ force: true, json });
128
+ case undefined:
129
+ case "status":
130
+ case "--ping":
131
+ case "--count": {
132
+ const flags = statusFlags(sub === "status" ? args.slice(1) : args);
133
+ if ("error" in flags) {
134
+ emitError({ json, message: flags.error });
135
+ return 2;
136
+ }
137
+ return cmdStatus({ ...flags, json });
138
+ }
108
139
  case "switch": {
109
140
  const rest = args.slice(1).filter((a) => a !== "--codex");
110
141
  return args.includes("--codex") ? cmdCodexSwitch(rest[0], json) : cmdSwitch(rest[0], json);
@@ -115,7 +146,6 @@ async function main(): Promise<number> {
115
146
  case "add": return args.includes("--codex") ? cmdCodexAdd() : cmdAdd();
116
147
  case "auth": return cmdAuth(args.slice(1));
117
148
  case "ls": return cmdLs(json);
118
- case "status": return cmdStatus({ force: args.includes("--force"), json });
119
149
  case "watch": return cmdWatch(args[1], json);
120
150
  case "doctor": return cmdDoctor(json);
121
151
  case "rm": {