claude-overnight 1.25.1 β†’ 1.25.6

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.
@@ -1 +1 @@
1
- export declare const VERSION = "1.25.1";
1
+ export declare const VERSION = "1.25.6";
package/dist/_version.js CHANGED
@@ -1,2 +1,2 @@
1
1
  // Auto-generated by build β€” do not edit manually.
2
- export const VERSION = "1.25.1";
2
+ export const VERSION = "1.25.6";
package/dist/bin.js CHANGED
@@ -12,6 +12,37 @@
12
12
  process.env.CURSOR_SKIP_KEYCHAIN = "1";
13
13
  const argv = process.argv.slice(2);
14
14
  const quiet = argv.includes("-h") || argv.includes("--help") || argv.includes("-v") || argv.includes("--version");
15
+ // Auto-update: check npm at most once every 4 hours, install and re-exec if newer.
16
+ // Skipped in non-TTY (CI/pipe) mode, on --help/--version, and if CLAUDE_OVERNIGHT_UPDATED is set.
17
+ if (process.stdout.isTTY && !quiet && !process.env.CLAUDE_OVERNIGHT_UPDATED) {
18
+ const UPDATE_INTERVAL_MS = 4 * 60 * 60 * 1000;
19
+ const { homedir } = await import("node:os");
20
+ const { join } = await import("node:path");
21
+ const { readFileSync, writeFileSync } = await import("node:fs");
22
+ const tsFile = join(homedir(), ".claude-overnight-update-ts");
23
+ let shouldCheck = true;
24
+ try {
25
+ shouldCheck = Date.now() - parseInt(readFileSync(tsFile, "utf-8").trim(), 10) > UPDATE_INTERVAL_MS;
26
+ }
27
+ catch { }
28
+ if (shouldCheck) {
29
+ try {
30
+ writeFileSync(tsFile, String(Date.now())); // stamp first so failures don't re-trigger
31
+ const { execFileSync, spawnSync } = await import("node:child_process");
32
+ const latest = execFileSync("npm", ["show", "claude-overnight", "version"], { encoding: "utf-8", timeout: 6000 }).trim();
33
+ const { VERSION } = await import("./_version.js");
34
+ if (latest !== VERSION) {
35
+ process.stdout.write(`\r\x1b[2K πŸŒ™ claude-overnight \x1b[33m${VERSION} β†’ ${latest}\x1b[0m updating…\n`);
36
+ execFileSync("npm", ["i", "-g", `claude-overnight@${latest}`], { stdio: "inherit", timeout: 60000 });
37
+ const r = spawnSync(process.argv[0], process.argv.slice(1), {
38
+ stdio: "inherit", env: { ...process.env, CLAUDE_OVERNIGHT_UPDATED: "1" },
39
+ });
40
+ process.exit(r.status ?? 0);
41
+ }
42
+ }
43
+ catch { } // silent β€” never block startup for a failed update check
44
+ }
45
+ }
15
46
  if (!quiet && process.stdout.isTTY) {
16
47
  const frames = ["β ‹", "β ™", "β Ή", "β Έ", "β Ό", "β ΄", "β ¦", "β §", "β ‡", "⠏"];
17
48
  let i = 0;
package/dist/cli.d.ts CHANGED
@@ -54,6 +54,9 @@ export interface FileArgs {
54
54
  permissionMode?: PermMode;
55
55
  cwd?: string;
56
56
  allowedTools?: string[];
57
+ beforeWave?: string | string[];
58
+ afterWave?: string | string[];
59
+ afterRun?: string | string[];
57
60
  useWorktrees?: boolean;
58
61
  mergeStrategy?: MergeStrategy;
59
62
  usageCap?: number;
package/dist/cli.js CHANGED
@@ -332,7 +332,7 @@ export async function selectKey(label, options) {
332
332
  });
333
333
  }
334
334
  const KNOWN_TASK_FILE_KEYS = new Set([
335
- "tasks", "objective", "concurrency", "cwd", "model", "permissionMode", "allowedTools", "worktrees", "mergeStrategy", "usageCap", "flexiblePlan",
335
+ "tasks", "objective", "concurrency", "cwd", "model", "permissionMode", "allowedTools", "beforeWave", "afterWave", "afterRun", "worktrees", "mergeStrategy", "usageCap", "flexiblePlan",
336
336
  ]);
337
337
  export function loadTaskFile(file) {
338
338
  const path = resolve(file);
@@ -394,6 +394,9 @@ export function loadTaskFile(file) {
394
394
  cwd: parsed.cwd ? resolve(parsed.cwd) : undefined,
395
395
  permissionMode: parsed.permissionMode,
396
396
  allowedTools: parsed.allowedTools,
397
+ beforeWave: parsed.beforeWave,
398
+ afterWave: parsed.afterWave,
399
+ afterRun: parsed.afterRun,
397
400
  useWorktrees: parsed.worktrees,
398
401
  mergeStrategy: parsed.mergeStrategy,
399
402
  usageCap,
package/dist/index.js CHANGED
@@ -271,6 +271,9 @@ async function main() {
271
271
  const nonInteractive = noTTY || fileCfg !== undefined || tasks.length > 0;
272
272
  const cwd = fileCfg?.cwd ?? process.cwd();
273
273
  const allowedTools = fileCfg?.allowedTools;
274
+ const beforeWave = fileCfg?.beforeWave;
275
+ const afterWave = fileCfg?.afterWave;
276
+ const afterRun = fileCfg?.afterRun;
274
277
  if (!existsSync(cwd)) {
275
278
  console.error(chalk.red(` Working directory does not exist: ${cwd}`));
276
279
  process.exit(1);
@@ -329,7 +332,7 @@ async function main() {
329
332
  if (action === "q")
330
333
  process.exit(0);
331
334
  if (action === "h") {
332
- showRunHistory(allRuns, cwd);
335
+ await showRunHistory(allRuns, cwd);
333
336
  continue;
334
337
  }
335
338
  if (action === "c") {
@@ -382,7 +385,7 @@ async function main() {
382
385
  break;
383
386
  }
384
387
  if (action === "h") {
385
- showRunHistory(allRuns, cwd);
388
+ await showRunHistory(allRuns, cwd);
386
389
  continue;
387
390
  }
388
391
  resuming = true;
@@ -426,7 +429,7 @@ async function main() {
426
429
  break;
427
430
  }
428
431
  if (action === "h") {
429
- showRunHistory(allRuns, cwd);
432
+ await showRunHistory(allRuns, cwd);
430
433
  continue;
431
434
  }
432
435
  const idx = parseInt(action) - 1;
@@ -979,6 +982,39 @@ async function main() {
979
982
  const thinkDisplay = new RunDisplay(thinkRunInfo, { remaining: 0, usageCap, concurrency, paused: false, dirty: false });
980
983
  thinkDisplay.setWave(thinkingSwarm);
981
984
  thinkDisplay.start();
985
+ // Save thinking-wave state on every exit path (normal, abort, double-q).
986
+ const saveThinkingState = () => {
987
+ thinkingUsed = thinkingSwarm.completed + thinkingSwarm.failed;
988
+ thinkingCost = thinkingSwarm.totalCostUsd;
989
+ thinkingIn = thinkingSwarm.totalInputTokens;
990
+ thinkingOut = thinkingSwarm.totalOutputTokens;
991
+ thinkingTools = thinkingSwarm.agents.reduce((sum, a) => sum + a.toolCalls, 0);
992
+ try {
993
+ saveRunState(runDir, {
994
+ id: runDir.split(/[/\\]/).pop() ?? "",
995
+ objective: objective, budget: budget ?? 10, remaining: (budget ?? 10) - thinkingUsed,
996
+ workerModel, plannerModel,
997
+ workerProviderId: workerProvider?.id, plannerProviderId: plannerProvider?.id,
998
+ concurrency, permissionMode,
999
+ usageCap, allowExtraUsage, extraUsageBudget,
1000
+ flex, useWorktrees, mergeStrategy,
1001
+ waveNum: 0, currentTasks: [],
1002
+ accCost: thinkingCost, accCompleted: thinkingUsed, accFailed: 0,
1003
+ accIn: thinkingIn, accOut: thinkingOut, accTools: thinkingTools,
1004
+ branches: [],
1005
+ phase: "planning",
1006
+ startedAt: new Date().toISOString(),
1007
+ cwd,
1008
+ });
1009
+ }
1010
+ catch { }
1011
+ };
1012
+ // Catch double-q / hard exit during thinking wave
1013
+ const exitHandler = () => { try {
1014
+ saveThinkingState();
1015
+ }
1016
+ catch { } };
1017
+ process.on("exit", exitHandler);
982
1018
  try {
983
1019
  await thinkingSwarm.run();
984
1020
  }
@@ -986,35 +1022,10 @@ async function main() {
986
1022
  thinkDisplay.pause();
987
1023
  console.log(renderSummary(thinkingSwarm));
988
1024
  thinkDisplay.stop();
1025
+ saveThinkingState();
1026
+ process.removeListener("exit", exitHandler);
989
1027
  }
990
- thinkingUsed = thinkingSwarm.completed + thinkingSwarm.failed;
991
- thinkingCost = thinkingSwarm.totalCostUsd;
992
- thinkingIn = thinkingSwarm.totalInputTokens;
993
- thinkingOut = thinkingSwarm.totalOutputTokens;
994
- thinkingTools = thinkingSwarm.agents.reduce((sum, a) => sum + a.toolCalls, 0);
995
1028
  thinkingHistory = { wave: -1, tasks: thinkingSwarm.agents.map(a => ({ prompt: a.task.prompt.slice(0, 200), status: a.status, filesChanged: a.filesChanged, error: a.error })) };
996
- // Persist thinking cost/count into run.json so if the user quits
997
- // between thinking and orchestrate, resume still sees the real spend
998
- // and the run stays visible in the picker (designs on disk = resumable).
999
- try {
1000
- saveRunState(runDir, {
1001
- id: runDir.split(/[/\\]/).pop() ?? "",
1002
- objective: objective, budget: budget ?? 10, remaining: (budget ?? 10) - thinkingUsed,
1003
- workerModel, plannerModel,
1004
- workerProviderId: workerProvider?.id, plannerProviderId: plannerProvider?.id,
1005
- concurrency, permissionMode,
1006
- usageCap, allowExtraUsage, extraUsageBudget,
1007
- flex, useWorktrees, mergeStrategy,
1008
- waveNum: 0, currentTasks: [],
1009
- accCost: thinkingCost, accCompleted: thinkingUsed, accFailed: 0,
1010
- accIn: thinkingIn, accOut: thinkingOut, accTools: thinkingTools,
1011
- branches: [],
1012
- phase: "planning",
1013
- startedAt: new Date().toISOString(),
1014
- cwd,
1015
- });
1016
- }
1017
- catch { }
1018
1029
  if (thinkingSwarm.rateLimitResetsAt) {
1019
1030
  const waitMs = thinkingSwarm.rateLimitResetsAt - Date.now();
1020
1031
  if (waitMs > 0) {
@@ -1124,7 +1135,7 @@ async function main() {
1124
1135
  tasks, objective, budget: budget ?? tasks.length, workerModel, plannerModel, fastModel,
1125
1136
  workerProvider, plannerProvider, fastProvider, concurrency,
1126
1137
  permissionMode, useWorktrees, mergeStrategy, usageCap, allowExtraUsage, extraUsageBudget,
1127
- flex, agentTimeoutMs, cwd, allowedTools, runDir, previousKnowledge,
1138
+ flex, agentTimeoutMs, cwd, allowedTools, beforeWave, afterWave, afterRun, runDir, previousKnowledge,
1128
1139
  resuming, resumeState: resumeState ?? undefined,
1129
1140
  thinkingUsed, thinkingCost, thinkingIn, thinkingOut, thinkingTools, thinkingHistory,
1130
1141
  runStartedAt: resuming && resumeState?.startedAt ? new Date(resumeState.startedAt).getTime() : Date.now(),
@@ -115,8 +115,8 @@ async function runPlannerQueryOnce(prompt, opts, onLog) {
115
115
  options: {
116
116
  cwd: opts.cwd,
117
117
  model: opts.model,
118
- tools: ["Read", "Glob", "Grep", "Write"],
119
- allowedTools: ["Read", "Glob", "Grep", "Write"],
118
+ tools: ["Read", "Glob", "Grep", "Write", "Bash", "WebFetch", "WebSearch", "TodoWrite", "Agent"],
119
+ allowedTools: ["Read", "Glob", "Grep", "Write", "Bash", "WebFetch", "WebSearch", "TodoWrite", "Agent"],
120
120
  permissionMode: opts.permissionMode,
121
121
  ...(opts.permissionMode === "bypassPermissions" && { allowDangerouslySkipPermissions: true }),
122
122
  persistSession: true,
package/dist/render.d.ts CHANGED
@@ -34,6 +34,7 @@ export declare function renderUnifiedFrame(params: {
34
34
  content: ContentRenderer;
35
35
  hotkeyRow?: string;
36
36
  extraFooterRows?: string[];
37
+ maxRows?: number;
37
38
  }): string;
38
39
  type RLGetter = () => {
39
40
  utilization: number;
@@ -41,7 +42,7 @@ type RLGetter = () => {
41
42
  windows: Map<string, RateLimitWindow>;
42
43
  resetsAt?: number;
43
44
  };
44
- export declare function renderFrame(swarm: Swarm, showHotkeys: boolean, runInfo?: RunInfo, selectedAgentId?: number): string;
45
+ export declare function renderFrame(swarm: Swarm, showHotkeys: boolean, runInfo?: RunInfo, selectedAgentId?: number, maxRows?: number, debrief?: string): string;
45
46
  export interface SteeringViewData {
46
47
  /** The ephemeral ticker heartbeat -- elapsed, tool count, cost, current reasoning snippet. */
47
48
  statusLine: string;
@@ -50,6 +51,6 @@ export interface SteeringViewData {
50
51
  /** Optional context read from disk at setSteering() time. */
51
52
  context?: SteeringContext;
52
53
  }
53
- export declare function renderSteeringFrame(runInfo: RunInfo, data: SteeringViewData, showHotkeys: boolean, rlGetter?: RLGetter): string;
54
+ export declare function renderSteeringFrame(runInfo: RunInfo, data: SteeringViewData, showHotkeys: boolean, rlGetter?: RLGetter, maxRows?: number, debrief?: string): string;
54
55
  export declare function renderSummary(swarm: Swarm): string;
55
56
  export {};
package/dist/render.js CHANGED
@@ -141,9 +141,9 @@ function renderUsageBars(out, w, swarm) {
141
141
  // ── Unified frame renderer ──
142
142
  export function renderUnifiedFrame(params) {
143
143
  const w = Math.max((process.stdout.columns ?? 80) || 80, 60);
144
- const out = [];
145
- // Header
146
- renderHeader(out, w, {
144
+ // ── Header (fixed) ──
145
+ const header = [];
146
+ renderHeader(header, w, {
147
147
  model: params.model,
148
148
  phase: params.phase,
149
149
  barPct: params.barPct,
@@ -160,42 +160,46 @@ export function renderUnifiedFrame(params) {
160
160
  sessionsBudget: params.sessionsBudget,
161
161
  remaining: params.remaining,
162
162
  });
163
- // Usage bar
164
- if (params.usageBarRender) {
165
- params.usageBarRender(out, w);
166
- }
167
- out.push("");
168
- // Content sections
163
+ if (params.usageBarRender)
164
+ params.usageBarRender(header, w);
165
+ header.push("");
166
+ // ── Footer (fixed) ──
167
+ const footer = [""];
168
+ if (params.hotkeyRow)
169
+ footer.push(params.hotkeyRow);
170
+ if (params.extraFooterRows)
171
+ for (const row of params.extraFooterRows)
172
+ footer.push(row);
173
+ footer.push("");
174
+ // ── Content (elastic β€” shrinks to fit) ──
175
+ const contentBudget = params.maxRows != null
176
+ ? Math.max(0, params.maxRows - header.length - footer.length)
177
+ : Infinity;
178
+ const content = [];
169
179
  const sections = params.content.sections();
170
180
  for (const sec of sections) {
171
- if (sec.title) {
172
- section(out, w, sec.title);
173
- }
181
+ if (content.length >= contentBudget)
182
+ break;
183
+ if (sec.title)
184
+ section(content, w, sec.title);
174
185
  for (const row of sec.rows) {
175
- out.push(row);
186
+ if (content.length >= contentBudget)
187
+ break;
188
+ content.push(row);
176
189
  }
177
190
  }
178
- // Footer
179
- out.push("");
180
- if (params.hotkeyRow) {
181
- out.push(params.hotkeyRow);
182
- }
183
- if (params.extraFooterRows) {
184
- for (const row of params.extraFooterRows) {
185
- out.push(row);
186
- }
187
- }
188
- out.push("");
189
- return out.join("\n");
191
+ return [...header, ...content, ...footer].join("\n");
190
192
  }
191
- export function renderFrame(swarm, showHotkeys, runInfo, selectedAgentId) {
193
+ export function renderFrame(swarm, showHotkeys, runInfo, selectedAgentId, maxRows, debrief) {
194
+ const allDone = swarm.agents.length > 0 && swarm.agents.every(a => a.status !== "running");
195
+ const doneTag = allDone && !swarm.aborted ? chalk.green("COMPLETE") : "";
192
196
  const stoppingTag = swarm.aborted ? chalk.yellow("STOPPING") : "";
193
197
  const pausedTag = swarm.paused ? chalk.yellow("PAUSED") : "";
194
198
  const stallTag = swarm.stallLevel >= 3 ? chalk.red("STALL") : swarm.stallLevel > 0 ? chalk.yellow(`STALL L${swarm.stallLevel}`) : "";
195
199
  const phaseLabel = swarm.phase === "planning" ? chalk.magenta("PLANNING")
196
200
  : swarm.phase === "merging" ? chalk.yellow("MERGING")
197
201
  : swarm.rateLimitPaused > 0 ? chalk.yellow("COOLING") : "";
198
- const phase = [phaseLabel, pausedTag, stallTag, stoppingTag].filter(Boolean).join(" ");
202
+ const phase = [phaseLabel, doneTag, pausedTag, stallTag, stoppingTag].filter(Boolean).join(" ");
199
203
  const waveUsed = swarm.completed + swarm.failed;
200
204
  const running = swarm.agents.filter(a => a.status === "running");
201
205
  const finished = swarm.agents.filter(a => a.status !== "running");
@@ -259,6 +263,11 @@ export function renderFrame(swarm, showHotkeys, runInfo, selectedAgentId) {
259
263
  // Event log (undecorated)
260
264
  const ww = Math.max((process.stdout.columns ?? 80) || 80, 60);
261
265
  const eventRows = [chalk.gray(" \u2500\u2500\u2500 Events " + "\u2500".repeat(Math.min(ww - 16, 90)))];
266
+ // All-done indicator: visible immediately when swarm finishes, before summary / steering
267
+ if (allDone && swarm.phase !== "done") {
268
+ eventRows.push(chalk.dim(" (all tasks done \u2014 processing)"));
269
+ eventRows.push("");
270
+ }
262
271
  const logN = Math.min(12, swarm.logs.length);
263
272
  for (const entry of swarm.logs.slice(-logN)) {
264
273
  const t = new Date(entry.time).toLocaleTimeString("en", { hour12: false });
@@ -281,6 +290,8 @@ export function renderFrame(swarm, showHotkeys, runInfo, selectedAgentId) {
281
290
  // Build footer
282
291
  let hotkeyRow;
283
292
  const extraFooterRows = [];
293
+ if (debrief)
294
+ extraFooterRows.push(chalk.dim(` ${debrief}`));
284
295
  if (showHotkeys) {
285
296
  const pending = runInfo?.pendingSteer ?? 0;
286
297
  const chip = pending > 0 ? chalk.cyan(` \u270E ${pending} steer queued`) : "";
@@ -314,6 +325,7 @@ export function renderFrame(swarm, showHotkeys, runInfo, selectedAgentId) {
314
325
  content,
315
326
  hotkeyRow,
316
327
  extraFooterRows,
328
+ maxRows,
317
329
  });
318
330
  }
319
331
  function section(out, w, title) {
@@ -387,7 +399,7 @@ function renderStatusBlock(out, w, status) {
387
399
  for (const ln of lines)
388
400
  out.push(` ${chalk.dim(truncate(ln.trim(), w - 4))}`);
389
401
  }
390
- export function renderSteeringFrame(runInfo, data, showHotkeys, rlGetter) {
402
+ export function renderSteeringFrame(runInfo, data, showHotkeys, rlGetter, maxRows, debrief) {
391
403
  const totalUsed = runInfo.accCompleted + runInfo.accFailed;
392
404
  const ctx = data.context;
393
405
  const content = {
@@ -457,6 +469,9 @@ export function renderSteeringFrame(runInfo, data, showHotkeys, rlGetter) {
457
469
  : undefined;
458
470
  // Footer
459
471
  let hotkeyRow;
472
+ const extraFooterRows = [];
473
+ if (debrief)
474
+ extraFooterRows.push(chalk.dim(` ${debrief}`));
460
475
  if (showHotkeys) {
461
476
  const pending = runInfo?.pendingSteer ?? 0;
462
477
  const chip = pending > 0 ? chalk.cyan(` \u270E ${pending} steer queued`) : "";
@@ -480,6 +495,8 @@ export function renderSteeringFrame(runInfo, data, showHotkeys, rlGetter) {
480
495
  usageBarRender,
481
496
  content,
482
497
  hotkeyRow,
498
+ extraFooterRows,
499
+ maxRows,
483
500
  });
484
501
  }
485
502
  export function renderSummary(swarm) {
package/dist/run.d.ts CHANGED
@@ -17,6 +17,12 @@ export interface RunConfig extends RunConfigBase {
17
17
  cwd: string;
18
18
  /** Allowlist of SDK tool names agents are permitted to use. */
19
19
  allowedTools?: string[];
20
+ /** Shell command(s) to run in cwd before each wave starts (e.g. "pnpm run generate"). */
21
+ beforeWave?: string | string[];
22
+ /** Shell command(s) to run in cwd after each wave completes (e.g. "supabase db push"). */
23
+ afterWave?: string | string[];
24
+ /** Shell command(s) to run in cwd once after the entire run finishes (e.g. "vercel deploy"). */
25
+ afterRun?: string | string[];
20
26
  /** Persisted run directory path. */
21
27
  runDir: string;
22
28
  /** Knowledge about the codebase from a pre-run thinking wave. */
package/dist/run.js CHANGED
@@ -17,7 +17,7 @@ export async function executeRun(cfg) {
17
17
  process.stdout.write("\x1B[?25h\n");
18
18
  }
19
19
  catch { } };
20
- const { objective, cwd, workerModel, plannerModel, fastModel, concurrency, permissionMode, allowedTools, runDir, previousKnowledge, } = cfg;
20
+ const { objective, cwd, workerModel, plannerModel, fastModel, concurrency, permissionMode, allowedTools, beforeWave: beforeWaveCmds, afterWave: afterWaveCmds, afterRun: afterRunCmds, runDir, previousKnowledge, } = cfg;
21
21
  const envForModel = buildEnvResolver({
22
22
  plannerModel, plannerProvider: cfg.plannerProvider,
23
23
  workerModel, workerProvider: cfg.workerProvider,
@@ -170,6 +170,21 @@ export async function executeRun(cfg) {
170
170
  else
171
171
  display.updateSteeringStatus(text);
172
172
  };
173
+ const runDebrief = (label) => {
174
+ const debriefModel = fastModel || workerModel;
175
+ const memory = readRunMemory(runDir, previousKnowledge || undefined);
176
+ const cap = (s, n) => s && s.length > n ? s.slice(0, n) + "…" : (s || "");
177
+ const ctx = [
178
+ objective ? `Objective: ${objective}` : "",
179
+ memory.status ? `Status:\n${cap(memory.status, 800)}` : "",
180
+ waveHistory.length ? `Waves done: ${waveHistory.length}` : "",
181
+ memory.reflections ? `Reflections:\n${cap(memory.reflections, 600)}` : "",
182
+ ].filter(Boolean).join("\n\n");
183
+ const prompt = `${label}\n\n${ctx}\n\nWrite one short sentence (max 120 chars) summarising progress and what's next. No preamble.`;
184
+ void runPlannerQuery(prompt, { cwd, model: debriefModel, permissionMode }, () => { })
185
+ .then(text => { display.setDebrief(text.trim().slice(0, 140)); })
186
+ .catch(() => { });
187
+ };
173
188
  // For flex + branch strategy: create one target branch
174
189
  let runBranch;
175
190
  let originalRef;
@@ -359,6 +374,22 @@ export async function executeRun(cfg) {
359
374
  await throttleBeforeWave(() => getPlannerRateLimitInfo(), (text) => display.appendSteeringEvent(text), () => stopping);
360
375
  if (stopping)
361
376
  break;
377
+ // Before-wave commands: run in cwd before each wave starts (e.g. generate types from schema).
378
+ if (beforeWaveCmds) {
379
+ const cmds = Array.isArray(beforeWaveCmds) ? beforeWaveCmds : [beforeWaveCmds];
380
+ for (const cmd of cmds) {
381
+ display.appendSteeringEvent(`Before-wave: ${cmd}`);
382
+ try {
383
+ const out = execSync(cmd, { cwd, encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"] });
384
+ if (out.trim())
385
+ display.appendSteeringEvent(` β†’ ${out.trim().slice(0, 200)}`);
386
+ }
387
+ catch (err) {
388
+ const msg = (err.stderr || err.stdout || err.message || "").trim().slice(0, 300);
389
+ display.appendSteeringEvent(` βœ— ${cmd}: ${msg}`);
390
+ }
391
+ }
392
+ }
362
393
  const swarm = new Swarm({
363
394
  tasks: currentTasks, concurrency, cwd, model: workerModel, permissionMode, allowedTools,
364
395
  useWorktrees, mergeStrategy: waveMerge, agentTimeoutMs: cfg.agentTimeoutMs,
@@ -424,6 +455,25 @@ export async function executeRun(cfg) {
424
455
  wave: waveNum,
425
456
  tasks: swarm.agents.map(a => ({ prompt: a.task.prompt, status: a.status, filesChanged: a.filesChanged, error: a.error })),
426
457
  });
458
+ // Fire-and-forget debrief after each wave.
459
+ runDebrief(`Wave ${waveNum + 1} just finished.`);
460
+ // After-wave commands: run shell commands in cwd after each wave (e.g. "supabase db push").
461
+ // Runs regardless of flex mode so migrations are applied before review/steering.
462
+ if (afterWaveCmds && !swarm.aborted && !swarm.cappedOut) {
463
+ const cmds = Array.isArray(afterWaveCmds) ? afterWaveCmds : [afterWaveCmds];
464
+ for (const cmd of cmds) {
465
+ display.appendSteeringEvent(`After-wave: ${cmd}`);
466
+ try {
467
+ const out = execSync(cmd, { cwd, encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"] });
468
+ if (out.trim())
469
+ display.appendSteeringEvent(` β†’ ${out.trim().slice(0, 200)}`);
470
+ }
471
+ catch (err) {
472
+ const msg = (err.stderr || err.stdout || err.message || "").trim().slice(0, 300);
473
+ display.appendSteeringEvent(` βœ— ${cmd}: ${msg}`);
474
+ }
475
+ }
476
+ }
427
477
  // Post-wave review: a single review agent against the consolidated diff.
428
478
  // Runs only when there was real work (not first wave, not abort/cap).
429
479
  if (flex && remaining > 0 && !swarm.aborted && !swarm.cappedOut && waveNum > 0) {
@@ -530,6 +580,23 @@ export async function executeRun(cfg) {
530
580
  }
531
581
  catch { }
532
582
  }
583
+ // After-run commands: run once after the entire run finishes (e.g. deploy).
584
+ if (afterRunCmds) {
585
+ const cmds = Array.isArray(afterRunCmds) ? afterRunCmds : [afterRunCmds];
586
+ for (const cmd of cmds) {
587
+ console.log(chalk.dim(` After-run: ${cmd}`));
588
+ try {
589
+ const out = execSync(cmd, { cwd, encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"] });
590
+ if (out.trim())
591
+ console.log(chalk.dim(` β†’ ${out.trim().slice(0, 300)}`));
592
+ }
593
+ catch (err) {
594
+ const msg = (err.stderr || err.stdout || err.message || "").trim().slice(0, 400);
595
+ console.log(chalk.red(` βœ— ${cmd}: ${msg}`));
596
+ }
597
+ }
598
+ console.log("");
599
+ }
533
600
  // ── Final summary ──
534
601
  const waves = waveNum + 1;
535
602
  const elapsed = Math.round((Date.now() - cfg.runStartedAt) / 1000);
package/dist/state.d.ts CHANGED
@@ -53,7 +53,7 @@ export declare function formatTimeAgo(isoStr: string): string;
53
53
  export declare function showRunHistory(allRuns: {
54
54
  dir: string;
55
55
  state: RunState;
56
- }[], filterCwd: string): void;
56
+ }[], filterCwd: string): Promise<void>;
57
57
  export declare function readPreviousRunKnowledge(rootDir: string): string;
58
58
  export declare function createRunDir(rootDir: string): string;
59
59
  export declare function updateLatestSymlink(rootDir: string, runDir: string): void;
package/dist/state.js CHANGED
@@ -4,6 +4,7 @@ import { join } from "path";
4
4
  import chalk from "chalk";
5
5
  import { forceMergeOverlay } from "./merge.js";
6
6
  import { FALLBACK_MODEL } from "./models.js";
7
+ import { selectKey } from "./cli.js";
7
8
  // ── File I/O helpers ──
8
9
  export function readMdDir(dir) {
9
10
  try {
@@ -193,12 +194,13 @@ export function findIncompleteRuns(rootDir, filterCwd) {
193
194
  const state = loadRunState(runDir);
194
195
  if (!state || state.phase === "done" || state.cwd !== filterCwd)
195
196
  continue;
196
- // Planning-phase runs are resumable if either tasks.json was written
197
- // (orchestrate completed) OR design docs exist on disk (thinking wave
198
- // got killed mid-way -- we can re-orchestrate from the designs on resume).
197
+ // Planning-phase runs are resumable if: tasks.json exists (orchestrate
198
+ // completed), design docs exist (thinking wave produced output), or the
199
+ // run already consumed tokens (thinking wave started but was killed).
199
200
  if (state.phase === "planning"
200
201
  && !existsSync(join(runDir, "tasks.json"))
201
- && !readMdDir(join(runDir, "designs")))
202
+ && !readMdDir(join(runDir, "designs"))
203
+ && state.accCompleted === 0 && state.accCost === 0)
202
204
  continue;
203
205
  results.push({ dir: runDir, state });
204
206
  }
@@ -303,34 +305,54 @@ export function formatTimeAgo(isoStr) {
303
305
  return `${hours}h ago`;
304
306
  return `${Math.floor(hours / 24)}d ago`;
305
307
  }
306
- export function showRunHistory(allRuns, filterCwd) {
307
- const runs = allRuns.filter(r => r.state.cwd === filterCwd);
308
+ export async function showRunHistory(allRuns, filterCwd) {
309
+ const runs = allRuns.filter(r => r.state.cwd === filterCwd && r.state.phase === "done");
308
310
  if (runs.length === 0) {
309
- console.log(chalk.dim("\n No run history.\n"));
311
+ console.log(chalk.dim("\n No completed runs.\n"));
310
312
  return;
311
313
  }
312
- const w = Math.min((process.stdout.columns ?? 80) - 6, 50);
313
- console.log(chalk.dim(`\n ── Run History ${"─".repeat(Math.max(0, w - 16))}\n`));
314
- let resumeIdx = 0;
315
- for (const run of runs) {
316
- const s = run.state;
317
- const done = s.phase === "done";
318
- const icon = done ? chalk.green("βœ“") : chalk.yellow("⚠");
319
- const date = s.startedAt?.slice(0, 16).replace("T", " ") || "unknown";
320
- const cost = s.accCost > 0 ? ` Β· $${s.accCost.toFixed(2)}` : "";
321
- const obj = s.objective?.slice(0, 50) || "";
322
- const num = done ? " " : chalk.cyan(String(++resumeIdx));
323
- const merged = s.branches.filter(b => b.status === "merged").length;
324
- console.log(` ${icon} ${num} ${chalk.dim(date)} Β· ${s.phase} Β· ${s.accCompleted}/${s.budget}${cost}${merged ? ` Β· ${merged} merged` : ""}`);
325
- console.log(` ${obj}${obj.length >= 50 ? "…" : ""}`);
326
- let status = "";
327
- try {
328
- status = readFileSync(join(run.dir, "status.md"), "utf-8").trim().split("\n")[0].slice(0, 70);
314
+ const PAGE = 5;
315
+ const pages = Math.ceil(runs.length / PAGE);
316
+ let page = 0;
317
+ while (true) {
318
+ const w = Math.min((process.stdout.columns ?? 80) - 6, 50);
319
+ const pageLabel = pages > 1 ? ` (${page + 1}/${pages})` : "";
320
+ console.log(chalk.dim(`\n ── Run History${pageLabel} ${"─".repeat(Math.max(0, w - 16 - pageLabel.length))}\n`));
321
+ for (const run of runs.slice(page * PAGE, (page + 1) * PAGE)) {
322
+ const s = run.state;
323
+ const date = s.startedAt?.slice(0, 16).replace("T", " ") || "unknown";
324
+ const cost = s.accCost > 0 ? ` Β· $${s.accCost.toFixed(2)}` : "";
325
+ const obj = s.objective?.slice(0, 50) || "";
326
+ const merged = s.branches.filter(b => b.status === "merged").length;
327
+ console.log(` ${chalk.green("βœ“")} ${chalk.dim(date)} Β· ${s.accCompleted}/${s.budget}${cost}${merged ? ` Β· ${merged} merged` : ""}`);
328
+ console.log(` ${obj}${obj.length >= 50 ? "…" : ""}`);
329
+ let status = "";
330
+ try {
331
+ status = readFileSync(join(run.dir, "status.md"), "utf-8").trim().split("\n")[0].slice(0, 70);
332
+ }
333
+ catch { }
334
+ if (status)
335
+ console.log(chalk.dim(` ${status}`));
336
+ console.log("");
337
+ }
338
+ if (pages === 1)
339
+ break;
340
+ const opts = [];
341
+ if (page < pages - 1)
342
+ opts.push({ key: "n", desc: "ext" });
343
+ if (page > 0)
344
+ opts.push({ key: "p", desc: "rev" });
345
+ opts.push({ key: "b", desc: "ack" });
346
+ const action = await selectKey("", opts);
347
+ if (action === "n") {
348
+ page++;
349
+ continue;
350
+ }
351
+ if (action === "p") {
352
+ page--;
353
+ continue;
329
354
  }
330
- catch { }
331
- if (status)
332
- console.log(chalk.dim(` ${status}`));
333
- console.log("");
355
+ break;
334
356
  }
335
357
  }
336
358
  export function readPreviousRunKnowledge(rootDir) {
package/dist/types.d.ts CHANGED
@@ -25,6 +25,12 @@ export interface TaskFile {
25
25
  permissionMode?: PermMode;
26
26
  /** Allowlist of SDK tool names agents are permitted to use. */
27
27
  allowedTools?: string[];
28
+ /** Shell command(s) to run in cwd before each wave starts (e.g. "pnpm run generate"). */
29
+ beforeWave?: string | string[];
30
+ /** Shell command(s) to run in cwd after each wave completes (e.g. "supabase db push"). */
31
+ afterWave?: string | string[];
32
+ /** Shell command(s) to run in cwd once after the entire run finishes (e.g. "vercel deploy"). */
33
+ afterRun?: string | string[];
28
34
  /** Merge strategy: "yolo" merges into current branch, "branch" creates a new branch. */
29
35
  mergeStrategy?: MergeStrategy;
30
36
  /** Stop dispatching new tasks when rate-limit utilization reaches this percentage (0-100). */
@@ -183,6 +189,12 @@ export interface RunConfigBase {
183
189
  flex: boolean;
184
190
  /** Use git worktree isolation for agents. */
185
191
  useWorktrees: boolean;
192
+ /** Shell command(s) to run in cwd before each wave starts (e.g. "pnpm run generate"). */
193
+ beforeWave?: string | string[];
194
+ /** Shell command(s) to run in cwd after each wave completes (e.g. "supabase db push"). */
195
+ afterWave?: string | string[];
196
+ /** Shell command(s) to run in cwd once after the entire run finishes (e.g. "vercel deploy"). */
197
+ afterRun?: string | string[];
186
198
  /** How worktree branches are merged. */
187
199
  mergeStrategy: MergeStrategy;
188
200
  }
package/dist/ui.d.ts CHANGED
@@ -74,6 +74,11 @@ export declare class RunDisplay {
74
74
  private navState;
75
75
  private onSteer?;
76
76
  private onAsk?;
77
+ private debriefText?;
78
+ /** Get the latest debrief line for footer rendering. */
79
+ getDebrief(): string | undefined;
80
+ /** Set or clear the debrief text shown in the footer. */
81
+ setDebrief(text: string | undefined): void;
77
82
  constructor(runInfo: RunInfo, liveConfig?: LiveConfig, callbacks?: {
78
83
  onSteer?: (text: string) => void;
79
84
  onAsk?: (text: string) => void;
@@ -113,7 +118,9 @@ export declare class RunDisplay {
113
118
  resume(): void;
114
119
  stop(): void;
115
120
  private resumeInterval;
116
- /** Write the full frame to stdout, clamped to terminal height. */
121
+ /** Write the full frame to stdout, clamped to terminal height.
122
+ * Layout: header + content (elastic) + footer + input/ask (fixed).
123
+ * The content area shrinks so input prompts are never clipped. */
117
124
  private flush;
118
125
  private render;
119
126
  private renderInputPrompt;
@@ -124,16 +131,16 @@ export declare class RunDisplay {
124
131
  private handlePaste;
125
132
  /** Handle a typed (non-pasted) chunk. Returns true if the frame needs a redraw.
126
133
  *
127
- * Demux pipeline -- routes arrow keys and ESC BEFORE hotkey matching:
134
+ * Demux pipeline -- routes escape sequences and modifiers BEFORE hotkey matching:
128
135
  * Raw stdin chunk β†’ splitPaste
129
- * β”œβ”€ paste β†’ handlePaste (existing, fine)
136
+ * β”œβ”€ paste β†’ handlePaste
130
137
  * └─ typed β†’ demux
131
- * β”œβ”€ ESC + [A/B/C/D β†’ this.navigate("up"/"down"/"right"/"left")
132
- * β”œβ”€ ESC β†’ cancel input / close detail / dismiss panel
133
- * β”œβ”€ Enter β†’ submit / reveal / select
134
- * β”œβ”€ Ctrl+C β†’ abort
135
- * β”œβ”€ Backspace β†’ delete
136
- * └─ printable β†’ hotkey matching (b, t, c, e, p, s, q, ?, d, 0-9)
138
+ * 1. ESC + [A/B/C/D β†’ navigate; other CSI β†’ swallow
139
+ * 2. ESC + non-[ β†’ Alt/Option+key β†’ swallow
140
+ * 3. ESC alone β†’ cancel input / close detail / dismiss panel
141
+ * 4. numeric input β†’ digits, Enter, Backspace
142
+ * 5. text input β†’ printable chars, Enter, Backspace, ESC (with lookahead)
143
+ * 6. hotkey mode β†’ b, t, c, e, p, s, q, ?, d, 0-9
137
144
  */
138
145
  private handleTyped;
139
146
  private plainTick;
package/dist/ui.js CHANGED
@@ -34,6 +34,11 @@ export class RunDisplay {
34
34
  navState = { focusSection: 0, focusRow: 0, scrollOffset: 0 };
35
35
  onSteer;
36
36
  onAsk;
37
+ debriefText;
38
+ /** Get the latest debrief line for footer rendering. */
39
+ getDebrief() { return this.debriefText; }
40
+ /** Set or clear the debrief text shown in the footer. */
41
+ setDebrief(text) { this.debriefText = text; }
37
42
  constructor(runInfo, liveConfig, callbacks) {
38
43
  this.runInfo = runInfo;
39
44
  this.liveConfig = liveConfig;
@@ -345,37 +350,42 @@ export class RunDisplay {
345
350
  }
346
351
  this.interval = setInterval(() => this.flush(), 250);
347
352
  }
348
- /** Write the full frame to stdout, clamped to terminal height. */
353
+ /** Write the full frame to stdout, clamped to terminal height.
354
+ * Layout: header + content (elastic) + footer + input/ask (fixed).
355
+ * The content area shrinks so input prompts are never clipped. */
349
356
  flush() {
350
357
  try {
351
358
  const maxRows = (process.stdout.rows || 40) - 1;
352
- const frame = this.render();
353
- const lines = frame.split("\n");
354
- process.stdout.write("\x1B[H\x1B[J");
355
- process.stdout.write(lines.length > maxRows ? lines.slice(0, maxRows).join("\n") : frame);
359
+ const frame = this.render(maxRows);
360
+ process.stdout.write("\x1B[H\x1B[J" + frame);
356
361
  }
357
362
  catch {
358
363
  this.pause();
359
364
  }
360
365
  }
361
- render() {
366
+ render(maxRows) {
367
+ // Compute how many rows the input prompt + ask panel need so the
368
+ // main frame can shrink its content area to leave room.
369
+ const inputPrompt = this.renderInputPrompt();
370
+ const askPanel = this.renderAskPanel();
371
+ const bottom = inputPrompt + askPanel;
372
+ const bottomRows = bottom ? (bottom.match(/\n/g) || []).length : 0;
373
+ const frameBudget = maxRows != null ? maxRows - bottomRows : undefined;
362
374
  let frame = "";
363
375
  if (this.swarm) {
364
- frame = renderFrame(this.swarm, this.hasHotkeys(), this.runInfo, this.selectedAgentId);
376
+ frame = renderFrame(this.swarm, this.hasHotkeys(), this.runInfo, this.selectedAgentId, frameBudget, this.debriefText);
365
377
  }
366
378
  else if (this.steeringActive) {
367
379
  frame = renderSteeringFrame(this.runInfo, {
368
380
  statusLine: this.steeringStatusLine,
369
381
  events: this.steeringEvents,
370
382
  context: this.steeringContext,
371
- }, this.hasHotkeys(), this.rlGetter);
383
+ }, this.hasHotkeys(), this.rlGetter, frameBudget, this.debriefText);
372
384
  }
373
385
  else {
374
386
  return "";
375
387
  }
376
- frame += this.renderInputPrompt();
377
- frame += this.renderAskPanel();
378
- return frame;
388
+ return frame + bottom;
379
389
  }
380
390
  renderInputPrompt() {
381
391
  if (this.inputMode === "none")
@@ -482,16 +492,16 @@ export class RunDisplay {
482
492
  }
483
493
  /** Handle a typed (non-pasted) chunk. Returns true if the frame needs a redraw.
484
494
  *
485
- * Demux pipeline -- routes arrow keys and ESC BEFORE hotkey matching:
495
+ * Demux pipeline -- routes escape sequences and modifiers BEFORE hotkey matching:
486
496
  * Raw stdin chunk β†’ splitPaste
487
- * β”œβ”€ paste β†’ handlePaste (existing, fine)
497
+ * β”œβ”€ paste β†’ handlePaste
488
498
  * └─ typed β†’ demux
489
- * β”œβ”€ ESC + [A/B/C/D β†’ this.navigate("up"/"down"/"right"/"left")
490
- * β”œβ”€ ESC β†’ cancel input / close detail / dismiss panel
491
- * β”œβ”€ Enter β†’ submit / reveal / select
492
- * β”œβ”€ Ctrl+C β†’ abort
493
- * β”œβ”€ Backspace β†’ delete
494
- * └─ printable β†’ hotkey matching (b, t, c, e, p, s, q, ?, d, 0-9)
499
+ * 1. ESC + [A/B/C/D β†’ navigate; other CSI β†’ swallow
500
+ * 2. ESC + non-[ β†’ Alt/Option+key β†’ swallow
501
+ * 3. ESC alone β†’ cancel input / close detail / dismiss panel
502
+ * 4. numeric input β†’ digits, Enter, Backspace
503
+ * 5. text input β†’ printable chars, Enter, Backspace, ESC (with lookahead)
504
+ * 6. hotkey mode β†’ b, t, c, e, p, s, q, ?, d, 0-9
495
505
  */
496
506
  handleTyped(s) {
497
507
  const lc = this.liveConfig;
@@ -517,7 +527,11 @@ export class RunDisplay {
517
527
  // Other ANSI sequences -- swallow silently
518
528
  return true;
519
529
  }
520
- // ── 2. Standalone ESC ──
530
+ // ── 2. Alt/Option+key: \x1B followed by a non-bracket byte (e.g. \x1Bb, \x1Bf) ──
531
+ if (s.length >= 2 && s[0] === "\x1B" && s[1] !== "[") {
532
+ return false; // swallow β€” don't cancel input, don't trigger hotkeys
533
+ }
534
+ // ── 3. Standalone ESC ──
521
535
  if (s === "\x1B") {
522
536
  if (this.inputMode !== "none") {
523
537
  this.inputMode = "none";
@@ -535,7 +549,7 @@ export class RunDisplay {
535
549
  }
536
550
  return false;
537
551
  }
538
- // ── 3. Input mode: budget / threshold / concurrency / extra ──
552
+ // ── 4. Input mode: budget / threshold / concurrency / extra ──
539
553
  if (this.inputMode === "budget" || this.inputMode === "threshold" || this.inputMode === "concurrency" || this.inputMode === "extra") {
540
554
  let dirty = false;
541
555
  for (const ch of s) {
@@ -586,7 +600,7 @@ export class RunDisplay {
586
600
  }
587
601
  return dirty;
588
602
  }
589
- // ── 4. Input mode: steer / ask ──
603
+ // ── 5. Input mode: steer / ask ──
590
604
  if (this.inputMode === "steer" || this.inputMode === "ask") {
591
605
  let dirty = false;
592
606
  for (let ci = 0; ci < s.length; ci++) {
@@ -609,9 +623,13 @@ export class RunDisplay {
609
623
  this.inputSegs = [];
610
624
  return true;
611
625
  }
612
- // ESC cancels input mode (no ANSI-byte consumption loop -- arrows arrive
613
- // as "\x1B[A" in a single call and are caught by step 1 above)
626
+ // ESC: if next byte exists it's part of an Alt+key sequence β€” skip both.
627
+ // Standalone ESC (no following byte) cancels input mode.
614
628
  if (ch === "\x1B") {
629
+ if (ci + 1 < s.length) {
630
+ ci++;
631
+ continue;
632
+ }
615
633
  this.inputMode = "none";
616
634
  this.inputSegs = [];
617
635
  return true;
@@ -633,7 +651,7 @@ export class RunDisplay {
633
651
  }
634
652
  return dirty;
635
653
  }
636
- // ── 5. Hotkey mode ──
654
+ // ── 6. Hotkey mode ──
637
655
  // Enter
638
656
  if (s === "\r" || s === "\n") {
639
657
  if (this.askTempFile) {
@@ -717,7 +735,7 @@ export class RunDisplay {
717
735
  if (this.askState && !this.askState.streaming) {
718
736
  this.askState = undefined;
719
737
  this.clearAskTempFile();
720
- return false;
738
+ return true;
721
739
  }
722
740
  this.inputMode = "ask";
723
741
  this.inputSegs = [];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-overnight",
3
- "version": "1.25.1",
3
+ "version": "1.25.6",
4
4
  "description": "Parallel Claude agents in git worktrees with a usage cap that reserves headroom for your interactive Claude Code. Crash-safe resume. Provider-agnostic model catalog (Anthropic, Cursor, OpenAI, Gemini, DeepSeek, Llama, Qwen) with capability-based task scoping.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-overnight",
3
- "version": "1.25.1",
3
+ "version": "1.25.6",
4
4
  "description": "Claude Code skill for understanding, installing, and inspecting claude-overnight runs -- parallel Claude agents in git worktrees with thinking waves, multi-wave steering, and crash-safe resume. Supports Cursor API Proxy, Qwen, OpenRouter.",
5
5
  "author": {
6
6
  "name": "Francesco Fornace"
@@ -31,6 +31,20 @@ claude-overnight "task a" "task b" # inline
31
31
 
32
32
  Common flags: `--budget=N`, `--concurrency=N`, `--model=<name>`, `--usage-cap=N`, `--allow-extra-usage`, `--extra-usage-budget=N`, `--timeout=SECONDS`, `--no-flex`, `--dry-run`.
33
33
 
34
+ Task file supports lifecycle hooks β€” shell commands run in `cwd` at key points:
35
+
36
+ ```json
37
+ {
38
+ "objective": "...",
39
+ "beforeWave": "pnpm run db:generate",
40
+ "afterWave": "supabase db push",
41
+ "afterRun": "vercel deploy --prod",
42
+ "tasks": []
43
+ }
44
+ ```
45
+
46
+ `beforeWave` runs before each wave starts Β· `afterWave` runs after workers merge (before review/steering) Β· `afterRun` runs once after the entire run. All accept a string or `string[]`. Failures are surfaced but never abort the run.
47
+
34
48
  Live keys while running: `b` change budget Β· `t` change usage cap Β· `q` graceful stop (twice = force).
35
49
 
36
50
  Exit codes: `0` all ok Β· `1` some failed Β· `2` all/none.