tokenmaxxing 1.63.0 → 1.65.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
@@ -31,7 +31,7 @@ It is a process manager only - spawn with inherited stdio (stdin through a relay
31
31
  - Five `~/.claude/settings.json` entries (merged - other settings keys are preserved, but the `statusLine` slot is taken over): the tokenmaxxing `statusLine` renderer (native since 2026-07-11; it also tees usage), a `subagentStatusLine` (per-subagent rows in the agents panel), a `Stop` hook, a `StopFailure` hook, a `SessionStart` hook.
32
32
  - **`~/.config/tokenmaxxing/`** - the single home for config and state:
33
33
  - `config.json` - SPARSE overrides only (thresholds.session/weekly, claudeBin/codexBin/grokBin/opencodeBin pins, policy.*); defaults apply at read time, `xx config` prints the path and the effective values, and edits happen in an editor.
34
- - `accounts.json` - non-secret index `{id, label, email, tier, addedAt, windows, lastUsageAt, lastProbeAt, probeFails, storeFails, enforcedUntil, needsReauth, oauthAccount}` (each cached window carries its name, duration, reset, and sample time; the same shape serves the Codex pool as `codex-accounts.json`). Neither index names an active account: the schema is `{version, accounts}`, and an older file that still carries `activeId` loads and drops the key on the next save.
34
+ - `accounts.json` - non-secret index `{id, label, email, tier, addedAt, windows, lastUsageAt, lastProbeAt, probeFails, storeFails, usageRetryAt, enforcedUntil, needsReauth, oauthAccount}` (each cached window carries its name, duration, reset, and sample time; the same shape serves the Codex pool as `codex-accounts.json`). Neither index names an active account: the schema is `{version, accounts}`, and an older file that still carries `activeId` loads and drops the key on the next save.
35
35
  - `stores/<uuid8>/` - one Claude Code credential store per pooled account (see below).
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.
@@ -59,7 +59,7 @@ The Stop hook's stdin has no usage data, but the **statusLine does** (`rate_limi
59
59
  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.
60
60
 
61
61
  ### 3.3 Move and depleted-pool pause (supervisor)
62
- 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 and from the supervisor's own seat watch, `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. In a terminal session the supervisor's own notices go to stderr. In a stream-json session (`--output-format stream-json`, read from the effective flags like the relay) there is no terminal and no statusline, so the supervisor writes each notice (the compaction and its failure, the move, the depleted-pool wait with its resume time, the resume) to stdout as a `system` line of subtype `informational` and level `warning`, the shape Claude Code emits for its own print-mode notices, which the harness renders in the conversation while the stream stays parseable; the per-second countdown ticks stay terminal-only, and the supervisor writes only while no child is running. The `SessionStart` hook (every source) 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. The supervisor also watches its own seat every ten seconds while the child runs (account record plus tee, the same bars) and writes the marker itself when the seat is at or over a bar, so a move never depends on a hook firing: a harness session between turns whose background workflow burns the window, or a subagent refusal that stamped the wall, is moved within seconds. SIGTERM to the supervisor is forwarded to the child and the supervisor exits after it, so a harness that stops the wrapper (the Agent SDK does) never leaves an orphaned claude on the old seat. A marker whose `launchedAt` does not match the running child is discarded, and after a Ctrl-C on the countdown a later marker whose `waitUntil` falls inside the skipped wait is ignored. A marker that fails to parse is never deleted and never treated as absent: the supervisor stops the child, restores the terminal, and exits naming the marker path. In a stream-json session that fatal, the wrap-depth abort, the wrap-rate abort, and a failed presence write also go to stdout as the same `informational`/`warning` line.
62
+ 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 and from the supervisor's own seat watch, `false` from StopFailure: it runs `claude -p --resume <live-id> /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 <live-id>` (`--session-id <live-id>` when that id has no transcript yet, because `--resume` exits with `No conversation found` for it) with `CLAUDE_SECURESTORAGE_CONFIG_DIR` set to the target's store and rewrites the session's presence. The marker file is keyed by the launch id `<sid>`; the live id inside it is the session id the supervised process runs now, which `/clear` and an in-session `/resume` change. A hook adopts a differing stdin id as the live id (`current` in `sessions/<sid>.json`) only when its own process descends, through shell processes alone, from the presence record's pid; any other differing id is a nested session and writes nothing. The resumed process reads that store cold → runs on the target account, same conversation. The `--session-id` path is the exception: the cleared conversation has no prior turn, so it starts empty with no compaction and no first prompt. On the `--resume` path 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. In a terminal session the supervisor's own notices go to stderr. In a stream-json session (`--output-format stream-json`, read from the effective flags like the relay) there is no terminal and no statusline, so the supervisor writes each notice (the compaction and its failure, the move, the depleted-pool wait with its resume time, the resume) to stdout as a `system` line of subtype `informational` and level `warning`, the shape Claude Code emits for its own print-mode notices, which the harness renders in the conversation while the stream stays parseable; the per-second countdown ticks stay terminal-only, and the supervisor writes only while no child is running. The `SessionStart` hook (every source) 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. The supervisor also watches its own seat every ten seconds while the child runs (account record plus tee, the same bars) and writes the marker itself when the seat is at or over a bar, so a move never depends on a hook firing: a harness session between turns whose background workflow burns the window, or a subagent refusal that stamped the wall, is moved within seconds. SIGTERM to the supervisor is forwarded to the child and the supervisor exits after it, so a harness that stops the wrapper (the Agent SDK does) never leaves an orphaned claude on the old seat. A marker whose `launchedAt` does not match the running child is discarded, and after a Ctrl-C on the countdown a later marker whose `waitUntil` falls inside the skipped wait is ignored. A marker that fails to parse is never deleted and never treated as absent: the supervisor stops the child, restores the terminal, and exits naming the marker path. In a stream-json session that fatal, the wrap-depth abort, the wrap-rate abort, and a failed presence write also go to stdout as the same `informational`/`warning` line.
63
63
 
64
64
  ### 3.4 Placement (supervisor, under the pool lock)
65
65
  1. Fold every account's tee into the index.
@@ -84,7 +84,7 @@ Each terminal ran the supervisor, so each has its own child `claude`, its own `-
84
84
  The [switching policy](docs/content/docs/switching.mdx) is one rule: under both bars, hold; at or over a bar, move to the usable account that ranks first; nothing usable, wait for the soonest reset. The same ranking places a session at launch.
85
85
 
86
86
  - Ranking (decided by consult for #79): greatest remaining headroom to the session bar divided by the number of sessions after placement, `(bar - used) / (sessions + 1)`, then highest pace pressure (remaining percent of the binding gated per-model cap, or of the weekly aggregate for an account with no gated row, over time to its reset), then soonest weekly reset, then lowest weekly usage; unknown usage ranks last. Organization membership is not an input. Codex drops the headroom key, keeps the pace ordering and the weekly tie-breaks, and excludes accounts running in another codex session, because codex has no cross-process refresh lock and two sessions must never share a login.
87
- - Selection reads the cached figures only, after folding every account's tee. Each periodic check tick samples up to three accounts whose last sample attempt is oldest, skipping any attempted within `policy.usagePollTtlMs`, over the direct no-spend usage read with the account's stored token, so every account is attempted about once per three ticks per pooled account, or once per `policy.usagePollTtlMs` plus a tick when that is longer, and its cached figure is as fresh as its last successful attempt or its sessions' latest push. The pool lock covers only the reservation and the result write, never the usage GET. An expired stored access token stays unmeasured until a session on that store refreshes it.
87
+ - Selection reads the cached figures only, after folding every account's tee. Each periodic check tick samples up to three accounts whose last sample attempt is oldest, skipping any attempted within `policy.usagePollTtlMs`, over the direct no-spend usage read with the account's stored token, so every account is attempted about once per three ticks per pooled account, or once per `policy.usagePollTtlMs` plus a tick when that is longer, and its cached figure is as fresh as its last successful attempt or its sessions' latest push. The pool lock covers only the reservation and the result write, never the usage GET. The usage endpoint rate limits each account on its own and names the time to come back in the `retry-after` of its HTTP 429; that time is stored as `usageRetryAt`, and no sampler (the tick, `status`, the seat's in-decision sample) reads the account before it. An expired stored access token stays unmeasured until a session on that store refreshes it.
88
88
  - The depleted pause happens at the bar, not at 100; Codex has no pause and rides its account until the server refuses it, because a running sibling cannot adopt another account without restarting.
89
89
  - Neither pool has a post-swap cooldown or a manual `switch`: a move is a respawn of one session under a fixed store, so nothing another session reads is invalidated by it, and placement at launch replaces the manual pick.
90
90
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tokenmaxxing",
3
- "version": "1.63.0",
3
+ "version": "1.65.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 { supervisedSession, writeRespawnMarker } from "../lib/sessions.ts";
5
+ import { adoptLiveSession, 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";
@@ -39,15 +39,16 @@ export async function runStopFailureHook(): Promise<number> {
39
39
  if (stdin.error !== undefined && stdin.error !== "rate_limit") return 0;
40
40
 
41
41
  const stdinSid = stdin.session_id;
42
- const session = supervisedSession();
43
42
  const mainLoop = stdin.agent_id === undefined;
44
- if (stdinSid != null && session != null && stdinSid !== session.sid) {
45
- log("stopfailure.nested_session", { stdin: stdinSid.slice(0, 8), supervised: session.sid.slice(0, 8) });
46
- return 0;
47
- }
48
- const canRespawn = session != null && mainLoop;
49
43
 
50
44
  try {
45
+ const supervised = supervisedSession();
46
+ const session = supervised == null ? null : adoptLiveSession(supervised, stdinSid);
47
+ if (supervised != null && session == null) {
48
+ log("stopfailure.nested_session", { stdin: stdinSid?.slice(0, 8), supervised: supervised.live.slice(0, 8) });
49
+ return 0;
50
+ }
51
+ const canRespawn = session != null && mainLoop;
51
52
  const cfg = loadConfig();
52
53
  const row = stdin.transcript_path
53
54
  ? await awaitEnforcedRow({ transcriptPath: stdin.transcript_path, lastAssistantMessage: stdin.last_assistant_message, now })
@@ -81,8 +82,8 @@ export async function runStopFailureHook(): Promise<number> {
81
82
 
82
83
  const decision = await evaluateAndMaybeSwap(claude, now, canRespawn && enforced != null, enforced);
83
84
  if (enforced && session && canRespawn && decision.account && (decision.swapped || decision.waitUntil !== undefined)) {
84
- writeRespawnMarker({ session, sessionId: stdinSid ?? session.sid, accountId: decision.account.id, waitUntil: decision.waitUntil ?? now, compact: false });
85
- log("stopfailure.marker", { session: session.sid.slice(0, 8), account: decision.account.id.slice(0, 8), waitUntil: decision.waitUntil ?? now });
85
+ writeRespawnMarker({ session, accountId: decision.account.id, waitUntil: decision.waitUntil ?? now, compact: false });
86
+ 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 });
86
87
  } else {
87
88
  log("stopfailure.decision", { reason: decision.reason, swapped: decision.swapped, account: decision.account?.id.slice(0, 8), waitUntil: decision.waitUntil });
88
89
  }
@@ -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 { supervisedSession, writeRespawnMarker } from "../lib/sessions.ts";
5
+ import { adoptLiveSession, supervisedSession, writeRespawnMarker } from "../lib/sessions.ts";
6
6
  import { JsonTextSchema } from "../lib/types.ts";
7
7
  import { errorMessage, log } from "../lib/log.ts";
8
8
 
@@ -16,18 +16,18 @@ export async function runBoundaryHook(event: "stop" | "sessionstart"): Promise<n
16
16
 
17
17
  const parsed = BoundaryStdin.safeParse(JsonTextSchema.safeParse(await readStdin()).data);
18
18
  const stdin = parsed.success ? parsed.data : {};
19
- const session = supervisedSession();
20
- const nested = stdin.session_id != null && session != null && stdin.session_id !== session.sid;
21
- if (nested) {
22
- log(`${event}.nested_session`, { stdin: stdin.session_id?.slice(0, 8), supervised: session?.sid.slice(0, 8) });
23
- return 0;
24
- }
25
19
 
26
20
  try {
21
+ const supervised = supervisedSession();
22
+ const session = supervised == null ? null : adoptLiveSession(supervised, stdin.session_id);
23
+ if (supervised != null && session == null) {
24
+ log(`${event}.nested_session`, { stdin: stdin.session_id?.slice(0, 8), supervised: supervised.live.slice(0, 8), source: stdin.source });
25
+ return 0;
26
+ }
27
27
  const decision = await evaluateAndMaybeSwap(claude, Date.now(), session != null);
28
28
  if (session && decision.account && (decision.swapped || decision.waitUntil !== undefined)) {
29
- writeRespawnMarker({ session, sessionId: stdin.session_id ?? session.sid, accountId: decision.account.id, waitUntil: decision.waitUntil ?? Date.now(), compact: true });
30
- log(`${event}.${decision.waitUntil !== undefined ? "wait" : "move"}`, { source: stdin.source, account: decision.account.id.slice(0, 8), waitUntil: decision.waitUntil, session: session.sid.slice(0, 8) });
29
+ writeRespawnMarker({ session, accountId: decision.account.id, waitUntil: decision.waitUntil ?? Date.now(), compact: true });
30
+ log(`${event}.${decision.waitUntil !== undefined ? "wait" : "move"}`, { source: stdin.source, account: decision.account.id.slice(0, 8), waitUntil: decision.waitUntil, session: session.sid.slice(0, 8), live: session.live.slice(0, 8) });
31
31
  }
32
32
  } catch (e) {
33
33
  log(`${event}.error`, { err: errorMessage(e) });
@@ -15,7 +15,7 @@ import { readLines } from "../lib/proc.ts";
15
15
  import { teeObservation } from "../lib/sample.ts";
16
16
  import { exitStatus, loopGuardTripped, raceMarkerOrExit, recordPresenceOrStop, runPassthrough } from "../lib/supervise.ts";
17
17
  import { saveTermios } from "../lib/tty.ts";
18
- import { loadSessionFlags, pruneStaleSessions, saveSessionFlags, writeRespawnMarker } from "../lib/sessions.ts";
18
+ import { liveSessionId, loadSessionFlags, pruneStaleSessions, saveSessionFlags, writeRespawnMarker } from "../lib/sessions.ts";
19
19
  import { loadAccounts, loadConfig } from "../lib/state.ts";
20
20
  import { gatedFamilies, modelFromFlag } from "../lib/usage.ts";
21
21
  import { RespawnMarkerSchema, type Account, type Config, type ModelInfo } from "../lib/types.ts";
@@ -225,7 +225,7 @@ async function moveExhaustedSeat(seat: Account, sid: string, gate: MarkerGate, m
225
225
  const decision = await evaluateAndMaybeSwap(claude, now, true, null, seat.id, families);
226
226
  log("supervisor.seat_exhausted", { seat: seat.id.slice(0, 8), until, reason: decision.reason, account: decision.account?.id.slice(0, 8), waitUntil: decision.waitUntil });
227
227
  if (decision.account && (decision.swapped || decision.waitUntil !== undefined)) {
228
- writeRespawnMarker({ session: { sid, launchedAt: gate.launchedAt }, sessionId: sid, accountId: decision.account.id, waitUntil: decision.waitUntil ?? now, compact: true });
228
+ writeRespawnMarker({ session: { sid, launchedAt: gate.launchedAt, live: liveSessionId(sid) }, accountId: decision.account.id, waitUntil: decision.waitUntil ?? now, compact: true });
229
229
  }
230
230
  } catch (e) {
231
231
  log("supervisor.seat_watch_error", { err: errorMessage(e) });
@@ -508,7 +508,7 @@ export async function runSupervisor(argv: string[]): Promise<number> {
508
508
  saveSessionFlags(m.sessionId, persistable, process.cwd());
509
509
  const prompt = resumable ? resumePrompt(compacted) : null;
510
510
  firstLine = relay !== null && prompt !== null ? userLine(prompt) : null;
511
- launchArgs = ["--resume", m.sessionId, ...(relay === null && prompt !== null ? [prompt] : []), ...persistable];
511
+ launchArgs = [resumable ? "--resume" : "--session-id", m.sessionId, ...(relay === null && prompt !== null ? [prompt] : []), ...persistable];
512
512
  continue;
513
513
  }
514
514
  clearPresence({ dir: paths.presenceDir, id: sid });
package/src/lib/claude.ts CHANGED
@@ -12,7 +12,7 @@ import { claudePool, env, paths, seatFromEnv, storeDirFor } from "./paths.ts";
12
12
  import { pickBest, pickEarliestReset, thresholdBars, type PickCtx } from "./picker.ts";
13
13
  import { seatCounts } from "./presence.ts";
14
14
  import type { Observation, Provider, SampleReport } from "./provider.ts";
15
- import { foldTee, sampleAccountUsage, teeObservation } from "./sample.ts";
15
+ import { foldTee, sampleAccountUsage, teeObservation, usageBlockedUntil } from "./sample.ts";
16
16
  import { clearUsageSnapshot, loadAccounts, loadConfig, loadUsageSnapshot, pinBinOverride, saveAccounts, type Harvest } from "./state.ts";
17
17
  import { loadSetupTokens, saveSetupTokens } from "./setuptokens.ts";
18
18
  import { saveTermios, restoreTermios } from "./tty.ts";
@@ -43,7 +43,7 @@ async function observeLive(account: Account, cfg: Config, now: number, opts: { p
43
43
  const probeAttempted = account.lastProbeAt != null && now - account.lastProbeAt <= interval;
44
44
  const snap = loadUsageSnapshot(account.id);
45
45
  const fresh = snap != null && now - snap.at <= ttl;
46
- if (opts.probe && !probeAttempted && (!fresh || opts.perModel)) {
46
+ if (opts.probe && !probeAttempted && usageBlockedUntil(account, now) == null && (!fresh || opts.perModel)) {
47
47
  const startedAt = Date.now();
48
48
  const outcome = await sampleAccountUsage(account);
49
49
  await withLock(claudePool.lockFile, () => {
@@ -61,6 +61,7 @@ async function observeLive(account: Account, cfg: Config, now: number, opts: { p
61
61
  }
62
62
  } else {
63
63
  a.probeFails = (a.probeFails ?? 0) + 1;
64
+ if (outcome.retryAt != null) a.usageRetryAt = outcome.retryAt;
64
65
  }
65
66
  saveAccounts(claudePool, idx);
66
67
  });
@@ -82,6 +83,7 @@ async function samplePool(accounts: Account[]): Promise<Map<string, SampleReport
82
83
  }
83
84
  const outcome = await sampleAccountUsage(a);
84
85
  if (!outcome.ok) {
86
+ if (outcome.retryAt != null) a.usageRetryAt = outcome.retryAt;
85
87
  reports.set(a.id, { ok: false, reason: outcome.reason });
86
88
  return;
87
89
  }
@@ -215,7 +217,7 @@ async function login(): Promise<Harvest | null> {
215
217
 
216
218
  console.log(c.dim("sampling usage..."));
217
219
  const sampled = await fetchUsageDirect(blob.claudeAiOauth.accessToken);
218
- if (!sampled) console.log(c.yellow("could not sample usage now - it will fill in on first use."));
220
+ if (!sampled.ok) console.log(c.yellow("could not sample usage now - it will fill in on first use."));
219
221
  const at = Date.now();
220
222
  const id = oauthAccount.accountUuid;
221
223
  return {
@@ -223,7 +225,8 @@ async function login(): Promise<Harvest | null> {
223
225
  email: oauthAccount.emailAddress,
224
226
  tier: claudeTierLabel(blob.claudeAiOauth),
225
227
  oauthAccount,
226
- sample: sampled ? { windows: windowsOf(sampled, at), at } : null,
228
+ sample: sampled.ok ? { windows: windowsOf(sampled.usage, at), at } : null,
229
+ ...(!sampled.ok && sampled.retryAt != null ? { usageRetryAt: sampled.retryAt } : {}),
227
230
  park: () => writeItem(storeTarget(id), claudeAiOauthOnly(blobRaw)),
228
231
  };
229
232
  } finally {
@@ -18,6 +18,17 @@ export function writePresence(input: { dir: string; id: string; accountId: strin
18
18
  writeFileAtomic(join(input.dir, input.id), JSON.stringify(PresenceSchema.parse({ accountId: input.accountId, pid: input.pid, startedAt })));
19
19
  }
20
20
 
21
+ export function presencePid(input: { dir: string; id: string }): number | null {
22
+ let raw: string;
23
+ try {
24
+ raw = readFileSync(join(input.dir, input.id), "utf8");
25
+ } catch (e) {
26
+ if (ErrnoSchema.safeParse(e).data?.code === "ENOENT") return null;
27
+ throw e;
28
+ }
29
+ return PresenceSchema.parse(JSON.parse(raw)).pid;
30
+ }
31
+
21
32
  export function clearPresence(input: { dir: string; id: string }): void {
22
33
  rmSync(join(input.dir, input.id), { force: true });
23
34
  }
package/src/lib/proc.ts CHANGED
@@ -1,5 +1,30 @@
1
+ import { basename } from "node:path";
1
2
  import { ErrnoSchema } from "./types.ts";
2
3
 
4
+ const SHELLS = new Set(["sh", "bash", "dash", "zsh", "ksh", "fish"]);
5
+ const MAX_SHELL_HOPS = 4;
6
+
7
+ function parentAndName(pid: number): { ppid: number; comm: string } | null {
8
+ const res = Bun.spawnSync(["ps", "-p", String(pid), "-o", "ppid=", "-o", "comm="], { env: { ...process.env, LC_ALL: "C" } });
9
+ if (res.exitCode !== 0) return null;
10
+ const row = res.stdout.toString().trim();
11
+ const cut = row.indexOf(" ");
12
+ if (cut === -1) return null;
13
+ const ppid = Number(row.slice(0, cut));
14
+ return Number.isInteger(ppid) ? { ppid, comm: row.slice(cut).trim() } : null;
15
+ }
16
+
17
+ export function spawnedThroughShellsBy(ancestor: number): boolean {
18
+ let cur = process.ppid;
19
+ for (let hop = 0; hop <= MAX_SHELL_HOPS; hop++) {
20
+ if (cur === ancestor) return true;
21
+ const proc = parentAndName(cur);
22
+ if (proc == null || !SHELLS.has(basename(proc.comm))) return false;
23
+ cur = proc.ppid;
24
+ }
25
+ return false;
26
+ }
27
+
3
28
  export function pidStartTime(pid: number): string | null {
4
29
  const res = Bun.spawnSync(["ps", "-p", String(pid), "-o", "lstart="], { env: { ...process.env, LC_ALL: "C" } });
5
30
  if (res.exitCode !== 0) return null;
package/src/lib/sample.ts CHANGED
@@ -9,7 +9,7 @@ import { loadAccounts, loadUsageSnapshot, saveAccounts } from "./state.ts";
9
9
  import { fetchUsageDirect, mergeWindows, windowsOf } from "./usage.ts";
10
10
  import type { Account, Config, UsageWindows } from "./types.ts";
11
11
 
12
- export type SampleOutcome = { ok: true; usage: UsageWindows; via: "get" } | { ok: false; reason: string };
12
+ export type SampleOutcome = { ok: true; usage: UsageWindows; via: "get" } | { ok: false; reason: string; retryAt?: number };
13
13
 
14
14
  type PreparedSample = { ok: true; token: string | null } | { ok: false; reason: string };
15
15
 
@@ -48,13 +48,26 @@ export async function prepareSample(account: Account): Promise<PreparedSample> {
48
48
  return { ok: true, token: creds.accessToken };
49
49
  }
50
50
 
51
+ export function usageBlockedUntil(account: Account, now: number): number | null {
52
+ return account.usageRetryAt != null && account.usageRetryAt > now ? account.usageRetryAt : null;
53
+ }
54
+
55
+ function rateLimitedReason(retryAt: number, now: number): string {
56
+ return `the usage endpoint rate limited this account, next read in ${Math.ceil((retryAt - now) / 60_000)}m`;
57
+ }
58
+
51
59
  export async function runSample(account: Account, token: string | null): Promise<SampleOutcome> {
52
60
  if (!token) return { ok: false, reason: "no usable access token in the store - run `tokenmaxxing auth`" };
53
- const usage = await fetchUsageDirect(token);
54
- return usage ? { ok: true, usage, via: "get" } : { ok: false, reason: "usage read failed (see log)" };
61
+ const read = await fetchUsageDirect(token);
62
+ if (read.ok) return { ok: true, usage: read.usage, via: "get" };
63
+ if (read.retryAt == null) return { ok: false, reason: "usage read failed (see log)" };
64
+ return { ok: false, reason: rateLimitedReason(read.retryAt, Date.now()), retryAt: read.retryAt };
55
65
  }
56
66
 
57
67
  export async function sampleAccountUsage(account: Account): Promise<SampleOutcome> {
68
+ const now = Date.now();
69
+ const blockedUntil = usageBlockedUntil(account, now);
70
+ if (blockedUntil != null) return { ok: false, reason: rateLimitedReason(blockedUntil, now) };
58
71
  const prepared = await prepareSample(account);
59
72
  return prepared.ok ? runSample(account, prepared.token) : prepared;
60
73
  }
@@ -89,7 +102,7 @@ export async function sampleOldest(cfg: Config): Promise<void> {
89
102
  continue;
90
103
  }
91
104
  target.storeFails = 0;
92
- batch.push({ account: target, token: prepared.token });
105
+ if (usageBlockedUntil(target, now) == null) batch.push({ account: target, token: prepared.token });
93
106
  }
94
107
  saveAccounts(claudePool, idx);
95
108
  return batch;
@@ -106,6 +119,10 @@ export async function sampleOldest(cfg: Config): Promise<void> {
106
119
  stored.needsReauth = true;
107
120
  dirty = true;
108
121
  }
122
+ if (stored && !outcome.ok && outcome.retryAt != null) {
123
+ stored.usageRetryAt = outcome.retryAt;
124
+ dirty = true;
125
+ }
109
126
  if (stored && outcome.ok && (stored.lastUsageAt == null || startedAt > stored.lastUsageAt)) {
110
127
  stored.windows = mergeWindows(windowsOf(outcome.usage, startedAt), stored.windows);
111
128
  stored.lastUsageAt = startedAt;
@@ -1,12 +1,14 @@
1
1
  import { existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, rmSync, statSync, type Dirent } from "node:fs";
2
- import { join, sep } from "node:path";
2
+ import { basename, join, sep } from "node:path";
3
3
  import { z } from "zod";
4
4
  import { errorMessage, log } from "./log.ts";
5
5
  import { codexPaths, grokPaths, opencodeGoPaths, paths } from "./paths.ts";
6
6
  import { writeFileAtomic } from "./atomic.ts";
7
+ import { presencePid } from "./presence.ts";
8
+ import { spawnedThroughShellsBy } from "./proc.ts";
7
9
  import type { RespawnMarkerSchema } from "./types.ts";
8
10
 
9
- const SessionSchema = z.object({ flags: z.array(z.string()), cwd: z.string() });
11
+ const SessionSchema = z.object({ flags: z.array(z.string()), cwd: z.string(), current: z.uuid().optional() });
10
12
 
11
13
  const SESSION_RETENTION_MS = 30 * 24 * 3600 * 1000;
12
14
 
@@ -21,10 +23,24 @@ export function saveSessionFlags(sid: string, flags: string[], cwd: string): voi
21
23
  writeFileAtomic(sessionFile(sid), JSON.stringify({ flags, cwd }));
22
24
  }
23
25
 
24
- export function loadSessionFlags(sid: string): string[] | null {
26
+ function loadSession(sid: string): z.infer<typeof SessionSchema> | null {
25
27
  const f = sessionFile(sid);
26
28
  if (!existsSync(f)) return null;
27
- return SessionSchema.parse(JSON.parse(readFileSync(f, "utf8"))).flags;
29
+ return SessionSchema.parse(JSON.parse(readFileSync(f, "utf8")));
30
+ }
31
+
32
+ export function loadSessionFlags(sid: string): string[] | null {
33
+ return loadSession(sid)?.flags ?? null;
34
+ }
35
+
36
+ export function liveSessionId(sid: string): string {
37
+ return loadSession(sid)?.current ?? sid;
38
+ }
39
+
40
+ function recordLiveSession(sid: string, current: string): void {
41
+ const session = loadSession(sid);
42
+ mkdirSync(sessionsDir(), { recursive: true });
43
+ writeFileAtomic(sessionFile(sid), JSON.stringify({ flags: session?.flags ?? [], cwd: session?.cwd ?? process.cwd(), current }));
28
44
  }
29
45
 
30
46
  const DEAD_STATE_ENTRIES = [
@@ -101,13 +117,14 @@ export function pruneStaleSessions(now: number): void {
101
117
  for (const f of listDir(dir, root)) {
102
118
  const p = join(dir, f.name);
103
119
  try {
120
+ if (existsSync(join(paths.presenceDir, basename(f.name, ".json")))) continue;
104
121
  if (now - statSync(p).mtimeMs > SESSION_RETENTION_MS) rmSync(p, { force: true });
105
122
  } catch {
106
123
  }
107
124
  }
108
125
  }
109
126
 
110
- export type SupervisedSession = { sid: string; launchedAt: number | null };
127
+ export type SupervisedSession = { sid: string; launchedAt: number | null; live: string };
111
128
 
112
129
  const LaunchedAtSchema = z.coerce.number().finite().optional().catch(undefined);
113
130
 
@@ -115,16 +132,28 @@ export function supervisedSession(env: Record<string, string | undefined> = proc
115
132
  if (env.TOKENMAXXING_SUPERVISED !== "1") return null;
116
133
  const sid = env.TOKENMAXXING_SESSION_ID;
117
134
  if (sid == null || sid === "") return null;
118
- return { sid, launchedAt: LaunchedAtSchema.parse(env.TOKENMAXXING_LAUNCHED_AT) ?? null };
135
+ return { sid, launchedAt: LaunchedAtSchema.parse(env.TOKENMAXXING_LAUNCHED_AT) ?? null, live: liveSessionId(sid) };
136
+ }
137
+
138
+ export function adoptLiveSession(session: SupervisedSession, stdinSid: string | undefined): SupervisedSession | null {
139
+ if (stdinSid == null || stdinSid === session.live) return session;
140
+ const child = presencePid({ dir: paths.presenceDir, id: session.sid });
141
+ if (child == null || !spawnedThroughShellsBy(child)) {
142
+ log("session.live_id_refused", { reason: child == null ? "no-presence" : "not-descendant", stdin: stdinSid.slice(0, 8), live: session.live.slice(0, 8), session: session.sid.slice(0, 8) });
143
+ return null;
144
+ }
145
+ recordLiveSession(session.sid, stdinSid);
146
+ log("session.live_id", { from: session.live.slice(0, 8), to: stdinSid.slice(0, 8), session: session.sid.slice(0, 8) });
147
+ return { ...session, live: stdinSid };
119
148
  }
120
149
 
121
- export function writeRespawnMarker(input: { session: SupervisedSession; sessionId: string; accountId: string; waitUntil: number; compact: boolean }): void {
150
+ export function writeRespawnMarker(input: { session: SupervisedSession; accountId: string; waitUntil: number; compact: boolean }): void {
122
151
  mkdirSync(paths.respawnDir, { recursive: true });
123
152
  const payload: z.infer<typeof RespawnMarkerSchema> = {
124
153
  accountId: input.accountId,
125
154
  ts: Date.now(),
126
155
  waitUntil: input.waitUntil,
127
- sessionId: input.sessionId,
156
+ sessionId: input.session.live,
128
157
  compact: input.compact,
129
158
  ...(input.session.launchedAt != null ? { launchedAt: input.session.launchedAt } : {}),
130
159
  };
package/src/lib/state.ts CHANGED
@@ -73,6 +73,7 @@ export type Harvest = {
73
73
  tier: string | null;
74
74
  oauthAccount?: Account["oauthAccount"];
75
75
  sample: { windows: Window[]; at: number } | null;
76
+ usageRetryAt?: number;
76
77
  park: () => Promise<void>;
77
78
  };
78
79
 
@@ -92,6 +93,7 @@ export function upsertAccount(
92
93
  lastUsageAt: h.sample ? h.sample.at : existing?.lastUsageAt,
93
94
  needsReauth: false,
94
95
  ...(h.oauthAccount ? { oauthAccount: h.oauthAccount } : {}),
96
+ usageRetryAt: h.sample ? undefined : (h.usageRetryAt ?? existing?.usageRetryAt),
95
97
  };
96
98
  if (existing) Object.assign(existing, fresh);
97
99
  else idx.accounts.push(fresh);
package/src/lib/types.ts CHANGED
@@ -69,6 +69,7 @@ export const AccountSchema = z.object({
69
69
  lastProbeAt: z.number().optional(),
70
70
  probeFails: z.number().optional(),
71
71
  storeFails: z.number().optional(),
72
+ usageRetryAt: z.number().optional(),
72
73
  enforcedUntil: z.number().optional(),
73
74
  needsReauth: z.boolean().optional(),
74
75
  oauthAccount: OAuthAccountSchema.optional(),
package/src/lib/usage.ts CHANGED
@@ -208,7 +208,11 @@ function scopedRows(limits: unknown[]): Record<string, UsageWindow> {
208
208
  return perModel;
209
209
  }
210
210
 
211
- export async function fetchUsageDirect(accessToken: string): Promise<UsageWindows | null> {
211
+ const RetryAfterSecondsSchema = z.coerce.number().int().positive();
212
+
213
+ export type UsageRead = { ok: true; usage: UsageWindows } | { ok: false; retryAt: number | null };
214
+
215
+ export async function fetchUsageDirect(accessToken: string): Promise<UsageRead> {
212
216
  let res: Response;
213
217
  try {
214
218
  res = await http.get(USAGE_URL, {
@@ -218,17 +222,18 @@ export async function fetchUsageDirect(accessToken: string): Promise<UsageWindow
218
222
  });
219
223
  } catch (e) {
220
224
  log("usage.get_failed", { err: errorMessage(e) });
221
- return null;
225
+ return { ok: false, retryAt: null };
222
226
  }
223
227
  if (!res.ok) {
224
- log("usage.get_failed", { status: res.status });
225
- return null;
228
+ const retryAfter = res.status === 429 ? RetryAfterSecondsSchema.safeParse(res.headers.get("retry-after")).data : undefined;
229
+ log("usage.get_failed", { status: res.status, retryAfter });
230
+ return { ok: false, retryAt: retryAfter != null ? Date.now() + retryAfter * 1000 : null };
226
231
  }
227
232
  const parsed = UsageResponseSchema.safeParse(JsonTextSchema.safeParse(await res.text()).data);
228
233
  if (!parsed.success || !parsed.data.five_hour || !parsed.data.seven_day) {
229
234
  log("usage.get_incomplete", { ok: parsed.success, issue: parsed.success ? undefined : z.prettifyError(parsed.error).slice(0, 200) });
230
- return null;
235
+ return { ok: false, retryAt: null };
231
236
  }
232
237
  const win = (w: z.infer<typeof UsageLimitSchema>): UsageWindow => ({ usedPercentage: w.utilization, resetsAt: w.resets_at ?? null });
233
- return { fiveHour: win(parsed.data.five_hour), sevenDay: win(parsed.data.seven_day), perModel: scopedRows(parsed.data.limits ?? []) };
238
+ return { ok: true, usage: { fiveHour: win(parsed.data.five_hour), sevenDay: win(parsed.data.seven_day), perModel: scopedRows(parsed.data.limits ?? []) } };
234
239
  }