claude-overnight 1.11.14 → 1.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -656,7 +656,7 @@ async function main() {
656
656
  useWorktrees: false, mergeStrategy: "yolo", agentTimeoutMs, usageCap, allowExtraUsage, extraUsageBudget,
657
657
  });
658
658
  const thinkRunInfo = { accIn: 0, accOut: 0, accCost: 0, accCompleted: 0, accFailed: 0, sessionsBudget: budget ?? 10, waveNum: -1, remaining: budget ?? 10, model: plannerModel, startedAt: Date.now() };
659
- const thinkDisplay = new RunDisplay(thinkRunInfo, { remaining: 0, usageCap, dirty: false });
659
+ const thinkDisplay = new RunDisplay(thinkRunInfo, { remaining: 0, usageCap, concurrency, paused: false, dirty: false });
660
660
  thinkDisplay.setWave(thinkingSwarm);
661
661
  thinkDisplay.start();
662
662
  try {
package/dist/render.js CHANGED
@@ -42,10 +42,16 @@ function renderHeader(out, w, p) {
42
42
  const bar = chalk.green("\u2588".repeat(filled)) + chalk.gray("\u2591".repeat(barW - filled));
43
43
  const modelTag = p.model ? chalk.dim(` [${p.model}]`) : "";
44
44
  const phaseTag = p.phase ? " " + p.phase : "";
45
+ const blocked = p.blocked ?? 0;
46
+ const working = Math.max(0, p.active - blocked);
47
+ const stuck = blocked > 0 && working === 0;
48
+ const activeChip = p.active > 0
49
+ ? (stuck ? chalk.yellow(`${p.active} blocked`) : chalk.cyan(`${working} active`) + (blocked > 0 ? chalk.yellow(` (${blocked} blocked)`) : ""))
50
+ : "";
45
51
  out.push("");
46
52
  out.push(` ${chalk.bold.white("CLAUDE OVERNIGHT")}${modelTag}${phaseTag} ${bar} ` +
47
53
  `${p.barLabel} ` +
48
- (p.active > 0 ? chalk.cyan(`${p.active} active`) + " " : "") +
54
+ (activeChip ? activeChip + " " : "") +
49
55
  (p.queued > 0 ? chalk.gray(`${p.queued} queued`) + " " : "") +
50
56
  chalk.gray(`\u23F1 ${fmtDur(Date.now() - p.startedAt)}`));
51
57
  const tokIn = fmtTokens(p.totalIn);
@@ -129,17 +135,19 @@ export function renderFrame(swarm, showHotkeys, runInfo) {
129
135
  const w = Math.max((process.stdout.columns ?? 80) || 80, 60);
130
136
  const out = [];
131
137
  const stoppingTag = swarm.aborted ? chalk.yellow("STOPPING") : "";
138
+ const pausedTag = swarm.paused ? chalk.yellow("PAUSED") : "";
139
+ const stallTag = swarm.stallLevel >= 3 ? chalk.red("STALL") : swarm.stallLevel > 0 ? chalk.yellow(`STALL L${swarm.stallLevel}`) : "";
132
140
  const phaseLabel = swarm.phase === "planning" ? chalk.magenta("PLANNING")
133
141
  : swarm.phase === "merging" ? chalk.yellow("MERGING")
134
142
  : swarm.rateLimitPaused > 0 ? chalk.yellow("COOLING") : "";
135
- const phase = [phaseLabel, stoppingTag].filter(Boolean).join(" ");
143
+ const phase = [phaseLabel, pausedTag, stallTag, stoppingTag].filter(Boolean).join(" ");
136
144
  const waveUsed = swarm.completed + swarm.failed;
137
145
  renderHeader(out, w, {
138
146
  model: runInfo?.model ?? swarm.model,
139
147
  phase,
140
148
  barPct: swarm.total > 0 ? swarm.completed / swarm.total : 0,
141
149
  barLabel: `${swarm.completed}/${swarm.total}`,
142
- active: swarm.active, queued: swarm.pending,
150
+ active: swarm.active, blocked: swarm.blocked, queued: swarm.pending,
143
151
  startedAt: runInfo?.startedAt ?? swarm.startedAt,
144
152
  totalIn: (runInfo?.accIn ?? 0) + swarm.totalInputTokens,
145
153
  totalOut: (runInfo?.accOut ?? 0) + swarm.totalOutputTokens,
@@ -187,7 +195,11 @@ export function renderFrame(swarm, showHotkeys, runInfo) {
187
195
  const pending = runInfo?.pendingSteer ?? 0;
188
196
  const chip = pending > 0 ? chalk.cyan(` \u270E ${pending} steer queued`) : "";
189
197
  const fixChip = swarm.failed > 0 && swarm.active > 0 ? chalk.yellow(" [f] fix") : "";
190
- out.push(chalk.dim(" [b] budget [t] threshold [s] steer [?] ask [q] stop") + fixChip + chip);
198
+ const pauseLabel = swarm.paused ? "[p] resume" : "[p] pause";
199
+ out.push(chalk.dim(` [b] budget [t] threshold [c] conc ${pauseLabel} [s] steer [?] ask [q] stop`) + fixChip + chip);
200
+ if (swarm.blocked > 0 && swarm.blocked === swarm.active) {
201
+ out.push(chalk.yellow(` all workers rate-limited — press [c] to reduce concurrency, [p] to pause, [q] to quit`));
202
+ }
191
203
  }
192
204
  out.push("");
193
205
  return out.join("\n");
@@ -368,12 +380,15 @@ function fmtRow(a, w) {
368
380
  const elapsed = a.status === "running" && a.startedAt ? " " + chalk.dim(fmtDur(Date.now() - a.startedAt)) : "";
369
381
  const spin = SPINNER[Math.floor(Date.now() / 250) % SPINNER.length];
370
382
  const icon = a.status === "running"
371
- ? chalk.blue(`${spin} run`) + elapsed
383
+ ? (a.blockedAt ? chalk.yellow("\u25CF blk") : chalk.blue(`${spin} run`)) + elapsed
372
384
  : a.status === "done" ? chalk.green("\u2713 done") : chalk.red("\u2717 err ");
373
385
  const taskW = Math.max(20, Math.min(36, w - 50));
374
386
  const task = truncate(a.task.prompt, taskW).padEnd(taskW);
375
387
  let action;
376
- if (a.currentTool) {
388
+ if (a.blockedAt) {
389
+ action = chalk.yellow(`rate-limited ${fmtDur(Date.now() - a.blockedAt)}`);
390
+ }
391
+ else if (a.currentTool) {
377
392
  action = chalk.yellow(a.currentTool);
378
393
  }
379
394
  else if (a.status === "running") {
package/dist/run.js CHANGED
@@ -8,7 +8,7 @@ import { getTotalPlannerCost, getPlannerRateLimitInfo, runPlannerQuery } from ".
8
8
  import { RunDisplay } from "./ui.js";
9
9
  import { renderSummary } from "./render.js";
10
10
  import { fmtTokens } from "./render.js";
11
- import { isAuthError } from "./cli.js";
11
+ import { isAuthError, selectKey, ask } from "./cli.js";
12
12
  import { readRunMemory, writeStatus, writeGoalUpdate, saveRunState, saveWaveSession, loadWaveHistory, recordBranches, archiveMilestone, writeSteerInbox, consumeSteerInbox, countSteerInbox, appendOvernightLogStart, updateOvernightLogEnd, } from "./state.js";
13
13
  export async function executeRun(cfg) {
14
14
  const restore = () => { try {
@@ -25,12 +25,13 @@ export async function executeRun(cfg) {
25
25
  let currentSwarm;
26
26
  let remaining;
27
27
  let currentTasks;
28
- const liveConfig = { remaining: 0, usageCap, dirty: false };
28
+ const liveConfig = { remaining: 0, usageCap, concurrency, paused: false, dirty: false };
29
29
  let waveNum;
30
30
  const waveHistory = [];
31
31
  let accCost, accCompleted, accFailed, accTools;
32
32
  let accIn = 0, accOut = 0;
33
33
  let lastCapped = false, lastAborted = false, objectiveComplete = false, lastHealed = false;
34
+ let lastEstimate;
34
35
  const branches = [];
35
36
  if (cfg.resuming && cfg.resumeState) {
36
37
  const rs = cfg.resumeState;
@@ -216,6 +217,8 @@ export async function executeRun(cfg) {
216
217
  writeStatus(runDir, steer.statusUpdate);
217
218
  if (steer.goalUpdate)
218
219
  writeGoalUpdate(runDir, steer.goalUpdate);
220
+ if (typeof steer.estimatedSessionsRemaining === "number")
221
+ lastEstimate = steer.estimatedSessionsRemaining;
219
222
  const steerDir = join(runDir, "steering");
220
223
  mkdirSync(steerDir, { recursive: true });
221
224
  writeFileSync(join(steerDir, `wave-${waveNum}-attempt-${steerAttempts}.json`), JSON.stringify({
@@ -283,93 +286,127 @@ export async function executeRun(cfg) {
283
286
  if (!display.runInfo.startedAt)
284
287
  display.runInfo.startedAt = cfg.runStartedAt;
285
288
  display.start();
286
- // ── Main wave loop ──
287
- while (remaining > 0 && currentTasks.length > 0 && !stopping) {
288
- if (!lastHealed) {
289
- const healTask = checkProjectHealth(cwd);
290
- if (healTask && remaining > 0) {
291
- lastHealed = true;
292
- currentTasks = [healTask];
289
+ // ── Main wave loop (wrapped so exhaustion can prompt for an extension) ──
290
+ let runAnotherRound = true;
291
+ while (runAnotherRound) {
292
+ runAnotherRound = false;
293
+ while (remaining > 0 && currentTasks.length > 0 && !stopping) {
294
+ if (!lastHealed) {
295
+ const healTask = checkProjectHealth(cwd);
296
+ if (healTask && remaining > 0) {
297
+ lastHealed = true;
298
+ currentTasks = [healTask];
299
+ }
293
300
  }
301
+ else {
302
+ lastHealed = false;
303
+ }
304
+ if (currentTasks.length > remaining)
305
+ currentTasks = currentTasks.slice(0, remaining);
306
+ syncRunInfo();
307
+ const swarm = new Swarm({
308
+ tasks: currentTasks, concurrency, cwd, model: workerModel, permissionMode, allowedTools,
309
+ useWorktrees, mergeStrategy: waveMerge, agentTimeoutMs: cfg.agentTimeoutMs,
310
+ usageCap, allowExtraUsage: cfg.allowExtraUsage, extraUsageBudget: cfg.extraUsageBudget,
311
+ baseCostUsd: accCost,
312
+ });
313
+ currentSwarm = swarm;
314
+ display.setWave(swarm);
315
+ display.resume();
316
+ try {
317
+ await swarm.run();
318
+ }
319
+ catch (err) {
320
+ if (isAuthError(err)) {
321
+ display.stop();
322
+ restore();
323
+ console.error(chalk.red(`\n Authentication failed — check your API key or run: claude auth\n`));
324
+ process.exit(1);
325
+ }
326
+ throw err;
327
+ }
328
+ display.pause();
329
+ console.log(renderSummary(swarm));
330
+ accCost += swarm.totalCostUsd;
331
+ accIn += swarm.totalInputTokens;
332
+ accOut += swarm.totalOutputTokens;
333
+ accCompleted += swarm.completed;
334
+ accFailed += swarm.failed;
335
+ accTools += swarm.agents.reduce((sum, a) => sum + a.toolCalls, 0);
336
+ remaining = Math.max(0, remaining - swarm.completed - swarm.failed);
337
+ const totalConsumed = accCompleted + accFailed + cfg.thinkingUsed;
338
+ const expectedFloor = Math.max(0, cfg.budget - totalConsumed);
339
+ if (remaining < expectedFloor)
340
+ remaining = expectedFloor;
341
+ if (liveConfig.dirty) {
342
+ remaining = liveConfig.remaining;
343
+ usageCap = liveConfig.usageCap;
344
+ liveConfig.dirty = false;
345
+ }
346
+ liveConfig.remaining = remaining;
347
+ lastCapped = swarm.cappedOut;
348
+ lastAborted = swarm.aborted;
349
+ recordBranches(swarm.agents, swarm.mergeResults, branches);
350
+ saveWaveSession(runDir, waveNum, swarm.agents, swarm.totalCostUsd);
351
+ // Tasks that never made it into the swarm (queue cleared on abort/cap)
352
+ // are preserved as currentTasks so resume picks them up. Budget for these
353
+ // wasn't decremented (only attempted agents were), so no refund needed.
354
+ const attemptedPrompts = new Set(swarm.agents.map(a => a.task.prompt));
355
+ const neverStarted = currentTasks.filter(t => !attemptedPrompts.has(t.prompt));
356
+ saveRunState(runDir, {
357
+ id: `run-${new Date().toISOString().slice(0, 19)}`, objective: objective ?? "", budget: cfg.budget,
358
+ remaining, workerModel, plannerModel, concurrency, permissionMode,
359
+ usageCap, allowExtraUsage: cfg.allowExtraUsage, extraUsageBudget: cfg.extraUsageBudget,
360
+ flex, useWorktrees, mergeStrategy, waveNum, currentTasks: neverStarted,
361
+ accCost, accCompleted, accFailed, accIn, accOut, accTools,
362
+ branches, phase: "steering", startedAt: new Date(cfg.runStartedAt).toISOString(), cwd,
363
+ });
364
+ waveHistory.push({
365
+ wave: waveNum,
366
+ tasks: swarm.agents.map(a => ({ prompt: a.task.prompt, status: a.status, filesChanged: a.filesChanged, error: a.error })),
367
+ });
368
+ if (!flex || remaining <= 0 || swarm.aborted || swarm.cappedOut)
369
+ break;
370
+ syncRunInfo();
371
+ display.setSteering(rlGetter, buildSteeringContext());
372
+ display.resume();
373
+ const steered = await runSteering();
374
+ if (!steered)
375
+ break;
376
+ waveNum++;
294
377
  }
295
- else {
296
- lastHealed = false;
297
- }
298
- if (currentTasks.length > remaining)
299
- currentTasks = currentTasks.slice(0, remaining);
300
- syncRunInfo();
301
- const swarm = new Swarm({
302
- tasks: currentTasks, concurrency, cwd, model: workerModel, permissionMode, allowedTools,
303
- useWorktrees, mergeStrategy: waveMerge, agentTimeoutMs: cfg.agentTimeoutMs,
304
- usageCap, allowExtraUsage: cfg.allowExtraUsage, extraUsageBudget: cfg.extraUsageBudget,
305
- baseCostUsd: accCost,
306
- });
307
- currentSwarm = swarm;
308
- display.setWave(swarm);
309
- display.resume();
310
- try {
311
- await swarm.run();
312
- }
313
- catch (err) {
314
- if (isAuthError(err)) {
378
+ display.stop();
379
+ // ── Budget-exhausted: offer to extend with the same settings ──
380
+ const exhaustedByBudget = !objectiveComplete && !stopping && !lastAborted && !lastCapped &&
381
+ remaining <= 0 && !!process.stdin.isTTY;
382
+ if (exhaustedByBudget) {
383
+ const ext = await promptBudgetExtension({
384
+ estimate: lastEstimate,
385
+ spent: accCost,
386
+ sessionsUsed: accCompleted + accFailed + cfg.thinkingUsed,
387
+ budget: cfg.budget,
388
+ });
389
+ if (ext > 0) {
390
+ remaining = ext;
391
+ cfg.budget += ext;
392
+ lastCapped = false;
393
+ lastAborted = false;
394
+ runInfoRef.sessionsBudget = cfg.budget;
395
+ runInfoRef.remaining = remaining;
396
+ liveConfig.remaining = remaining;
397
+ liveConfig.usageCap = usageCap;
398
+ display.setSteering(rlGetter, buildSteeringContext());
399
+ display.start();
400
+ const steered = await runSteering();
401
+ if (steered) {
402
+ waveNum++;
403
+ runAnotherRound = true;
404
+ continue;
405
+ }
315
406
  display.stop();
316
- restore();
317
- console.error(chalk.red(`\n Authentication failed — check your API key or run: claude auth\n`));
318
- process.exit(1);
319
407
  }
320
- throw err;
321
408
  }
322
- display.pause();
323
- console.log(renderSummary(swarm));
324
- accCost += swarm.totalCostUsd;
325
- accIn += swarm.totalInputTokens;
326
- accOut += swarm.totalOutputTokens;
327
- accCompleted += swarm.completed;
328
- accFailed += swarm.failed;
329
- accTools += swarm.agents.reduce((sum, a) => sum + a.toolCalls, 0);
330
- remaining = Math.max(0, remaining - swarm.completed - swarm.failed);
331
- const totalConsumed = accCompleted + accFailed + cfg.thinkingUsed;
332
- const expectedFloor = Math.max(0, cfg.budget - totalConsumed);
333
- if (remaining < expectedFloor)
334
- remaining = expectedFloor;
335
- if (liveConfig.dirty) {
336
- remaining = liveConfig.remaining;
337
- usageCap = liveConfig.usageCap;
338
- liveConfig.dirty = false;
339
- }
340
- liveConfig.remaining = remaining;
341
- lastCapped = swarm.cappedOut;
342
- lastAborted = swarm.aborted;
343
- recordBranches(swarm.agents, swarm.mergeResults, branches);
344
- saveWaveSession(runDir, waveNum, swarm.agents, swarm.totalCostUsd);
345
- // Tasks that never made it into the swarm (queue cleared on abort/cap)
346
- // are preserved as currentTasks so resume picks them up. Budget for these
347
- // wasn't decremented (only attempted agents were), so no refund needed.
348
- const attemptedPrompts = new Set(swarm.agents.map(a => a.task.prompt));
349
- const neverStarted = currentTasks.filter(t => !attemptedPrompts.has(t.prompt));
350
- saveRunState(runDir, {
351
- id: `run-${new Date().toISOString().slice(0, 19)}`, objective: objective ?? "", budget: cfg.budget,
352
- remaining, workerModel, plannerModel, concurrency, permissionMode,
353
- usageCap, allowExtraUsage: cfg.allowExtraUsage, extraUsageBudget: cfg.extraUsageBudget,
354
- flex, useWorktrees, mergeStrategy, waveNum, currentTasks: neverStarted,
355
- accCost, accCompleted, accFailed, accIn, accOut, accTools,
356
- branches, phase: "steering", startedAt: new Date(cfg.runStartedAt).toISOString(), cwd,
357
- });
358
- waveHistory.push({
359
- wave: waveNum,
360
- tasks: swarm.agents.map(a => ({ prompt: a.task.prompt, status: a.status, filesChanged: a.filesChanged, error: a.error })),
361
- });
362
- if (!flex || remaining <= 0 || swarm.aborted || swarm.cappedOut)
363
- break;
364
- syncRunInfo();
365
- display.setSteering(rlGetter, buildSteeringContext());
366
- display.resume();
367
- const steered = await runSteering();
368
- if (!steered)
369
- break;
370
- waveNum++;
371
- }
372
- display.stop();
409
+ } // end outer extension loop
373
410
  // ── Finalize ──
374
411
  const trulyDone = objectiveComplete || (!flex && remaining <= 0);
375
412
  const wasCapped = lastCapped || lastAborted;
@@ -479,6 +516,37 @@ export async function executeRun(cfg) {
479
516
  if (lastAborted || accCompleted === 0)
480
517
  process.exit(2);
481
518
  }
519
+ async function promptBudgetExtension(ctx) {
520
+ const avg = ctx.sessionsUsed > 0 ? ctx.spent / ctx.sessionsUsed : 0;
521
+ const base = ctx.estimate && ctx.estimate > 0
522
+ ? ctx.estimate
523
+ : Math.max(10, Math.round(ctx.budget * 0.2));
524
+ // Wiggle room: 30% buffer, minimum 10, rounded up to a nearest-5.
525
+ const withBuffer = Math.max(10, Math.ceil(base * 1.3));
526
+ const suggested = Math.ceil(withBuffer / 5) * 5;
527
+ const estCost = avg > 0 ? ` · ~$${(suggested * avg).toFixed(2)}` : "";
528
+ const estLine = ctx.estimate != null
529
+ ? chalk.dim(` Planner estimate: ${ctx.estimate} sessions to complete${avg > 0 ? ` (~$${(ctx.estimate * avg).toFixed(2)} at $${avg.toFixed(2)}/session)` : ""}`)
530
+ : chalk.dim(` No planner estimate available — using default${avg > 0 ? ` (~$${avg.toFixed(2)}/session)` : ""}`);
531
+ console.log("");
532
+ console.log(chalk.yellow(` Budget exhausted — run not yet complete.`));
533
+ console.log(estLine);
534
+ console.log(chalk.dim(` Continue with ${chalk.bold.white(String(suggested))} more sessions${estCost}? Everything stays the same — just hit enter.`));
535
+ const action = await selectKey("", [
536
+ { key: "y", desc: "es (↵)" },
537
+ { key: "c", desc: "ustom" },
538
+ { key: "n", desc: "o — stop here" },
539
+ ]);
540
+ if (action === "y")
541
+ return suggested;
542
+ if (action === "n")
543
+ return 0;
544
+ const custom = await ask(` How many more sessions? ${chalk.dim(`[${suggested}]: `)}`);
545
+ const n = parseInt(custom);
546
+ if (isNaN(n) || n <= 0)
547
+ return suggested;
548
+ return n;
549
+ }
482
550
  function checkProjectHealth(cwd) {
483
551
  let pkg;
484
552
  try {
package/dist/steering.js CHANGED
@@ -9,6 +9,7 @@ const STEER_SCHEMA = {
9
9
  reasoning: { type: "string" },
10
10
  statusUpdate: { type: "string" },
11
11
  goalUpdate: { type: "string" },
12
+ estimatedSessionsRemaining: { type: "number" },
12
13
  tasks: {
13
14
  type: "array",
14
15
  items: {
@@ -18,7 +19,7 @@ const STEER_SCHEMA = {
18
19
  },
19
20
  },
20
21
  },
21
- required: ["done", "tasks", "reasoning", "statusUpdate"],
22
+ required: ["done", "tasks", "reasoning", "statusUpdate", "estimatedSessionsRemaining"],
22
23
  },
23
24
  };
24
25
  export async function steerWave(objective, history, remainingBudget, cwd, plannerModel, workerModel, permissionMode, concurrency, onLog, runMemory) {
@@ -96,6 +97,7 @@ Respond with ONLY a JSON object (no markdown fences):
96
97
  "reasoning": "your assessment and why you chose this wave composition",
97
98
  "goalUpdate": "optional — refine what 'amazing' means as you learn more",
98
99
  "statusUpdate": "REQUIRED — concise project status: what's built, what works, what's rough, quality level, key gaps. This replaces the previous status.",
100
+ "estimatedSessionsRemaining": 15,
99
101
  "tasks": [
100
102
  {"prompt": "task instruction...", "model": "worker"},
101
103
  {"prompt": "review task...", "model": "planner"},
@@ -103,10 +105,12 @@ Respond with ONLY a JSON object (no markdown fences):
103
105
  ]
104
106
  }
105
107
 
108
+ "estimatedSessionsRemaining" is REQUIRED. Your best honest estimate of how many MORE agent sessions (beyond the wave you just composed above) are needed to reach 'amazing' — include follow-up fixes, polish, verification, and anything else you'd want before shipping. Be realistic, not optimistic. Use 0 only if truly done.
109
+
106
110
  The "model" field on each task: use "worker" (${workerModel}) for implementation tasks, "planner" (${plannerModel}) for review/analysis/verification tasks. Default is "worker".
107
111
  Set "noWorktree": true for verify/user-test tasks — they need the real project directory with env files, dependencies, and local config.
108
112
 
109
- If done: {"done": true, "reasoning": "...", "statusUpdate": "...", "tasks": []}`;
113
+ If done: {"done": true, "reasoning": "...", "statusUpdate": "...", "estimatedSessionsRemaining": 0, "tasks": []}`;
110
114
  onLog("Assessing...", "status");
111
115
  onLog(`Reading codebase — wave ${history.length + 1}`, "event");
112
116
  const resultText = await runPlannerQuery(prompt, { cwd, model: plannerModel, permissionMode, outputFormat: STEER_SCHEMA }, onLog);
@@ -124,8 +128,10 @@ If done: {"done": true, "reasoning": "...", "statusUpdate": "...", "tasks": []}`
124
128
  })();
125
129
  const isDone = parsed.done === true;
126
130
  const statusUpdate = parsed.statusUpdate || undefined;
131
+ const estRaw = parsed.estimatedSessionsRemaining;
132
+ const estimatedSessionsRemaining = typeof estRaw === "number" && estRaw >= 0 ? Math.round(estRaw) : undefined;
127
133
  if (isDone) {
128
- return { done: true, tasks: [], reasoning: parsed.reasoning || "Objective complete", goalUpdate: parsed.goalUpdate, statusUpdate };
134
+ return { done: true, tasks: [], reasoning: parsed.reasoning || "Objective complete", goalUpdate: parsed.goalUpdate, statusUpdate, estimatedSessionsRemaining: estimatedSessionsRemaining ?? 0 };
129
135
  }
130
136
  let tasks = (parsed.tasks || []).map((t, i) => ({
131
137
  id: String(i),
@@ -134,5 +140,5 @@ If done: {"done": true, "reasoning": "...", "statusUpdate": "...", "tasks": []}`
134
140
  ...(t.noWorktree && { noWorktree: true }),
135
141
  }));
136
142
  tasks = postProcess(tasks, remainingBudget, onLog);
137
- return { done: tasks.length === 0, tasks, reasoning: parsed.reasoning || "", goalUpdate: parsed.goalUpdate, statusUpdate };
143
+ return { done: tasks.length === 0, tasks, reasoning: parsed.reasoning || "", goalUpdate: parsed.goalUpdate, statusUpdate, estimatedSessionsRemaining };
138
144
  }
package/dist/swarm.d.ts CHANGED
@@ -41,6 +41,20 @@ export declare class Swarm {
41
41
  rateLimitPaused: number;
42
42
  isUsingOverage: boolean;
43
43
  overageCostUsd: number;
44
+ /** Live-adjustable concurrency target. Workers above this count exit on the next task boundary. */
45
+ targetConcurrency: number;
46
+ /** When true, dispatch is frozen — workers wait without starting new tasks. */
47
+ paused: boolean;
48
+ /** Wall-clock ms of the last sign of real progress (assistant msg, tool use, result). */
49
+ lastProgressAt: number;
50
+ /** 0 = normal, 1 = halved once, 2 = halved twice, 3 = long cooldown at c=1, 4 = aborted. */
51
+ stallLevel: number;
52
+ /** Last time the watchdog took an action; used to debounce escalations. */
53
+ private stallActionAt;
54
+ /** Live worker coroutine count (not agents). */
55
+ private workerCount;
56
+ /** Growable list of worker promises; run() awaits until empty. */
57
+ private workerPromises;
44
58
  private queue;
45
59
  private config;
46
60
  private nextId;
@@ -56,7 +70,12 @@ export declare class Swarm {
56
70
  mergeBranch?: string;
57
71
  constructor(config: SwarmConfig);
58
72
  get active(): number;
73
+ get blocked(): number;
59
74
  get pending(): number;
75
+ /** Live-adjust concurrency. Shrinks by having excess workers exit on next task boundary; grows by spawning new workers. */
76
+ setConcurrency(n: number): void;
77
+ /** Freeze/resume dispatch without killing the run. Paused workers block at the top of their loop. */
78
+ setPaused(b: boolean): void;
60
79
  run(): Promise<void>;
61
80
  abort(): void;
62
81
  /** Re-queue all errored agents' tasks for retry within this wave. */
@@ -65,6 +84,17 @@ export declare class Swarm {
65
84
  log(agentId: number, text: string): void;
66
85
  cleanup(): void;
67
86
  private worker;
87
+ /** Mark real progress — resets stall state. Called on any assistant/tool/result message. */
88
+ private markProgress;
89
+ /**
90
+ * Stall watchdog. Called each time a worker finishes a rate-limit wait. Escalates when
91
+ * the whole swarm has been stuck with no progress for a while:
92
+ * L1 @ 5m → halve concurrency
93
+ * L2 @ 10m → halve again
94
+ * L3 @ 15m+ at c=1 → force a 10-minute cooldown instead of hammering every 60s
95
+ * L4 @ 30m → abort the run so it can be resumed later without burning the budget
96
+ */
97
+ private checkStall;
68
98
  private capForOverage;
69
99
  private throttle;
70
100
  private runAgent;
package/dist/swarm.js CHANGED
@@ -34,6 +34,20 @@ export class Swarm {
34
34
  rateLimitPaused = 0;
35
35
  isUsingOverage = false;
36
36
  overageCostUsd = 0;
37
+ /** Live-adjustable concurrency target. Workers above this count exit on the next task boundary. */
38
+ targetConcurrency;
39
+ /** When true, dispatch is frozen — workers wait without starting new tasks. */
40
+ paused = false;
41
+ /** Wall-clock ms of the last sign of real progress (assistant msg, tool use, result). */
42
+ lastProgressAt = Date.now();
43
+ /** 0 = normal, 1 = halved once, 2 = halved twice, 3 = long cooldown at c=1, 4 = aborted. */
44
+ stallLevel = 0;
45
+ /** Last time the watchdog took an action; used to debounce escalations. */
46
+ stallActionAt = 0;
47
+ /** Live worker coroutine count (not agents). */
48
+ workerCount = 0;
49
+ /** Growable list of worker promises; run() awaits until empty. */
50
+ workerPromises = [];
37
51
  queue;
38
52
  config;
39
53
  nextId = 0;
@@ -68,9 +82,33 @@ export class Swarm {
68
82
  this.baseCostUsd = config.baseCostUsd ?? 0;
69
83
  this.queue = [...config.tasks];
70
84
  this.total = config.tasks.length;
85
+ this.targetConcurrency = config.concurrency;
71
86
  }
72
87
  get active() { return this.agents.filter(a => a.status === "running").length; }
88
+ get blocked() { return this.agents.filter(a => a.status === "running" && a.blockedAt != null).length; }
73
89
  get pending() { return this.queue.length; }
90
+ /** Live-adjust concurrency. Shrinks by having excess workers exit on next task boundary; grows by spawning new workers. */
91
+ setConcurrency(n) {
92
+ if (!Number.isFinite(n) || n < 1)
93
+ return;
94
+ const prev = this.targetConcurrency;
95
+ if (n === prev)
96
+ return;
97
+ this.targetConcurrency = n;
98
+ this.log(-1, `Concurrency changed: ${prev} → ${n}`);
99
+ if (n > prev && this.queue.length > 0 && !this.aborted && !this.cappedOut) {
100
+ const toSpawn = Math.min(n - this.workerCount, this.queue.length);
101
+ for (let i = 0; i < toSpawn; i++)
102
+ this.workerPromises.push(this.worker());
103
+ }
104
+ }
105
+ /** Freeze/resume dispatch without killing the run. Paused workers block at the top of their loop. */
106
+ setPaused(b) {
107
+ if (this.paused === b)
108
+ return;
109
+ this.paused = b;
110
+ this.log(-1, b ? "Dispatch paused" : "Dispatch resumed");
111
+ }
74
112
  async run() {
75
113
  try {
76
114
  if (this.config.useWorktrees) {
@@ -80,8 +118,15 @@ export class Swarm {
80
118
  this.log(-1, `Worktrees: ${this.worktreeBase}`);
81
119
  }
82
120
  this.phase = "running";
83
- const n = Math.min(this.config.concurrency, this.queue.length);
84
- await Promise.all(Array.from({ length: n }, () => this.worker()));
121
+ const n = Math.min(this.targetConcurrency, this.queue.length);
122
+ for (let i = 0; i < n; i++)
123
+ this.workerPromises.push(this.worker());
124
+ // setConcurrency() can grow workerPromises during execution, so drain in a loop.
125
+ while (this.workerPromises.length > 0) {
126
+ const batch = this.workerPromises.slice();
127
+ this.workerPromises.length = 0;
128
+ await Promise.all(batch);
129
+ }
85
130
  if (this.config.useWorktrees) {
86
131
  this.phase = "merging";
87
132
  const branches = this.agents.filter(a => a.branch && a.status === "done" && (a.filesChanged ?? 0) > 0)
@@ -96,7 +141,7 @@ export class Swarm {
96
141
  finally {
97
142
  this.cleanup();
98
143
  this.logFile = writeSwarmLog({
99
- startedAt: this.startedAt, model: this.config.model, concurrency: this.config.concurrency,
144
+ startedAt: this.startedAt, model: this.config.model, concurrency: this.targetConcurrency,
100
145
  useWorktrees: this.config.useWorktrees, mergeStrategy: this.config.mergeStrategy,
101
146
  completed: this.completed, failed: this.failed, aborted: this.aborted,
102
147
  cost: this.totalCostUsd, inputTokens: this.totalInputTokens, outputTokens: this.totalOutputTokens,
@@ -151,23 +196,83 @@ export class Swarm {
151
196
  }
152
197
  // ── Worker loop ──
153
198
  async worker() {
199
+ this.workerCount++;
154
200
  let tasksProcessed = 0;
155
- while (this.queue.length > 0 && !this.aborted && !this.cappedOut) {
156
- await this.throttle();
157
- if (this.cappedOut)
158
- break;
159
- const task = this.queue.shift();
160
- if (!task)
161
- break;
162
- try {
163
- await this.runAgent(task);
164
- }
165
- catch (err) {
166
- this.log(-1, `Worker error: ${String(err?.message || err).slice(0, 80)}`);
201
+ try {
202
+ while (this.queue.length > 0 && !this.aborted && !this.cappedOut) {
203
+ // Shrink: exit if we're above the live target.
204
+ if (this.workerCount > this.targetConcurrency) {
205
+ this.log(-1, `Worker exiting (concurrency shrunk to ${this.targetConcurrency})`);
206
+ return;
207
+ }
208
+ // Pause: block here without holding a task, so unpausing resumes cleanly.
209
+ while (this.paused && !this.aborted && !this.cappedOut)
210
+ await sleep(500);
211
+ await this.throttle();
212
+ if (this.cappedOut || this.aborted)
213
+ break;
214
+ if (this.workerCount > this.targetConcurrency)
215
+ return;
216
+ const task = this.queue.shift();
217
+ if (!task)
218
+ break;
219
+ try {
220
+ await this.runAgent(task);
221
+ }
222
+ catch (err) {
223
+ this.log(-1, `Worker error: ${String(err?.message || err).slice(0, 80)}`);
224
+ }
225
+ tasksProcessed++;
167
226
  }
168
- tasksProcessed++;
227
+ this.log(-1, `Worker finished (${tasksProcessed} tasks)`);
228
+ }
229
+ finally {
230
+ this.workerCount--;
231
+ }
232
+ }
233
+ /** Mark real progress — resets stall state. Called on any assistant/tool/result message. */
234
+ markProgress() {
235
+ this.lastProgressAt = Date.now();
236
+ if (this.stallLevel > 0 && this.lastProgressAt > this.stallActionAt)
237
+ this.stallLevel = 0;
238
+ }
239
+ /**
240
+ * Stall watchdog. Called each time a worker finishes a rate-limit wait. Escalates when
241
+ * the whole swarm has been stuck with no progress for a while:
242
+ * L1 @ 5m → halve concurrency
243
+ * L2 @ 10m → halve again
244
+ * L3 @ 15m+ at c=1 → force a 10-minute cooldown instead of hammering every 60s
245
+ * L4 @ 30m → abort the run so it can be resumed later without burning the budget
246
+ */
247
+ checkStall() {
248
+ const stalledFor = Date.now() - this.lastProgressAt;
249
+ if (stalledFor < 5 * 60_000)
250
+ return;
251
+ // Debounce so multiple workers waking at once don't double-escalate.
252
+ if (Date.now() - this.stallActionAt < 60_000)
253
+ return;
254
+ if (stalledFor >= 30 * 60_000) {
255
+ this.stallLevel = 4;
256
+ this.stallActionAt = Date.now();
257
+ this.log(-1, `Stalled ${Math.round(stalledFor / 60000)}m with no progress — aborting run so you can resume later`);
258
+ this.abort();
259
+ return;
260
+ }
261
+ if (this.targetConcurrency <= 1 && stalledFor >= 15 * 60_000) {
262
+ this.stallLevel = 3;
263
+ this.stallActionAt = Date.now();
264
+ const until = Date.now() + 10 * 60_000;
265
+ this.rateLimitResetsAt = until;
266
+ this.log(-1, `Stalled at concurrency 1 for ${Math.round(stalledFor / 60000)}m — forcing 10m cooldown`);
267
+ return;
268
+ }
269
+ if (this.stallLevel < 2 && this.targetConcurrency > 1) {
270
+ const next = Math.max(1, Math.floor(this.targetConcurrency / 2));
271
+ this.stallLevel++;
272
+ this.stallActionAt = Date.now();
273
+ this.log(-1, `Auto-throttle L${this.stallLevel}: concurrency ${this.targetConcurrency} → ${next} (stalled ${Math.round(stalledFor / 60000)}m)`);
274
+ this.setConcurrency(next);
169
275
  }
170
- this.log(-1, `Worker finished (${tasksProcessed} tasks)`);
171
276
  }
172
277
  capForOverage(reason) {
173
278
  if (this.cappedOut)
@@ -210,6 +315,9 @@ export class Swarm {
210
315
  this.rateLimitUtilization = 0;
211
316
  this.rateLimitResetsAt = undefined;
212
317
  consecutiveWaits++;
318
+ this.checkStall();
319
+ if (this.aborted || this.cappedOut)
320
+ return;
213
321
  }
214
322
  }
215
323
  // ── Agent execution ──
@@ -361,12 +469,12 @@ export class Swarm {
361
469
  agent.status = "error";
362
470
  agent.error = "Agent did no work — exited without tool use";
363
471
  this.failed++;
472
+ this.log(id, agent.error);
364
473
  }
365
474
  else {
366
475
  agent.status = "done";
367
476
  this.completed++;
368
477
  }
369
- this.log(id, this.agentSummary(agent));
370
478
  }
371
479
  break;
372
480
  }
@@ -378,14 +486,23 @@ export class Swarm {
378
486
  const waitMs = this.rateLimitResetsAt && this.rateLimitResetsAt > Date.now()
379
487
  ? Math.max(5000, this.rateLimitResetsAt - Date.now())
380
488
  : 120_000;
381
- this.log(id, `Rate limited waiting ${Math.ceil(waitMs / 1000)}s (attempt not counted)`);
489
+ // If the whole swarm has been making zero progress for a while, stop giving
490
+ // rate-limit retries a free pass — force them to count against maxRetries so
491
+ // we eventually surrender instead of looping forever.
492
+ const globallyStalled = Date.now() - this.lastProgressAt > 15 * 60_000;
493
+ const freebie = !globallyStalled;
494
+ this.log(id, `Rate limited — waiting ${Math.ceil(waitMs / 1000)}s${freebie ? " (attempt not counted)" : " (counted — swarm stalled)"}`);
495
+ agent.blockedAt = Date.now();
382
496
  this.rateLimitPaused++;
383
497
  await sleep(waitMs);
384
498
  this.rateLimitPaused--;
499
+ agent.blockedAt = undefined;
385
500
  this.isUsingOverage = false;
386
501
  this.rateLimitUtilization = 0;
387
502
  this.rateLimitResetsAt = undefined;
388
- attempt--; // don't count this against retries
503
+ this.checkStall();
504
+ if (freebie)
505
+ attempt--; // normal case: don't count against retries
389
506
  continue;
390
507
  }
391
508
  const canRetry = attempt < maxRetries && !this.aborted && isTransientError(err);
@@ -403,16 +520,26 @@ export class Swarm {
403
520
  if (this.config.useWorktrees && agent.branch) {
404
521
  agent.filesChanged = autoCommit(agent.id, agent.task.prompt, agentCwd, agent.baseRef, (id, text) => this.log(id, text));
405
522
  }
523
+ if (agent.status === "done")
524
+ this.log(agent.id, this.agentSummary(agent));
406
525
  }
407
526
  agentSummary(agent) {
408
527
  const dur = (agent.finishedAt ?? Date.now()) - (agent.startedAt ?? Date.now());
409
528
  const m = Math.floor(dur / 60000);
410
529
  const s = Math.round((dur % 60000) / 1000);
411
530
  const verb = agent.status === "error" ? "errored" : "done";
412
- return `Agent ${agent.id} ${verb}: ${m}m ${s}s, ${agent.toolCalls} tools, ${agent.filesChanged ?? 0} files changed`;
531
+ const files = agent.filesChanged != null ? `, ${agent.filesChanged} files changed` : "";
532
+ return `Agent ${agent.id} ${verb}: ${m}m ${s}s, ${agent.toolCalls} tools${files}`;
413
533
  }
414
534
  // ── Message handler ──
415
535
  handleMsg(agent, msg) {
536
+ // Any message that isn't a rate-limit event counts as real progress and
537
+ // resets the stall watchdog + clears the per-agent blocked flag.
538
+ if (msg.type !== "rate_limit_event") {
539
+ this.markProgress();
540
+ if (agent.blockedAt != null)
541
+ agent.blockedAt = undefined;
542
+ }
416
543
  switch (msg.type) {
417
544
  case "assistant": {
418
545
  const m = msg;
@@ -462,16 +589,39 @@ export class Swarm {
462
589
  this.totalInputTokens += safeAdd(r.usage.input_tokens);
463
590
  this.totalOutputTokens += safeAdd(r.usage.output_tokens);
464
591
  }
592
+ // Surface SDK diagnostics so silent failures stop looking like "did no work".
593
+ const denials = r.permission_denials ?? [];
594
+ if (denials.length > 0) {
595
+ const tools = Array.from(new Set(denials.map(d => d.tool_name))).join(", ");
596
+ this.log(agent.id, `${denials.length} permission denial(s): ${tools}`);
597
+ }
598
+ if (r.terminal_reason && r.terminal_reason !== "completed") {
599
+ this.log(agent.id, `terminal: ${r.terminal_reason}`);
600
+ }
601
+ if (r.stop_reason && r.stop_reason !== "end_turn" && r.stop_reason !== "stop_sequence") {
602
+ this.log(agent.id, `stop: ${r.stop_reason}`);
603
+ }
604
+ if (typeof r.num_turns === "number" && r.num_turns > 0) {
605
+ this.log(agent.id, `${r.num_turns} turns`);
606
+ }
465
607
  if (r.subtype === "success") {
466
608
  agent.status = "done";
467
609
  this.completed++;
468
- this.log(agent.id, this.agentSummary(agent));
469
610
  }
470
611
  else {
471
612
  agent.status = "error";
472
- agent.error = r.subtype;
613
+ const parts = [r.subtype];
614
+ if (r.terminal_reason && r.terminal_reason !== "completed")
615
+ parts.push(r.terminal_reason);
616
+ const errs = r.errors;
617
+ if (Array.isArray(errs) && errs.length > 0) {
618
+ parts.push(errs[0]);
619
+ for (const e of errs.slice(1, 3))
620
+ this.log(agent.id, `err: ${String(e).slice(0, 160)}`);
621
+ }
622
+ agent.error = parts.join(" — ").slice(0, 180);
473
623
  this.failed++;
474
- this.log(agent.id, r.subtype);
624
+ this.log(agent.id, agent.error);
475
625
  }
476
626
  break;
477
627
  }
package/dist/types.d.ts CHANGED
@@ -68,6 +68,8 @@ export interface AgentState {
68
68
  baseRef?: string;
69
69
  /** Number of files changed by the agent (from git diff). */
70
70
  filesChanged?: number;
71
+ /** Unix timestamp (ms) when this agent entered a rate-limit wait inside its retry loop. Cleared when work resumes. */
72
+ blockedAt?: number;
71
73
  }
72
74
  /** A timestamped log line from an agent's execution. */
73
75
  export interface LogEntry {
@@ -136,6 +138,7 @@ export interface SteerResult {
136
138
  reasoning: string;
137
139
  goalUpdate?: string;
138
140
  statusUpdate?: string;
141
+ estimatedSessionsRemaining?: number;
139
142
  }
140
143
  /** Accumulated run memory — designs, verifications, etc. — fed to the steerer. */
141
144
  export interface RunMemory {
package/dist/ui.d.ts CHANGED
@@ -30,6 +30,8 @@ export interface RunInfo {
30
30
  export interface LiveConfig {
31
31
  remaining: number;
32
32
  usageCap: number | undefined;
33
+ concurrency: number;
34
+ paused: boolean;
33
35
  dirty: boolean;
34
36
  }
35
37
  /** State of an in-flight or recently-completed ask side query. */
package/dist/ui.js CHANGED
@@ -161,6 +161,9 @@ export class RunDisplay {
161
161
  if (this.inputMode === "threshold") {
162
162
  return `\n ${chalk.cyan(">")} New usage cap (0-100%): ${rendered}\u2588`;
163
163
  }
164
+ if (this.inputMode === "concurrency") {
165
+ return `\n ${chalk.cyan(">")} New concurrency (min 1): ${rendered}\u2588`;
166
+ }
164
167
  if (this.inputMode === "steer") {
165
168
  return `\n ${chalk.cyan(">")} ${chalk.bold("Steer next wave")} ${chalk.dim("(Enter to queue, Esc to cancel)")}\n ${rendered}\u2588`;
166
169
  }
@@ -226,7 +229,7 @@ export class RunDisplay {
226
229
  }
227
230
  /** Handle a pasted block. Returns true if the frame needs a redraw. */
228
231
  handlePaste(text) {
229
- if (this.inputMode === "budget" || this.inputMode === "threshold") {
232
+ if (this.inputMode === "budget" || this.inputMode === "threshold" || this.inputMode === "concurrency") {
230
233
  const clean = text.replace(/[^0-9.]/g, "");
231
234
  if (clean)
232
235
  appendCharToSegments(this.inputSegs, clean);
@@ -243,7 +246,7 @@ export class RunDisplay {
243
246
  /** Handle a typed (non-pasted) chunk. Returns true if the frame needs a redraw. */
244
247
  handleTyped(s) {
245
248
  const lc = this.liveConfig;
246
- if (this.inputMode === "budget" || this.inputMode === "threshold") {
249
+ if (this.inputMode === "budget" || this.inputMode === "threshold" || this.inputMode === "concurrency") {
247
250
  let dirty = false;
248
251
  for (const ch of s) {
249
252
  if (ch === "\r" || ch === "\n") {
@@ -261,6 +264,12 @@ export class RunDisplay {
261
264
  this.swarm.usageCap = lc.usageCap;
262
265
  this.swarm?.log(-1, `Usage cap changed to ${val > 0 ? val + "%" : "unlimited"}`);
263
266
  }
267
+ else if (this.inputMode === "concurrency" && !isNaN(val) && val >= 1) {
268
+ const n = Math.round(val);
269
+ lc.concurrency = n;
270
+ lc.dirty = true;
271
+ this.swarm?.setConcurrency(n);
272
+ }
264
273
  this.inputMode = "none";
265
274
  this.inputSegs = [];
266
275
  return true;
@@ -340,6 +349,24 @@ export class RunDisplay {
340
349
  }
341
350
  return false;
342
351
  }
352
+ if (s === "c" || s === "C") {
353
+ if (this.swarm) {
354
+ this.inputMode = "concurrency";
355
+ this.inputSegs = [];
356
+ return true;
357
+ }
358
+ return false;
359
+ }
360
+ if (s === "p" || s === "P") {
361
+ if (this.swarm) {
362
+ const next = !this.swarm.paused;
363
+ this.swarm.setPaused(next);
364
+ lc.paused = next;
365
+ lc.dirty = true;
366
+ return true;
367
+ }
368
+ return false;
369
+ }
343
370
  if ((s === "f" || s === "F") && this.swarm && this.swarm.failed > 0 && this.swarm.active > 0) {
344
371
  this.swarm.requeueFailed();
345
372
  return false;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-overnight",
3
- "version": "1.11.14",
3
+ "version": "1.13.0",
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": {