tokenmaxxing 1.49.0 → 1.50.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
@@ -21,7 +21,7 @@ supervisor (you type `claude`) → real claude, CLAUDE_SECURESTORAGE_CONFIG_DI
21
21
  ▲______________ relaunch --resume <sid> under another store ______________|
22
22
  ```
23
23
 
24
- It is a process manager only - spawn with inherited stdio plus saved `stty -g` termios (not a PTY copy), wait, restore the terminal, relaunch. It never proxies API traffic and never handles tokens. Everything else about `claude` is unchanged.
24
+ It is a process manager only - spawn with inherited stdio (stdin through a relay pipe for a stream-json session, see §3.3) plus saved `stty -g` termios (not a PTY copy), wait, restore the terminal, relaunch. It never proxies API traffic and never handles tokens. Everything else about `claude` is unchanged.
25
25
 
26
26
  ---
27
27
 
@@ -58,7 +58,7 @@ The Stop hook's stdin has no usage data, but the **statusLine does** (`rate_limi
58
58
  4. Write `respawn/<session-id>` (atomic temp+rename) naming the target, with `waitUntil` now for a plain move or the soonest reset when the pool is depleted. No credential is written.
59
59
 
60
60
  ### 3.3 Move and depleted-pool pause (supervisor)
61
- The supervisor sees `respawn/<sid>`, SIGTERMs its child at the already-committed turn boundary, deletes the marker, resets the terminal, compacts the conversation when the marker asks for it (`compact: true` from the Stop and SessionStart hooks, `false` from StopFailure: it runs `claude -p --resume <sid> /compact` under the old seat's store, bounded at five minutes, and a failure only prints why), and (when `waitUntil` is in the future) shows an interruptible countdown to the reset; then it relaunches `claude --resume <sid>` with `CLAUDE_SECURESTORAGE_CONFIG_DIR` set to the target's store and rewrites the session's presence. The resumed process reads that store cold → runs on the target account, same conversation. The `SessionStart` hook (source `resume`) re-runs the decision before the first turn and writes the same marker if the seat is already over its bar or the pool is depleted. A marker that fails to parse is never deleted and treated as absent: the supervisor stops the child, restores the terminal, and exits naming the marker path.
61
+ The supervisor sees `respawn/<sid>`, SIGTERMs its child at the already-committed turn boundary, deletes the marker, resets the terminal, compacts the conversation when the marker asks for it (`compact: true` from the Stop and SessionStart hooks, `false` from StopFailure: it runs `claude -p --resume <sid> /compact` under the old seat's store, bounded at five minutes, and a failure only prints why), and (when `waitUntil` is in the future) shows an interruptible countdown to the reset; then it relaunches `claude --resume <sid>` with `CLAUDE_SECURESTORAGE_CONFIG_DIR` set to the target's store and rewrites the session's presence. The resumed process reads that store cold → runs on the target account, same conversation. The relaunch submits a first prompt that names the move (and the compaction, when it landed) and asks Claude to continue the task, so the conversation goes on without a keystroke: in a terminal session it is the positional prompt after the session id; in a stream-json session (`--input-format stream-json`, where the binary ignores a positional prompt) the supervisor owns stdin, relays the harness's lines to the child through a pipe, and writes the prompt as the first `user` line of the new process. The supervisor's own notices go to stderr, so a stream-json stdout stays parseable. The `SessionStart` hook (source `resume`) re-runs the decision before the first turn and writes the same marker if the seat is already over its bar or the pool is depleted. A marker that fails to parse is never deleted and treated as absent: the supervisor stops the child, restores the terminal, and exits naming the marker path.
62
62
 
63
63
  ### 3.4 Placement (supervisor, under the pool lock)
64
64
  1. Fold every account's tee into the index.
@@ -95,7 +95,7 @@ The [profile](docs/content/docs/switching-profile.mdx) records observed cache re
95
95
 
96
96
  ## 6. Honest papercuts
97
97
  - **A move restarts the process.** Every move is a `claude` stop and `--resume`; anything typed in the split second before the SIGTERM is lost, the supervisor resets terminal mode so nothing is left garbled, and the first turn on the new account re-uploads context once (prompt cache is org-scoped). A bar-triggered move compacts on the old account first so that upload is the summary, not the transcript; a move after a refusal cannot, and re-uploads the full context.
98
- - **Single-turn overshoot.** If one turn jumps from under the threshold straight past the wall, that turn ends rate-limited; the StopFailure hook walls the seat and moves the session, and the next prompt continues the conversation on the fresh account. The static session-bar margin (`policy.projectionMargin`, default 3) reduces this.
98
+ - **Single-turn overshoot.** If one turn jumps from under the threshold straight past the wall, that turn ends rate-limited; the StopFailure hook walls the seat and moves the session, and the relaunch continues the conversation on the fresh account from the supervisor's resume prompt. The static session-bar margin (`policy.projectionMargin`, default 3) reduces this.
99
99
  - **Unsupervised sessions are not moved.** A claude started outside the supervisor runs on whatever login its environment names, by default Claude Code's default store, which tokenmaxxing never writes.
100
100
  - **One shared identity file.** `~/.claude.json` keeps a single `oauthAccount`, which Claude Code rewrites after whichever session refreshed last, so `/status` can show another session's email. The store decides which credential a session uses.
101
101
  - **Dead stores.** A refresh Claude Code cannot complete dead-clears the store; the sampler and the decision recognize the empty tokens without a request, flag the account, and `auth` repairs it in place.
@@ -123,7 +123,7 @@ The [profile](docs/content/docs/switching-profile.mdx) records observed cache re
123
123
  ---
124
124
 
125
125
  ## 8. Stack
126
- TypeScript on Bun, shipped as source: one multi-call entry (`src/main.ts`, `#!/usr/bin/env bun`) serves the CLI, the `claude` supervisor, the statusLine shim, and the hooks; `init` installs a 2-line shim that `exec`s bun on the installed package's entry (the Stop path runs every turn; bun's start-up stays low-millisecond). Published to npm as `tokenmaxxing` (source, platform-independent - a compiled binary was tried and shipped one architecture's Mach-O to every platform). Shipping is PR-based since 2026-07-18: work reaches main only through a pull request (branch, PR, CI green, a 10-minute review wait, every review handled, merge, teardown), and a release is a PR-landed version bump followed by `gh release create` (details in AGENTS.md "Release and CI"). The supervisor spawns claude with inherited stdio and restores saved `stty -g` termios between runs - no PTY layer; resize and signals flow through the shared foreground process group.
126
+ TypeScript on Bun, shipped as source: one multi-call entry (`src/main.ts`, `#!/usr/bin/env bun`) serves the CLI, the `claude` supervisor, the statusLine shim, and the hooks; `init` installs a 2-line shim that `exec`s bun on the installed package's entry (the Stop path runs every turn; bun's start-up stays low-millisecond). Published to npm as `tokenmaxxing` (source, platform-independent - a compiled binary was tried and shipped one architecture's Mach-O to every platform). Shipping is PR-based since 2026-07-18: work reaches main only through a pull request (branch, PR, CI green, a 10-minute review wait, every review handled, merge, teardown), and a release is a PR-landed version bump followed by `gh release create` (details in AGENTS.md "Release and CI"). The supervisor spawns claude with inherited stdio (a relay pipe on stdin for a stream-json session) and restores saved `stty -g` termios between runs - no PTY layer; resize and signals flow through the shared foreground process group.
127
127
 
128
128
  ---
129
129
 
package/README.md CHANGED
@@ -133,7 +133,7 @@ A Cursor Cloud Agent can hand substantial work to Claude Code running on your ow
133
133
 
134
134
  ## Honest limitations
135
135
 
136
- - **A move restarts the process.** The session resumes the same transcript under the new account, but the process restarts and the first turn on the new account re-uploads context once (prompt cache is org-scoped).
136
+ - **A move restarts the process.** The session resumes the same transcript under the new account and continues on its own from a first prompt the supervisor submits, but the process restarts and the first turn on the new account re-uploads context once (prompt cache is org-scoped).
137
137
  - **Depleted-pause hiccup.** When the whole pool is at its limit, `claude` stops for the countdown; anything typed in that split second is lost.
138
138
  - **Unsupervised sessions are not moved.** A claude started outside the supervisor runs on Claude Code's own login, which tokenmaxxing never writes.
139
139
  - **One shared identity file.** `~/.claude.json` holds one `oauthAccount` that Claude Code rewrites after whichever session refreshed last, so `/status` can show another session's email; the statusline's `◆` marks the account a session actually uses.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tokenmaxxing",
3
- "version": "1.49.0",
3
+ "version": "1.50.0",
4
4
  "description": "Automatic Claude Code account switching: pool multiple accounts, start each session on its own account, and resume a session on a fresher account when quota fills.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -1,6 +1,6 @@
1
1
  import { existsSync, mkdirSync, readFileSync, rmSync, readdirSync, statSync } from "node:fs";
2
2
  import { basename, join } from "node:path";
3
- import type { Subprocess } from "bun";
3
+ import type { FileSink, Subprocess } from "bun";
4
4
  import { maxBy } from "es-toolkit";
5
5
  import { z } from "zod";
6
6
  import { claudePool, paths, storeDirFor } from "../lib/paths.ts";
@@ -22,18 +22,21 @@ const NONINTERACTIVE_SUBCMDS = new Set([
22
22
 
23
23
  const VALUE_TAKING_ROOT_FLAGS = new Set([
24
24
  "--agent", "--agents", "--append-system-prompt", "--append-system-prompt-file",
25
- "--debug-file", "--effort", "--fallback-model", "--input-format",
26
- "--json-schema", "--max-budget-usd", "--model", "-n", "--name",
27
- "--output-format", "--permission-mode", "--plugin-dir", "--plugin-url",
28
- "--remote-control-session-name-prefix", "--setting-sources", "--settings",
29
- "--system-prompt",
25
+ "--autocompact", "--debug-file", "--effort", "--environment", "--fallback-model",
26
+ "--input-format", "--json-schema", "--managed-settings", "--max-budget-usd",
27
+ "--max-thinking-tokens", "--max-turns", "--model", "-n", "--name",
28
+ "--output-format", "--permission-mode", "--permission-prompt-tool", "--permission-prompts",
29
+ "--plugin-dir", "--plugin-dir-no-mcp", "--plugin-url", "--remote-control-session-name-prefix",
30
+ "--setting-sources", "--settings", "--system-prompt", "--system-prompt-snapshot",
31
+ "--task-budget", "--thinking", "--thinking-display",
30
32
  ]);
31
33
  const VARIADIC_ROOT_FLAGS = new Set([
32
34
  "--add-dir", "--allowedTools", "--allowed-tools", "--betas",
33
35
  "--disallowedTools", "--disallowed-tools", "--file", "--mcp-config", "--tools",
34
36
  ]);
35
37
  const OPTIONAL_VALUE_ROOT_FLAGS = new Set([
36
- "-d", "--debug", "--from-pr", "--prompt-suggestions", "--remote-control", "-w", "--worktree",
38
+ "--cloud", "-d", "--debug", "--from-pr", "--prompt-suggestions", "--remote-control",
39
+ "--teleport", "-w", "--worktree",
37
40
  ]);
38
41
 
39
42
  const isUuid = (s: string) => z.uuid().safeParse(s).success;
@@ -43,6 +46,7 @@ const AnalysisSchema = z.object({
43
46
  sessionId: z.string().nullable(),
44
47
  resumeId: z.string().nullable(),
45
48
  continueLatest: z.boolean(),
49
+ streamInput: z.boolean(),
46
50
  });
47
51
  type Analysis = z.infer<typeof AnalysisSchema>;
48
52
 
@@ -50,6 +54,7 @@ export function analyzeArgs(argv: string[]): Analysis {
50
54
  let sessionId: string | null = null;
51
55
  let resumeId: string | null = null;
52
56
  let continueLatest = false;
57
+ let streamInput = false;
53
58
  let printMode = false;
54
59
  let invalidSessionArg = false;
55
60
  let pickerResume = false;
@@ -82,6 +87,8 @@ export function analyzeArgs(argv: string[]): Analysis {
82
87
  if (isUuid(value)) resumeId = value;
83
88
  else pickerResume = true;
84
89
  }
90
+ else if (a === "--input-format") streamInput = argv[++i] === "stream-json";
91
+ else if (a.startsWith("--input-format=")) streamInput = a.slice("--input-format=".length) === "stream-json";
85
92
  else if (VALUE_TAKING_ROOT_FLAGS.has(a)) i++;
86
93
  else if (VARIADIC_ROOT_FLAGS.has(a)) {
87
94
  while (i + 1 < argv.length && !argv[i + 1]!.startsWith("-")) i++;
@@ -97,7 +104,7 @@ export function analyzeArgs(argv: string[]): Analysis {
97
104
  const isSubcmd = firstPositional !== null && NONINTERACTIVE_SUBCMDS.has(firstPositional);
98
105
  const forkResume = forkSession && (resumeId !== null || continueLatest);
99
106
  const manage = !printMode && !isSubcmd && !invalidSessionArg && !pickerResume && !forkResume && !process.env.TOKENMAXXING_PROBE;
100
- return { manage, sessionId, resumeId, continueLatest };
107
+ return { manage, sessionId, resumeId, continueLatest, streamInput };
101
108
  }
102
109
 
103
110
  export function stripSessionFlags(argv: string[]): string[] {
@@ -187,19 +194,97 @@ async function countdownWait(acct: string, until: number): Promise<boolean> {
187
194
  let aborted = false;
188
195
  const onInt = () => { aborted = true; };
189
196
  process.on("SIGINT", onInt);
190
- process.stdout.write(`\n\x1b[36m⏳ tokenmaxxing: all accounts at their limit. Resuming on ${acct} when it resets (Ctrl-C to resume now).\x1b[0m\n`);
197
+ process.stderr.write(`\n\x1b[36m⏳ tokenmaxxing: all accounts at their limit. Resuming on ${acct} when it resets (Ctrl-C to resume now).\x1b[0m\n`);
191
198
  while (!aborted && Date.now() < until) {
192
199
  const left = until - Date.now();
193
200
  const m = Math.floor(left / 60000);
194
201
  const s = Math.floor((left % 60000) / 1000);
195
- process.stdout.write(`\r\x1b[36m resuming in ${m}m ${String(s).padStart(2, "0")}s \x1b[0m`);
202
+ process.stderr.write(`\r\x1b[36m resuming in ${m}m ${String(s).padStart(2, "0")}s \x1b[0m`);
196
203
  await Bun.sleep(1000);
197
204
  }
198
205
  process.removeListener("SIGINT", onInt);
199
- process.stdout.write(`\n\x1b[36m↻ resuming on ${acct}...\x1b[0m\n`);
206
+ process.stderr.write(`\n\x1b[36m↻ resuming on ${acct}...\x1b[0m\n`);
200
207
  return aborted;
201
208
  }
202
209
 
210
+ function resumePrompt(compacted: boolean): string {
211
+ const moved = compacted
212
+ ? "tokenmaxxing compacted this conversation and resumed the session on an account with quota headroom."
213
+ : "tokenmaxxing resumed this session on an account with quota headroom.";
214
+ return `${moved} Continue the task from where the previous turn left off. If the previous turn ended waiting on the user, restate what you need and wait.`;
215
+ }
216
+
217
+ function userLine(text: string): string {
218
+ return `${JSON.stringify({ type: "user", message: { role: "user", content: [{ type: "text", text }] } })}\n`;
219
+ }
220
+
221
+ class StdinRelay {
222
+ private sink: FileSink | null = null;
223
+ private queue: string[] = [];
224
+ private ended = false;
225
+
226
+ constructor() {
227
+ this.pump()
228
+ .catch((e: unknown) => log("supervisor.relay_read_failed", { err: e instanceof Error ? e.message : String(e) }))
229
+ .finally(() => {
230
+ this.ended = true;
231
+ this.close();
232
+ });
233
+ }
234
+
235
+ attach(sink: FileSink, first: string | null): void {
236
+ this.sink = sink;
237
+ const lines = first === null ? this.queue : [first, ...this.queue];
238
+ this.queue = [];
239
+ for (const line of lines) this.forward(line);
240
+ if (this.ended) this.close();
241
+ }
242
+
243
+ detach(): void {
244
+ this.sink = null;
245
+ }
246
+
247
+ private async pump(): Promise<void> {
248
+ const decoder = new TextDecoder();
249
+ let pending = "";
250
+ for await (const chunk of Bun.stdin.stream()) {
251
+ pending += decoder.decode(chunk, { stream: true });
252
+ let nl = pending.indexOf("\n");
253
+ while (nl !== -1) {
254
+ this.forward(pending.slice(0, nl + 1));
255
+ pending = pending.slice(nl + 1);
256
+ nl = pending.indexOf("\n");
257
+ }
258
+ }
259
+ pending += decoder.decode();
260
+ if (pending.length > 0) this.forward(`${pending}\n`);
261
+ }
262
+
263
+ private forward(line: string): void {
264
+ if (this.sink !== null) {
265
+ try {
266
+ this.sink.write(line);
267
+ this.sink.flush();
268
+ return;
269
+ } catch (e) {
270
+ log("supervisor.relay_write_failed", { err: e instanceof Error ? e.message : String(e) });
271
+ this.sink = null;
272
+ }
273
+ }
274
+ this.queue.push(line);
275
+ }
276
+
277
+ private close(): void {
278
+ if (this.sink === null) return;
279
+ try {
280
+ this.sink.end();
281
+ } catch (e) {
282
+ log("supervisor.relay_end_failed", { err: e instanceof Error ? e.message : String(e) });
283
+ }
284
+ this.sink = null;
285
+ }
286
+ }
287
+
203
288
  async function recordPresence(child: Subprocess, sid: string, seat: Account, savedTermios: string | null): Promise<void> {
204
289
  for (let attempt = 0; ; attempt++) {
205
290
  try {
@@ -280,6 +365,8 @@ export async function runSupervisor(argv: string[]): Promise<number> {
280
365
  process.on("SIGINT", () => {});
281
366
  process.on("SIGHUP", () => {});
282
367
 
368
+ const relay = analyzeArgs(base).streamInput ? new StdinRelay() : null;
369
+ let firstLine: string | null = null;
283
370
  let respawns = 0;
284
371
  let overriddenUntil = 0;
285
372
  let wanted: string | null = null;
@@ -289,9 +376,9 @@ export async function runSupervisor(argv: string[]): Promise<number> {
289
376
  const gate: MarkerGate = { launchedAt: Date.now(), overriddenUntil };
290
377
  const { child, seat } = await withLock(claudePool.lockFile, async () => {
291
378
  const picked = (wanted == null ? null : (loadAccounts(claudePool).accounts.find((a) => a.id === wanted) ?? null)) ?? pickSeat(gate.launchedAt);
292
- log("supervisor.launch", { sid, respawns, seat: picked?.id.slice(0, 8) ?? null, args: launchArgs.join(" ") });
379
+ log("supervisor.launch", { sid, respawns, seat: picked?.id.slice(0, 8) ?? null, args: launchArgs.join(" "), injected: firstLine !== null });
293
380
  const spawned = Bun.spawn([real, ...launchArgs], {
294
- stdin: "inherit",
381
+ stdin: relay === null ? "inherit" : "pipe",
295
382
  stdout: "inherit",
296
383
  stderr: "inherit",
297
384
  env: {
@@ -302,6 +389,8 @@ export async function runSupervisor(argv: string[]): Promise<number> {
302
389
  ...(picked ? { CLAUDE_SECURESTORAGE_CONFIG_DIR: storeDirFor(picked.id) } : {}),
303
390
  },
304
391
  });
392
+ if (relay !== null) relay.attach(spawned.stdin!, firstLine);
393
+ firstLine = null;
305
394
  if (picked) await recordPresence(spawned, sid, picked, savedTermios);
306
395
  return { child: spawned, seat: picked };
307
396
  });
@@ -317,13 +406,18 @@ export async function runSupervisor(argv: string[]): Promise<number> {
317
406
  const exited = child.exited.then(() => { done = true; return "exit" as const; });
318
407
  try {
319
408
  const winner = await Promise.race([exited, markerWatch.then((m) => (m ? "marker" : "exit"))]);
320
- if (winner === "marker") child.kill();
409
+ if (winner === "marker") {
410
+ relay?.detach();
411
+ child.kill();
412
+ }
321
413
  } catch (e) {
414
+ relay?.detach();
322
415
  child.kill();
323
416
  clearPresence({ dir: paths.presenceDir, id: sid });
324
417
  throw e;
325
418
  } finally {
326
419
  await child.exited;
420
+ relay?.detach();
327
421
  done = true;
328
422
  await markerWatch.catch(() => {});
329
423
  restoreTermios(savedTermios);
@@ -334,22 +428,27 @@ export async function runSupervisor(argv: string[]): Promise<number> {
334
428
  rmSync(marker, { force: true });
335
429
  respawns++;
336
430
  const label = loadAccounts(claudePool).accounts.find((a) => a.id === m.accountId)?.label ?? m.accountId.slice(0, 8);
337
- if (m.compact && seat && existsSync(transcriptPath(m.sessionId))) {
338
- process.stdout.write(`\n\x1b[36m↻ tokenmaxxing: compacting the conversation on ${seat.label} before the move...\x1b[0m\n`);
431
+ const resumable = existsSync(transcriptPath(m.sessionId));
432
+ let compacted = false;
433
+ if (m.compact && seat && resumable) {
434
+ process.stderr.write(`\n\x1b[36m↻ tokenmaxxing: compacting the conversation on ${seat.label} before the move...\x1b[0m\n`);
339
435
  const compactEnv: Record<string, string | undefined> = { ...childEnv, TOKENMAXXING_PROBE: "1", CLAUDE_SECURESTORAGE_CONFIG_DIR: storeDirFor(seat.id) };
340
436
  delete compactEnv.TOKENMAXXING_SUPERVISED;
341
437
  delete compactEnv.TOKENMAXXING_SESSION_ID;
342
438
  delete compactEnv.TOKENMAXXING_LAUNCHED_AT;
343
439
  const outcome = await compactClaudeSession({ real, sid: m.sessionId, env: compactEnv });
344
440
  log("supervisor.compact", { sid: m.sessionId.slice(0, 8), seat: seat.id.slice(0, 8), ok: outcome.ok, reason: outcome.ok ? undefined : outcome.reason });
345
- if (!outcome.ok) process.stdout.write(`\x1b[33m compaction did not land (${outcome.reason}) - resuming with the full context\x1b[0m\n`);
441
+ if (!outcome.ok) process.stderr.write(`\x1b[33m compaction did not land (${outcome.reason}) - resuming with the full context\x1b[0m\n`);
442
+ compacted = outcome.ok;
346
443
  }
347
444
  if (m.waitUntil > Date.now()) {
348
445
  if (await countdownWait(label, m.waitUntil)) overriddenUntil = m.waitUntil;
349
- } else process.stdout.write(`\n\x1b[36m↻ tokenmaxxing: moving to ${label} - resuming...\x1b[0m\n`);
446
+ } else process.stderr.write(`\n\x1b[36m↻ tokenmaxxing: moving to ${label} - resuming...\x1b[0m\n`);
350
447
  wanted = m.accountId;
351
448
  saveSessionFlags(m.sessionId, persistable, process.cwd());
352
- launchArgs = ["--resume", m.sessionId, ...persistable];
449
+ const prompt = resumable ? resumePrompt(compacted) : null;
450
+ firstLine = relay !== null && prompt !== null ? userLine(prompt) : null;
451
+ launchArgs = ["--resume", m.sessionId, ...(relay === null && prompt !== null ? [prompt] : []), ...persistable];
353
452
  continue;
354
453
  }
355
454
  clearPresence({ dir: paths.presenceDir, id: sid });