tokenmaxxing 1.120.0 → 1.122.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 +1 -1
- package/README.md +1 -1
- package/package.json +1 -1
- package/src/entries/pisupervisor.ts +1 -1
- package/src/entries/stophook.ts +2 -2
- package/src/entries/supervisor.ts +66 -14
- package/src/lib/decide.ts +7 -3
package/DESIGN.md
CHANGED
|
@@ -62,7 +62,7 @@ The Stop hook's stdin has no usage data, but the **statusLine does** (`rate_limi
|
|
|
62
62
|
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.
|
|
63
63
|
|
|
64
64
|
### 3.3 Move and depleted-pool pause (supervisor)
|
|
65
|
-
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
|
|
65
|
+
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 conversation has no prior turn (a `/clear` with no turn since, or a move before the first turn), so it starts with no compaction and no first prompt, and in a stream-json session the relay replays the harness lines the killed child received: `user` lines and the `initialize` and session-state control requests in `REPLAYED_REQUESTS`; every other control request gets an error `control_response` on stdout, because a repeat could run its effect twice, and the `control_response` answers to the killed child's own requests are dropped. A first launch with `--resume <id>` takes the same path when the supervisor holds `sessions/<id>.json` for that id and no project directory holds its transcript. 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 wrapper-chain abort, and a failed presence write also go to stdout as the same `informational`/`warning` line.
|
|
66
66
|
|
|
67
67
|
### 3.4 Placement (supervisor, under the pool lock)
|
|
68
68
|
1. Fold every account's tee into the index.
|
package/README.md
CHANGED
|
@@ -112,7 +112,7 @@ See [How switching decides](docs/content/docs/switching.mdx) for the policy and
|
|
|
112
112
|
}
|
|
113
113
|
```
|
|
114
114
|
|
|
115
|
-
`thresholds.session` and `thresholds.weekly` are the two bars, one number each (an array `thresholds.session`, the former ladder, fails config loading with a message naming the field); `projectionMargin` is a fixed safety margin subtracted from the session bar only (default 0), so a large turn is less likely to blow past the 5-hour bar between checks; the weekly bar takes no margin because one turn cannot overshoot a week; `thresholds.accounts` gives one account its own pair, keyed by label (`{"shared-seat": {"session": 75, "weekly": 75}}`), and `"credits": true` in an entry keeps an account with purchased usage credits usable past its windows, ranked after every account with plan headroom left; `policy.accountReleaseMs` (30 minutes for the session window, 5 hours for a weekly window by default) lifts each of its windows to the higher of its own and the global bar that close to the window's reset; `switchModels` names the models whose per-model cap triggers a move; `usagePollTtlMs` is the shortest sample interval, the one an account near a bar gets, for a seat's own sample, for `status`, and for the accounts each check tick samples; `maxWaitMs` bounds the depleted-pool countdown - a reset further out than this does not pause the session (no respawn marker is written and the session simply keeps hitting its limit until an account recovers); `checkIntervalMs` is the periodic check tick (default 60s), which `init` writes into the timer - re-run `tokenmaxxing init` after changing it so the timer unit picks up the new tick. `claudeBin`, `codexBin`, `grokBin`, `opencodeBin`, and `piBin` pin the real binaries; `init` writes them, and `TOKENMAXXING_CLAUDE_BIN`, `TOKENMAXXING_CODEX_BIN`, `TOKENMAXXING_GROK_BIN`, `TOKENMAXXING_OPENCODE_BIN`, and `TOKENMAXXING_PI_BIN` override them for one process, except that `init` pins the binary it resolved into `config.json`.
|
|
115
|
+
`thresholds.session` and `thresholds.weekly` are the two bars, one number each (an array `thresholds.session`, the former ladder, fails config loading with a message naming the field); `projectionMargin` is a fixed safety margin subtracted from the session bar only (default 0), so a large turn is less likely to blow past the 5-hour bar between checks; the weekly bar takes no margin because one turn cannot overshoot a week; `thresholds.accounts` gives one account its own pair, keyed by label (`{"shared-seat": {"session": 75, "weekly": 75}}`), and `"credits": true` in an entry keeps an account with purchased usage credits usable past its windows, ranked after every account with plan headroom left, and a session on it past its bars moves to such an account; `policy.accountReleaseMs` (30 minutes for the session window, 5 hours for a weekly window by default) lifts each of its windows to the higher of its own and the global bar that close to the window's reset; `switchModels` names the models whose per-model cap triggers a move; `usagePollTtlMs` is the shortest sample interval, the one an account near a bar gets, for a seat's own sample, for `status`, and for the accounts each check tick samples; `maxWaitMs` bounds the depleted-pool countdown - a reset further out than this does not pause the session (no respawn marker is written and the session simply keeps hitting its limit until an account recovers); `checkIntervalMs` is the periodic check tick (default 60s), which `init` writes into the timer - re-run `tokenmaxxing init` after changing it so the timer unit picks up the new tick. `claudeBin`, `codexBin`, `grokBin`, `opencodeBin`, and `piBin` pin the real binaries; `init` writes them, and `TOKENMAXXING_CLAUDE_BIN`, `TOKENMAXXING_CODEX_BIN`, `TOKENMAXXING_GROK_BIN`, `TOKENMAXXING_OPENCODE_BIN`, and `TOKENMAXXING_PI_BIN` override them for one process, except that `init` pins the binary it resolved into `config.json`.
|
|
116
116
|
|
|
117
117
|
State lives in `~/.config/tokenmaxxing/`; outside it the install touches Claude Code's `settings.json`, codex's `hooks.json`, the timer and usage hub units, and your shell rc. Each account's credential store is `stores/<uuid8>/`, which Claude Code reads and refreshes as its own: on macOS the credential is a login-keychain item keyed by the store path (never plaintext on disk), on Linux a 0600 `.credentials.json` inside the store (the same plaintext model claude itself uses for `~/.claude/.credentials.json`).
|
|
118
118
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tokenmaxxing",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.122.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",
|
|
@@ -286,7 +286,7 @@ async function watchSeat(input: { pool: PiPool; seatId: string; id: string; fami
|
|
|
286
286
|
const unmeasured = input.pool === "codex" && (observed == null || now - observed.at > cfg.policy.usagePollTtlMs);
|
|
287
287
|
if (unmeasured) log("pisupervisor.seat_unmeasured", { seat: account.id.slice(0, 8), usageAt: observed?.at });
|
|
288
288
|
const current = observed ? { ...account, windows: observed.windows } : account;
|
|
289
|
-
const until = usableAt(current, { now, thresholds: thresholdBars(cfg), currentId: account.id, families: input.families, seats: null });
|
|
289
|
+
const until = usableAt({ ...current, hasCredits: false }, { now, thresholds: thresholdBars(cfg), currentId: account.id, families: input.families, seats: null });
|
|
290
290
|
if (until <= now || until <= input.overriddenUntil) return { decided: unmeasured, move: null };
|
|
291
291
|
const decision = await evaluateAndMaybeSwap(mover, now, true, null, { seatId: account.id, ...(input.families ? { sessionFamilies: input.families } : {}), waiterId: input.id });
|
|
292
292
|
log("pisupervisor.seat_exhausted", { pool: input.pool, seat: account.id.slice(0, 8), until, reason: decision.reason, account: decision.account?.id.slice(0, 8), waitUntil: decision.waitUntil });
|
package/src/entries/stophook.ts
CHANGED
|
@@ -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 { JsonTextSchema } from "../lib/types.ts";
|
|
7
7
|
import { errorMessage, log } from "../lib/log.ts";
|
|
8
8
|
|
|
@@ -24,7 +24,7 @@ export async function runBoundaryHook(event: "stop" | "sessionstart"): Promise<n
|
|
|
24
24
|
log(`${event}.nested_session`, { stdin: stdin.session_id?.slice(0, 8), supervised: supervised.live.slice(0, 8), source: stdin.source });
|
|
25
25
|
return 0;
|
|
26
26
|
}
|
|
27
|
-
const decision = await evaluateAndMaybeSwap(claude, Date.now(), session != null, null, { waiterId: session?.sid });
|
|
27
|
+
const decision = await evaluateAndMaybeSwap(claude, Date.now(), session != null, null, { waiterId: session?.sid, refused: refusedAccounts() });
|
|
28
28
|
if (session && decision.account && (decision.swapped || decision.waitUntil !== undefined)) {
|
|
29
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) });
|
|
@@ -19,7 +19,7 @@ import { saveTermios } from "../lib/tty.ts";
|
|
|
19
19
|
import { liveSessionId, loadSessionFlags, pruneStaleSessions, saveSessionFlags, writeRespawnMarker } from "../lib/sessions.ts";
|
|
20
20
|
import { loadAccounts, loadConfig, readJsonFile, releaseWaitClaim, writeUsage } from "../lib/state.ts";
|
|
21
21
|
import { gatedFamilies, modelFromFlag, parseStreamLine, scrubCredEnv } from "../lib/usage.ts";
|
|
22
|
-
import { RespawnMarkerSchema, type Account, type Config, type ModelInfo, type UsageWindows } from "../lib/types.ts";
|
|
22
|
+
import { JsonTextSchema, RespawnMarkerSchema, type Account, type Config, type ModelInfo, type UsageWindows } from "../lib/types.ts";
|
|
23
23
|
import { errorMessage, log } from "../lib/log.ts";
|
|
24
24
|
|
|
25
25
|
const SUBCOMMANDS = new Set([
|
|
@@ -168,6 +168,14 @@ function transcriptPath(sessionId: string): string {
|
|
|
168
168
|
return join(projectDirForCwd(), `${sessionId}.jsonl`);
|
|
169
169
|
}
|
|
170
170
|
|
|
171
|
+
function findTranscript(sessionId: string): string | null {
|
|
172
|
+
const own = transcriptPath(sessionId);
|
|
173
|
+
if (existsSync(own)) return own;
|
|
174
|
+
const root = join(paths.claudeDir, "projects");
|
|
175
|
+
if (!existsSync(root)) return null;
|
|
176
|
+
return readdirSync(root).map((dir) => join(root, dir, `${sessionId}.jsonl`)).find((p) => existsSync(p)) ?? null;
|
|
177
|
+
}
|
|
178
|
+
|
|
171
179
|
function latestSessionForCwd(): string | null {
|
|
172
180
|
const projDir = projectDirForCwd();
|
|
173
181
|
if (!existsSync(projDir)) return null;
|
|
@@ -228,18 +236,18 @@ function seatBlockedUntil(seatId: string, now: number, families: string[], cfg:
|
|
|
228
236
|
if (!account) return null;
|
|
229
237
|
const observed = teeObservation(account);
|
|
230
238
|
const current = observed ? { ...account, windows: observed.windows } : account;
|
|
231
|
-
const until = usableAt(current, { now, thresholds: thresholdBars(cfg), currentId: seatId, families, seats: null });
|
|
239
|
+
const until = usableAt({ ...current, hasCredits: false }, { now, thresholds: thresholdBars(cfg), currentId: seatId, families, seats: null });
|
|
232
240
|
return until > now ? until : null;
|
|
233
241
|
}
|
|
234
242
|
|
|
235
|
-
async function moveExhaustedSeat(seat: Account, sid: string, gate: MarkerGate, model: ModelInfo | null): Promise<boolean> {
|
|
243
|
+
async function moveExhaustedSeat(seat: Account, sid: string, gate: MarkerGate, model: ModelInfo | null, refused: string[]): Promise<boolean> {
|
|
236
244
|
try {
|
|
237
245
|
const now = Date.now();
|
|
238
246
|
const cfg = loadConfig();
|
|
239
247
|
const families = gatedFamilies(model, cfg.policy.switchModels);
|
|
240
248
|
const until = seatBlockedUntil(seat.id, now, families, cfg);
|
|
241
249
|
if (until == null || until <= gate.overriddenUntil) return false;
|
|
242
|
-
const decision = await evaluateAndMaybeSwap(claude, now, true, null, { seatId: seat.id, sessionFamilies: families, waiterId: sid });
|
|
250
|
+
const decision = await evaluateAndMaybeSwap(claude, now, true, null, { seatId: seat.id, sessionFamilies: families, waiterId: sid, refused });
|
|
243
251
|
log("supervisor.seat_exhausted", { seat: seat.id.slice(0, 8), until, reason: decision.reason, account: decision.account?.id.slice(0, 8), waitUntil: decision.waitUntil });
|
|
244
252
|
if (decision.account && (decision.swapped || decision.waitUntil !== undefined)) {
|
|
245
253
|
writeRespawnMarker({ session: { sid, launchedAt: gate.launchedAt, live: liveSessionId(sid) }, accountId: decision.account.id, waitUntil: decision.waitUntil ?? now, compact: true, origin: "seatwatch" });
|
|
@@ -316,9 +324,23 @@ function relayStdout(source: ReadableStream<Uint8Array>, onLine: (line: string)
|
|
|
316
324
|
};
|
|
317
325
|
}
|
|
318
326
|
|
|
327
|
+
const REPLAYED_REQUESTS = new Set(["initialize", "set_max_thinking_tokens", "set_model", "set_permission_mode"]);
|
|
328
|
+
|
|
329
|
+
const RelayLineSchema = z.discriminatedUnion("type", [
|
|
330
|
+
z.looseObject({ type: z.literal("control_response") }),
|
|
331
|
+
z.looseObject({ type: z.literal("control_request"), request_id: z.string(), request: z.looseObject({ subtype: z.string() }) }),
|
|
332
|
+
]);
|
|
333
|
+
|
|
334
|
+
function controlErrorLine(requestId: string): string {
|
|
335
|
+
return `${JSON.stringify({ type: "control_response", response: { subtype: "error", request_id: requestId, error: "tokenmaxxing restarted Claude Code for an account move, and this request may or may not have run." } })}\n`;
|
|
336
|
+
}
|
|
337
|
+
|
|
319
338
|
class StdinRelay {
|
|
320
339
|
private sink: FileSink | null = null;
|
|
321
340
|
private queue: string[] = [];
|
|
341
|
+
private replayable: string[] = [];
|
|
342
|
+
private dropped: string[] = [];
|
|
343
|
+
private pendingTranscript: string | null = null;
|
|
322
344
|
private ended = false;
|
|
323
345
|
|
|
324
346
|
constructor() {
|
|
@@ -330,8 +352,11 @@ class StdinRelay {
|
|
|
330
352
|
});
|
|
331
353
|
}
|
|
332
354
|
|
|
333
|
-
attach(sink: FileSink, first: string | null): void {
|
|
355
|
+
attach(sink: FileSink, first: string | null, pendingTranscript: string | null): void {
|
|
334
356
|
this.sink = sink;
|
|
357
|
+
this.replayable = [];
|
|
358
|
+
this.dropped = [];
|
|
359
|
+
this.pendingTranscript = pendingTranscript;
|
|
335
360
|
const lines = first === null ? this.queue : [first, ...this.queue];
|
|
336
361
|
this.queue = [];
|
|
337
362
|
for (const line of lines) this.forward(line);
|
|
@@ -342,6 +367,28 @@ class StdinRelay {
|
|
|
342
367
|
this.sink = null;
|
|
343
368
|
}
|
|
344
369
|
|
|
370
|
+
replay(): string[] {
|
|
371
|
+
const dropped = this.dropped;
|
|
372
|
+
this.queue = [...this.replayable, ...this.queue];
|
|
373
|
+
this.replayable = [];
|
|
374
|
+
this.dropped = [];
|
|
375
|
+
return dropped;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
private keep(line: string): void {
|
|
379
|
+
if (this.pendingTranscript === null) return;
|
|
380
|
+
if (existsSync(this.pendingTranscript)) {
|
|
381
|
+
this.pendingTranscript = null;
|
|
382
|
+
this.replayable = [];
|
|
383
|
+
this.dropped = [];
|
|
384
|
+
return;
|
|
385
|
+
}
|
|
386
|
+
const parsed = RelayLineSchema.safeParse(JsonTextSchema.safeParse(line).data);
|
|
387
|
+
if (!parsed.success) this.replayable.push(line);
|
|
388
|
+
else if (parsed.data.type === "control_request" && REPLAYED_REQUESTS.has(parsed.data.request.subtype)) this.replayable.push(line);
|
|
389
|
+
else if (parsed.data.type === "control_request") this.dropped.push(parsed.data.request_id);
|
|
390
|
+
}
|
|
391
|
+
|
|
345
392
|
private async pump(): Promise<void> {
|
|
346
393
|
for await (const line of readLines(Bun.stdin.stream())) this.forward(`${line}\n`);
|
|
347
394
|
}
|
|
@@ -351,6 +398,7 @@ class StdinRelay {
|
|
|
351
398
|
try {
|
|
352
399
|
this.sink.write(line);
|
|
353
400
|
this.sink.flush();
|
|
401
|
+
this.keep(line);
|
|
354
402
|
return;
|
|
355
403
|
} catch (e) {
|
|
356
404
|
log("supervisor.relay_write_failed", { err: errorMessage(e) });
|
|
@@ -449,15 +497,15 @@ export async function runSupervisor(argv: string[]): Promise<number> {
|
|
|
449
497
|
}
|
|
450
498
|
claimSid = sid;
|
|
451
499
|
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
}
|
|
500
|
+
const persisted = resuming ? loadSessionFlags(sid) : null;
|
|
501
|
+
if (persisted && base.length === 0) base = stripPositionals(persisted);
|
|
502
|
+
const resume = resuming && (persisted === null || findTranscript(sid) !== null);
|
|
456
503
|
const persistable = stripPositionals(base);
|
|
457
504
|
saveSessionFlags(sid, persistable, process.cwd());
|
|
458
505
|
pruneStaleSessions(Date.now());
|
|
459
506
|
|
|
460
|
-
let launchArgs =
|
|
507
|
+
let launchArgs = resume ? ["--resume", sid, ...base] : ["--session-id", sid, ...base];
|
|
508
|
+
let pendingTranscript = resume ? null : transcriptPath(sid);
|
|
461
509
|
|
|
462
510
|
mkdirSync(paths.respawnDir, { recursive: true });
|
|
463
511
|
const marker = join(paths.respawnDir, sid);
|
|
@@ -506,7 +554,7 @@ export async function runSupervisor(argv: string[]): Promise<number> {
|
|
|
506
554
|
});
|
|
507
555
|
child = spawned;
|
|
508
556
|
const drain = tee !== null && spawned.stdout instanceof ReadableStream ? relayStdout(spawned.stdout, tee) : null;
|
|
509
|
-
if (relay !== null) relay.attach(spawned.stdin!, firstLine);
|
|
557
|
+
if (relay !== null) relay.attach(spawned.stdin!, firstLine, pendingTranscript);
|
|
510
558
|
firstLine = null;
|
|
511
559
|
if (picked) {
|
|
512
560
|
await recordPresenceOrStop({
|
|
@@ -535,7 +583,7 @@ export async function runSupervisor(argv: string[]): Promise<number> {
|
|
|
535
583
|
tick: async () => {
|
|
536
584
|
if (existsSync(marker) && (await consumableMarker(marker, gate)) != null) return true;
|
|
537
585
|
if (seat && !terminating && Date.now() >= seatCheckAt) {
|
|
538
|
-
const decided = await moveExhaustedSeat(seat, sid, gate, model);
|
|
586
|
+
const decided = await moveExhaustedSeat(seat, sid, gate, model, refused);
|
|
539
587
|
seatCheckAt = Date.now() + (decided ? SEAT_RETRY_MS : SEAT_POLL_MS);
|
|
540
588
|
}
|
|
541
589
|
return false;
|
|
@@ -559,8 +607,11 @@ export async function runSupervisor(argv: string[]): Promise<number> {
|
|
|
559
607
|
const accounts = loadAccounts(claudePool).accounts;
|
|
560
608
|
const label = accounts.find((a) => a.id === m.accountId)?.label ?? m.accountId.slice(0, 8);
|
|
561
609
|
const walledUntil = accounts.find((a) => a.id === seat?.id)?.enforcedUntil ?? 0;
|
|
562
|
-
const transcript =
|
|
563
|
-
const resumable =
|
|
610
|
+
const transcript = findTranscript(m.sessionId);
|
|
611
|
+
const resumable = transcript !== null;
|
|
612
|
+
if (!resumable && relay !== null) {
|
|
613
|
+
for (const id of relay.replay()) process.stdout.write(controlErrorLine(id));
|
|
614
|
+
}
|
|
564
615
|
let compacted = false;
|
|
565
616
|
if (m.compact && seat && resumable && walledUntil > Date.now()) {
|
|
566
617
|
log("supervisor.compact_skipped", { sid: m.sessionId.slice(0, 8), seat: seat.id.slice(0, 8), until: walledUntil });
|
|
@@ -585,6 +636,7 @@ export async function runSupervisor(argv: string[]): Promise<number> {
|
|
|
585
636
|
saveSessionFlags(m.sessionId, persistable, process.cwd());
|
|
586
637
|
const prompt = resumable ? resumePrompt({ compacted, origin: m.origin }) : null;
|
|
587
638
|
firstLine = relay !== null && prompt !== null ? userLine(prompt) : null;
|
|
639
|
+
pendingTranscript = resumable ? null : transcriptPath(m.sessionId);
|
|
588
640
|
launchArgs = [resumable ? "--resume" : "--session-id", m.sessionId, ...(relay === null && prompt !== null ? [prompt] : []), ...persistable];
|
|
589
641
|
continue;
|
|
590
642
|
}
|
package/src/lib/decide.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { countBy } from "es-toolkit";
|
|
|
2
2
|
import { CLAUDE_COMPACT_KILL_MS } from "./compact.ts";
|
|
3
3
|
import { withLock } from "./lock.ts";
|
|
4
4
|
import { loadAccounts, loadConfig, liveWaitClaims, releaseWaitClaim, replaceWaitClaim, saveAccounts } from "./state.ts";
|
|
5
|
-
import { MAX_WAITERS_PER_ACCOUNT, isExhausted, landWindows, limitWindows, liveUsed, nextWeeklyReset, pickBest, pickWaitTarget, screensUntilReset, sessionWindow, thresholdBars, weeklyWindow, type PickCtx } from "./picker.ts";
|
|
5
|
+
import { MAX_WAITERS_PER_ACCOUNT, isExhausted, landWindows, limitWindows, liveUsed, nextWeeklyReset, onCredits, pickBest, pickWaitTarget, screensUntilReset, sessionWindow, thresholdBars, weeklyWindow, type PickCtx } from "./picker.ts";
|
|
6
6
|
import { familyTokens } from "./usage.ts";
|
|
7
7
|
import { errorMessage, log } from "./log.ts";
|
|
8
8
|
import type { Observation, Provider } from "./provider.ts";
|
|
@@ -15,6 +15,7 @@ export type EvalOpts = {
|
|
|
15
15
|
sessionFamilies?: string[];
|
|
16
16
|
waiterId?: string;
|
|
17
17
|
exclude?: string[];
|
|
18
|
+
refused?: string[];
|
|
18
19
|
};
|
|
19
20
|
|
|
20
21
|
const MOVE_CLAIM_MS = 2 * CLAUDE_COMPACT_KILL_MS;
|
|
@@ -27,7 +28,8 @@ function isOver(account: Account | undefined, observed: Observation | null, ctx:
|
|
|
27
28
|
if (account.needsReauth === true) return true;
|
|
28
29
|
if (account.enforcedUntil != null && account.enforcedUntil > ctx.now) return true;
|
|
29
30
|
if (!observed) return false;
|
|
30
|
-
|
|
31
|
+
const seen = { ...account, windows: observed.windows };
|
|
32
|
+
return isExhausted(seen, ctx) || onCredits(seen, ctx);
|
|
31
33
|
}
|
|
32
34
|
|
|
33
35
|
function modelKindCovered(limit: EnforcedLimit, account: Account, now: number, bars: Bars): boolean {
|
|
@@ -140,7 +142,9 @@ export async function evaluateAndMaybeSwap(p: Provider, now = Date.now(), canRes
|
|
|
140
142
|
const cur = loadAccounts(p.pool);
|
|
141
143
|
const seat = seatOf(cur);
|
|
142
144
|
const ctx: PickCtx = { now, thresholds: bars, currentId: seat?.id ?? null, families: switchFamilies, seats };
|
|
143
|
-
const
|
|
145
|
+
const credited = !enforced2 && seat != null && !seat.needsReauth && !isExhausted(seat, ctx) && onCredits(seat, ctx);
|
|
146
|
+
const best = pickBest(usable(cur.accounts).filter((a) => !(credited && opts.refused?.includes(a.id))), ctx);
|
|
147
|
+
if (credited && (best == null || onCredits(best, ctx))) return { swapped: false, account: null, reason: "no-plan-headroom" };
|
|
144
148
|
if (!best) break;
|
|
145
149
|
try {
|
|
146
150
|
await p.swap(best);
|