claude-overnight 1.6.0 → 1.7.1

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/dist/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { readFileSync, existsSync, mkdirSync, readdirSync, rmSync, writeFileSync } from "fs";
2
+ import { readFileSync, existsSync, mkdirSync, readdirSync, rmSync, writeFileSync, symlinkSync, unlinkSync } from "fs";
3
3
  import { resolve, dirname, join } from "path";
4
4
  import { fileURLToPath } from "url";
5
5
  import { execSync } from "child_process";
@@ -7,8 +7,8 @@ import { createInterface } from "readline";
7
7
  import chalk from "chalk";
8
8
  import { query } from "@anthropic-ai/claude-agent-sdk";
9
9
  import { Swarm } from "./swarm.js";
10
- import { planTasks, refinePlan, detectModelTier, steerWave, identifyThemes, buildThinkingTasks, orchestrate } from "./planner.js";
11
- import { startRenderLoop, renderSummary } from "./ui.js";
10
+ import { planTasks, refinePlan, detectModelTier, steerWave, identifyThemes, buildThinkingTasks, orchestrate, getTotalPlannerCost, getPlannerRateLimitInfo } from "./planner.js";
11
+ import { RunDisplay, renderSummary } from "./ui.js";
12
12
  // ── CLI flag parsing ──
13
13
  function parseCliFlags(argv) {
14
14
  const known = new Set(["concurrency", "model", "timeout", "budget", "usage-cap", "extra-usage-budget"]);
@@ -317,19 +317,23 @@ function loadRunState(runDir) {
317
317
  return null;
318
318
  }
319
319
  }
320
- /** Find the latest incomplete run, or null. */
321
- function findIncompleteRun(rootDir) {
320
+ /** Find all incomplete runs for a given working directory, newest first. */
321
+ function findIncompleteRuns(rootDir, filterCwd) {
322
322
  const runsDir = join(rootDir, "runs");
323
323
  try {
324
- const dirs = readdirSync(runsDir).sort().reverse(); // newest first
324
+ const dirs = readdirSync(runsDir).sort().reverse();
325
+ const results = [];
325
326
  for (const d of dirs) {
326
327
  const state = loadRunState(join(runsDir, d));
327
- if (state && state.phase !== "done")
328
- return { dir: join(runsDir, d), state };
328
+ if (state && state.phase !== "done" && state.cwd === filterCwd) {
329
+ results.push({ dir: join(runsDir, d), state });
330
+ }
329
331
  }
332
+ return results;
333
+ }
334
+ catch {
335
+ return [];
330
336
  }
331
- catch { }
332
- return null;
333
337
  }
334
338
  /** Find orphaned designs: a run where thinking succeeded but orchestration crashed (has designs, no run.json). */
335
339
  function findOrphanedDesigns(rootDir) {
@@ -349,6 +353,49 @@ function findOrphanedDesigns(rootDir) {
349
353
  catch { }
350
354
  return null;
351
355
  }
356
+ function formatTimeAgo(isoStr) {
357
+ const ms = Date.now() - new Date(isoStr).getTime();
358
+ const mins = Math.floor(ms / 60000);
359
+ if (mins < 1)
360
+ return "just now";
361
+ if (mins < 60)
362
+ return `${mins}m ago`;
363
+ const hours = Math.floor(mins / 60);
364
+ if (hours < 24)
365
+ return `${hours}h ago`;
366
+ const days = Math.floor(hours / 24);
367
+ return `${days}d ago`;
368
+ }
369
+ function showRunHistory(allRuns, filterCwd) {
370
+ const runs = allRuns.filter(r => r.state.cwd === filterCwd);
371
+ if (runs.length === 0) {
372
+ console.log(chalk.dim("\n No run history.\n"));
373
+ return;
374
+ }
375
+ const w = Math.min((process.stdout.columns ?? 80) - 6, 50);
376
+ console.log(chalk.dim(`\n ── Run History ${"─".repeat(Math.max(0, w - 16))}\n`));
377
+ let resumeIdx = 0;
378
+ for (const run of runs) {
379
+ const s = run.state;
380
+ const done = s.phase === "done";
381
+ const icon = done ? chalk.green("✓") : chalk.yellow("⚠");
382
+ const date = s.startedAt?.slice(0, 16).replace("T", " ") || "unknown";
383
+ const cost = s.accCost > 0 ? ` · $${s.accCost.toFixed(2)}` : "";
384
+ const obj = s.objective?.slice(0, 50) || "";
385
+ const num = done ? " " : chalk.cyan(String(++resumeIdx));
386
+ const merged = s.branches.filter(b => b.status === "merged").length;
387
+ console.log(` ${icon} ${num} ${chalk.dim(date)} · ${s.phase} · ${s.accCompleted}/${s.budget}${cost}${merged ? ` · ${merged} merged` : ""}`);
388
+ console.log(` ${obj}${obj.length >= 50 ? "…" : ""}`);
389
+ let status = "";
390
+ try {
391
+ status = readFileSync(join(run.dir, "status.md"), "utf-8").trim().split("\n")[0].slice(0, 70);
392
+ }
393
+ catch { }
394
+ if (status)
395
+ console.log(chalk.dim(` ${status}`));
396
+ console.log("");
397
+ }
398
+ }
352
399
  /** Read final status + goal from all completed previous runs (newest first, max 5). */
353
400
  function readPreviousRunKnowledge(rootDir) {
354
401
  const runsDir = join(rootDir, "runs");
@@ -389,8 +436,20 @@ function createRunDir(rootDir) {
389
436
  mkdirSync(join(runDir, "verifications"), { recursive: true });
390
437
  mkdirSync(join(runDir, "milestones"), { recursive: true });
391
438
  mkdirSync(join(runDir, "sessions"), { recursive: true });
439
+ updateLatestSymlink(rootDir, runDir);
392
440
  return runDir;
393
441
  }
442
+ function updateLatestSymlink(rootDir, runDir) {
443
+ const link = join(rootDir, "latest");
444
+ try {
445
+ unlinkSync(link);
446
+ }
447
+ catch { }
448
+ try {
449
+ symlinkSync(runDir, link);
450
+ }
451
+ catch { }
452
+ }
394
453
  function saveWaveSession(baseDir, waveNum, kind, swarm) {
395
454
  const dir = join(baseDir, "sessions");
396
455
  mkdirSync(dir, { recursive: true });
@@ -625,16 +684,17 @@ async function main() {
625
684
  // ── Show run history ──
626
685
  const rootDir = join(cwd, ".claude-overnight");
627
686
  const runsDir = join(rootDir, "runs");
628
- let completedRuns = [];
687
+ const allRuns = [];
629
688
  try {
630
689
  const dirs = readdirSync(runsDir).sort().reverse();
631
690
  for (const d of dirs) {
632
691
  const s = loadRunState(join(runsDir, d));
633
- if (s && s.phase === "done")
634
- completedRuns.push({ dir: join(runsDir, d), state: s });
692
+ if (s)
693
+ allRuns.push({ dir: join(runsDir, d), state: s });
635
694
  }
636
695
  }
637
696
  catch { }
697
+ const completedRuns = allRuns.filter(r => r.state.phase === "done" && r.state.cwd === cwd);
638
698
  if (completedRuns.length > 0 && !noTTY) {
639
699
  console.log(chalk.dim(`\n ${completedRuns.length} previous run${completedRuns.length > 1 ? "s" : ""}`));
640
700
  for (const r of completedRuns.slice(0, 3)) {
@@ -643,7 +703,6 @@ async function main() {
643
703
  const cost = r.state.accCost > 0 ? ` · $${r.state.accCost.toFixed(0)}` : "";
644
704
  const merged = r.state.branches.filter(b => b.status === "merged").length;
645
705
  console.log(chalk.dim(` ${date} · ${r.state.accCompleted} done · ${merged} merged${cost}${obj ? ` · ${obj}` : ""}${obj.length >= 50 ? "…" : ""}`));
646
- // Show status if available
647
706
  let status = "";
648
707
  try {
649
708
  status = readFileSync(join(r.dir, "status.md"), "utf-8").trim().split("\n")[0].slice(0, 80);
@@ -657,52 +716,113 @@ async function main() {
657
716
  let resuming = false;
658
717
  let resumeState = null;
659
718
  let resumeRunDir;
660
- const incomplete = findIncompleteRun(rootDir);
661
- if (incomplete && incomplete.state.cwd === cwd && !noTTY && tasks.length === 0) {
662
- const prev = incomplete.state;
663
- const merged = prev.branches.filter(b => b.status === "merged").length;
664
- const unmerged = prev.branches.filter(b => b.status === "unmerged").length;
665
- const failed = prev.branches.filter(b => b.status === "failed" || b.status === "merge-failed").length;
666
- const obj = prev.objective?.slice(0, 50) || "";
667
- // Read last status for context
668
- let lastStatus = "";
669
- try {
670
- lastStatus = readFileSync(join(incomplete.dir, "status.md"), "utf-8").trim().slice(0, 120);
671
- }
672
- catch { }
673
- const label = "Unfinished run";
674
- console.log(chalk.yellow(`\n ⚠ ${label}`));
675
- const boxLines = [
676
- `${obj}${obj.length >= 50 ? "…" : ""}`,
677
- `${prev.accCompleted}/${prev.budget} sessions · ${prev.remaining} remaining · $${prev.accCost.toFixed(2)}`,
678
- ];
679
- if (lastStatus)
680
- boxLines.push(lastStatus);
681
- if (merged + unmerged + failed > 0)
682
- boxLines.push(`${merged} merged · ${unmerged} unmerged · ${failed} failed branches`);
683
- const boxW = Math.max(...boxLines.map(l => l.length)) + 4;
684
- console.log(chalk.dim(` ╭${"─".repeat(boxW)}╮`));
685
- for (const line of boxLines)
686
- console.log(chalk.dim(" │") + ` ${line.padEnd(boxW - 2)}` + chalk.dim("│"));
687
- console.log(chalk.dim(` ╰${"─".repeat(boxW)}╯`));
688
- const action = await selectKey("", [
689
- { key: "r", desc: "esume" },
690
- { key: "f", desc: "resh" },
691
- { key: "q", desc: "uit" },
692
- ]);
693
- if (action === "q") {
694
- process.exit(0);
719
+ const incompleteRuns = findIncompleteRuns(rootDir, cwd);
720
+ if (incompleteRuns.length > 0 && !noTTY && tasks.length === 0) {
721
+ let decided = false;
722
+ while (!decided) {
723
+ if (incompleteRuns.length === 1) {
724
+ // Single incomplete run show detailed box
725
+ const run = incompleteRuns[0];
726
+ const prev = run.state;
727
+ const merged = prev.branches.filter(b => b.status === "merged").length;
728
+ const unmerged = prev.branches.filter(b => b.status === "unmerged").length;
729
+ const failed = prev.branches.filter(b => b.status === "failed" || b.status === "merge-failed").length;
730
+ const obj = prev.objective?.slice(0, 50) || "";
731
+ const ago = formatTimeAgo(prev.startedAt);
732
+ let lastStatus = "";
733
+ try {
734
+ lastStatus = readFileSync(join(run.dir, "status.md"), "utf-8").trim().slice(0, 120);
735
+ }
736
+ catch { }
737
+ console.log(chalk.yellow(`\n ⚠ Unfinished run`) + chalk.dim(` · ${ago}`));
738
+ const boxLines = [
739
+ `${obj}${obj.length >= 50 ? "…" : ""}`,
740
+ `${prev.accCompleted}/${prev.budget} sessions · ${Math.max(1, (prev.budget ?? 0) - prev.accCompleted)} remaining · $${prev.accCost.toFixed(2)}`,
741
+ `Wave ${prev.waveNum + 1} · ${prev.phase}`,
742
+ ];
743
+ if (lastStatus)
744
+ boxLines.push(lastStatus);
745
+ if (merged + unmerged + failed > 0)
746
+ boxLines.push(`${merged} merged · ${unmerged} unmerged · ${failed} failed`);
747
+ const boxW = Math.max(...boxLines.map(l => l.length)) + 4;
748
+ console.log(chalk.dim(` ╭${"".repeat(boxW)}╮`));
749
+ for (const line of boxLines)
750
+ console.log(chalk.dim(" │") + ` ${line.padEnd(boxW - 2)}` + chalk.dim(""));
751
+ console.log(chalk.dim(` ╰${"─".repeat(boxW)}╯`));
752
+ const action = await selectKey("", [
753
+ { key: "r", desc: "esume" },
754
+ { key: "h", desc: "istory" },
755
+ { key: "f", desc: "resh" },
756
+ { key: "q", desc: "uit" },
757
+ ]);
758
+ if (action === "q")
759
+ process.exit(0);
760
+ if (action === "f") {
761
+ decided = true;
762
+ break;
763
+ }
764
+ if (action === "h") {
765
+ showRunHistory(allRuns, cwd);
766
+ continue;
767
+ }
768
+ resuming = true;
769
+ resumeState = prev;
770
+ resumeRunDir = run.dir;
771
+ decided = true;
772
+ }
773
+ else {
774
+ // Multiple incomplete runs — show numbered list (cap at 9 for single-keystroke selection)
775
+ const shown = incompleteRuns.slice(0, 9);
776
+ console.log(chalk.yellow(`\n ⚠ ${incompleteRuns.length} unfinished runs${incompleteRuns.length > 9 ? ` (showing newest 9)` : ""}\n`));
777
+ for (let i = 0; i < shown.length; i++) {
778
+ const run = shown[i];
779
+ const s = run.state;
780
+ const ago = formatTimeAgo(s.startedAt);
781
+ const obj = s.objective?.slice(0, 50) || "";
782
+ const merged = s.branches.filter(b => b.status === "merged").length;
783
+ let lastStatus = "";
784
+ try {
785
+ lastStatus = readFileSync(join(run.dir, "status.md"), "utf-8").trim().split("\n")[0].slice(0, 70);
786
+ }
787
+ catch { }
788
+ console.log(chalk.cyan(` ${i + 1}`) + ` ${obj}${obj.length >= 50 ? "…" : ""}`);
789
+ console.log(chalk.dim(` ${s.accCompleted}/${s.budget} · $${s.accCost.toFixed(2)} · ${ago} · ${s.phase} at wave ${s.waveNum + 1}${merged ? ` · ${merged} merged` : ""}`));
790
+ if (lastStatus)
791
+ console.log(chalk.dim(` ${lastStatus}`));
792
+ console.log("");
793
+ }
794
+ const action = await selectKey(` ${chalk.dim(`[1-${shown.length}] resume`)}`, [
795
+ ...shown.map((_, i) => ({ key: String(i + 1), desc: "" })),
796
+ { key: "h", desc: "istory" },
797
+ { key: "f", desc: "resh" },
798
+ { key: "q", desc: "uit" },
799
+ ]);
800
+ if (action === "q")
801
+ process.exit(0);
802
+ if (action === "f") {
803
+ decided = true;
804
+ break;
805
+ }
806
+ if (action === "h") {
807
+ showRunHistory(allRuns, cwd);
808
+ continue;
809
+ }
810
+ const idx = parseInt(action) - 1;
811
+ if (idx >= 0 && idx < shown.length) {
812
+ resuming = true;
813
+ resumeState = shown[idx].state;
814
+ resumeRunDir = shown[idx].dir;
815
+ decided = true;
816
+ }
817
+ }
695
818
  }
696
- if (action === "r") {
697
- resuming = true;
698
- resumeState = prev;
699
- resumeRunDir = incomplete.dir;
819
+ if (resuming && resumeState && resumeRunDir) {
820
+ const unmerged = resumeState.branches.filter(b => b.status === "unmerged").length;
700
821
  if (unmerged > 0) {
701
822
  console.log("");
702
- autoMergeBranches(cwd, prev.branches, (msg) => console.log(chalk.dim(` ${msg}`)));
703
- // Persist merged branch statuses immediately so they survive a crash before next saveRunState
823
+ autoMergeBranches(cwd, resumeState.branches, (msg) => console.log(chalk.dim(` ${msg}`)));
704
824
  try {
705
- saveRunState(incomplete.dir, prev);
825
+ saveRunState(resumeRunDir, resumeState);
706
826
  }
707
827
  catch { }
708
828
  }
@@ -883,6 +1003,8 @@ async function main() {
883
1003
  // Create run directory — reuse orphaned run (thinking succeeded, orchestration crashed) if available
884
1004
  const orphanedDir = !resuming ? findOrphanedDesigns(rootDir) : null;
885
1005
  const runDir = resuming && resumeRunDir ? resumeRunDir : (orphanedDir ?? createRunDir(rootDir));
1006
+ if (resuming && resumeRunDir)
1007
+ updateLatestSymlink(rootDir, resumeRunDir);
886
1008
  const previousKnowledge = readPreviousRunKnowledge(rootDir);
887
1009
  // ── Plan phase (interactive: review loop, non-interactive: auto-plan or skip) ──
888
1010
  const needsPlan = tasks.length === 0 && !resuming;
@@ -969,14 +1091,22 @@ async function main() {
969
1091
  agentTimeoutMs,
970
1092
  usageCap, allowExtraUsage, extraUsageBudget,
971
1093
  });
972
- const stopThinkRender = startRenderLoop(thinkingSwarm, { remaining: 0, usageCap, dirty: false });
1094
+ const thinkRunInfo = {
1095
+ accIn: 0, accOut: 0, accCost: 0, accCompleted: 0, accFailed: 0,
1096
+ sessionsBudget: budget ?? 10, waveNum: -1, remaining: (budget ?? 10),
1097
+ model: plannerModel, startedAt: Date.now(),
1098
+ };
1099
+ const thinkDisplay = new RunDisplay(thinkRunInfo, { remaining: 0, usageCap, dirty: false });
1100
+ thinkDisplay.setWave(thinkingSwarm);
1101
+ thinkDisplay.start();
973
1102
  try {
974
1103
  await thinkingSwarm.run();
975
1104
  }
976
1105
  finally {
977
- stopThinkRender();
1106
+ thinkDisplay.pause();
1107
+ console.log(renderSummary(thinkingSwarm));
1108
+ thinkDisplay.stop();
978
1109
  }
979
- console.log(renderSummary(thinkingSwarm));
980
1110
  thinkingUsed = thinkingSwarm.completed + thinkingSwarm.failed;
981
1111
  thinkingCost = thinkingSwarm.totalCostUsd;
982
1112
  thinkingIn = thinkingSwarm.totalInputTokens;
@@ -1109,8 +1239,10 @@ async function main() {
1109
1239
  process.exit(0);
1110
1240
  }
1111
1241
  // ── Run (wave loop) ──
1112
- process.stdout.write("\x1B[?25l");
1113
- const restore = () => process.stdout.write("\x1B[?25h\n");
1242
+ const restore = () => { try {
1243
+ process.stdout.write("\x1B[?25h\n");
1244
+ }
1245
+ catch { } };
1114
1246
  const runStartedAt = resuming && resumeState?.startedAt ? new Date(resumeState.startedAt).getTime() : Date.now();
1115
1247
  // Wave-loop state — either fresh or resumed
1116
1248
  mkdirSync(join(runDir, "reflections"), { recursive: true });
@@ -1129,32 +1261,26 @@ async function main() {
1129
1261
  let overheadBudgetUsed;
1130
1262
  const branches = [];
1131
1263
  if (resuming && resumeState) {
1132
- // Restore ALL config from saved state
1133
- remaining = resumeState.remaining;
1264
+ // Restore wave-loop state (config already restored in the early resume block + ternaries above)
1265
+ // Recalculate remaining from budget — don't trust saved value which counts failures
1266
+ // and may have been zeroed by a premature "done" signal from the steerer.
1267
+ // The user explicitly chose Resume, so give back budget not yet successfully used.
1268
+ remaining = Math.max(1, (resumeState.budget ?? 0) - resumeState.accCompleted);
1134
1269
  currentTasks = resumeState.currentTasks;
1135
1270
  waveNum = resumeState.waveNum;
1136
1271
  accCost = resumeState.accCost;
1137
1272
  accCompleted = resumeState.accCompleted;
1138
1273
  accFailed = resumeState.accFailed;
1139
- accTools = 0;
1274
+ accTools = resumeState.accTools ?? 0;
1275
+ accIn = resumeState.accIn ?? 0;
1276
+ accOut = resumeState.accOut ?? 0;
1140
1277
  lastWaveKind = resumeState.lastWaveKind;
1141
1278
  overheadBudgetUsed = resumeState.overheadBudgetUsed ?? (resumeState.reflectionBudgetUsed ?? 0) + (resumeState.verificationBudgetUsed ?? 0);
1142
1279
  branches.push(...resumeState.branches);
1143
- objective = resumeState.objective;
1144
- workerModel = resumeState.workerModel;
1145
- plannerModel = resumeState.plannerModel;
1146
- budget = resumeState.budget;
1147
- concurrency = resumeState.concurrency;
1148
- flex = resumeState.flex;
1149
- usageCap = resumeState.usageCap;
1150
- allowExtraUsage = resumeState.allowExtraUsage ?? false;
1151
- extraUsageBudget = resumeState.extraUsageBudget;
1152
- permissionMode = resumeState.permissionMode;
1153
- useWorktrees = resumeState.useWorktrees;
1154
- mergeStrategy = resumeState.mergeStrategy;
1155
- // Restore wave history from saved session files so steerer has full context
1280
+ flex = resumeState.flex; // override computed flex with saved value
1156
1281
  waveHistory.push(...loadWaveHistory(runDir));
1157
1282
  console.log(chalk.green(`\n ✓ Resumed`) + chalk.dim(` · wave ${waveNum + 1} · ${remaining} remaining · $${accCost.toFixed(2)} spent · ${waveHistory.length} prior waves\n`));
1283
+ waveNum++; // advance past last completed wave so next session file doesn't overwrite
1158
1284
  }
1159
1285
  else {
1160
1286
  // Fresh run
@@ -1178,6 +1304,17 @@ async function main() {
1178
1304
  liveConfig.remaining = remaining;
1179
1305
  liveConfig.usageCap = usageCap;
1180
1306
  const maxOverheadBudget = Math.max(4, Math.ceil((budget ?? 10) * 0.15));
1307
+ // Unified display — one instance for the entire run
1308
+ const runInfoRef = {
1309
+ accIn, accOut, accCost, accCompleted, accFailed,
1310
+ sessionsBudget: budget ?? tasks.length, waveNum, remaining,
1311
+ model: workerModel, startedAt: runStartedAt,
1312
+ };
1313
+ const display = new RunDisplay(runInfoRef, liveConfig);
1314
+ const rlGetter = () => {
1315
+ const rl = getPlannerRateLimitInfo();
1316
+ return { utilization: rl.utilization, isUsingOverage: rl.isUsingOverage, windows: rl.windows, resetsAt: rl.resetsAt };
1317
+ };
1181
1318
  // For flex + branch strategy: create one target branch, waves merge via yolo into it
1182
1319
  let runBranch;
1183
1320
  let originalRef;
@@ -1199,79 +1336,104 @@ async function main() {
1199
1336
  const gracefulStop = (signal) => {
1200
1337
  if (stopping) {
1201
1338
  currentSwarm?.cleanup();
1339
+ display.stop();
1202
1340
  restore();
1203
1341
  process.exit(0);
1204
1342
  }
1205
1343
  stopping = true;
1206
- process.stdout.write(`\n ${chalk.yellow(`${signal}: stopping... (send again to force)`)}\n`);
1207
1344
  currentSwarm?.abort();
1208
1345
  };
1209
1346
  process.on("SIGINT", () => gracefulStop("SIGINT"));
1210
1347
  process.on("SIGTERM", () => gracefulStop("SIGTERM"));
1211
- process.on("uncaughtException", (err) => { currentSwarm?.abort(); currentSwarm?.cleanup(); restore(); console.error(chalk.red(`\n Uncaught: ${err.message}`)); process.exit(1); });
1212
- process.on("unhandledRejection", (reason) => { currentSwarm?.abort(); currentSwarm?.cleanup(); restore(); console.error(chalk.red(`\n Unhandled: ${reason instanceof Error ? reason.message : reason}`)); process.exit(1); });
1348
+ process.on("uncaughtException", (err) => { currentSwarm?.abort(); currentSwarm?.cleanup(); display.stop(); restore(); console.error(chalk.red(`\n Uncaught: ${err.message}`)); process.exit(1); });
1349
+ process.on("unhandledRejection", (reason) => { currentSwarm?.abort(); currentSwarm?.cleanup(); display.stop(); restore(); console.error(chalk.red(`\n Unhandled: ${reason instanceof Error ? reason.message : reason}`)); process.exit(1); });
1350
+ // Helper: sync mutable runInfo with accumulated state
1351
+ const syncRunInfo = () => Object.assign(runInfoRef, { accIn, accOut, accCost, accCompleted, accFailed, waveNum, remaining });
1213
1352
  // When resuming a flex run with no queued tasks, steer immediately to get the next wave
1214
1353
  if (resuming && flex && currentTasks.length === 0 && remaining > 0) {
1215
- console.log(chalk.cyan(`\n ◆ Assessing...\n`));
1216
- process.stdout.write("\x1B[?25l");
1217
- try {
1218
- const memory = readRunMemory(runDir, previousKnowledge || undefined);
1219
- const steer = await steerWave(objective, waveHistory, remaining, cwd, plannerModel, workerModel, permissionMode, concurrency, makeProgressLog(), memory);
1220
- process.stdout.write(`\x1B[2K\r`);
1221
- process.stdout.write("\x1B[?25h");
1222
- if (steer.statusUpdate)
1223
- writeStatus(runDir, steer.statusUpdate);
1224
- if (steer.goalUpdate)
1225
- writeGoalUpdate(runDir, steer.goalUpdate);
1226
- if (!steer.done && steer.tasks.length > 0) {
1227
- console.log(chalk.dim(` ${steer.reasoning}\n`));
1228
- currentTasks = steer.tasks.map(t => ({
1229
- ...t,
1230
- model: t.model === "planner" ? plannerModel : t.model === "worker" ? workerModel : t.model,
1231
- }));
1232
- lastWaveKind = steer.waveKind;
1354
+ let steerAttempts = 0;
1355
+ display.setSteering(rlGetter);
1356
+ display.start();
1357
+ while (currentTasks.length === 0 && remaining > 0 && !objectiveComplete && steerAttempts < 3) {
1358
+ steerAttempts++;
1359
+ const plannerCostBefore = getTotalPlannerCost();
1360
+ try {
1361
+ const memory = readRunMemory(runDir, previousKnowledge || undefined);
1362
+ const steer = await steerWave(objective, waveHistory, remaining, cwd, plannerModel, workerModel, permissionMode, concurrency, (text) => display.updateText(text), memory);
1363
+ const steerCost = getTotalPlannerCost() - plannerCostBefore;
1364
+ accCost += steerCost;
1365
+ syncRunInfo();
1366
+ if (steer.statusUpdate)
1367
+ writeStatus(runDir, steer.statusUpdate);
1368
+ if (steer.goalUpdate)
1369
+ writeGoalUpdate(runDir, steer.goalUpdate);
1370
+ if (steer.done || steer.tasks.length === 0) {
1371
+ const hasVerification = waveHistory.some(w => w.kind.includes("verif"));
1372
+ if (!hasVerification && remaining >= 1) {
1373
+ display.updateText(`Done blocked \u2014 verification required`);
1374
+ lastWaveKind = "done-blocked";
1375
+ continue;
1376
+ }
1377
+ objectiveComplete = true;
1378
+ remaining = 0;
1379
+ }
1380
+ else {
1381
+ const isOverhead = steer.waveKind !== "execute";
1382
+ if (isOverhead && overheadBudgetUsed + steer.tasks.length > maxOverheadBudget) {
1383
+ display.updateText(`Overhead budget exhausted (${overheadBudgetUsed}/${maxOverheadBudget}) \u2014 re-assessing`);
1384
+ lastWaveKind = "overhead-capped";
1385
+ continue;
1386
+ }
1387
+ currentTasks = steer.tasks.map(t => ({
1388
+ ...t,
1389
+ model: t.model === "planner" ? plannerModel : t.model === "worker" ? workerModel
1390
+ : isOverhead && !t.model ? plannerModel : t.model,
1391
+ }));
1392
+ lastWaveKind = steer.waveKind;
1393
+ if (isOverhead)
1394
+ overheadBudgetUsed += currentTasks.length;
1395
+ }
1233
1396
  }
1234
- else if (steer.done) {
1235
- console.log(chalk.green(` \u2713 ${steer.reasoning}\n`));
1236
- objectiveComplete = true;
1237
- remaining = 0;
1397
+ catch (err) {
1398
+ const steerCost = getTotalPlannerCost() - plannerCostBefore;
1399
+ accCost += steerCost;
1400
+ display.stop();
1401
+ console.log(chalk.yellow(` Steering failed: ${err.message?.slice(0, 80)} \u2014 stopping\n`));
1402
+ break;
1238
1403
  }
1239
1404
  }
1240
- catch (err) {
1241
- process.stdout.write("\x1B[?25h");
1242
- console.log(chalk.yellow(` Steering failed: ${err.message?.slice(0, 80)} \u2014 stopping\n`));
1243
- }
1244
1405
  }
1406
+ // Start unified display (first call — idempotent)
1407
+ if (!display.runInfo.startedAt)
1408
+ display.runInfo.startedAt = runStartedAt;
1409
+ display.start();
1245
1410
  while (remaining > 0 && currentTasks.length > 0 && !stopping) {
1246
1411
  if (currentTasks.length > remaining)
1247
1412
  currentTasks = currentTasks.slice(0, remaining);
1248
- if (flex) {
1249
- const costSoFar = accCost > 0 ? ` · $${accCost.toFixed(2)} spent` : "";
1250
- console.log(chalk.cyan(`\n ◆ Wave ${waveNum + 1}`) + chalk.dim(` · ${currentTasks.length} tasks · ${remaining} remaining${costSoFar}\n`));
1251
- }
1413
+ syncRunInfo();
1252
1414
  const swarm = new Swarm({
1253
1415
  tasks: currentTasks, concurrency, cwd, model: workerModel, permissionMode, allowedTools,
1254
1416
  useWorktrees, mergeStrategy: waveMerge, agentTimeoutMs, usageCap, allowExtraUsage, extraUsageBudget,
1255
1417
  baseCostUsd: accCost,
1256
1418
  });
1257
1419
  currentSwarm = swarm;
1258
- const stopRender = startRenderLoop(swarm, liveConfig);
1420
+ display.setWave(swarm);
1421
+ display.resume();
1259
1422
  try {
1260
1423
  await swarm.run();
1261
1424
  }
1262
1425
  catch (err) {
1263
1426
  if (isAuthError(err)) {
1264
- stopRender();
1427
+ display.stop();
1265
1428
  restore();
1266
1429
  console.error(chalk.red(`\n Authentication failed — check your API key or run: claude auth\n`));
1267
1430
  process.exit(1);
1268
1431
  }
1269
1432
  throw err;
1270
1433
  }
1271
- finally {
1272
- stopRender();
1273
- console.log(renderSummary(swarm));
1274
- }
1434
+ // Show wave summary (pauses render, prints, then we switch to steering)
1435
+ display.pause();
1436
+ console.log(renderSummary(swarm));
1275
1437
  // Accumulate
1276
1438
  accCost += swarm.totalCostUsd;
1277
1439
  accIn += swarm.totalInputTokens;
@@ -1279,8 +1441,10 @@ async function main() {
1279
1441
  accCompleted += swarm.completed;
1280
1442
  accFailed += swarm.failed;
1281
1443
  accTools += swarm.agents.reduce((sum, a) => sum + a.toolCalls, 0);
1282
- remaining -= swarm.completed + swarm.failed;
1283
- // Apply live config changes if user adjusted budget/threshold mid-wave
1444
+ remaining = Math.max(0, remaining - swarm.completed - swarm.failed);
1445
+ const expectedFloor = Math.max(0, (budget ?? 0) - accCompleted - accFailed);
1446
+ if (remaining < expectedFloor)
1447
+ remaining = expectedFloor;
1284
1448
  if (liveConfig.dirty) {
1285
1449
  remaining = liveConfig.remaining;
1286
1450
  usageCap = liveConfig.usageCap;
@@ -1295,7 +1459,7 @@ async function main() {
1295
1459
  id: `run-${new Date().toISOString().slice(0, 19)}`, objective: objective, budget: budget ?? tasks.length,
1296
1460
  remaining, workerModel, plannerModel, concurrency, permissionMode,
1297
1461
  usageCap, allowExtraUsage, extraUsageBudget, flex, useWorktrees, mergeStrategy, waveNum, currentTasks: [],
1298
- lastWaveKind, overheadBudgetUsed, accCost, accCompleted, accFailed,
1462
+ lastWaveKind, overheadBudgetUsed, accCost, accCompleted, accFailed, accIn, accOut, accTools,
1299
1463
  branches, phase: "steering", startedAt: new Date(runStartedAt).toISOString(), cwd,
1300
1464
  });
1301
1465
  waveHistory.push({
@@ -1311,48 +1475,44 @@ async function main() {
1311
1475
  if (!flex || remaining <= 0 || swarm.aborted || swarm.cappedOut)
1312
1476
  break;
1313
1477
  // ── Steer: assess and compose the next wave ──
1478
+ syncRunInfo();
1479
+ display.setSteering(rlGetter);
1480
+ display.resume();
1314
1481
  let steered = false;
1315
1482
  let steerAttempts = 0;
1316
1483
  while (!steered && remaining > 0 && !stopping && steerAttempts < 3) {
1317
1484
  steerAttempts++;
1318
- console.log(chalk.cyan(`\n ◆ Assessing...\n`));
1319
- process.stdout.write("\x1B[?25l");
1485
+ const plannerCostBefore = getTotalPlannerCost();
1320
1486
  try {
1321
1487
  const memory = readRunMemory(runDir, previousKnowledge || undefined);
1322
- const steer = await steerWave(objective, waveHistory, remaining, cwd, plannerModel, workerModel, permissionMode, concurrency, makeProgressLog(), memory);
1323
- process.stdout.write(`\x1B[2K\r`);
1324
- process.stdout.write("\x1B[?25h");
1488
+ const steer = await steerWave(objective, waveHistory, remaining, cwd, plannerModel, workerModel, permissionMode, concurrency, (text) => display.updateText(text), memory);
1489
+ const steerCost = getTotalPlannerCost() - plannerCostBefore;
1490
+ accCost += steerCost;
1491
+ syncRunInfo();
1325
1492
  if (steer.statusUpdate)
1326
1493
  writeStatus(runDir, steer.statusUpdate);
1327
- if (steer.goalUpdate) {
1494
+ if (steer.goalUpdate)
1328
1495
  writeGoalUpdate(runDir, steer.goalUpdate);
1329
- console.log(chalk.dim(` Goal refined: ${steer.goalUpdate.slice(0, 100)}\n`));
1330
- }
1331
1496
  const execWaves = waveHistory.filter(w => w.kind === "execute").length;
1332
1497
  if (execWaves > 0 && execWaves % 5 === 0)
1333
1498
  archiveMilestone(runDir, waveNum);
1334
1499
  if (steer.done || steer.tasks.length === 0) {
1335
1500
  const hasVerification = waveHistory.some(w => w.kind.includes("verif"));
1336
1501
  if (!hasVerification && remaining >= 1) {
1337
- console.log(chalk.dim(` ${steer.reasoning}`));
1338
- console.log(chalk.yellow(` Done blocked — verification required before completion\n`));
1502
+ display.updateText(`Done blocked \u2014 verification required`);
1339
1503
  lastWaveKind = "done-blocked";
1340
- continue; // re-steer — steerer will see the hint
1504
+ continue;
1341
1505
  }
1342
- console.log(chalk.green(` \u2713 ${steer.reasoning}\n`));
1343
1506
  objectiveComplete = true;
1344
1507
  remaining = 0;
1345
1508
  break;
1346
1509
  }
1347
1510
  const isOverhead = steer.waveKind !== "execute";
1348
1511
  if (isOverhead && overheadBudgetUsed + steer.tasks.length > maxOverheadBudget) {
1349
- console.log(chalk.dim(` ${steer.reasoning}`));
1350
- console.log(chalk.yellow(` Overhead budget exhausted (${overheadBudgetUsed}/${maxOverheadBudget}) — re-assessing\n`));
1512
+ display.updateText(`Overhead budget exhausted (${overheadBudgetUsed}/${maxOverheadBudget}) \u2014 re-assessing`);
1351
1513
  lastWaveKind = "overhead-capped";
1352
- continue; // re-steer
1514
+ continue;
1353
1515
  }
1354
- console.log(chalk.dim(` ${steer.reasoning}\n`));
1355
- // Resolve model aliases: "planner" → plannerModel, "worker" → workerModel
1356
1516
  currentTasks = steer.tasks.map(t => ({
1357
1517
  ...t,
1358
1518
  model: t.model === "planner" ? plannerModel : t.model === "worker" ? workerModel
@@ -1364,14 +1524,18 @@ async function main() {
1364
1524
  steered = true;
1365
1525
  }
1366
1526
  catch (err) {
1367
- process.stdout.write("\x1B[?25h");
1527
+ const steerCost = getTotalPlannerCost() - plannerCostBefore;
1528
+ accCost += steerCost;
1529
+ display.stop();
1368
1530
  console.log(chalk.yellow(` Steering failed: ${err.message?.slice(0, 80)} \u2014 stopping\n`));
1369
- // Don't zero out remaining — preserve unspent budget so resume works
1370
1531
  break;
1371
1532
  }
1372
1533
  }
1534
+ if (!steered)
1535
+ break;
1373
1536
  waveNum++;
1374
1537
  }
1538
+ display.stop();
1375
1539
  // Only truly "done" if steering explicitly completed the objective (or non-flex single wave with budget exhausted)
1376
1540
  const trulyDone = objectiveComplete || (!flex && remaining <= 0);
1377
1541
  const finalPhase = trulyDone ? "done" : "capped";
@@ -1379,7 +1543,7 @@ async function main() {
1379
1543
  id: `run-${new Date().toISOString().slice(0, 19)}`, objective: objective ?? "", budget: budget ?? tasks.length,
1380
1544
  remaining, workerModel, plannerModel, concurrency, permissionMode,
1381
1545
  usageCap, allowExtraUsage, extraUsageBudget, flex, useWorktrees, mergeStrategy, waveNum, currentTasks: [],
1382
- lastWaveKind, overheadBudgetUsed, accCost, accCompleted, accFailed,
1546
+ lastWaveKind, overheadBudgetUsed, accCost, accCompleted, accFailed, accIn, accOut, accTools,
1383
1547
  branches, phase: finalPhase, startedAt: new Date(runStartedAt).toISOString(), cwd,
1384
1548
  });
1385
1549
  if (trulyDone) {
@@ -1391,6 +1555,10 @@ async function main() {
1391
1555
  rmSync(join(runDir, "reflections"), { recursive: true, force: true });
1392
1556
  }
1393
1557
  catch { }
1558
+ try {
1559
+ rmSync(join(runDir, "verifications"), { recursive: true, force: true });
1560
+ }
1561
+ catch { }
1394
1562
  }
1395
1563
  // Switch back if we created a run branch
1396
1564
  if (runBranch && originalRef) {