tokenmaxxing 1.111.0 → 1.113.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
@@ -66,7 +66,7 @@ The supervisor sees `respawn/<sid>`, SIGTERMs its child at the already-committed
66
66
 
67
67
  ### 3.4 Placement (supervisor, under the pool lock)
68
68
  1. Fold every account's tee into the index.
69
- 2. Rank the usable accounts (§5) with the live session counts from `live/`, or the soonest-recovering account when none is usable; with no pooled account or every account needing re-auth, launch without a store (still supervised, on whatever login the environment names). Print mode, the non-interactive subcommands, the bare resume picker, an invalid `--session-id`, and `--fork-session` with a resume are passed through unmanaged.
69
+ 2. Rank the usable accounts (§5) with the live session counts from `live/`, or the soonest-recovering account when none is usable; with no pooled account or every account needing re-auth, launch without a store (still supervised, on whatever login the environment names). Print mode, every subcommand, the bare resume picker, an invalid `--session-id`, and `--fork-session` with a resume are passed through unmanaged.
70
70
  3. Spawn claude with the store env set, then write `live/<sid>` with the child's pid and start time. The lock covers the pick and the presence write, so concurrent launches see each other.
71
71
 
72
72
  ### 3.5 Multiple concurrent sessions
package/README.md CHANGED
@@ -14,7 +14,7 @@ $ claude
14
14
 
15
15
  ## Why
16
16
 
17
- Every pooled account owns one Claude Code credential store under `~/.config/tokenmaxxing/stores/`. A thin `claude` supervisor on your PATH picks an account for each session and points `CLAUDE_SECURESTORAGE_CONFIG_DIR` at its store, so sessions on different accounts never share a credential, while sessions on the same account share its store and Claude Code's own refresh keeps them in step. A session moves only by respawn: the supervisor stops it at a committed turn boundary (the transcript is already on disk, nothing is lost) and runs `claude --resume <id>` under another store. tokenmaxxing writes a store once at onboarding and never again; it never copies a credential between stores, because a refresh rotation revokes the previous access token and two copies of one grant break each other. Everything else about `claude` is unchanged - all flags, MCP, hooks, and skills pass through. Print mode and the non-interactive subcommands run without a seat, on whatever login the environment names.
17
+ Every pooled account owns one Claude Code credential store under `~/.config/tokenmaxxing/stores/`. A thin `claude` supervisor on your PATH picks an account for each session and points `CLAUDE_SECURESTORAGE_CONFIG_DIR` at its store, so sessions on different accounts never share a credential, while sessions on the same account share its store and Claude Code's own refresh keeps them in step. A session moves only by respawn: the supervisor stops it at a committed turn boundary (the transcript is already on disk, nothing is lost) and runs `claude --resume <id>` under another store. tokenmaxxing writes a store once at onboarding and never again; it never copies a credential between stores, because a refresh rotation revokes the previous access token and two copies of one grant break each other. Everything else about `claude` is unchanged - all flags, MCP, hooks, and skills pass through. Print mode and every `claude` subcommand run without a seat, on whatever login the environment names.
18
18
 
19
19
  ## Install
20
20
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tokenmaxxing",
3
- "version": "1.111.0",
3
+ "version": "1.113.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",
@@ -21,10 +21,15 @@ import { gatedFamilies, modelFromFlag, scrubCredEnv } from "../lib/usage.ts";
21
21
  import { RespawnMarkerSchema, type Account, type Config, type ModelInfo } from "../lib/types.ts";
22
22
  import { errorMessage, log } from "../lib/log.ts";
23
23
 
24
- const NONINTERACTIVE_SUBCMDS = new Set([
25
- "mcp", "config", "doctor", "update", "install", "migrate-installer",
26
- "setup-token", "plugin", "agents", "completion", "help",
24
+ const SUBCOMMANDS = new Set([
25
+ "agents", "attach", "auth", "auto-mode", "daemon", "design-login", "doctor",
26
+ "drop-worktree-registrations", "edit-chrome-settings", "edit-hook", "edit-memory-settings",
27
+ "edit-permission-rules", "edit-sandbox-settings", "edit-skill-overrides", "gateway", "help",
28
+ "import", "import-conversations", "install", "kill", "logs", "mcp", "plugin", "plugins",
29
+ "project", "rc", "remote-control", "respawn", "rm", "sandbox", "setup-token", "stop",
30
+ "ultrareview", "update", "upgrade",
27
31
  ]);
32
+ const SOLE_UPDATE_FLAGS = new Set(["--update", "--upgrade"]);
28
33
 
29
34
  const VALUE_TAKING_ROOT_FLAGS = new Set([
30
35
  "--agent", "--agents", "--append-system-prompt", "--append-system-prompt-file",
@@ -115,7 +120,7 @@ export function analyzeArgs(argv: string[]): Analysis {
115
120
  }
116
121
  }
117
122
 
118
- const isSubcmd = firstPositional !== null && NONINTERACTIVE_SUBCMDS.has(firstPositional);
123
+ const isSubcmd = (firstPositional !== null && SUBCOMMANDS.has(firstPositional)) || (argv.length === 1 && SOLE_UPDATE_FLAGS.has(argv[0]!));
119
124
  const forkResume = forkSession && (resumeId !== null || continueLatest);
120
125
  const manage = !printMode && !isSubcmd && !invalidSessionArg && !pickerResume && !forkResume && !process.env.TOKENMAXXING_PROBE;
121
126
  return { manage, sessionId, resumeId, continueLatest, streamInput, streamOutput, model };
package/src/lib/claude.ts CHANGED
@@ -24,8 +24,8 @@ function liveId(): string | null {
24
24
  return seatFromEnv(loadAccounts(claudePool).accounts.map((a) => a.id));
25
25
  }
26
26
 
27
- function presence(): Map<string, number> {
28
- const seats = new Map(livingPresences(paths.presenceDir).map((s) => [s.id, s.accountId]));
27
+ function presence(living = livingPresences(paths.presenceDir)): Map<string, number> {
28
+ const seats = new Map(living.map((s) => [s.id, s.accountId]));
29
29
  for (const claim of liveWaitClaims(Date.now())) seats.set(claim.sessionId, claim.accountId);
30
30
  return new Map(Object.entries(countBy([...seats.values()], (id) => id)));
31
31
  }
@@ -165,7 +165,7 @@ export async function borrowClaudeSeat(pid: number): Promise<SeatBorrow> {
165
165
  }
166
166
  const open = idx.accounts.filter((a) => a.needsReauth !== true);
167
167
  const usable = await Promise.all(open.map(storeUsable));
168
- const ctx: PickCtx = { now: Date.now(), thresholds: bars, currentId: null, families: gatedFamilies(null, cfg.policy.switchModels), seats: presence() };
168
+ const ctx: PickCtx = { now: Date.now(), thresholds: bars, currentId: null, families: gatedFamilies(null, cfg.policy.switchModels), seats: presence(living) };
169
169
  const picked = pickBest(open.filter((_, i) => usable[i]), ctx);
170
170
  if (!picked) return null;
171
171
  writePresence({ dir: paths.presenceDir, id: seatId, accountId: picked.id, pid });
@@ -3,7 +3,7 @@ import { join } from "node:path";
3
3
  import { countBy } from "es-toolkit";
4
4
  import { z } from "zod";
5
5
  import { writeFileAtomic } from "./atomic.ts";
6
- import { pidExists, pidStartTime } from "./proc.ts";
6
+ import { pidExists, pidStartTimes } from "./proc.ts";
7
7
  import { readJsonFile } from "./state.ts";
8
8
  import { ErrnoSchema, JsonTextSchema } from "./types.ts";
9
9
 
@@ -16,7 +16,7 @@ const PresenceSchema = z.object({
16
16
  });
17
17
 
18
18
  export function writePresence(input: { dir: string; id: string; accountId: string; pid: number }): void {
19
- const startedAt = pidStartTime(input.pid);
19
+ const startedAt = pidStartTimes([input.pid]).get(input.pid);
20
20
  if (startedAt == null) throw new Error(`could not read pid ${input.pid}'s start time (ps lstart) - refusing to write an unverifiable presence file`);
21
21
  writeFileAtomic(join(input.dir, input.id), JSON.stringify(PresenceSchema.parse({ accountId: input.accountId, pid: input.pid, startedAt })));
22
22
  }
@@ -37,8 +37,8 @@ export function clearPresence(input: { dir: string; id: string }): void {
37
37
  export type LivingPresence = { id: string; accountId: string };
38
38
 
39
39
  export function livingPresences(dir: string): LivingPresence[] {
40
- const living: LivingPresence[] = [];
41
- if (!existsSync(dir)) return living;
40
+ if (!existsSync(dir)) return [];
41
+ const records: { name: string; file: string; record: z.infer<typeof PresenceSchema> }[] = [];
42
42
  for (const name of readdirSync(dir)) {
43
43
  const file = join(dir, name);
44
44
  let raw: string;
@@ -52,15 +52,20 @@ export function livingPresences(dir: string): LivingPresence[] {
52
52
  if (!parsed.success) {
53
53
  throw new Error(`${file} is not a readable presence record - it may belong to a RUNNING session, refusing to treat it as absent; remove the file (or respawn that session) to proceed`);
54
54
  }
55
- const observed = pidStartTime(parsed.data.pid);
56
- if (observed !== parsed.data.startedAt) {
57
- if (observed == null && pidExists(parsed.data.pid)) {
58
- throw new Error(`ps could not read the start time of live pid ${parsed.data.pid} (${file}) - refusing to clear a presence file that may guard a RUNNING session`);
55
+ records.push({ name, file, record: parsed.data });
56
+ }
57
+ const started = pidStartTimes(records.map((r) => r.record.pid));
58
+ const living: LivingPresence[] = [];
59
+ for (const { name, file, record } of records) {
60
+ const observed = started.get(record.pid);
61
+ if (observed !== record.startedAt) {
62
+ if (observed == null && pidExists(record.pid)) {
63
+ throw new Error(`ps could not read the start time of live pid ${record.pid} (${file}) - refusing to clear a presence file that may guard a RUNNING session`);
59
64
  }
60
65
  rmSync(file, { force: true });
61
66
  continue;
62
67
  }
63
- living.push({ id: name, accountId: parsed.data.accountId });
68
+ living.push({ id: name, accountId: record.accountId });
64
69
  }
65
70
  return living;
66
71
  }
package/src/lib/proc.ts CHANGED
@@ -38,11 +38,17 @@ export function ancestorPids(): number[] {
38
38
  return out;
39
39
  }
40
40
 
41
- export function pidStartTime(pid: number): string | null {
42
- const res = Bun.spawnSync(["ps", "-p", String(pid), "-o", "lstart="], { env: { ...process.env, LC_ALL: "C" } });
43
- if (res.exitCode !== 0) return null;
44
- const lstart = res.stdout.toString().trim();
45
- return lstart === "" ? null : lstart;
41
+ export function pidStartTimes(pids: number[]): Map<number, string> {
42
+ const started = new Map<number, string>();
43
+ if (pids.length === 0) return started;
44
+ const res = Bun.spawnSync(["ps", "-p", pids.join(","), "-o", "pid=", "-o", "lstart="], { env: { ...process.env, LC_ALL: "C" } });
45
+ if (res.exitCode !== 0) return started;
46
+ for (const row of res.stdout.toString().split("\n")) {
47
+ const line = row.trim();
48
+ const cut = line.indexOf(" ");
49
+ if (cut !== -1) started.set(Number(line.slice(0, cut)), line.slice(cut).trim());
50
+ }
51
+ return started;
46
52
  }
47
53
 
48
54
  export async function readStdin(): Promise<string> {