tokenmaxxing 1.88.0 → 1.89.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
@@ -36,7 +36,7 @@ It is a process manager only - spawn with inherited stdio (stdin through a relay
36
36
  - `codex-stores/<uuid8>/` - one Codex credential store per pooled account: its `auth.json`, with everything else symlinked to the shared `~/.codex` so sessions, history, and config stay shared and `codex resume` works across stores.
37
37
  - `usage/<uuid8>.json` - the statusline tee per account, written by the sessions running on it.
38
38
  - `live/<session-id>` - PID-validated presence per supervised session, naming its account; placement counts them.
39
- - `respawn/<session-id>` - per-session respawn markers (the hook→supervisor signal): the target account, the session to resume, a `waitUntil`, a `compact` flag, the `origin` (the hook or the seat watch that wrote it), and the launch time the marker was written for.
39
+ - `respawn/<session-id>` - per-session respawn markers (the hook→supervisor signal): the target account, the session to resume, a `waitUntil`, a `compact` flag, the `origin` (the hook or the seat watch that wrote it), the launch time the marker was written for, and after a usage-credits refusal the accounts that have refused the session.
40
40
  - `wait-queue.json` - the claims of supervised sessions that are moving or waiting (`{sessionId, accountId, at, waitUntil}`), naming the account each relaunches on: placement counts a claimed session there, and at most four sessions wait on one account; a session that arrives when every account already has four waiters takes the soonest reset anyway.
41
41
  - `bin/claude` and `bin/codex` - the supervisors, beside the `tokenmaxxing` and `xx` entry points; `bin/pi` after `init --pi`.
42
42
  - `pi-stores/claude/<uuid8>/` and `pi-stores/codex/<uuid8>/` - one pi store per pooled account that has a pi login: the account's own pi grant in `auth.json`, everything else symlinked to the shared `~/.pi/agent` ([pi support](docs/content/docs/pi.mdx)).
package/README.md CHANGED
@@ -84,7 +84,7 @@ claude # use claude as always; each session gets its own accoun
84
84
  Each session runs on its own seat. One rule: while the seat is under its bars, nothing happens; once it is at or over a bar, the session moves to the usable account that ranks first. The session bar is `thresholds.session` (90) minus `policy.projectionMargin` (3), so **87%** of the 5-hour window by default, and the weekly bar is **98%**. The bars also screen candidates on any of:
85
85
 
86
86
  - **Session** (5-hour) or **week (all models)** - the aggregate windows, fed free/push-based by the statusLine into one tee per account.
87
- - **Per-model weekly cap** - the most capable model (Fable) has its own tighter weekly limit that binds *before* the aggregate (per-model caps currently exist only for Sonnet and Fable, and Sonnet's is generous). tokenmaxxing reads it from the direct no-spend usage read for the seat (free, 0 tokens, one attempt per interval) whenever the seat's tee is older than `policy.usagePollTtlMs` or the session's model is one of `policy.switchModels`, backing off after a failed sample, so a Fable session moves on the Fable cap while a Sonnet session rides the aggregate. A session launched with `--model` on another family (`claude --model opus`) ignores the Fable cap from launch on: it is placed on, and moves to, an account whose Fable cap is spent as long as that account's session and weekly windows have headroom. The exception is a move off an account the server already refused (an enforced-limit wall), which screens every configured family.
87
+ - **Per-model weekly cap** - the most capable model (Fable) has its own tighter weekly limit that binds *before* the aggregate (per-model caps currently exist only for Sonnet and Fable, and Sonnet's is generous). tokenmaxxing reads it from the direct no-spend usage read for the seat (free, 0 tokens, one attempt per interval) whenever the seat's tee is older than `policy.usagePollTtlMs` or the session's model is one of `policy.switchModels`, backing off after a failed sample, so a Fable session moves on the Fable cap while a Sonnet session rides the aggregate. A session launched with `--model` on another family (`claude --model opus`) ignores the Fable cap from launch on: it is placed on, and moves to, an account whose Fable cap is spent as long as that account's session and weekly windows have headroom. The exception is a move off an account the server already refused (an enforced-limit wall), which screens every configured family. A refusal for usage credits, which the usage read cannot predict, walls nothing: the refused session moves with the Fable cap screened, and a later credits move never returns it to an account that already refused it.
88
88
 
89
89
  The bars' headroom is deliberate: it's the budget to reach a clean turn boundary before the account's real limit. The session bar sits lower (90, minus the margin) because a 5-hour reset is cheap to sit out; weekly quota is use-it-or-lose-it, so it drains closer to the limit (98).
90
90
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tokenmaxxing",
3
- "version": "1.88.0",
3
+ "version": "1.89.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",
@@ -2,7 +2,7 @@ import { z } from "zod";
2
2
  import { claude } from "../lib/claude.ts";
3
3
  import { evaluateAndMaybeSwap } from "../lib/decide.ts";
4
4
  import { readStdin } from "../lib/proc.ts";
5
- import { adoptLiveSession, supervisedSession, writeRespawnMarker } from "../lib/sessions.ts";
5
+ import { adoptLiveSession, refusedAccounts, supervisedSession, writeRespawnMarker } from "../lib/sessions.ts";
6
6
  import { loadConfig } from "../lib/state.ts";
7
7
  import { classifyEnforcedLimit, findEnforcedRow, parseErrorBody, readTranscriptTail, type TranscriptRow } from "../lib/usage.ts";
8
8
  import { paths } from "../lib/paths.ts";
@@ -57,7 +57,7 @@ export async function runStopFailureHook(): Promise<number> {
57
57
 
58
58
  let enforced: EnforcedLimit | null = null;
59
59
  if (limit && account) {
60
- enforced = { account, kind: limit.kind, family: limit.kind === "model" ? limit.family : null, resetsAt: limit.resetsAt, blind: !mainLoop && limit.kind !== "model" };
60
+ enforced = { account, kind: limit.kind, family: limit.kind === "model" || limit.kind === "credits" ? limit.family : null, resetsAt: limit.resetsAt, blind: !mainLoop && limit.kind !== "model" };
61
61
  log("stopfailure.enforced", { kind: limit.kind, family: enforced.family ?? undefined, resetsAt: limit.resetsAt, subagent: !mainLoop });
62
62
  } else {
63
63
  log("stopfailure.unclassified", {
@@ -80,9 +80,10 @@ export async function runStopFailureHook(): Promise<number> {
80
80
  );
81
81
  }
82
82
 
83
- const decision = await evaluateAndMaybeSwap(claude, now, canRespawn, enforced, { waiterId: canRespawn ? session?.sid : undefined });
83
+ const refused = enforced?.kind === "credits" ? [...new Set([...refusedAccounts(), enforced.account])] : undefined;
84
+ const decision = await evaluateAndMaybeSwap(claude, now, canRespawn, enforced, { waiterId: canRespawn ? session?.sid : undefined, exclude: refused });
84
85
  if (session && canRespawn && decision.account && (decision.swapped || decision.waitUntil !== undefined)) {
85
- writeRespawnMarker({ session, accountId: decision.account.id, waitUntil: decision.waitUntil ?? now, compact: false, origin: "stopfailure" });
86
+ writeRespawnMarker({ session, accountId: decision.account.id, waitUntil: decision.waitUntil ?? now, compact: false, origin: "stopfailure", refused });
86
87
  log("stopfailure.marker", { session: session.sid.slice(0, 8), live: session.live.slice(0, 8), account: decision.account.id.slice(0, 8), waitUntil: decision.waitUntil ?? now });
87
88
  } else {
88
89
  log("stopfailure.decision", { reason: decision.reason, swapped: decision.swapped, account: decision.account?.id.slice(0, 8), waitUntil: decision.waitUntil });
@@ -378,6 +378,7 @@ export async function runSupervisor(argv: string[]): Promise<number> {
378
378
  delete passthroughEnv.TOKENMAXXING_SUPERVISED;
379
379
  delete passthroughEnv.TOKENMAXXING_SESSION_ID;
380
380
  delete passthroughEnv.TOKENMAXXING_MODEL;
381
+ delete passthroughEnv.TOKENMAXXING_REFUSED;
381
382
  return runPassthrough({ real, argv, env: passthroughEnv, onSpawn: (p) => { child = p; } });
382
383
  }
383
384
 
@@ -427,6 +428,7 @@ export async function runSupervisor(argv: string[]): Promise<number> {
427
428
  let respawns = 0;
428
429
  let overriddenUntil = 0;
429
430
  let wanted: string | null = null;
431
+ let refused: string[] = [];
430
432
  try {
431
433
  while (true) {
432
434
  if (existsSync(marker)) rmSync(marker, { force: true });
@@ -446,6 +448,7 @@ export async function runSupervisor(argv: string[]): Promise<number> {
446
448
  TOKENMAXXING_SESSION_ID: sid,
447
449
  TOKENMAXXING_LAUNCHED_AT: String(gate.launchedAt),
448
450
  TOKENMAXXING_MODEL: effective.model ?? "",
451
+ TOKENMAXXING_REFUSED: refused.join(","),
449
452
  ...(picked ? { CLAUDE_SECURESTORAGE_CONFIG_DIR: storeDirFor(picked.id) } : {}),
450
453
  },
451
454
  });
@@ -512,6 +515,7 @@ export async function runSupervisor(argv: string[]): Promise<number> {
512
515
  delete compactEnv.TOKENMAXXING_SESSION_ID;
513
516
  delete compactEnv.TOKENMAXXING_LAUNCHED_AT;
514
517
  delete compactEnv.TOKENMAXXING_MODEL;
518
+ delete compactEnv.TOKENMAXXING_REFUSED;
515
519
  const outcome = await compactClaudeSession({ real, sid: m.sessionId, transcript, env: compactEnv, onSpawn: (p) => { child = p; } });
516
520
  child = null;
517
521
  if (terminating) {
@@ -526,6 +530,7 @@ export async function runSupervisor(argv: string[]): Promise<number> {
526
530
  if (await countdownWait(label, m.waitUntil, { stream, say })) overriddenUntil = m.waitUntil;
527
531
  } else say(`\n\x1b[36m↻ tokenmaxxing: moving to ${label} - resuming...\x1b[0m\n`, `tokenmaxxing: moving to ${label} and resuming.`);
528
532
  wanted = m.accountId;
533
+ refused = m.refused ?? refused;
529
534
  saveSessionFlags(m.sessionId, persistable, process.cwd());
530
535
  const prompt = resumable ? resumePrompt({ compacted, origin: m.origin }) : null;
531
536
  firstLine = relay !== null && prompt !== null ? userLine(prompt) : null;
package/src/lib/decide.ts CHANGED
@@ -14,6 +14,7 @@ export type EvalOpts = {
14
14
  seatId?: string | null;
15
15
  sessionFamilies?: string[];
16
16
  waiterId?: string;
17
+ exclude?: string[];
17
18
  };
18
19
 
19
20
  const MAX_WAITERS_PER_ACCOUNT = 4;
@@ -82,7 +83,8 @@ export async function evaluateAndMaybeSwap(p: Provider, now = Date.now(), canRes
82
83
  const origin = enforced ? idx.accounts.find((a) => a.id === enforced.account) : undefined;
83
84
  const prior = origin?.enforcedUntil != null && origin.enforcedUntil > now;
84
85
  let stamped = false;
85
- if (enforced && origin) {
86
+ const credits = enforced?.kind === "credits";
87
+ if (enforced && origin && !credits) {
86
88
  const covered = modelKindCovered(enforced, origin, now, bars.weekly);
87
89
  if (!covered || prior) {
88
90
  origin.enforcedUntil = Math.max(origin.enforcedUntil ?? 0, enforcedWall(enforced, origin, now));
@@ -92,7 +94,7 @@ export async function evaluateAndMaybeSwap(p: Provider, now = Date.now(), canRes
92
94
  saveAccounts(p.pool, idx);
93
95
  log("usage.enforced_limit", { kind: enforced.kind, family: enforced.family ?? undefined, resetsAt: origin.enforcedUntil, blind: prior || enforced.blind, live: origin === active, covered: covered && !prior });
94
96
  }
95
- const enforced2 = stamped && origin === active ? enforced : null;
97
+ const enforced2 = (stamped || credits) && origin === active ? enforced : null;
96
98
 
97
99
  if (id2 != null && !active) {
98
100
  return { swapped: false, account: null, reason: "live-credential-not-in-pool" };
@@ -125,7 +127,7 @@ export async function evaluateAndMaybeSwap(p: Provider, now = Date.now(), canRes
125
127
 
126
128
  const seatOf = (cur: { accounts: Account[] }): Account | null => cur.accounts.find((a) => a.id === id2) ?? null;
127
129
 
128
- const rejected = new Set<string>();
130
+ const rejected = new Set<string>(opts.exclude);
129
131
  const usable = (accounts: Account[]): Account[] => accounts.filter((a) => !rejected.has(a.id) && (p.seats === "shared" || a.id === id2 || !present.has(a.id)));
130
132
  const skipOrThrow = (e: unknown, candidate: Account): void => {
131
133
  if (p.classifySwapError(e) === "fatal") throw e;
@@ -104,6 +104,7 @@ export function pruneStaleSessions(now: number): void {
104
104
  export type SupervisedSession = { sid: string; launchedAt: number | null; live: string };
105
105
 
106
106
  const LaunchedAtSchema = z.coerce.number().finite().optional().catch(undefined);
107
+ const RefusedSchema = z.string().optional().transform((ids) => (ids ?? "").split(",").filter((id) => id !== ""));
107
108
 
108
109
  export function supervisedSession(env: Record<string, string | undefined> = process.env): SupervisedSession | null {
109
110
  if (env.TOKENMAXXING_SUPERVISED !== "1") return null;
@@ -112,6 +113,10 @@ export function supervisedSession(env: Record<string, string | undefined> = proc
112
113
  return { sid, launchedAt: LaunchedAtSchema.parse(env.TOKENMAXXING_LAUNCHED_AT) ?? null, live: liveSessionId(sid) };
113
114
  }
114
115
 
116
+ export function refusedAccounts(env: Record<string, string | undefined> = process.env): string[] {
117
+ return RefusedSchema.parse(env.TOKENMAXXING_REFUSED);
118
+ }
119
+
115
120
  export function adoptLiveSession(session: SupervisedSession, stdinSid: string | undefined): SupervisedSession | null {
116
121
  if (stdinSid == null || stdinSid === session.live) return session;
117
122
  const child = presencePid({ dir: paths.presenceDir, id: session.sid });
@@ -124,7 +129,7 @@ export function adoptLiveSession(session: SupervisedSession, stdinSid: string |
124
129
  return { ...session, live: stdinSid };
125
130
  }
126
131
 
127
- export function writeRespawnMarker(input: { session: SupervisedSession; accountId: string; waitUntil: number; compact: boolean; origin: z.infer<typeof RespawnMarkerSchema>["origin"] }): void {
132
+ export function writeRespawnMarker(input: { session: SupervisedSession; accountId: string; waitUntil: number; compact: boolean; origin: z.infer<typeof RespawnMarkerSchema>["origin"]; refused?: string[] }): void {
128
133
  const payload: z.infer<typeof RespawnMarkerSchema> = {
129
134
  accountId: input.accountId,
130
135
  ts: Date.now(),
@@ -133,6 +138,7 @@ export function writeRespawnMarker(input: { session: SupervisedSession; accountI
133
138
  compact: input.compact,
134
139
  origin: input.origin,
135
140
  ...(input.session.launchedAt != null ? { launchedAt: input.session.launchedAt } : {}),
141
+ ...(input.refused != null ? { refused: input.refused } : {}),
136
142
  };
137
143
  writeFileAtomic(join(paths.respawnDir, input.session.sid), JSON.stringify(payload));
138
144
  }
package/src/lib/types.ts CHANGED
@@ -84,7 +84,7 @@ export type AccountsIndex = z.infer<typeof AccountsIndexSchema>;
84
84
 
85
85
  export type EnforcedLimit = {
86
86
  account: string;
87
- kind: "session" | "weekly" | "model";
87
+ kind: "session" | "weekly" | "model" | "credits";
88
88
  family: string | null;
89
89
  resetsAt: number | null;
90
90
  blind: boolean;
@@ -150,6 +150,7 @@ export const RespawnMarkerSchema = z.object({
150
150
  compact: z.boolean(),
151
151
  origin: z.enum(["stop", "sessionstart", "stopfailure", "seatwatch"]),
152
152
  launchedAt: z.number().optional(),
153
+ refused: z.array(z.string()).optional(),
153
154
  });
154
155
 
155
156
  export const EpochSecondsSchema = z.number().transform((seconds) => seconds * 1000);
package/src/lib/usage.ts CHANGED
@@ -56,6 +56,7 @@ export const TranscriptRowSchema = z.looseObject({
56
56
  timestamp: z.string().optional(),
57
57
  isApiErrorMessage: z.boolean().optional(),
58
58
  apiErrorIsTransient: z.boolean().optional(),
59
+ apiError: z.string().optional(),
59
60
  error: z.string().optional(),
60
61
  errorDetails: z.string().optional(),
61
62
  quotaLimits: z.looseObject({ rateLimitType: z.string().optional(), resetsAt: EpochSecondsSchema.optional() }).optional(),
@@ -114,13 +115,14 @@ export function findEnforcedRow(input: { rows: TranscriptRow[]; lastAssistantMes
114
115
  export type EnforcedClass =
115
116
  | { kind: "session"; resetsAt: number | null }
116
117
  | { kind: "weekly"; resetsAt: number | null }
117
- | { kind: "model"; family: string; resetsAt: number | null };
118
+ | { kind: "model"; family: string; resetsAt: number | null }
119
+ | { kind: "credits"; family: string; resetsAt: null };
118
120
 
119
121
  const ErrorBodySchema = z.looseObject({
120
122
  error: z.looseObject({ type: z.string().optional(), details: z.looseObject({ error_code: z.string().optional() }).optional() }).optional(),
121
123
  });
122
124
 
123
- const CREDITS_GATED_FAMILIES = ["fable"];
125
+ const CREDITS_FAMILY = "fable";
124
126
 
125
127
  export function parseErrorBody(errorDetails: string | undefined): z.infer<typeof ErrorBodySchema> | null {
126
128
  if (!errorDetails) return null;
@@ -131,19 +133,15 @@ export function parseErrorBody(errorDetails: string | undefined): z.infer<typeof
131
133
  }
132
134
 
133
135
  export function classifyEnforcedLimit(row: TranscriptRow, switchModels: string[]): EnforcedClass | null {
136
+ if (row.apiError === "model_requires_usage_credits") return { kind: "credits", family: CREDITS_FAMILY, resetsAt: null };
134
137
  const q = row.quotaLimits;
135
- if (q) {
136
- const resetsAt = q.resetsAt ?? null;
137
- const type = q.rateLimitType ?? "";
138
- if (type === "five_hour") return { kind: "session", resetsAt };
139
- if (type === "seven_day") return { kind: "weekly", resetsAt };
140
- const family = switchModels.find((f) => type.includes(f));
141
- return family ? { kind: "model", family, resetsAt } : null;
142
- }
143
- if (row.apiErrorIsTransient === true) return null;
144
- if (parseErrorBody(row.errorDetails)?.error?.type !== "rate_limit_error") return null;
145
- const family = switchModels.find((f) => CREDITS_GATED_FAMILIES.includes(f));
146
- return family ? { kind: "model", family, resetsAt: null } : null;
138
+ if (!q) return null;
139
+ const resetsAt = q.resetsAt ?? null;
140
+ const type = q.rateLimitType ?? "";
141
+ if (type === "five_hour") return { kind: "session", resetsAt };
142
+ if (type === "seven_day") return { kind: "weekly", resetsAt };
143
+ const family = switchModels.find((f) => type.includes(f));
144
+ return family ? { kind: "model", family, resetsAt } : null;
147
145
  }
148
146
 
149
147
  const FIVE_HOURS_S = 5 * 3600;