claude-overnight 1.9.1 → 1.10.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.
@@ -1,4 +1,11 @@
1
1
  import type { Task, PermMode, RateLimitWindow } from "./types.js";
2
+ /**
3
+ * Logging callback used by planner/steering queries.
4
+ * `kind` distinguishes ephemeral status updates (heartbeat ticker) from
5
+ * discrete events worth persisting in a scrollback log (tool uses, retries).
6
+ * Plain (text) callers still work — extra arg is ignored.
7
+ */
8
+ export type PlannerLog = (text: string, kind?: "status" | "event") => void;
2
9
  export interface PlannerRateLimitInfo {
3
10
  utilization: number;
4
11
  status: string;
@@ -22,7 +29,7 @@ export declare function detectModelTier(model: string): ModelTier;
22
29
  export declare function modelCapabilityBlock(model: string): string;
23
30
  export declare function getTotalPlannerCost(): number;
24
31
  export declare function getPlannerRateLimitInfo(): PlannerRateLimitInfo;
25
- export declare function runPlannerQuery(prompt: string, opts: PlannerOpts, onLog: (text: string) => void): Promise<string>;
32
+ export declare function runPlannerQuery(prompt: string, opts: PlannerOpts, onLog: PlannerLog): Promise<string>;
26
33
  export declare function postProcess(raw: Task[], budget: number | undefined, onLog: (text: string) => void): Task[];
27
34
  export declare function attemptJsonParse(text: string): any | null;
28
35
  export declare function extractTaskJson(raw: string, retry: () => Promise<string>, onLog?: (text: string) => void, outFile?: string): Promise<{
@@ -51,18 +51,18 @@ export async function runPlannerQuery(prompt, opts, onLog) {
51
51
  catch (err) {
52
52
  if (err instanceof NudgeError) {
53
53
  if (err.sessionId) {
54
- onLog("Silent 15m — resuming session with continue");
54
+ onLog("Silent 15m — resuming session with continue", "event");
55
55
  currentPrompt = "Continue. Complete the task.";
56
56
  currentOpts = { ...opts, resumeSessionId: err.sessionId };
57
57
  }
58
58
  else {
59
- onLog("Silent 15m — restarting planner (no session to resume)");
59
+ onLog("Silent 15m — restarting planner (no session to resume)", "event");
60
60
  }
61
61
  continue;
62
62
  }
63
63
  if (attempt < MAX_RETRIES && isRateLimitError(err)) {
64
64
  const waitMs = BACKOFF[attempt];
65
- onLog(`Rate limited — waiting ${Math.round(waitMs / 1000)}s before retry ${attempt + 1}/${MAX_RETRIES}`);
65
+ onLog(`Rate limited — waiting ${Math.round(waitMs / 1000)}s before retry ${attempt + 1}/${MAX_RETRIES}`, "event");
66
66
  await new Promise((r) => setTimeout(r, waitMs));
67
67
  continue;
68
68
  }
@@ -105,7 +105,7 @@ async function runPlannerQueryOnce(prompt, opts, onLog) {
105
105
  const rlPct = _plannerRateLimitInfo.utilization;
106
106
  const rlStr = rlPct > 0 ? ` · ${Math.round(rlPct * 100)}%` : "";
107
107
  const extra = lastLogText ? ` · ${lastLogText}` : "";
108
- onLog(`${timeStr}${toolStr}${costStr}${rlStr}${extra}`);
108
+ onLog(`${timeStr}${toolStr}${costStr}${rlStr}${extra}`, "status");
109
109
  }, 500);
110
110
  const timeoutMs = isResume ? HARD_TIMEOUT_MS : NUDGE_MS;
111
111
  let sessionId;
@@ -143,7 +143,7 @@ async function runPlannerQueryOnce(prompt, opts, onLog) {
143
143
  if (ev?.type === "content_block_start" && ev.content_block?.type === "tool_use") {
144
144
  toolCount++;
145
145
  lastLogText = ev.content_block.name;
146
- onLog(ev.content_block.name);
146
+ onLog(ev.content_block.name, "event");
147
147
  }
148
148
  if (ev?.type === "content_block_delta") {
149
149
  const delta = ev.delta;
package/dist/render.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import type { Swarm } from "./swarm.js";
2
2
  import type { RateLimitWindow } from "./types.js";
3
- import type { RunInfo } from "./ui.js";
3
+ import type { RunInfo, SteeringContext, SteeringEvent } from "./ui.js";
4
4
  export declare function truncate(s: string, max: number): string;
5
5
  export declare function fmtTokens(n: number): string;
6
6
  export declare function fmtDur(ms: number): string;
@@ -11,6 +11,14 @@ type RLGetter = () => {
11
11
  resetsAt?: number;
12
12
  };
13
13
  export declare function renderFrame(swarm: Swarm, showHotkeys: boolean, runInfo?: RunInfo): string;
14
- export declare function renderSteeringFrame(runInfo: RunInfo, steeringText: string, showHotkeys: boolean, rlGetter?: RLGetter): string;
14
+ export interface SteeringViewData {
15
+ /** The ephemeral ticker heartbeat — elapsed, tool count, cost, current reasoning snippet. */
16
+ statusLine: string;
17
+ /** Persistent scrollback of discrete events (tool uses, retries, nudges). */
18
+ events: SteeringEvent[];
19
+ /** Optional context read from disk at setSteering() time. */
20
+ context?: SteeringContext;
21
+ }
22
+ export declare function renderSteeringFrame(runInfo: RunInfo, data: SteeringViewData, showHotkeys: boolean, rlGetter?: RLGetter): string;
15
23
  export declare function renderSummary(swarm: Swarm): string;
16
24
  export {};
package/dist/render.js CHANGED
@@ -180,12 +180,86 @@ export function renderFrame(swarm, showHotkeys, runInfo) {
180
180
  const tag = entry.agentId < 0 ? chalk.magenta("[sys]") : chalk.cyan(`[${entry.agentId}]`);
181
181
  out.push(chalk.gray(` ${t} `) + tag + ` ${colorEvent(truncate(entry.text, w - 22))}`);
182
182
  }
183
- if (showHotkeys)
184
- out.push(chalk.dim(" [b] budget [t] threshold [q] stop"));
183
+ if (showHotkeys) {
184
+ const pending = runInfo?.pendingSteer ?? 0;
185
+ const chip = pending > 0 ? chalk.cyan(` \u270E ${pending} steer queued`) : "";
186
+ out.push(chalk.dim(" [b] budget [t] threshold [s] steer [?] ask [q] stop") + chip);
187
+ }
185
188
  out.push("");
186
189
  return out.join("\n");
187
190
  }
188
- export function renderSteeringFrame(runInfo, steeringText, showHotkeys, rlGetter) {
191
+ function section(out, w, title) {
192
+ const inner = ` ${title} `;
193
+ const dashW = Math.max(3, Math.min(w - 6, 96) - inner.length);
194
+ out.push(chalk.gray(" \u2500\u2500\u2500" + inner + "\u2500".repeat(dashW)));
195
+ }
196
+ function renderSteeringUsageBar(out, w, rl) {
197
+ const rlBarW = Math.min(30, w - 40);
198
+ const draw = (pct, label) => {
199
+ let barStr = "";
200
+ const f = Math.round(pct * rlBarW);
201
+ for (let i = 0; i < rlBarW; i++) {
202
+ if (i < f)
203
+ barStr += pct > 0.9 ? chalk.red("\u2588") : pct > 0.75 ? chalk.yellow("\u2588") : chalk.blue("\u2588");
204
+ else
205
+ barStr += chalk.gray("\u2591");
206
+ }
207
+ let lbl = `${Math.round(pct * 100)}% used`;
208
+ if (rl.isUsingOverage)
209
+ lbl += chalk.red(" [EXTRA USAGE]");
210
+ if (rl.resetsAt && rl.resetsAt > Date.now()) {
211
+ const waitSec = Math.ceil((rl.resetsAt - Date.now()) / 1000);
212
+ const mm = Math.floor(waitSec / 60), ss = waitSec % 60;
213
+ lbl = chalk.red(`Waiting for reset ${mm > 0 ? `${mm}m ${ss}s` : `${ss}s`}`);
214
+ }
215
+ const prefix = label ? chalk.dim(label.padEnd(6)) : chalk.dim("Usage ");
216
+ out.push(` ${prefix}${barStr} ${lbl}`);
217
+ };
218
+ if (rl.windows.size > 1) {
219
+ const wins = Array.from(rl.windows.values());
220
+ const idx = Math.floor(Date.now() / 3000) % wins.length;
221
+ draw(wins[idx].utilization, wins[idx].type.replace(/_/g, " ").slice(0, 5));
222
+ }
223
+ else {
224
+ draw(rl.utilization);
225
+ }
226
+ }
227
+ function renderLastWave(out, w, lw) {
228
+ section(out, w, `Wave ${lw.wave + 1} summary`);
229
+ const done = lw.tasks.filter(t => t.status === "done").length;
230
+ const failed = lw.tasks.filter(t => t.status === "error").length;
231
+ const running = lw.tasks.filter(t => t.status === "running").length;
232
+ const parts = [];
233
+ if (done > 0)
234
+ parts.push(chalk.green(`\u2713 ${done} done`));
235
+ if (failed > 0)
236
+ parts.push(chalk.red(`\u2717 ${failed} failed`));
237
+ if (running > 0)
238
+ parts.push(chalk.blue(`~ ${running} running`));
239
+ if (parts.length === 0)
240
+ parts.push(chalk.dim("(no tasks)"));
241
+ out.push(" " + parts.join(" "));
242
+ const show = lw.tasks.slice(0, 5);
243
+ for (const t of show) {
244
+ const icon = t.status === "done" ? chalk.green("\u2713")
245
+ : t.status === "error" ? chalk.red("\u2717")
246
+ : t.status === "running" ? chalk.blue("~")
247
+ : chalk.gray("\u00b7");
248
+ const line = t.prompt.replace(/\n/g, " ");
249
+ out.push(` ${icon} ${chalk.dim(truncate(line, w - 8))}`);
250
+ }
251
+ if (lw.tasks.length > 5)
252
+ out.push(chalk.dim(` \u2026 + ${lw.tasks.length - 5} more`));
253
+ }
254
+ function renderStatusBlock(out, w, status) {
255
+ const lines = status.trim().split("\n").filter(l => l.trim()).slice(0, 6);
256
+ if (lines.length === 0)
257
+ return;
258
+ section(out, w, "Status");
259
+ for (const ln of lines)
260
+ out.push(` ${chalk.dim(truncate(ln.trim(), w - 4))}`);
261
+ }
262
+ export function renderSteeringFrame(runInfo, data, showHotkeys, rlGetter) {
189
263
  const w = Math.max((process.stdout.columns ?? 80) || 80, 60);
190
264
  const out = [];
191
265
  const totalUsed = runInfo.accCompleted + runInfo.accFailed;
@@ -201,45 +275,43 @@ export function renderSteeringFrame(runInfo, steeringText, showHotkeys, rlGetter
201
275
  sessionsUsed: totalUsed, sessionsBudget: runInfo.sessionsBudget, remaining: runInfo.remaining,
202
276
  });
203
277
  const rl = rlGetter?.();
204
- if (rl && (rl.utilization > 0 || rl.windows.size > 0)) {
205
- const rlBarW = Math.min(30, w - 40);
206
- const renderBar = (pct, label) => {
207
- const f = Math.round(pct * rlBarW);
208
- let barStr = "";
209
- for (let i = 0; i < rlBarW; i++) {
210
- if (i < f)
211
- barStr += pct > 0.9 ? chalk.red("\u2588") : pct > 0.75 ? chalk.yellow("\u2588") : chalk.blue("\u2588");
212
- else
213
- barStr += chalk.gray("\u2591");
214
- }
215
- let lbl = `${Math.round(pct * 100)}% used`;
216
- if (rl.isUsingOverage)
217
- lbl += chalk.red(" [EXTRA USAGE]");
218
- if (rl.resetsAt && rl.resetsAt > Date.now()) {
219
- const waitSec = Math.ceil((rl.resetsAt - Date.now()) / 1000);
220
- const mm = Math.floor(waitSec / 60), ss = waitSec % 60;
221
- lbl = chalk.red(`Waiting for reset ${mm > 0 ? `${mm}m ${ss}s` : `${ss}s`}`);
222
- }
223
- const prefix = label ? chalk.dim(label.padEnd(6)) : chalk.dim("Usage ");
224
- out.push(` ${prefix}${barStr} ${lbl}`);
225
- };
226
- if (rl.windows.size > 1) {
227
- const wins = Array.from(rl.windows.values());
228
- const idx = Math.floor(Date.now() / 3000) % wins.length;
229
- renderBar(wins[idx].utilization, wins[idx].type.replace(/_/g, " ").slice(0, 5));
230
- }
231
- else {
232
- renderBar(rl.utilization);
278
+ if (rl && (rl.utilization > 0 || rl.windows.size > 0))
279
+ renderSteeringUsageBar(out, w, rl);
280
+ out.push("");
281
+ const ctx = data.context;
282
+ if (ctx?.objective) {
283
+ const obj = ctx.objective.replace(/\s+/g, " ").trim();
284
+ out.push(` ${chalk.bold.white("Objective")} ${chalk.dim(truncate(obj, w - 15))}`);
285
+ out.push("");
286
+ }
287
+ if (ctx?.lastWave && ctx.lastWave.tasks.length > 0) {
288
+ renderLastWave(out, w, ctx.lastWave);
289
+ out.push("");
290
+ }
291
+ if (ctx?.status) {
292
+ renderStatusBlock(out, w, ctx.status);
293
+ out.push("");
294
+ }
295
+ section(out, w, "Planner activity");
296
+ const events = data.events.slice(-10);
297
+ if (events.length === 0) {
298
+ out.push(chalk.dim(" (waiting for planner\u2026)"));
299
+ }
300
+ else {
301
+ for (const e of events) {
302
+ const t = new Date(e.time).toLocaleTimeString("en", { hour12: false });
303
+ out.push(chalk.gray(` ${t} `) + chalk.magenta("[plan] ") + colorEvent(truncate(e.text, w - 22)));
233
304
  }
234
305
  }
235
306
  out.push("");
236
- out.push(chalk.gray(" " + "\u2500".repeat(Math.min(w - 4, 60))));
237
- const clean = steeringText.replace(/\n/g, " ");
238
- const maxTextW = w - 8;
239
- out.push(` ${chalk.cyan("\u25C6")} ${clean.length > maxTextW ? clean.slice(0, maxTextW - 1) + "\u2026" : clean}`);
307
+ const liveClean = data.statusLine.replace(/\n/g, " ");
308
+ out.push(` ${chalk.cyan("\u25B6")} ${chalk.dim(truncate(liveClean, w - 6))}`);
240
309
  out.push("");
241
- if (showHotkeys)
242
- out.push(chalk.dim(" [b] budget [q] stop"));
310
+ if (showHotkeys) {
311
+ const pending = runInfo?.pendingSteer ?? 0;
312
+ const chip = pending > 0 ? chalk.cyan(` \u270E ${pending} steer queued`) : "";
313
+ out.push(chalk.dim(" [b] budget [s] steer [q] stop") + chip);
314
+ }
243
315
  out.push("");
244
316
  return out.join("\n");
245
317
  }
package/dist/run.js CHANGED
@@ -4,12 +4,12 @@ import { execSync } from "child_process";
4
4
  import chalk from "chalk";
5
5
  import { Swarm } from "./swarm.js";
6
6
  import { steerWave } from "./steering.js";
7
- import { getTotalPlannerCost, getPlannerRateLimitInfo } from "./planner-query.js";
7
+ import { getTotalPlannerCost, getPlannerRateLimitInfo, runPlannerQuery } from "./planner-query.js";
8
8
  import { RunDisplay } from "./ui.js";
9
9
  import { renderSummary } from "./render.js";
10
10
  import { fmtTokens } from "./render.js";
11
11
  import { isAuthError } from "./cli.js";
12
- import { readRunMemory, writeStatus, writeGoalUpdate, saveRunState, saveWaveSession, loadWaveHistory, recordBranches, archiveMilestone, } from "./state.js";
12
+ import { readRunMemory, writeStatus, writeGoalUpdate, saveRunState, saveWaveSession, loadWaveHistory, recordBranches, archiveMilestone, writeSteerInbox, consumeSteerInbox, countSteerInbox, } from "./state.js";
13
13
  export async function executeRun(cfg) {
14
14
  const restore = () => { try {
15
15
  process.stdout.write("\x1B[?25h\n");
@@ -71,13 +71,79 @@ export async function executeRun(cfg) {
71
71
  accIn, accOut, accCost, accCompleted, accFailed,
72
72
  sessionsBudget: cfg.budget, waveNum, remaining,
73
73
  model: workerModel, startedAt: cfg.runStartedAt,
74
+ pendingSteer: countSteerInbox(runDir),
74
75
  };
75
- const display = new RunDisplay(runInfoRef, liveConfig);
76
+ let display;
77
+ const onSteer = (text) => {
78
+ try {
79
+ writeSteerInbox(runDir, text);
80
+ runInfoRef.pendingSteer = countSteerInbox(runDir);
81
+ if (currentSwarm)
82
+ currentSwarm.log(-1, `Steer queued: ${text.slice(0, 80)}`);
83
+ }
84
+ catch { }
85
+ };
86
+ let askInFlight = false;
87
+ const onAsk = (question) => {
88
+ if (askInFlight)
89
+ return;
90
+ askInFlight = true;
91
+ display.setAskBusy(true);
92
+ display.setAsk({ question, answer: "", streaming: true });
93
+ void (async () => {
94
+ const plannerCostBefore = getTotalPlannerCost();
95
+ try {
96
+ const memory = readRunMemory(runDir, previousKnowledge || undefined);
97
+ const cap = (s, max) => s && s.length > max ? s.slice(0, max) + "\n...(truncated)" : (s || "");
98
+ const memBlob = [
99
+ objective ? `Objective: ${objective}` : "",
100
+ memory.goal ? `Goal:\n${cap(memory.goal, 1500)}` : "",
101
+ memory.status ? `Current status:\n${cap(memory.status, 2000)}` : "",
102
+ memory.verifications ? `Latest verification:\n${cap(memory.verifications, 1500)}` : "",
103
+ memory.reflections ? `Latest reflections:\n${cap(memory.reflections, 1500)}` : "",
104
+ waveHistory.length ? `Waves completed: ${waveHistory.length}` : "",
105
+ ].filter(Boolean).join("\n\n");
106
+ const prompt = `You are answering a user question about an in-progress autonomous agent run. Use the context below; read files in the repo if needed. Answer concisely (a few sentences) and cite files or waves when relevant.\n\n${memBlob}\n\n---\nUser question: ${question}`;
107
+ const answer = await runPlannerQuery(prompt, { cwd, model: plannerModel, permissionMode }, () => { });
108
+ accCost += getTotalPlannerCost() - plannerCostBefore;
109
+ syncRunInfo();
110
+ display.setAsk({ question, answer: answer.trim() || "(no answer)", streaming: false });
111
+ }
112
+ catch (err) {
113
+ accCost += getTotalPlannerCost() - plannerCostBefore;
114
+ syncRunInfo();
115
+ display.setAsk({ question, answer: "", streaming: false, error: err?.message?.slice(0, 200) || "ask failed" });
116
+ }
117
+ finally {
118
+ askInFlight = false;
119
+ display.setAskBusy(false);
120
+ }
121
+ })();
122
+ };
123
+ display = new RunDisplay(runInfoRef, liveConfig, { onSteer, onAsk });
76
124
  const rlGetter = () => {
77
125
  const rl = getPlannerRateLimitInfo();
78
126
  return { utilization: rl.utilization, isUsingOverage: rl.isUsingOverage, windows: rl.windows, resetsAt: rl.resetsAt };
79
127
  };
80
128
  const syncRunInfo = () => Object.assign(runInfoRef, { accIn, accOut, accCost, accCompleted, accFailed, waveNum, remaining });
129
+ const buildSteeringContext = () => {
130
+ let status;
131
+ try {
132
+ status = readFileSync(join(runDir, "status.md"), "utf-8");
133
+ }
134
+ catch { }
135
+ return {
136
+ objective: objective || undefined,
137
+ status,
138
+ lastWave: waveHistory[waveHistory.length - 1],
139
+ };
140
+ };
141
+ const steeringLog = (text, kind) => {
142
+ if (kind === "event")
143
+ display.appendSteeringEvent(text);
144
+ else
145
+ display.updateSteeringStatus(text);
146
+ };
81
147
  // For flex + branch strategy: create one target branch
82
148
  let runBranch;
83
149
  let originalRef;
@@ -119,7 +185,10 @@ export async function executeRun(cfg) {
119
185
  const plannerCostBefore = getTotalPlannerCost();
120
186
  try {
121
187
  const memory = readRunMemory(runDir, previousKnowledge || undefined);
122
- const steer = await steerWave(objective, waveHistory, remaining, cwd, plannerModel, workerModel, permissionMode, concurrency, (text) => display.updateText(text), memory);
188
+ const appliedGuidance = memory.userGuidance;
189
+ if (appliedGuidance)
190
+ display.appendSteeringEvent(`User directives applied: ${appliedGuidance.slice(0, 80)}`);
191
+ const steer = await steerWave(objective, waveHistory, remaining, cwd, plannerModel, workerModel, permissionMode, concurrency, steeringLog, memory);
123
192
  accCost += getTotalPlannerCost() - plannerCostBefore;
124
193
  syncRunInfo();
125
194
  if (steer.statusUpdate)
@@ -131,13 +200,18 @@ export async function executeRun(cfg) {
131
200
  writeFileSync(join(steerDir, `wave-${waveNum}-attempt-${steerAttempts}.json`), JSON.stringify({
132
201
  done: steer.done, reasoning: steer.reasoning,
133
202
  taskCount: steer.tasks.length, statusUpdate: steer.statusUpdate, goalUpdate: steer.goalUpdate,
203
+ appliedGuidance: appliedGuidance || undefined,
134
204
  }, null, 2), "utf-8");
205
+ if (appliedGuidance) {
206
+ consumeSteerInbox(runDir, waveNum);
207
+ runInfoRef.pendingSteer = countSteerInbox(runDir);
208
+ }
135
209
  if (waveHistory.length > 0 && waveHistory.length % 5 === 0)
136
210
  archiveMilestone(runDir, waveNum);
137
211
  if (steer.done || steer.tasks.length === 0) {
138
212
  const hasVerification = waveHistory.some(w => w.tasks.some(t => t.prompt.toLowerCase().includes("verif")));
139
213
  if (!hasVerification && remaining >= 1) {
140
- display.updateText(`Done blocked — auto-composing verification wave`);
214
+ display.appendSteeringEvent("Done blocked — auto-composing verification wave");
141
215
  currentTasks = [{
142
216
  id: "verify-0",
143
217
  prompt: `## Verification: Build, run, and test the application end-to-end\n\nYou are the final gatekeeper before this run is marked complete. The steerer believes the objective is done. Your job: prove it or disprove it.\n\n1. Run the build (npm run build, or whatever this project uses). Report ALL errors.\n2. Start the dev server. If a port is taken, try another. If a dependency is missing, install it.\n3. Navigate key flows as a real user would. Check that the main features work.\n4. Write your findings to .claude-overnight/latest/verifications/final-verify.md\n\nBe relentless. Do not give up if the first approach fails. Search the codebase for dev login routes, test tokens, seed users, env vars, CLI auth commands, or any bypass.`,
@@ -159,7 +233,7 @@ export async function executeRun(cfg) {
159
233
  catch (err) {
160
234
  accCost += getTotalPlannerCost() - plannerCostBefore;
161
235
  if (steerAttempts < 3) {
162
- display.updateText(`Steering failed (attempt ${steerAttempts}/3) — retrying...`);
236
+ display.appendSteeringEvent(`Steering failed (attempt ${steerAttempts}/3) — retrying...`);
163
237
  continue;
164
238
  }
165
239
  display.stop();
@@ -172,7 +246,7 @@ export async function executeRun(cfg) {
172
246
  };
173
247
  // Resume: steer immediately if no queued tasks
174
248
  if (cfg.resuming && flex && currentTasks.length === 0 && remaining > 0) {
175
- display.setSteering(rlGetter);
249
+ display.setSteering(rlGetter, buildSteeringContext());
176
250
  display.start();
177
251
  await runSteering();
178
252
  }
@@ -244,7 +318,7 @@ export async function executeRun(cfg) {
244
318
  if (!flex || remaining <= 0 || swarm.aborted || swarm.cappedOut)
245
319
  break;
246
320
  syncRunInfo();
247
- display.setSteering(rlGetter);
321
+ display.setSteering(rlGetter, buildSteeringContext());
248
322
  display.resume();
249
323
  const steered = await runSteering();
250
324
  if (!steered)
package/dist/state.d.ts CHANGED
@@ -1,6 +1,14 @@
1
1
  import type { RunState, BranchRecord, AgentState, RunMemory, WaveSummary } from "./types.js";
2
2
  export declare function readMdDir(dir: string): string;
3
3
  export declare function readRunMemory(runDir: string, previousRuns?: string): RunMemory;
4
+ /** Read pending .md files in steer-inbox/ (top-level only, not processed/). */
5
+ export declare function readSteerInbox(runDir: string): string;
6
+ /** Count pending steer files without reading them. */
7
+ export declare function countSteerInbox(runDir: string): number;
8
+ /** Append a user directive to the inbox as its own timestamped file. Returns the file path. */
9
+ export declare function writeSteerInbox(runDir: string, text: string): string;
10
+ /** Move all pending .md files from steer-inbox/ into steer-inbox/processed/wave-N/. Returns moved count. */
11
+ export declare function consumeSteerInbox(runDir: string, waveNum: number): number;
4
12
  export declare function writeStatus(baseDir: string, status: string): void;
5
13
  export declare function writeGoalUpdate(baseDir: string, update: string): void;
6
14
  export declare function saveRunState(runDir: string, state: RunState): void;
package/dist/state.js CHANGED
@@ -1,4 +1,4 @@
1
- import { readFileSync, existsSync, mkdirSync, readdirSync, writeFileSync, symlinkSync, unlinkSync } from "fs";
1
+ import { readFileSync, existsSync, mkdirSync, readdirSync, writeFileSync, symlinkSync, unlinkSync, renameSync } from "fs";
2
2
  import { execSync } from "child_process";
3
3
  import { join } from "path";
4
4
  import chalk from "chalk";
@@ -31,8 +31,68 @@ export function readRunMemory(runDir, previousRuns) {
31
31
  verifications: readMdDir(join(runDir, "verifications")),
32
32
  milestones: readMdDir(join(runDir, "milestones")),
33
33
  status, goal, previousRuns,
34
+ userGuidance: readSteerInbox(runDir),
34
35
  };
35
36
  }
37
+ // ── Steer inbox (user directives queued for the next steering call) ──
38
+ /** Read pending .md files in steer-inbox/ (top-level only, not processed/). */
39
+ export function readSteerInbox(runDir) {
40
+ const dir = join(runDir, "steer-inbox");
41
+ try {
42
+ const files = readdirSync(dir).filter(f => f.endsWith(".md")).sort();
43
+ return files.map(f => {
44
+ try {
45
+ return readFileSync(join(dir, f), "utf-8").trim();
46
+ }
47
+ catch {
48
+ return "";
49
+ }
50
+ }).filter(Boolean).join("\n\n---\n\n");
51
+ }
52
+ catch {
53
+ return "";
54
+ }
55
+ }
56
+ /** Count pending steer files without reading them. */
57
+ export function countSteerInbox(runDir) {
58
+ try {
59
+ return readdirSync(join(runDir, "steer-inbox")).filter(f => f.endsWith(".md")).length;
60
+ }
61
+ catch {
62
+ return 0;
63
+ }
64
+ }
65
+ /** Append a user directive to the inbox as its own timestamped file. Returns the file path. */
66
+ export function writeSteerInbox(runDir, text) {
67
+ const dir = join(runDir, "steer-inbox");
68
+ mkdirSync(dir, { recursive: true });
69
+ const ts = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19);
70
+ const rand = Math.random().toString(36).slice(2, 6);
71
+ const path = join(dir, `${ts}-${rand}.md`);
72
+ writeFileSync(path, text.trim() + "\n", "utf-8");
73
+ return path;
74
+ }
75
+ /** Move all pending .md files from steer-inbox/ into steer-inbox/processed/wave-N/. Returns moved count. */
76
+ export function consumeSteerInbox(runDir, waveNum) {
77
+ const dir = join(runDir, "steer-inbox");
78
+ let moved = 0;
79
+ try {
80
+ const files = readdirSync(dir).filter(f => f.endsWith(".md"));
81
+ if (files.length === 0)
82
+ return 0;
83
+ const processedDir = join(dir, "processed", `wave-${waveNum}`);
84
+ mkdirSync(processedDir, { recursive: true });
85
+ for (const f of files) {
86
+ try {
87
+ renameSync(join(dir, f), join(processedDir, f));
88
+ moved++;
89
+ }
90
+ catch { }
91
+ }
92
+ }
93
+ catch { }
94
+ return moved;
95
+ }
36
96
  export function writeStatus(baseDir, status) {
37
97
  writeFileSync(join(baseDir, "status.md"), status, "utf-8");
38
98
  }
@@ -1,2 +1,3 @@
1
1
  import type { PermMode, SteerResult, RunMemory, WaveSummary } from "./types.js";
2
- export declare function steerWave(objective: string, history: WaveSummary[], remainingBudget: number, cwd: string, plannerModel: string, workerModel: string, permissionMode: PermMode, concurrency: number, onLog: (text: string) => void, runMemory?: RunMemory): Promise<SteerResult>;
2
+ import { type PlannerLog } from "./planner-query.js";
3
+ export declare function steerWave(objective: string, history: WaveSummary[], remainingBudget: number, cwd: string, plannerModel: string, workerModel: string, permissionMode: PermMode, concurrency: number, onLog: PlannerLog, runMemory?: RunMemory): Promise<SteerResult>;
package/dist/steering.js CHANGED
@@ -40,8 +40,9 @@ export async function steerWave(objective, history, remainingBudget, cwd, planne
40
40
  const verificationBlock = runMemory?.verifications ? `\nVerification results (from actually running the app):\n${cap(runMemory.verifications, 3000)}\n` : "";
41
41
  const goalBlock = runMemory?.goal ? `\nNorth star — what "amazing" means:\n${runMemory.goal}\n` : "";
42
42
  const prevRunBlock = runMemory?.previousRuns ? `\nKnowledge from previous runs:\n${cap(runMemory.previousRuns, 3000)}\n` : "";
43
+ const guidanceBlock = runMemory?.userGuidance ? `\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\nUSER DIRECTIVES — highest priority\nThese come directly from the user running this session. They override prior assumptions about status, goal, and next steps. Incorporate them into the wave you compose below. If they conflict with earlier decisions, the user wins. Reflect the new direction in statusUpdate so future waves remember.\n\n${cap(runMemory.userGuidance, 4000)}\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n` : "";
43
44
  const prompt = `You are the quality director for an autonomous multi-wave agent system. Your job is to push the work toward "amazing," not just "done."
44
-
45
+ ${guidanceBlock}
45
46
  Objective: ${objective}
46
47
  ${goalBlock}${statusBlock}${milestoneBlock}${prevRunBlock}
47
48
  Recent waves:
@@ -104,13 +105,14 @@ The "model" field on each task: use "worker" (${workerModel}) for implementation
104
105
  Set "noWorktree": true for verify/user-test tasks — they need the real project directory with env files, dependencies, and local config.
105
106
 
106
107
  If done: {"done": true, "reasoning": "...", "statusUpdate": "...", "tasks": []}`;
107
- onLog("Assessing...");
108
+ onLog("Assessing...", "status");
109
+ onLog(`Reading codebase — wave ${history.length + 1}`, "event");
108
110
  const resultText = await runPlannerQuery(prompt, { cwd, model: plannerModel, permissionMode, outputFormat: STEER_SCHEMA }, onLog);
109
111
  const parsed = await (async () => {
110
112
  const first = attemptJsonParse(resultText);
111
113
  if (first)
112
114
  return first;
113
- onLog(`Steering parse failed (${resultText.length} chars). Asking model to fix...`);
115
+ onLog(`Steering parse failed (${resultText.length} chars). Asking model to fix...`, "event");
114
116
  const snippet = resultText.length > 2000 ? resultText.slice(0, 1000) + "\n...\n" + resultText.slice(-800) : resultText;
115
117
  const retryText = await runPlannerQuery(`Your previous steering response could not be parsed as JSON. Here is what you returned:\n\n---\n${snippet}\n---\n\nExtract or rewrite the above as ONLY a valid JSON object with this schema: {"done":boolean,"reasoning":"...","statusUpdate":"...","tasks":[{"prompt":"..."}]}\n\nRespond with ONLY the JSON, no markdown fences, no explanation.`, { cwd, model: plannerModel, permissionMode, outputFormat: STEER_SCHEMA }, onLog);
116
118
  const retryParsed = attemptJsonParse(retryText);
package/dist/types.d.ts CHANGED
@@ -144,6 +144,8 @@ export interface RunMemory {
144
144
  status: string;
145
145
  goal: string;
146
146
  previousRuns?: string;
147
+ /** Pending user directives from the steer inbox, consumed by the next successful steering call. */
148
+ userGuidance?: string;
147
149
  }
148
150
  /** Persisted run state for crash recovery and resume. */
149
151
  export interface RunState {
package/dist/ui.d.ts CHANGED
@@ -1,5 +1,16 @@
1
1
  import type { Swarm } from "./swarm.js";
2
- import type { RateLimitWindow } from "./types.js";
2
+ import type { RateLimitWindow, WaveSummary } from "./types.js";
3
+ /** Short-lived context the steering view renders around its live log. */
4
+ export interface SteeringContext {
5
+ objective?: string;
6
+ status?: string;
7
+ lastWave?: WaveSummary;
8
+ }
9
+ /** One scrollback line in the steering event log. */
10
+ export interface SteeringEvent {
11
+ time: number;
12
+ text: string;
13
+ }
3
14
  /** Cumulative run-level stats — mutable, updated between phases. */
4
15
  export interface RunInfo {
5
16
  accIn: number;
@@ -12,6 +23,8 @@ export interface RunInfo {
12
23
  remaining: number;
13
24
  model?: string;
14
25
  startedAt: number;
26
+ /** Number of pending directives in the steer inbox; displayed as a chip in the hotkey row. */
27
+ pendingSteer?: number;
15
28
  }
16
29
  /** Mutable config that can be changed live during execution. */
17
30
  export interface LiveConfig {
@@ -19,6 +32,13 @@ export interface LiveConfig {
19
32
  usageCap: number | undefined;
20
33
  dirty: boolean;
21
34
  }
35
+ /** State of an in-flight or recently-completed ask side query. */
36
+ export interface AskState {
37
+ question: string;
38
+ answer: string;
39
+ streaming: boolean;
40
+ error?: string;
41
+ }
22
42
  type RLGetter = () => {
23
43
  utilization: number;
24
44
  isUsingOverage: boolean;
@@ -29,7 +49,10 @@ export declare class RunDisplay {
29
49
  readonly runInfo: RunInfo;
30
50
  private liveConfig?;
31
51
  private swarm?;
32
- private steeringText?;
52
+ private steeringActive;
53
+ private steeringStatusLine;
54
+ private steeringEvents;
55
+ private steeringContext?;
33
56
  private rlGetter?;
34
57
  private interval?;
35
58
  private keyHandler?;
@@ -39,16 +62,34 @@ export declare class RunDisplay {
39
62
  private readonly isTTY;
40
63
  private lastSeq;
41
64
  private lastCompleted;
42
- constructor(runInfo: RunInfo, liveConfig?: LiveConfig);
65
+ private askState?;
66
+ private askBusy;
67
+ private onSteer?;
68
+ private onAsk?;
69
+ constructor(runInfo: RunInfo, liveConfig?: LiveConfig, callbacks?: {
70
+ onSteer?: (text: string) => void;
71
+ onAsk?: (text: string) => void;
72
+ });
73
+ /** Replace the ask state. Called by run.ts as the side query streams and completes. */
74
+ setAsk(state: AskState | undefined): void;
75
+ /** Signal to the UI whether an ask is in progress (prevents duplicate firings). */
76
+ setAskBusy(busy: boolean): void;
43
77
  start(): void;
44
78
  setWave(swarm: Swarm): void;
45
- setSteering(rlGetter?: RLGetter): void;
79
+ setSteering(rlGetter?: RLGetter, ctx?: SteeringContext): void;
80
+ /** Replace the single live status line (ticker heartbeat). */
81
+ updateSteeringStatus(text: string): void;
82
+ /** Append a discrete, persistent line to the steering scrollback. */
83
+ appendSteeringEvent(text: string): void;
84
+ /** Backwards-compat alias — treats input as the current status line. */
46
85
  updateText(text: string): void;
47
86
  pause(): void;
48
87
  resume(): void;
49
88
  stop(): void;
50
89
  private resumeInterval;
51
90
  private render;
91
+ private renderInputPrompt;
92
+ private renderAskPanel;
52
93
  private hasHotkeys;
53
94
  private setupHotkeys;
54
95
  private plainTick;
package/dist/ui.js CHANGED
@@ -1,10 +1,15 @@
1
1
  import chalk from "chalk";
2
2
  import { renderFrame, renderSteeringFrame } from "./render.js";
3
+ const MAX_STEERING_EVENTS = 60;
4
+ const MAX_INPUT_LEN = 600;
3
5
  export class RunDisplay {
4
6
  runInfo;
5
7
  liveConfig;
6
8
  swarm;
7
- steeringText;
9
+ steeringActive = false;
10
+ steeringStatusLine = "Assessing...";
11
+ steeringEvents = [];
12
+ steeringContext;
8
13
  rlGetter;
9
14
  interval;
10
15
  keyHandler;
@@ -14,11 +19,21 @@ export class RunDisplay {
14
19
  isTTY;
15
20
  lastSeq = 0;
16
21
  lastCompleted = -1;
17
- constructor(runInfo, liveConfig) {
22
+ askState;
23
+ askBusy = false;
24
+ onSteer;
25
+ onAsk;
26
+ constructor(runInfo, liveConfig, callbacks) {
18
27
  this.runInfo = runInfo;
19
28
  this.liveConfig = liveConfig;
29
+ this.onSteer = callbacks?.onSteer;
30
+ this.onAsk = callbacks?.onAsk;
20
31
  this.isTTY = !!process.stdout.isTTY;
21
32
  }
33
+ /** Replace the ask state. Called by run.ts as the side query streams and completes. */
34
+ setAsk(state) { this.askState = state; }
35
+ /** Signal to the UI whether an ask is in progress (prevents duplicate firings). */
36
+ setAskBusy(busy) { this.askBusy = busy; }
22
37
  start() {
23
38
  if (this.started)
24
39
  return;
@@ -28,17 +43,29 @@ export class RunDisplay {
28
43
  }
29
44
  setWave(swarm) {
30
45
  this.swarm = swarm;
31
- this.steeringText = undefined;
46
+ this.steeringActive = false;
32
47
  this.rlGetter = undefined;
33
48
  this.lastSeq = 0;
34
49
  this.lastCompleted = -1;
35
50
  }
36
- setSteering(rlGetter) {
51
+ setSteering(rlGetter, ctx) {
37
52
  this.swarm = undefined;
38
- this.steeringText = "Assessing...";
53
+ this.steeringActive = true;
54
+ this.steeringStatusLine = "Assessing...";
55
+ this.steeringEvents = [];
56
+ this.steeringContext = ctx;
39
57
  this.rlGetter = rlGetter;
40
58
  }
41
- updateText(text) { this.steeringText = text; }
59
+ /** Replace the single live status line (ticker heartbeat). */
60
+ updateSteeringStatus(text) { this.steeringStatusLine = text; }
61
+ /** Append a discrete, persistent line to the steering scrollback. */
62
+ appendSteeringEvent(text) {
63
+ this.steeringEvents.push({ time: Date.now(), text });
64
+ if (this.steeringEvents.length > MAX_STEERING_EVENTS)
65
+ this.steeringEvents.shift();
66
+ }
67
+ /** Backwards-compat alias — treats input as the current status line. */
68
+ updateText(text) { this.updateSteeringStatus(text); }
42
69
  pause() {
43
70
  if (this.interval) {
44
71
  clearInterval(this.interval);
@@ -96,23 +123,61 @@ export class RunDisplay {
96
123
  }, 250);
97
124
  }
98
125
  render() {
126
+ let frame = "";
99
127
  if (this.swarm) {
100
- let frame = renderFrame(this.swarm, this.hasHotkeys(), this.runInfo);
101
- if (this.inputMode !== "none") {
102
- const label = this.inputMode === "budget" ? "New budget (remaining sessions)" : "New usage cap (0-100%)";
103
- frame += `\n ${chalk.cyan(">")} ${label}: ${this.inputBuf}\u2588`;
104
- }
105
- return frame;
128
+ frame = renderFrame(this.swarm, this.hasHotkeys(), this.runInfo);
106
129
  }
107
- if (this.steeringText != null) {
108
- let frame = renderSteeringFrame(this.runInfo, this.steeringText, this.hasHotkeys(), this.rlGetter);
109
- if (this.inputMode === "budget") {
110
- frame += `\n ${chalk.cyan(">")} New budget (remaining sessions): ${this.inputBuf}\u2588`;
111
- }
112
- return frame;
130
+ else if (this.steeringActive) {
131
+ frame = renderSteeringFrame(this.runInfo, {
132
+ statusLine: this.steeringStatusLine,
133
+ events: this.steeringEvents,
134
+ context: this.steeringContext,
135
+ }, this.hasHotkeys(), this.rlGetter);
136
+ }
137
+ else {
138
+ return "";
139
+ }
140
+ frame += this.renderInputPrompt();
141
+ frame += this.renderAskPanel();
142
+ return frame;
143
+ }
144
+ renderInputPrompt() {
145
+ if (this.inputMode === "none")
146
+ return "";
147
+ if (this.inputMode === "budget") {
148
+ return `\n ${chalk.cyan(">")} New budget (remaining sessions): ${this.inputBuf}\u2588`;
149
+ }
150
+ if (this.inputMode === "threshold") {
151
+ return `\n ${chalk.cyan(">")} New usage cap (0-100%): ${this.inputBuf}\u2588`;
152
+ }
153
+ if (this.inputMode === "steer") {
154
+ return `\n ${chalk.cyan(">")} ${chalk.bold("Steer next wave")} ${chalk.dim("(Enter to queue, Esc to cancel)")}\n ${this.inputBuf}\u2588`;
155
+ }
156
+ if (this.inputMode === "ask") {
157
+ return `\n ${chalk.cyan(">")} ${chalk.bold("Ask the planner")} ${chalk.dim("(Enter to send, Esc to cancel)")}\n ${this.inputBuf}\u2588`;
113
158
  }
114
159
  return "";
115
160
  }
161
+ renderAskPanel() {
162
+ const a = this.askState;
163
+ if (!a)
164
+ return "";
165
+ const out = ["", chalk.gray(" \u2500\u2500\u2500 Ask " + "\u2500".repeat(40))];
166
+ out.push(` ${chalk.bold.cyan("Q:")} ${a.question}`);
167
+ if (a.error) {
168
+ out.push(` ${chalk.red("A:")} ${chalk.red(a.error)}`);
169
+ }
170
+ else if (a.streaming) {
171
+ out.push(` ${chalk.dim("A: " + (a.answer || "thinking..."))}`);
172
+ }
173
+ else {
174
+ const lines = a.answer.split("\n").slice(0, 20);
175
+ out.push(` ${chalk.bold.green("A:")} ${lines[0] || ""}`);
176
+ for (const ln of lines.slice(1))
177
+ out.push(` ${ln}`);
178
+ }
179
+ return "\n" + out.join("\n");
180
+ }
116
181
  hasHotkeys() {
117
182
  return !!this.liveConfig && !!process.stdin.isTTY;
118
183
  }
@@ -129,7 +194,7 @@ export class RunDisplay {
129
194
  const lc = this.liveConfig;
130
195
  this.keyHandler = (buf) => {
131
196
  const s = buf.toString();
132
- if (this.inputMode !== "none") {
197
+ if (this.inputMode === "budget" || this.inputMode === "threshold") {
133
198
  if (s === "\r" || s === "\n") {
134
199
  const val = parseFloat(this.inputBuf);
135
200
  if (this.inputMode === "budget" && !isNaN(val) && val > 0) {
@@ -160,6 +225,37 @@ export class RunDisplay {
160
225
  }
161
226
  return;
162
227
  }
228
+ if (this.inputMode === "steer" || this.inputMode === "ask") {
229
+ for (const ch of s) {
230
+ if (ch === "\r" || ch === "\n") {
231
+ const text = this.inputBuf.trim();
232
+ const wasAsk = this.inputMode === "ask";
233
+ this.inputMode = "none";
234
+ this.inputBuf = "";
235
+ if (text) {
236
+ if (wasAsk)
237
+ this.onAsk?.(text);
238
+ else
239
+ this.onSteer?.(text);
240
+ }
241
+ return;
242
+ }
243
+ if (ch === "\x1B" || ch === "\x03") {
244
+ this.inputMode = "none";
245
+ this.inputBuf = "";
246
+ return;
247
+ }
248
+ if (ch === "\x7F" || ch === "\b") {
249
+ this.inputBuf = this.inputBuf.slice(0, -1);
250
+ continue;
251
+ }
252
+ const code = ch.charCodeAt(0);
253
+ if (code >= 0x20 && code <= 0x7E && this.inputBuf.length < MAX_INPUT_LEN) {
254
+ this.inputBuf += ch;
255
+ }
256
+ }
257
+ return;
258
+ }
163
259
  if (s === "b" || s === "B") {
164
260
  this.inputMode = "budget";
165
261
  this.inputBuf = "";
@@ -170,6 +266,14 @@ export class RunDisplay {
170
266
  this.inputBuf = "";
171
267
  }
172
268
  }
269
+ else if ((s === "s" || s === "S") && this.onSteer) {
270
+ this.inputMode = "steer";
271
+ this.inputBuf = "";
272
+ }
273
+ else if (s === "?" && this.onAsk && this.swarm && !this.askBusy) {
274
+ this.inputMode = "ask";
275
+ this.inputBuf = "";
276
+ }
173
277
  else if (s === "q" || s === "Q" || s === "\x03") {
174
278
  if (this.swarm) {
175
279
  if (this.swarm.aborted)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-overnight",
3
- "version": "1.9.1",
3
+ "version": "1.10.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": {