tokenmaxxing 1.79.0 → 1.81.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 +2 -2
- package/README.md +1 -1
- package/package.json +1 -1
- package/src/entries/codexsupervisor.ts +3 -3
- package/src/entries/stopfailurehook.ts +1 -1
- package/src/entries/stophook.ts +1 -1
- package/src/entries/supervisor.ts +18 -12
- package/src/lib/claude.ts +2 -2
- package/src/lib/codexauth.ts +8 -7
- package/src/lib/compact.ts +9 -3
- package/src/lib/install.ts +4 -4
- package/src/lib/presence.ts +4 -11
- package/src/lib/sessions.ts +5 -3
- package/src/lib/settings.ts +5 -9
- package/src/lib/state.ts +14 -38
- package/src/lib/types.ts +1 -0
- package/src/lib/update.ts +4 -9
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, 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), and the launch time the marker was written for.
|
|
40
40
|
- `wait-queue.json` - the depleted-pool wait claims (`{sessionId, accountId, at, waitUntil}`), so 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.
|
|
42
42
|
- `sessions/<sid>.json`, `onboard/`, `hub-key`, `update.json`, `spawnrate.json`, `tokenmaxxing.log`, the codex `codex-onboard/`, `codex-respawn/`, and `codex-live/`, and the `grok-*` and `opencode-go-*` state of the status-only pools: the full table is in [State directory layout](docs/content/docs/state-directory.mdx).
|
|
@@ -61,7 +61,7 @@ The Stop hook's stdin has no usage data, but the **statusLine does** (`rate_limi
|
|
|
61
61
|
4. Write `respawn/<session-id>` (atomic temp+rename) naming the target, with `waitUntil` now for a plain move or the assigned reset when the pool is depleted. No credential is written.
|
|
62
62
|
|
|
63
63
|
### 3.3 Move and depleted-pool pause (supervisor)
|
|
64
|
-
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,
|
|
64
|
+
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 and the old seat's record carries no live `enforcedUntil` (`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; the compaction counts only when a `compact_boundary` row lands in the transcript, because a refused compaction still exits 0, 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). After a Stop move the previous turn had finished, so the prompt asks Claude to end the turn without restating it, and to relaunch any background Bash task, Monitor, or Workflow run that turn waited on, because the move ended it. After any other move the prompt asks Claude to continue the task, says the restart ended every background Bash task, Monitor, and Workflow run the session started, and asks Claude to relaunch what the task still needs; a SessionStart move is in that group because a prompt sent inside the marker poll window can already have started on the old seat. 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.
|
|
65
65
|
|
|
66
66
|
### 3.4 Placement (supervisor, under the pool lock)
|
|
67
67
|
1. Fold every account's tee into the index.
|
package/README.md
CHANGED
|
@@ -130,7 +130,7 @@ Two codex-specific facts worth knowing: codex does not run hooks it has not been
|
|
|
130
130
|
|
|
131
131
|
## Honest limitations
|
|
132
132
|
|
|
133
|
-
- **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; a bar-triggered move compacts the conversation on the old account first, so the first turn on the new account uploads the summary, while a move after a refusal re-uploads the full context once (prompt cache is org-scoped).
|
|
133
|
+
- **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, which ends every background Bash task, Monitor, and Workflow run the session started (the first prompt asks Claude to relaunch what the task still needs); a bar-triggered move compacts the conversation on the old account first, so the first turn on the new account uploads the summary (the full context when the compaction does not land), while a move after a refusal re-uploads the full context once (prompt cache is org-scoped).
|
|
134
134
|
- **Depleted-pause hiccup.** When the whole pool is at its limit, `claude` stops for the countdown; anything typed in that split second is lost.
|
|
135
135
|
- **Unsupervised sessions are not moved.** A claude started outside the supervisor runs on Claude Code's own login, which tokenmaxxing never writes; when a hard limit lands there, the StopFailure hook prints the shim command that resumes the session under the supervisor.
|
|
136
136
|
- **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.
|
|
3
|
+
"version": "1.81.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,4 +1,4 @@
|
|
|
1
|
-
import { existsSync, mkdirSync,
|
|
1
|
+
import { existsSync, mkdirSync, rmSync } from "node:fs";
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import { z } from "zod";
|
|
4
4
|
import { codexPaths, codexPool } from "../lib/paths.ts";
|
|
@@ -10,7 +10,7 @@ import { clearPresence, livingPresences } from "../lib/presence.ts";
|
|
|
10
10
|
import { isExhausted } from "../lib/picker.ts";
|
|
11
11
|
import { exitStatus, loopGuardTripped, raceMarkerOrExit, recordPresenceOrStop, runPassthrough } from "../lib/supervise.ts";
|
|
12
12
|
import { saveTermios } from "../lib/tty.ts";
|
|
13
|
-
import { loadAccounts } from "../lib/state.ts";
|
|
13
|
+
import { loadAccounts, readJsonFile } from "../lib/state.ts";
|
|
14
14
|
import { CodexRespawnMarkerSchema, type Account } from "../lib/types.ts";
|
|
15
15
|
import { errorMessage, log } from "../lib/log.ts";
|
|
16
16
|
|
|
@@ -26,7 +26,7 @@ const PASSTHROUGH_FLAGS = new Set(["--version", "-V", "--help", "-h"]);
|
|
|
26
26
|
|
|
27
27
|
function readCodexMarker(marker: string): z.infer<typeof CodexRespawnMarkerSchema> {
|
|
28
28
|
try {
|
|
29
|
-
return
|
|
29
|
+
return readJsonFile(marker, CodexRespawnMarkerSchema);
|
|
30
30
|
} catch (e) {
|
|
31
31
|
log("codexsupervisor.marker_invalid", { err: errorMessage(e) });
|
|
32
32
|
throw new Error(
|
|
@@ -82,7 +82,7 @@ export async function runStopFailureHook(): Promise<number> {
|
|
|
82
82
|
|
|
83
83
|
const decision = await evaluateAndMaybeSwap(claude, now, canRespawn && enforced != null, enforced, { waiterId: canRespawn ? session?.sid : undefined });
|
|
84
84
|
if (enforced && session && canRespawn && decision.account && (decision.swapped || decision.waitUntil !== undefined)) {
|
|
85
|
-
writeRespawnMarker({ session, accountId: decision.account.id, waitUntil: decision.waitUntil ?? now, compact: false });
|
|
85
|
+
writeRespawnMarker({ session, accountId: decision.account.id, waitUntil: decision.waitUntil ?? now, compact: false, origin: "stopfailure" });
|
|
86
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 });
|
|
87
87
|
} else {
|
|
88
88
|
log("stopfailure.decision", { reason: decision.reason, swapped: decision.swapped, account: decision.account?.id.slice(0, 8), waitUntil: decision.waitUntil });
|
package/src/entries/stophook.ts
CHANGED
|
@@ -26,7 +26,7 @@ export async function runBoundaryHook(event: "stop" | "sessionstart"): Promise<n
|
|
|
26
26
|
}
|
|
27
27
|
const decision = await evaluateAndMaybeSwap(claude, Date.now(), session != null, null, { waiterId: session?.sid });
|
|
28
28
|
if (session && decision.account && (decision.swapped || decision.waitUntil !== undefined)) {
|
|
29
|
-
writeRespawnMarker({ session, accountId: decision.account.id, waitUntil: decision.waitUntil ?? Date.now(), compact: true });
|
|
29
|
+
writeRespawnMarker({ session, accountId: decision.account.id, waitUntil: decision.waitUntil ?? Date.now(), compact: true, origin: event });
|
|
30
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) {
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { existsSync, mkdirSync,
|
|
1
|
+
import { existsSync, mkdirSync, rmSync, readdirSync, statSync } from "node:fs";
|
|
2
2
|
import { basename, join } from "node:path";
|
|
3
3
|
import type { FileSink, Subprocess } from "bun";
|
|
4
4
|
import { maxBy } from "es-toolkit";
|
|
@@ -16,7 +16,7 @@ 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
18
|
import { liveSessionId, loadSessionFlags, pruneStaleSessions, saveSessionFlags, writeRespawnMarker } from "../lib/sessions.ts";
|
|
19
|
-
import { loadAccounts, loadConfig, releaseWaitClaim } from "../lib/state.ts";
|
|
19
|
+
import { loadAccounts, loadConfig, readJsonFile, releaseWaitClaim } 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";
|
|
22
22
|
import { errorMessage, log } from "../lib/log.ts";
|
|
@@ -183,7 +183,7 @@ type MarkerGate = {
|
|
|
183
183
|
function consumableMarker(marker: string, gate: MarkerGate): z.infer<typeof RespawnMarkerSchema> | null {
|
|
184
184
|
let m: z.infer<typeof RespawnMarkerSchema>;
|
|
185
185
|
try {
|
|
186
|
-
m =
|
|
186
|
+
m = readJsonFile(marker, RespawnMarkerSchema);
|
|
187
187
|
} catch (e) {
|
|
188
188
|
log("supervisor.marker_invalid", { err: errorMessage(e) });
|
|
189
189
|
throw new Error(
|
|
@@ -225,7 +225,7 @@ async function moveExhaustedSeat(seat: Account, sid: string, gate: MarkerGate, m
|
|
|
225
225
|
const decision = await evaluateAndMaybeSwap(claude, now, true, null, { seatId: seat.id, sessionFamilies: families, waiterId: sid });
|
|
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, live: liveSessionId(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, origin: "seatwatch" });
|
|
229
229
|
}
|
|
230
230
|
} catch (e) {
|
|
231
231
|
log("supervisor.seat_watch_error", { err: errorMessage(e) });
|
|
@@ -264,11 +264,14 @@ async function countdownWait(acct: string, until: number, out: { stream: boolean
|
|
|
264
264
|
return aborted;
|
|
265
265
|
}
|
|
266
266
|
|
|
267
|
-
function resumePrompt(compacted: boolean): string {
|
|
268
|
-
const moved = compacted
|
|
267
|
+
function resumePrompt(input: { compacted: boolean; origin: z.infer<typeof RespawnMarkerSchema>["origin"] }): string {
|
|
268
|
+
const moved = input.compacted
|
|
269
269
|
? "tokenmaxxing compacted this conversation and resumed the session on an account with quota headroom."
|
|
270
270
|
: "tokenmaxxing resumed this session on an account with quota headroom.";
|
|
271
|
-
|
|
271
|
+
if (input.origin === "stop") {
|
|
272
|
+
return `${moved} The previous turn finished before the move. If it waited on the user or completed the task, end this turn without restating it. If it waited on a background Bash task, Monitor, or Workflow run, relaunch that work, because the move ended it.`;
|
|
273
|
+
}
|
|
274
|
+
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. The move restarted Claude Code, which ended every background Bash task, Monitor, and Workflow run this session started. Relaunch what the task still needs.`;
|
|
272
275
|
}
|
|
273
276
|
|
|
274
277
|
function userLine(text: string): string {
|
|
@@ -504,17 +507,20 @@ export async function runSupervisor(argv: string[]): Promise<number> {
|
|
|
504
507
|
rmSync(marker, { force: true });
|
|
505
508
|
respawns++;
|
|
506
509
|
noticeSid = m.sessionId;
|
|
507
|
-
const
|
|
508
|
-
const
|
|
510
|
+
const accounts = loadAccounts(claudePool).accounts;
|
|
511
|
+
const label = accounts.find((a) => a.id === m.accountId)?.label ?? m.accountId.slice(0, 8);
|
|
512
|
+
const walled = accounts.some((a) => a.id === seat?.id && a.enforcedUntil != null && a.enforcedUntil > Date.now());
|
|
513
|
+
const transcript = transcriptPath(m.sessionId);
|
|
514
|
+
const resumable = existsSync(transcript);
|
|
509
515
|
let compacted = false;
|
|
510
|
-
if (m.compact && seat && resumable) {
|
|
516
|
+
if (m.compact && seat && resumable && !walled) {
|
|
511
517
|
say(`\n\x1b[36m↻ tokenmaxxing: compacting the conversation on ${seat.label} before the move...\x1b[0m\n`, `tokenmaxxing: compacting the conversation on ${seat.label} before the move.`);
|
|
512
518
|
const compactEnv: Record<string, string | undefined> = { ...childEnv, TOKENMAXXING_PROBE: "1", CLAUDE_SECURESTORAGE_CONFIG_DIR: storeDirFor(seat.id) };
|
|
513
519
|
delete compactEnv.TOKENMAXXING_SUPERVISED;
|
|
514
520
|
delete compactEnv.TOKENMAXXING_SESSION_ID;
|
|
515
521
|
delete compactEnv.TOKENMAXXING_LAUNCHED_AT;
|
|
516
522
|
delete compactEnv.TOKENMAXXING_MODEL;
|
|
517
|
-
const outcome = await compactClaudeSession({ real, sid: m.sessionId, env: compactEnv });
|
|
523
|
+
const outcome = await compactClaudeSession({ real, sid: m.sessionId, transcript, env: compactEnv });
|
|
518
524
|
log("supervisor.compact", { sid: m.sessionId.slice(0, 8), seat: seat.id.slice(0, 8), ok: outcome.ok, reason: outcome.ok ? undefined : outcome.reason });
|
|
519
525
|
if (!outcome.ok) say(`\x1b[33m compaction did not land (${outcome.reason}) - resuming with the full context\x1b[0m\n`, `tokenmaxxing: compaction did not land; resuming with the full context. (${outcome.reason})`);
|
|
520
526
|
compacted = outcome.ok;
|
|
@@ -525,7 +531,7 @@ export async function runSupervisor(argv: string[]): Promise<number> {
|
|
|
525
531
|
await releaseClaim();
|
|
526
532
|
wanted = m.accountId;
|
|
527
533
|
saveSessionFlags(m.sessionId, persistable, process.cwd());
|
|
528
|
-
const prompt = resumable ? resumePrompt(compacted) : null;
|
|
534
|
+
const prompt = resumable ? resumePrompt({ compacted, origin: m.origin }) : null;
|
|
529
535
|
firstLine = relay !== null && prompt !== null ? userLine(prompt) : null;
|
|
530
536
|
launchArgs = [resumable ? "--resume" : "--session-id", m.sessionId, ...(relay === null && prompt !== null ? [prompt] : []), ...persistable];
|
|
531
537
|
continue;
|
package/src/lib/claude.ts
CHANGED
|
@@ -12,7 +12,7 @@ import { pickBest, pickEarliestReset, thresholdBars, type PickCtx } from "./pick
|
|
|
12
12
|
import { seatCounts } from "./presence.ts";
|
|
13
13
|
import { StoreUnusableError, type Observation, type Provider, type SampleReport } from "./provider.ts";
|
|
14
14
|
import { foldTee, sampleAccountUsage, teeObservation, usageBlockedUntil } from "./sample.ts";
|
|
15
|
-
import { clearUsageSnapshot, loadAccounts, loadConfig, loadUsageSnapshot, pinBinOverride, saveAccounts, type Harvest } from "./state.ts";
|
|
15
|
+
import { clearUsageSnapshot, loadAccounts, loadConfig, loadUsageSnapshot, pinBinOverride, readJsonFile, saveAccounts, type Harvest } from "./state.ts";
|
|
16
16
|
import { saveTermios, restoreTermios } from "./tty.ts";
|
|
17
17
|
import { fetchUsageDirect, gatedFamilies, mergeWindows, modelFromFlag, scrubCredentialEnv, windowsOf } from "./usage.ts";
|
|
18
18
|
import { CredentialBlobSchema, JsonTextSchema, OAuthAccountSchema, type Account, type Config, type ModelInfo } from "./types.ts";
|
|
@@ -185,7 +185,7 @@ async function login(): Promise<Harvest | null> {
|
|
|
185
185
|
let blob, oauthAccount;
|
|
186
186
|
try {
|
|
187
187
|
blob = CredentialBlobSchema.parse(JSON.parse(blobRaw));
|
|
188
|
-
oauthAccount =
|
|
188
|
+
oauthAccount = readJsonFile(cjPath, z.object({ oauthAccount: OAuthAccountSchema })).oauthAccount;
|
|
189
189
|
} catch {
|
|
190
190
|
console.error(c.red("could not parse the onboarded account's credential/identity."));
|
|
191
191
|
return null;
|
package/src/lib/codexauth.ts
CHANGED
|
@@ -1,22 +1,23 @@
|
|
|
1
|
-
import { existsSync, mkdirSync, readdirSync,
|
|
1
|
+
import { existsSync, mkdirSync, readdirSync, rmSync, symlinkSync } from "node:fs";
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import { z } from "zod";
|
|
4
4
|
import { writeFileAtomic } from "./atomic.ts";
|
|
5
5
|
import { codexAuthJsonFor, codexPaths, codexStoreDirFor } from "./paths.ts";
|
|
6
|
+
import { readJsonFile } from "./state.ts";
|
|
6
7
|
import { CodexAuthJsonSchema, ErrnoSchema, type CodexAuthJson } from "./types.ts";
|
|
7
8
|
|
|
9
|
+
const CodexAuthFileSchema = z
|
|
10
|
+
.looseObject({ tokens: z.unknown().optional() })
|
|
11
|
+
.transform((auth) => (auth.tokens === undefined || auth.tokens === null ? null : auth))
|
|
12
|
+
.pipe(CodexAuthJsonSchema.nullable());
|
|
13
|
+
|
|
8
14
|
export function readCodexAuthAt(input: { path: string }): CodexAuthJson | null {
|
|
9
|
-
let raw: string;
|
|
10
15
|
try {
|
|
11
|
-
|
|
16
|
+
return readJsonFile(input.path, CodexAuthFileSchema);
|
|
12
17
|
} catch (e) {
|
|
13
18
|
if (ErrnoSchema.safeParse(e).data?.code === "ENOENT") return null;
|
|
14
19
|
throw e;
|
|
15
20
|
}
|
|
16
|
-
const parsed = JSON.parse(raw);
|
|
17
|
-
const probe = z.looseObject({ tokens: z.unknown().optional() }).parse(parsed);
|
|
18
|
-
if (probe.tokens === undefined || probe.tokens === null) return null;
|
|
19
|
-
return CodexAuthJsonSchema.parse(parsed);
|
|
20
21
|
}
|
|
21
22
|
|
|
22
23
|
export function readCodexStoreAuth(accountId: string): CodexAuthJson | null {
|
package/src/lib/compact.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { statSync } from "node:fs";
|
|
1
2
|
import { delay } from "es-toolkit";
|
|
2
3
|
import { z } from "zod";
|
|
3
4
|
import pkg from "../../package.json" with { type: "json" };
|
|
@@ -10,7 +11,10 @@ export type CompactOutcome = { ok: true } | { ok: false; reason: string };
|
|
|
10
11
|
export const CLAUDE_COMPACT_KILL_MS = 300_000;
|
|
11
12
|
const PIPE_GRACE_MS = 2_000;
|
|
12
13
|
|
|
13
|
-
|
|
14
|
+
const CompactBoundarySchema = z.looseObject({ type: z.literal("system"), subtype: z.literal("compact_boundary") });
|
|
15
|
+
|
|
16
|
+
export async function compactClaudeSession(input: { real: string; sid: string; transcript: string; env: Record<string, string | undefined> }): Promise<CompactOutcome> {
|
|
17
|
+
const offset = statSync(input.transcript).size;
|
|
14
18
|
const p = Bun.spawn([input.real, "-p", "--resume", input.sid, "/compact"], {
|
|
15
19
|
env: input.env,
|
|
16
20
|
stdin: "ignore",
|
|
@@ -22,11 +26,13 @@ export async function compactClaudeSession(input: { real: string; sid: string; e
|
|
|
22
26
|
const reads = Promise.all([p.stdout.text(), p.stderr.text()]);
|
|
23
27
|
const settled = await Promise.race([reads, p.exited.then(() => delay(PIPE_GRACE_MS)).then(() => null)]);
|
|
24
28
|
await p.exited;
|
|
29
|
+
const appended = await Bun.file(input.transcript).slice(offset).text();
|
|
30
|
+
if (appended.split("\n").some((line) => CompactBoundarySchema.safeParse(JsonTextSchema.safeParse(line).data).success)) return { ok: true };
|
|
25
31
|
if (settled === null) return { ok: false, reason: "output pipes still open after child exit (leaked descendant)" };
|
|
26
32
|
const [stdout, stderr] = settled;
|
|
27
|
-
if (p.exitCode === 0) return { ok: true };
|
|
28
33
|
if (p.exitCode === null) return { ok: false, reason: `killed after ${CLAUDE_COMPACT_KILL_MS / 1000}s` };
|
|
29
|
-
|
|
34
|
+
const output = (stderr.trim() || stdout.trim()).slice(0, 200);
|
|
35
|
+
return { ok: false, reason: output ? `no compact boundary (exit ${p.exitCode}): ${output}` : `no compact boundary (exit ${p.exitCode})` };
|
|
30
36
|
}
|
|
31
37
|
|
|
32
38
|
export const CODEX_COMPACT_KILL_MS = 180_000;
|
package/src/lib/install.ts
CHANGED
|
@@ -7,7 +7,7 @@ import { codexPaths, codexStoreDirFor, HOME, optionalEnv, paths } from "./paths.
|
|
|
7
7
|
import { writeFileAtomic } from "./atomic.ts";
|
|
8
8
|
import { installedBin, installSettings, isOurHookCommand, uninstallSettings } from "./settings.ts";
|
|
9
9
|
import { CLAUDE_BIN, resolveRealBin } from "./claudebin.ts";
|
|
10
|
-
import { loadConfig } from "./state.ts";
|
|
10
|
+
import { loadConfig, readJsonFile } from "./state.ts";
|
|
11
11
|
import { ErrnoSchema } from "./types.ts";
|
|
12
12
|
|
|
13
13
|
export type InstallOutcome = { claudeWrapper: string; installedBin: string; pathAhead: boolean; timerLoaded: boolean; hubLoaded: boolean; checkIntervalS: number };
|
|
@@ -134,7 +134,7 @@ function withoutOurCodexStopHooks(groups: { hooks: { type?: string; command?: st
|
|
|
134
134
|
|
|
135
135
|
export function installCodexStopHook(): void {
|
|
136
136
|
const current = existsSync(codexPaths.hooksJson)
|
|
137
|
-
?
|
|
137
|
+
? readJsonFile(codexPaths.hooksJson, CodexHooksFileSchema)
|
|
138
138
|
: CodexHooksFileSchema.parse({});
|
|
139
139
|
const next = {
|
|
140
140
|
...current,
|
|
@@ -151,7 +151,7 @@ export function installCodexStopHook(): void {
|
|
|
151
151
|
|
|
152
152
|
export function uninstallCodexStopHook(): void {
|
|
153
153
|
if (!existsSync(codexPaths.hooksJson)) return;
|
|
154
|
-
const current =
|
|
154
|
+
const current = readJsonFile(codexPaths.hooksJson, CodexHooksFileSchema);
|
|
155
155
|
const next = {
|
|
156
156
|
...current,
|
|
157
157
|
hooks: {
|
|
@@ -166,7 +166,7 @@ export function codexStopHookGroupIndex(): number | null {
|
|
|
166
166
|
if (!existsSync(codexPaths.hooksJson)) return null;
|
|
167
167
|
let parsed: z.infer<typeof CodexHooksFileSchema>;
|
|
168
168
|
try {
|
|
169
|
-
parsed =
|
|
169
|
+
parsed = readJsonFile(codexPaths.hooksJson, CodexHooksFileSchema);
|
|
170
170
|
} catch {
|
|
171
171
|
return null;
|
|
172
172
|
}
|
package/src/lib/presence.ts
CHANGED
|
@@ -4,7 +4,8 @@ import { countBy } from "es-toolkit";
|
|
|
4
4
|
import { z } from "zod";
|
|
5
5
|
import { writeFileAtomic } from "./atomic.ts";
|
|
6
6
|
import { pidExists, pidStartTime } from "./proc.ts";
|
|
7
|
-
import {
|
|
7
|
+
import { readJsonFile } from "./state.ts";
|
|
8
|
+
import { ErrnoSchema, JsonTextSchema } from "./types.ts";
|
|
8
9
|
|
|
9
10
|
const PresenceSchema = z.object({
|
|
10
11
|
accountId: z.string(),
|
|
@@ -19,14 +20,12 @@ export function writePresence(input: { dir: string; id: string; accountId: strin
|
|
|
19
20
|
}
|
|
20
21
|
|
|
21
22
|
export function presencePid(input: { dir: string; id: string }): number | null {
|
|
22
|
-
let raw: string;
|
|
23
23
|
try {
|
|
24
|
-
|
|
24
|
+
return readJsonFile(join(input.dir, input.id), PresenceSchema).pid;
|
|
25
25
|
} catch (e) {
|
|
26
26
|
if (ErrnoSchema.safeParse(e).data?.code === "ENOENT") return null;
|
|
27
27
|
throw e;
|
|
28
28
|
}
|
|
29
|
-
return PresenceSchema.parse(JSON.parse(raw)).pid;
|
|
30
29
|
}
|
|
31
30
|
|
|
32
31
|
export function clearPresence(input: { dir: string; id: string }): void {
|
|
@@ -47,13 +46,7 @@ export function livingPresences(dir: string): LivingPresence[] {
|
|
|
47
46
|
if (ErrnoSchema.safeParse(e).data?.code === "ENOENT") continue;
|
|
48
47
|
throw e;
|
|
49
48
|
}
|
|
50
|
-
const parsed = PresenceSchema.safeParse((
|
|
51
|
-
try {
|
|
52
|
-
return JSON.parse(raw);
|
|
53
|
-
} catch {
|
|
54
|
-
return null;
|
|
55
|
-
}
|
|
56
|
-
})());
|
|
49
|
+
const parsed = PresenceSchema.safeParse(JsonTextSchema.safeParse(raw).data);
|
|
57
50
|
if (!parsed.success) {
|
|
58
51
|
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`);
|
|
59
52
|
}
|
package/src/lib/sessions.ts
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
|
-
import { existsSync, readdirSync,
|
|
1
|
+
import { existsSync, readdirSync, realpathSync, rmSync, statSync, type Dirent } from "node:fs";
|
|
2
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
7
|
import { presencePid } from "./presence.ts";
|
|
8
|
+
import { readJsonFile } from "./state.ts";
|
|
8
9
|
import { spawnedThroughShellsBy } from "./proc.ts";
|
|
9
10
|
import type { RespawnMarkerSchema } from "./types.ts";
|
|
10
11
|
|
|
@@ -25,7 +26,7 @@ export function saveSessionFlags(sid: string, flags: string[], cwd: string): voi
|
|
|
25
26
|
function loadSession(sid: string): z.infer<typeof SessionSchema> | null {
|
|
26
27
|
const f = sessionFile(sid);
|
|
27
28
|
if (!existsSync(f)) return null;
|
|
28
|
-
return
|
|
29
|
+
return readJsonFile(f, SessionSchema);
|
|
29
30
|
}
|
|
30
31
|
|
|
31
32
|
export function loadSessionFlags(sid: string): string[] | null {
|
|
@@ -123,13 +124,14 @@ export function adoptLiveSession(session: SupervisedSession, stdinSid: string |
|
|
|
123
124
|
return { ...session, live: stdinSid };
|
|
124
125
|
}
|
|
125
126
|
|
|
126
|
-
export function writeRespawnMarker(input: { session: SupervisedSession; accountId: string; waitUntil: number; compact: boolean }): void {
|
|
127
|
+
export function writeRespawnMarker(input: { session: SupervisedSession; accountId: string; waitUntil: number; compact: boolean; origin: z.infer<typeof RespawnMarkerSchema>["origin"] }): void {
|
|
127
128
|
const payload: z.infer<typeof RespawnMarkerSchema> = {
|
|
128
129
|
accountId: input.accountId,
|
|
129
130
|
ts: Date.now(),
|
|
130
131
|
waitUntil: input.waitUntil,
|
|
131
132
|
sessionId: input.session.live,
|
|
132
133
|
compact: input.compact,
|
|
134
|
+
origin: input.origin,
|
|
133
135
|
...(input.session.launchedAt != null ? { launchedAt: input.session.launchedAt } : {}),
|
|
134
136
|
};
|
|
135
137
|
writeFileAtomic(join(paths.respawnDir, input.session.sid), JSON.stringify(payload));
|
package/src/lib/settings.ts
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
|
-
import { existsSync,
|
|
1
|
+
import { existsSync, statSync } from "node:fs";
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import { z } from "zod";
|
|
4
4
|
import { paths } from "./paths.ts";
|
|
5
5
|
import { writeFileAtomic } from "./atomic.ts";
|
|
6
|
+
import { readJsonFile } from "./state.ts";
|
|
7
|
+
import { JsonTextSchema } from "./types.ts";
|
|
6
8
|
|
|
7
9
|
export function installedBin(): string {
|
|
8
10
|
return join(paths.binDir, "tokenmaxxing");
|
|
@@ -31,7 +33,7 @@ const STOP_FAILURE_MATCHER = "rate_limit";
|
|
|
31
33
|
|
|
32
34
|
function readSettings(): Settings {
|
|
33
35
|
if (!existsSync(paths.claudeSettings)) return {};
|
|
34
|
-
return
|
|
36
|
+
return readJsonFile(paths.claudeSettings, SettingsSchema);
|
|
35
37
|
}
|
|
36
38
|
|
|
37
39
|
function writeSettings(s: Settings): void {
|
|
@@ -71,13 +73,7 @@ export function isOurHookCommand(cmd: string, sub: string): boolean {
|
|
|
71
73
|
if (!cmd.endsWith(` ${sub}`)) return false;
|
|
72
74
|
const quotedPath = cmd.slice(0, cmd.length - (sub.length + 1));
|
|
73
75
|
if (!quotedPath.startsWith('"') || !quotedPath.endsWith('"')) return false;
|
|
74
|
-
|
|
75
|
-
try {
|
|
76
|
-
path = JSON.parse(quotedPath);
|
|
77
|
-
} catch {
|
|
78
|
-
return false;
|
|
79
|
-
}
|
|
80
|
-
const parsed = z.string().safeParse(path);
|
|
76
|
+
const parsed = z.string().safeParse(JsonTextSchema.safeParse(quotedPath).data);
|
|
81
77
|
return parsed.success && parsed.data.endsWith("/tokenmaxxing");
|
|
82
78
|
}
|
|
83
79
|
|
package/src/lib/state.ts
CHANGED
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
AccountsIndexSchema,
|
|
8
8
|
ConfigSchema,
|
|
9
9
|
ErrnoSchema,
|
|
10
|
+
JsonTextSchema,
|
|
10
11
|
UsageStateSchema,
|
|
11
12
|
WaitQueueSchema,
|
|
12
13
|
type Account,
|
|
@@ -17,21 +18,19 @@ import {
|
|
|
17
18
|
type Window,
|
|
18
19
|
} from "./types.ts";
|
|
19
20
|
|
|
20
|
-
export function
|
|
21
|
-
|
|
22
|
-
if (
|
|
23
|
-
|
|
24
|
-
raw = JSON.parse(readFileSync(paths.configJson, "utf8"));
|
|
25
|
-
} catch {
|
|
26
|
-
throw new Error(`${paths.configJson} is corrupt (unparsable JSON) - fix or remove it`);
|
|
27
|
-
}
|
|
28
|
-
}
|
|
29
|
-
const parsed = ConfigSchema.safeParse(raw);
|
|
21
|
+
export function readJsonFile<T extends z.ZodType>(path: string, schema: T): z.output<T> {
|
|
22
|
+
const json = JsonTextSchema.safeParse(readFileSync(path, "utf8"));
|
|
23
|
+
if (!json.success) throw new Error(`${path} is corrupt (unparsable JSON) - repair or remove the file`);
|
|
24
|
+
const parsed = schema.safeParse(json.data);
|
|
30
25
|
if (!parsed.success) {
|
|
31
26
|
const fields = parsed.error.issues.map((issue) => `${issue.path.join(".")}: ${issue.message}`).join(", ");
|
|
32
|
-
throw new Error(`${
|
|
27
|
+
throw new Error(`${path} does not match its schema (${fields}) - repair or remove the file`);
|
|
33
28
|
}
|
|
34
|
-
|
|
29
|
+
return parsed.data;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function loadConfig(): Config {
|
|
33
|
+
const cfg = existsSync(paths.configJson) ? readJsonFile(paths.configJson, ConfigSchema) : ConfigSchema.parse({});
|
|
35
34
|
cfg.claudeBin = optionalEnv("TOKENMAXXING_CLAUDE_BIN") ?? cfg.claudeBin;
|
|
36
35
|
cfg.codexBin = optionalEnv("TOKENMAXXING_CODEX_BIN") ?? cfg.codexBin;
|
|
37
36
|
cfg.grokBin = optionalEnv("TOKENMAXXING_GROK_BIN") ?? cfg.grokBin;
|
|
@@ -42,10 +41,7 @@ export function loadConfig(): Config {
|
|
|
42
41
|
export type BinKey = "claudeBin" | "codexBin" | "grokBin" | "opencodeBin";
|
|
43
42
|
|
|
44
43
|
export function pinBinOverride(input: { key: BinKey; bin: string }): void {
|
|
45
|
-
|
|
46
|
-
if (existsSync(paths.configJson)) {
|
|
47
|
-
raw = z.record(z.string(), z.unknown()).parse(JSON.parse(readFileSync(paths.configJson, "utf8")));
|
|
48
|
-
}
|
|
44
|
+
const raw: Record<string, unknown> = existsSync(paths.configJson) ? readJsonFile(paths.configJson, z.record(z.string(), z.unknown())) : {};
|
|
49
45
|
raw[input.key] = input.bin;
|
|
50
46
|
writeFileAtomic(paths.configJson, JSON.stringify(raw, null, 2) + "\n");
|
|
51
47
|
}
|
|
@@ -54,17 +50,7 @@ const emptyIndex = (): AccountsIndex => ({ version: 2, accounts: [] });
|
|
|
54
50
|
|
|
55
51
|
export function loadAccounts(pool: PoolPaths): AccountsIndex {
|
|
56
52
|
if (!existsSync(pool.accountsJson)) return emptyIndex();
|
|
57
|
-
|
|
58
|
-
try {
|
|
59
|
-
json = JSON.parse(readFileSync(pool.accountsJson, "utf8"));
|
|
60
|
-
} catch {
|
|
61
|
-
throw new Error(`${pool.accountsJson} is corrupt (unparsable JSON) - refusing to treat a damaged pool as empty; repair or remove the file`);
|
|
62
|
-
}
|
|
63
|
-
const parsed = AccountsIndexSchema.safeParse(json);
|
|
64
|
-
if (!parsed.success) {
|
|
65
|
-
throw new Error(`${pool.accountsJson} does not match the accounts schema - refusing to treat a damaged pool as empty; repair or remove the file`);
|
|
66
|
-
}
|
|
67
|
-
return parsed.data;
|
|
53
|
+
return readJsonFile(pool.accountsJson, AccountsIndexSchema);
|
|
68
54
|
}
|
|
69
55
|
|
|
70
56
|
export function saveAccounts(pool: PoolPaths, idx: AccountsIndex): void {
|
|
@@ -73,17 +59,7 @@ export function saveAccounts(pool: PoolPaths, idx: AccountsIndex): void {
|
|
|
73
59
|
|
|
74
60
|
function loadWaitQueue(): WaitClaim[] {
|
|
75
61
|
if (!existsSync(claudePool.waitQueueJson)) return [];
|
|
76
|
-
|
|
77
|
-
try {
|
|
78
|
-
json = JSON.parse(readFileSync(claudePool.waitQueueJson, "utf8"));
|
|
79
|
-
} catch {
|
|
80
|
-
throw new Error(`${claudePool.waitQueueJson} is corrupt (unparsable JSON) - refusing to treat a damaged wait queue as empty; repair or remove the file`);
|
|
81
|
-
}
|
|
82
|
-
const parsed = WaitQueueSchema.safeParse(json);
|
|
83
|
-
if (!parsed.success) {
|
|
84
|
-
throw new Error(`${claudePool.waitQueueJson} does not match the wait-queue schema - refusing to treat a damaged wait queue as empty; repair or remove the file`);
|
|
85
|
-
}
|
|
86
|
-
return parsed.data.claims;
|
|
62
|
+
return readJsonFile(claudePool.waitQueueJson, WaitQueueSchema).claims;
|
|
87
63
|
}
|
|
88
64
|
|
|
89
65
|
function saveWaitQueue(claims: WaitClaim[]): void {
|
package/src/lib/types.ts
CHANGED
package/src/lib/update.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { existsSync, lstatSync
|
|
1
|
+
import { existsSync, lstatSync } from "node:fs";
|
|
2
2
|
import { dirname, join, resolve } from "node:path";
|
|
3
3
|
import { z } from "zod";
|
|
4
4
|
import { writeFileAtomic } from "./atomic.ts";
|
|
@@ -8,6 +8,7 @@ import { isNixPackaged } from "./install.ts";
|
|
|
8
8
|
import { withLock } from "./lock.ts";
|
|
9
9
|
import { errorMessage, log } from "./log.ts";
|
|
10
10
|
import { env, HOME, optionalEnv, paths } from "./paths.ts";
|
|
11
|
+
import { readJsonFile } from "./state.ts";
|
|
11
12
|
|
|
12
13
|
const UPDATE_INTERVAL_MS = 24 * 60 * 60 * 1000;
|
|
13
14
|
const INSTALL_DEADLINE_MS = 60_000;
|
|
@@ -67,18 +68,12 @@ function detectGlobalRoot(): string | null {
|
|
|
67
68
|
}
|
|
68
69
|
|
|
69
70
|
function installedVersion(root: string): string {
|
|
70
|
-
return
|
|
71
|
+
return readJsonFile(join(packageDir(root), "package.json"), VersionSchema).version;
|
|
71
72
|
}
|
|
72
73
|
|
|
73
74
|
function isDue(now: number): boolean {
|
|
74
75
|
if (!existsSync(updateJson)) return true;
|
|
75
|
-
|
|
76
|
-
try {
|
|
77
|
-
json = JSON.parse(readFileSync(updateJson, "utf8"));
|
|
78
|
-
} catch {
|
|
79
|
-
throw new Error(`${updateJson} is corrupt (unparsable JSON) - repair or remove the file`);
|
|
80
|
-
}
|
|
81
|
-
return now - AttemptSchema.parse(json).attemptedAt >= UPDATE_INTERVAL_MS;
|
|
76
|
+
return now - readJsonFile(updateJson, AttemptSchema).attemptedAt >= UPDATE_INTERVAL_MS;
|
|
82
77
|
}
|
|
83
78
|
|
|
84
79
|
async function updateToLatest(root: string): Promise<void> {
|