claude-overnight 1.5.1 → 1.6.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";
@@ -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 });
@@ -410,6 +469,35 @@ function saveWaveSession(baseDir, waveNum, kind, swarm) {
410
469
  totalCost: swarm.totalCostUsd,
411
470
  }, null, 2), "utf-8");
412
471
  }
472
+ /** Rebuild waveHistory from saved session files on resume. */
473
+ function loadWaveHistory(runDir) {
474
+ const dir = join(runDir, "sessions");
475
+ try {
476
+ return readdirSync(dir)
477
+ .filter(f => f.startsWith("wave-") && f.endsWith(".json"))
478
+ .sort((a, b) => {
479
+ const numA = parseInt(a.replace("wave-", "").replace(".json", ""));
480
+ const numB = parseInt(b.replace("wave-", "").replace(".json", ""));
481
+ return numA - numB;
482
+ })
483
+ .map(f => {
484
+ const data = JSON.parse(readFileSync(join(dir, f), "utf-8"));
485
+ return {
486
+ wave: data.wave,
487
+ kind: data.kind,
488
+ tasks: (data.agents || []).map((a) => ({
489
+ prompt: a.prompt,
490
+ status: a.status,
491
+ filesChanged: a.filesChanged,
492
+ error: a.error,
493
+ })),
494
+ };
495
+ });
496
+ }
497
+ catch {
498
+ return [];
499
+ }
500
+ }
413
501
  function recordBranches(swarm, branches) {
414
502
  for (const a of swarm.agents) {
415
503
  if (a.branch) {
@@ -596,16 +684,17 @@ async function main() {
596
684
  // ── Show run history ──
597
685
  const rootDir = join(cwd, ".claude-overnight");
598
686
  const runsDir = join(rootDir, "runs");
599
- let completedRuns = [];
687
+ const allRuns = [];
600
688
  try {
601
689
  const dirs = readdirSync(runsDir).sort().reverse();
602
690
  for (const d of dirs) {
603
691
  const s = loadRunState(join(runsDir, d));
604
- if (s && s.phase === "done")
605
- completedRuns.push({ dir: join(runsDir, d), state: s });
692
+ if (s)
693
+ allRuns.push({ dir: join(runsDir, d), state: s });
606
694
  }
607
695
  }
608
696
  catch { }
697
+ const completedRuns = allRuns.filter(r => r.state.phase === "done" && r.state.cwd === cwd);
609
698
  if (completedRuns.length > 0 && !noTTY) {
610
699
  console.log(chalk.dim(`\n ${completedRuns.length} previous run${completedRuns.length > 1 ? "s" : ""}`));
611
700
  for (const r of completedRuns.slice(0, 3)) {
@@ -614,7 +703,6 @@ async function main() {
614
703
  const cost = r.state.accCost > 0 ? ` · $${r.state.accCost.toFixed(0)}` : "";
615
704
  const merged = r.state.branches.filter(b => b.status === "merged").length;
616
705
  console.log(chalk.dim(` ${date} · ${r.state.accCompleted} done · ${merged} merged${cost}${obj ? ` · ${obj}` : ""}${obj.length >= 50 ? "…" : ""}`));
617
- // Show status if available
618
706
  let status = "";
619
707
  try {
620
708
  status = readFileSync(join(r.dir, "status.md"), "utf-8").trim().split("\n")[0].slice(0, 80);
@@ -628,49 +716,115 @@ async function main() {
628
716
  let resuming = false;
629
717
  let resumeState = null;
630
718
  let resumeRunDir;
631
- const incomplete = findIncompleteRun(rootDir);
632
- if (incomplete && incomplete.state.cwd === cwd && !noTTY && tasks.length === 0) {
633
- const prev = incomplete.state;
634
- const merged = prev.branches.filter(b => b.status === "merged").length;
635
- const unmerged = prev.branches.filter(b => b.status === "unmerged").length;
636
- const failed = prev.branches.filter(b => b.status === "failed" || b.status === "merge-failed").length;
637
- const obj = prev.objective?.slice(0, 50) || "";
638
- // Read last status for context
639
- let lastStatus = "";
640
- try {
641
- lastStatus = readFileSync(join(incomplete.dir, "status.md"), "utf-8").trim().slice(0, 120);
642
- }
643
- catch { }
644
- const label = "Unfinished run";
645
- console.log(chalk.yellow(`\n ⚠ ${label}`));
646
- const boxLines = [
647
- `${obj}${obj.length >= 50 ? "…" : ""}`,
648
- `${prev.accCompleted}/${prev.budget} sessions · ${prev.remaining} remaining · $${prev.accCost.toFixed(2)}`,
649
- ];
650
- if (lastStatus)
651
- boxLines.push(lastStatus);
652
- if (merged + unmerged + failed > 0)
653
- boxLines.push(`${merged} merged · ${unmerged} unmerged · ${failed} failed branches`);
654
- const boxW = Math.max(...boxLines.map(l => l.length)) + 4;
655
- console.log(chalk.dim(` ╭${"─".repeat(boxW)}╮`));
656
- for (const line of boxLines)
657
- console.log(chalk.dim(" │") + ` ${line.padEnd(boxW - 2)}` + chalk.dim("│"));
658
- console.log(chalk.dim(` ╰${"─".repeat(boxW)}╯`));
659
- const action = await selectKey("", [
660
- { key: "r", desc: "esume" },
661
- { key: "f", desc: "resh" },
662
- { key: "q", desc: "uit" },
663
- ]);
664
- if (action === "q") {
665
- 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
+ }
666
818
  }
667
- if (action === "r") {
668
- resuming = true;
669
- resumeState = prev;
670
- resumeRunDir = incomplete.dir;
819
+ if (resuming && resumeState && resumeRunDir) {
820
+ const unmerged = resumeState.branches.filter(b => b.status === "unmerged").length;
671
821
  if (unmerged > 0) {
672
822
  console.log("");
673
- autoMergeBranches(cwd, prev.branches, (msg) => console.log(chalk.dim(` ${msg}`)));
823
+ autoMergeBranches(cwd, resumeState.branches, (msg) => console.log(chalk.dim(` ${msg}`)));
824
+ try {
825
+ saveRunState(resumeRunDir, resumeState);
826
+ }
827
+ catch { }
674
828
  }
675
829
  }
676
830
  }
@@ -683,7 +837,18 @@ async function main() {
683
837
  let usageCap;
684
838
  let allowExtraUsage = false;
685
839
  let extraUsageBudget;
686
- if (!nonInteractive) {
840
+ if (resuming) {
841
+ // Skip interactive flow entirely — all config is restored from saved state later
842
+ workerModel = resumeState.workerModel;
843
+ plannerModel = resumeState.plannerModel;
844
+ budget = resumeState.budget;
845
+ concurrency = resumeState.concurrency;
846
+ objective = resumeState.objective;
847
+ usageCap = resumeState.usageCap;
848
+ allowExtraUsage = resumeState.allowExtraUsage ?? false;
849
+ extraUsageBudget = resumeState.extraUsageBudget;
850
+ }
851
+ else if (!nonInteractive) {
687
852
  // ① Objective
688
853
  while (true) {
689
854
  objective = await ask(`\n ${chalk.cyan("①")} ${chalk.bold("What should the agents do?")}\n ${chalk.cyan(">")} `);
@@ -819,11 +984,11 @@ async function main() {
819
984
  }
820
985
  }
821
986
  validateConcurrency(concurrency);
822
- const permissionMode = fileCfg?.permissionMode ?? "auto";
823
- const useWorktrees = fileCfg?.useWorktrees ?? (isGitRepo(cwd));
987
+ let permissionMode = resuming ? resumeState.permissionMode : (fileCfg?.permissionMode ?? "auto");
988
+ let useWorktrees = resuming ? resumeState.useWorktrees : (fileCfg?.useWorktrees ?? isGitRepo(cwd));
824
989
  if (useWorktrees)
825
990
  validateGitRepo(cwd);
826
- const mergeStrategy = fileCfg?.mergeStrategy ?? "yolo";
991
+ let mergeStrategy = resuming ? resumeState.mergeStrategy : (fileCfg?.mergeStrategy ?? "yolo");
827
992
  if (nonInteractive) {
828
993
  const capStr = usageCap != null ? ` cap=${Math.round(usageCap * 100)}%` : "";
829
994
  const extraStr = allowExtraUsage ? (extraUsageBudget ? ` extra=$${extraUsageBudget}` : " extra=∞") : " extra=off";
@@ -838,9 +1003,11 @@ async function main() {
838
1003
  // Create run directory — reuse orphaned run (thinking succeeded, orchestration crashed) if available
839
1004
  const orphanedDir = !resuming ? findOrphanedDesigns(rootDir) : null;
840
1005
  const runDir = resuming && resumeRunDir ? resumeRunDir : (orphanedDir ?? createRunDir(rootDir));
1006
+ if (resuming && resumeRunDir)
1007
+ updateLatestSymlink(rootDir, resumeRunDir);
841
1008
  const previousKnowledge = readPreviousRunKnowledge(rootDir);
842
1009
  // ── Plan phase (interactive: review loop, non-interactive: auto-plan or skip) ──
843
- const needsPlan = tasks.length === 0;
1010
+ const needsPlan = tasks.length === 0 && !resuming;
844
1011
  const designDir = join(runDir, "designs");
845
1012
  if (needsPlan) {
846
1013
  if (noTTY) {
@@ -1054,7 +1221,7 @@ async function main() {
1054
1221
  process.exit(1);
1055
1222
  }
1056
1223
  }
1057
- if (tasks.length === 0) {
1224
+ if (tasks.length === 0 && !resuming) {
1058
1225
  console.error("No tasks provided.");
1059
1226
  process.exit(1);
1060
1227
  }
@@ -1066,7 +1233,7 @@ async function main() {
1066
1233
  // ── Run (wave loop) ──
1067
1234
  process.stdout.write("\x1B[?25l");
1068
1235
  const restore = () => process.stdout.write("\x1B[?25h\n");
1069
- const runStartedAt = Date.now();
1236
+ const runStartedAt = resuming && resumeState?.startedAt ? new Date(resumeState.startedAt).getTime() : Date.now();
1070
1237
  // Wave-loop state — either fresh or resumed
1071
1238
  mkdirSync(join(runDir, "reflections"), { recursive: true });
1072
1239
  mkdirSync(join(runDir, "milestones"), { recursive: true });
@@ -1084,27 +1251,26 @@ async function main() {
1084
1251
  let overheadBudgetUsed;
1085
1252
  const branches = [];
1086
1253
  if (resuming && resumeState) {
1087
- // Restore ALL config from saved state
1088
- remaining = resumeState.remaining;
1254
+ // Restore wave-loop state (config already restored in the early resume block + ternaries above)
1255
+ // Recalculate remaining from budget — don't trust saved value which counts failures
1256
+ // and may have been zeroed by a premature "done" signal from the steerer.
1257
+ // The user explicitly chose Resume, so give back budget not yet successfully used.
1258
+ remaining = Math.max(1, (resumeState.budget ?? 0) - resumeState.accCompleted);
1089
1259
  currentTasks = resumeState.currentTasks;
1090
1260
  waveNum = resumeState.waveNum;
1091
1261
  accCost = resumeState.accCost;
1092
1262
  accCompleted = resumeState.accCompleted;
1093
1263
  accFailed = resumeState.accFailed;
1094
- accTools = 0;
1264
+ accTools = resumeState.accTools ?? 0;
1265
+ accIn = resumeState.accIn ?? 0;
1266
+ accOut = resumeState.accOut ?? 0;
1095
1267
  lastWaveKind = resumeState.lastWaveKind;
1096
1268
  overheadBudgetUsed = resumeState.overheadBudgetUsed ?? (resumeState.reflectionBudgetUsed ?? 0) + (resumeState.verificationBudgetUsed ?? 0);
1097
1269
  branches.push(...resumeState.branches);
1098
- objective = resumeState.objective;
1099
- workerModel = resumeState.workerModel;
1100
- plannerModel = resumeState.plannerModel;
1101
- budget = resumeState.budget;
1102
- concurrency = resumeState.concurrency;
1103
- flex = resumeState.flex;
1104
- usageCap = resumeState.usageCap;
1105
- allowExtraUsage = resumeState.allowExtraUsage ?? false;
1106
- extraUsageBudget = resumeState.extraUsageBudget;
1107
- console.log(chalk.green(`\n ✓ Resumed`) + chalk.dim(` · wave ${waveNum + 1} · ${remaining} remaining · $${accCost.toFixed(2)} spent\n`));
1270
+ flex = resumeState.flex; // override computed flex with saved value
1271
+ waveHistory.push(...loadWaveHistory(runDir));
1272
+ console.log(chalk.green(`\n ✓ Resumed`) + chalk.dim(` · wave ${waveNum + 1} · ${remaining} remaining · $${accCost.toFixed(2)} spent · ${waveHistory.length} prior waves\n`));
1273
+ waveNum++; // advance past last completed wave so next session file doesn't overwrite
1108
1274
  }
1109
1275
  else {
1110
1276
  // Fresh run
@@ -1131,7 +1297,7 @@ async function main() {
1131
1297
  // For flex + branch strategy: create one target branch, waves merge via yolo into it
1132
1298
  let runBranch;
1133
1299
  let originalRef;
1134
- if (flex && mergeStrategy === "branch" && useWorktrees) {
1300
+ if (flex && mergeStrategy === "branch" && useWorktrees && !resuming) {
1135
1301
  try {
1136
1302
  originalRef = execSync("git rev-parse --abbrev-ref HEAD", { cwd, encoding: "utf-8", stdio: "pipe" }).trim();
1137
1303
  if (originalRef === "HEAD")
@@ -1160,6 +1326,60 @@ async function main() {
1160
1326
  process.on("SIGTERM", () => gracefulStop("SIGTERM"));
1161
1327
  process.on("uncaughtException", (err) => { currentSwarm?.abort(); currentSwarm?.cleanup(); restore(); console.error(chalk.red(`\n Uncaught: ${err.message}`)); process.exit(1); });
1162
1328
  process.on("unhandledRejection", (reason) => { currentSwarm?.abort(); currentSwarm?.cleanup(); restore(); console.error(chalk.red(`\n Unhandled: ${reason instanceof Error ? reason.message : reason}`)); process.exit(1); });
1329
+ // When resuming a flex run with no queued tasks, steer immediately to get the next wave
1330
+ if (resuming && flex && currentTasks.length === 0 && remaining > 0) {
1331
+ let steerAttempts = 0;
1332
+ while (currentTasks.length === 0 && remaining > 0 && !objectiveComplete && steerAttempts < 3) {
1333
+ steerAttempts++;
1334
+ console.log(chalk.cyan(`\n ◆ Assessing...\n`));
1335
+ process.stdout.write("\x1B[?25l");
1336
+ try {
1337
+ const memory = readRunMemory(runDir, previousKnowledge || undefined);
1338
+ const steer = await steerWave(objective, waveHistory, remaining, cwd, plannerModel, workerModel, permissionMode, concurrency, makeProgressLog(), memory);
1339
+ process.stdout.write(`\x1B[2K\r`);
1340
+ process.stdout.write("\x1B[?25h");
1341
+ if (steer.statusUpdate)
1342
+ writeStatus(runDir, steer.statusUpdate);
1343
+ if (steer.goalUpdate)
1344
+ writeGoalUpdate(runDir, steer.goalUpdate);
1345
+ if (steer.done || steer.tasks.length === 0) {
1346
+ const hasVerification = waveHistory.some(w => w.kind.includes("verif"));
1347
+ if (!hasVerification && remaining >= 1) {
1348
+ console.log(chalk.dim(` ${steer.reasoning}`));
1349
+ console.log(chalk.yellow(` Done blocked — verification required before completion\n`));
1350
+ lastWaveKind = "done-blocked";
1351
+ continue;
1352
+ }
1353
+ console.log(chalk.green(` \u2713 ${steer.reasoning}\n`));
1354
+ objectiveComplete = true;
1355
+ remaining = 0;
1356
+ }
1357
+ else {
1358
+ const isOverhead = steer.waveKind !== "execute";
1359
+ if (isOverhead && overheadBudgetUsed + steer.tasks.length > maxOverheadBudget) {
1360
+ console.log(chalk.dim(` ${steer.reasoning}`));
1361
+ console.log(chalk.yellow(` Overhead budget exhausted (${overheadBudgetUsed}/${maxOverheadBudget}) — re-assessing\n`));
1362
+ lastWaveKind = "overhead-capped";
1363
+ continue;
1364
+ }
1365
+ console.log(chalk.dim(` ${steer.reasoning}\n`));
1366
+ currentTasks = steer.tasks.map(t => ({
1367
+ ...t,
1368
+ model: t.model === "planner" ? plannerModel : t.model === "worker" ? workerModel
1369
+ : isOverhead && !t.model ? plannerModel : t.model,
1370
+ }));
1371
+ lastWaveKind = steer.waveKind;
1372
+ if (isOverhead)
1373
+ overheadBudgetUsed += currentTasks.length;
1374
+ }
1375
+ }
1376
+ catch (err) {
1377
+ process.stdout.write("\x1B[?25h");
1378
+ console.log(chalk.yellow(` Steering failed: ${err.message?.slice(0, 80)} \u2014 stopping\n`));
1379
+ break;
1380
+ }
1381
+ }
1382
+ }
1163
1383
  while (remaining > 0 && currentTasks.length > 0 && !stopping) {
1164
1384
  if (currentTasks.length > remaining)
1165
1385
  currentTasks = currentTasks.slice(0, remaining);
@@ -1197,7 +1417,11 @@ async function main() {
1197
1417
  accCompleted += swarm.completed;
1198
1418
  accFailed += swarm.failed;
1199
1419
  accTools += swarm.agents.reduce((sum, a) => sum + a.toolCalls, 0);
1200
- remaining -= swarm.completed + swarm.failed;
1420
+ remaining = Math.max(0, remaining - swarm.completed - swarm.failed);
1421
+ // Sanity check: remaining should never drop below budget - total consumed
1422
+ const expectedFloor = Math.max(0, (budget ?? 0) - accCompleted - accFailed);
1423
+ if (remaining < expectedFloor)
1424
+ remaining = expectedFloor;
1201
1425
  // Apply live config changes if user adjusted budget/threshold mid-wave
1202
1426
  if (liveConfig.dirty) {
1203
1427
  remaining = liveConfig.remaining;
@@ -1212,8 +1436,8 @@ async function main() {
1212
1436
  saveRunState(runDir, {
1213
1437
  id: `run-${new Date().toISOString().slice(0, 19)}`, objective: objective, budget: budget ?? tasks.length,
1214
1438
  remaining, workerModel, plannerModel, concurrency, permissionMode,
1215
- usageCap, allowExtraUsage, extraUsageBudget, flex, useWorktrees, mergeStrategy, waveNum, currentTasks,
1216
- lastWaveKind, overheadBudgetUsed, accCost, accCompleted, accFailed,
1439
+ usageCap, allowExtraUsage, extraUsageBudget, flex, useWorktrees, mergeStrategy, waveNum, currentTasks: [],
1440
+ lastWaveKind, overheadBudgetUsed, accCost, accCompleted, accFailed, accIn, accOut, accTools,
1217
1441
  branches, phase: "steering", startedAt: new Date(runStartedAt).toISOString(), cwd,
1218
1442
  });
1219
1443
  waveHistory.push({
@@ -1284,10 +1508,12 @@ async function main() {
1284
1508
  catch (err) {
1285
1509
  process.stdout.write("\x1B[?25h");
1286
1510
  console.log(chalk.yellow(` Steering failed: ${err.message?.slice(0, 80)} \u2014 stopping\n`));
1287
- remaining = 0;
1511
+ // Don't zero out remaining preserve unspent budget so resume works
1288
1512
  break;
1289
1513
  }
1290
1514
  }
1515
+ if (!steered)
1516
+ break; // steering failed — stop, don't re-run old tasks
1291
1517
  waveNum++;
1292
1518
  }
1293
1519
  // Only truly "done" if steering explicitly completed the objective (or non-flex single wave with budget exhausted)
@@ -1297,7 +1523,7 @@ async function main() {
1297
1523
  id: `run-${new Date().toISOString().slice(0, 19)}`, objective: objective ?? "", budget: budget ?? tasks.length,
1298
1524
  remaining, workerModel, plannerModel, concurrency, permissionMode,
1299
1525
  usageCap, allowExtraUsage, extraUsageBudget, flex, useWorktrees, mergeStrategy, waveNum, currentTasks: [],
1300
- lastWaveKind, overheadBudgetUsed, accCost, accCompleted, accFailed,
1526
+ lastWaveKind, overheadBudgetUsed, accCost, accCompleted, accFailed, accIn, accOut, accTools,
1301
1527
  branches, phase: finalPhase, startedAt: new Date(runStartedAt).toISOString(), cwd,
1302
1528
  });
1303
1529
  if (trulyDone) {
@@ -1309,6 +1535,10 @@ async function main() {
1309
1535
  rmSync(join(runDir, "reflections"), { recursive: true, force: true });
1310
1536
  }
1311
1537
  catch { }
1538
+ try {
1539
+ rmSync(join(runDir, "verifications"), { recursive: true, force: true });
1540
+ }
1541
+ catch { }
1312
1542
  }
1313
1543
  // Switch back if we created a run branch
1314
1544
  if (runBranch && originalRef) {
package/dist/planner.js CHANGED
@@ -643,7 +643,8 @@ export async function steerWave(objective, history, remainingBudget, cwd, planne
643
643
  return `Wave ${w.wave + 1} (${w.kind}):\n${lines}`;
644
644
  }).join("\n\n") : "(first wave)";
645
645
  const lastKind = history.length > 0 ? history[history.length - 1].kind : "";
646
- const repeatHint = lastKind && lastKind !== "execute"
646
+ const isSyntheticKind = lastKind.includes("blocked") || lastKind.includes("capped");
647
+ const repeatHint = lastKind && lastKind !== "execute" && !isSyntheticKind
647
648
  ? `\nThe previous wave was "${lastKind}". Don't repeat the same wave kind unless you have a strong reason.\n`
648
649
  : "";
649
650
  const cap = (s, max) => s.length > max ? s.slice(0, max) + "\n...(truncated)" : s;
@@ -725,7 +726,12 @@ If done: {"done": true, "waveKind": "done", "reasoning": "...", "statusUpdate":
725
726
  return first;
726
727
  onLog("Retrying...");
727
728
  const retryText = await runPlannerQuery(`Your previous response was not valid JSON. Respond with ONLY a JSON object {"done":false,"waveKind":"execute","reasoning":"...","statusUpdate":"...","tasks":[{"prompt":"..."}]}.\n\n${prompt}`, { cwd, model: plannerModel, permissionMode }, onLog);
728
- return attemptJsonParse(retryText) ?? { done: true, waveKind: "done", reasoning: "Could not parse steering response" };
729
+ const retryParsed = attemptJsonParse(retryText);
730
+ if (retryParsed)
731
+ return retryParsed;
732
+ // Don't return done:true on parse failure — that permanently marks the run complete.
733
+ // Throw so the caller's catch block handles it as a transient steering failure.
734
+ throw new Error("Could not parse steering response after retry");
729
735
  })();
730
736
  const isDone = parsed.done === true;
731
737
  const waveKind = parsed.waveKind || parsed.action || (isDone ? "done" : "execute");
package/dist/types.d.ts CHANGED
@@ -138,8 +138,11 @@ export interface RunState {
138
138
  accCost: number;
139
139
  accCompleted: number;
140
140
  accFailed: number;
141
+ accIn?: number;
142
+ accOut?: number;
143
+ accTools?: number;
141
144
  branches: BranchRecord[];
142
- phase: "executing" | "steering" | "reflecting" | "verifying" | "capped" | "done";
145
+ phase: "steering" | "capped" | "done";
143
146
  startedAt: string;
144
147
  cwd: string;
145
148
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-overnight",
3
- "version": "1.5.1",
3
+ "version": "1.6.1",
4
4
  "description": "Run 10, 100, or 1000 Claude agents overnight. Parallel autonomous AI coding with thinking waves, iterative quality steering, crash recovery, and rate limit handling. Built on the Claude Agent SDK.",
5
5
  "type": "module",
6
6
  "bin": {