claude-overnight 1.16.12 → 1.16.16

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.
@@ -0,0 +1,65 @@
1
+ # Playwright Parallel Usage
2
+
3
+ When running claude-overnight with parallel agents that use the Playwright MCP server, avoid lock conflicts and session cross-contamination.
4
+
5
+ ## Isolation Levels
6
+
7
+ | Goal | Approach |
8
+ |---|---|
9
+ | Non-disruptive, no focus steal | Headless mode (default) |
10
+ | Several agents in parallel, no shared cookies | Headless + each MCP server: `--isolated` (or `isolated: true`) |
11
+ | Several agents, each with saved login | Headless + each MCP server: unique `userDataDir` or its own `--storage-state` file |
12
+ | Anti-bot interception (CAPTCHA, Cloudflare) | Fall back to headed mode only when necessary |
13
+
14
+ **Headless preferred by default.** Every headed browser launch becomes the foreground app on macOS, which is disruptive during long runs. Only fall back to headed when anti-bot detection (CAPTCHA, Cloudflare challenge, etc.) requires visible browser interaction.
15
+
16
+ ## MCP Server Configuration
17
+
18
+ Add to your `settings.json` or `.claude/settings.local.json`:
19
+
20
+ ```json
21
+ {
22
+ "mcpServers": {
23
+ "playwright-1": {
24
+ "command": "npx",
25
+ "args": ["@anthropic-ai/mcp-playwright@latest", "--isolated", "--headless"],
26
+ "env": {}
27
+ },
28
+ "playwright-2": {
29
+ "command": "npx",
30
+ "args": ["@anthropic-ai/mcp-playwright@latest", "--isolated", "--headless"],
31
+ "env": {}
32
+ }
33
+ }
34
+ }
35
+ ```
36
+
37
+ For saved logins, give each server its own `userDataDir`:
38
+
39
+ ```json
40
+ {
41
+ "mcpServers": {
42
+ "playwright-agent-a": {
43
+ "command": "npx",
44
+ "args": ["@anthropic-ai/mcp-playwright@latest", "--headless", "--userDataDir", "/tmp/pw-agent-a"],
45
+ "env": {}
46
+ },
47
+ "playwright-agent-b": {
48
+ "command": "npx",
49
+ "args": ["@anthropic-ai/mcp-playwright@latest", "--headless", "--userDataDir", "/tmp/pw-agent-b"],
50
+ "env": {}
51
+ }
52
+ }
53
+ }
54
+ ```
55
+
56
+ ## Context7 Documentation
57
+
58
+ For the latest Playwright API docs and patterns:
59
+
60
+ ```bash
61
+ npx ctx7@latest library playwright "parallel browser instances isolation"
62
+ npx ctx7@latest docs <libraryId> "parallel browser instances"
63
+ ```
64
+
65
+ **Note:** ctx7 requires authentication (`npx ctx7@latest login` or `CONTEXT7_API_KEY` env var). If unauthenticated, lookups will fail — agents should fall back to training data.
package/README.md CHANGED
@@ -280,6 +280,36 @@ Saved providers live user-level at `~/.claude/claude-overnight/providers.json` (
280
280
 
281
281
  **Non-interactive / CI.** `claude-overnight --model=qwen3.6-plus` auto-resolves the model id to a saved provider — no separate `--provider` flag.
282
282
 
283
+ ## Parallel Playwright Testing
284
+
285
+ When agents use the Playwright MCP server for testing, parallel instances conflict on browser locks and cookie state. Add multiple MCP entries to `settings.json`:
286
+
287
+ ```json
288
+ {
289
+ "mcpServers": {
290
+ "playwright-1": {
291
+ "command": "npx",
292
+ "args": ["@anthropic-ai/mcp-playwright@latest", "--isolated", "--headless"]
293
+ },
294
+ "playwright-2": {
295
+ "command": "npx",
296
+ "args": ["@anthropic-ai/mcp-playwright@latest", "--isolated", "--headless"]
297
+ }
298
+ }
299
+ }
300
+ ```
301
+
302
+ **Isolation levels:**
303
+
304
+ | Goal | Approach |
305
+ |---|---|
306
+ | Non-disruptive, no focus steal | Headless mode (default) |
307
+ | Parallel agents, no shared cookies | Headless + `--isolated` per MCP server |
308
+ | Parallel agents, each with saved login | Headless + unique `userDataDir` or `--storage-state` per server |
309
+ | Anti-bot interception (CAPTCHA, Cloudflare) | Drop `--headless` only when necessary |
310
+
311
+ See `QUICKSHEET_PLAYWRIGHT.md` for full config examples.
312
+
283
313
  ## Spend caps and usage controls
284
314
 
285
315
  ### Extra usage protection
package/dist/cli.js CHANGED
@@ -219,10 +219,12 @@ export function ask(question) {
219
219
  redraw();
220
220
  continue;
221
221
  }
222
- // Skip ESC and any bytes that are part of an ANSI escape sequence
223
- // (arrow keys, function keys, etc. arrive as \x1B [ ... letter)
222
+ // ESC submits the current input (same as Enter)
224
223
  if (ch === "\x1B") {
225
- continue;
224
+ stdout.write("\n");
225
+ cleanup();
226
+ resolve(segmentsToString(segs).trim());
227
+ return;
226
228
  }
227
229
  const code = ch.charCodeAt(0);
228
230
  if (code < 0x20)
package/dist/index.js CHANGED
@@ -541,7 +541,7 @@ async function main() {
541
541
  const plannerPick = await pickModel(`${chalk.cyan("④")} Planner model ${chalk.dim("(thinking, steering — use your strongest)")}:`, models);
542
542
  plannerModel = plannerPick.model;
543
543
  plannerProvider = plannerPick.provider;
544
- const workerPick = await pickModel(`${chalk.cyan("⑤")} Executor model ${chalk.dim("(what runs the tasks — Qwen/OpenRouter/etc via Other…)")}:`, models);
544
+ const workerPick = await pickModel(`${chalk.cyan("⑤")} Executor model ${chalk.dim("(what runs the tasks — Qwen 3.6 Plus / OpenRouter / etc via Other…)")}:`, models);
545
545
  workerModel = workerPick.model;
546
546
  workerProvider = workerPick.provider;
547
547
  usageCap = await select(`${chalk.cyan("⑥")} Usage cap:`, [
package/dist/providers.js CHANGED
@@ -92,7 +92,7 @@ export async function pickModel(label, anthropicModels, currentModelId) {
92
92
  const keySrc = p.keyEnv ? `env ${p.keyEnv}` : "stored key";
93
93
  items.push({ name: `${p.displayName}`, value: { kind: "provider", provider: p }, hint: `${p.model} · ${keySrc}` });
94
94
  }
95
- items.push({ name: chalk.cyan("Other…"), value: { kind: "other" }, hint: "custom OpenAI/Anthropic-compatible endpoint" });
95
+ items.push({ name: chalk.cyan("Other…"), value: { kind: "other" }, hint: "Qwen 3.6 Plus, OpenRouter, or any Anthropic-compatible endpoint" });
96
96
  let defaultIdx = 0;
97
97
  if (currentModelId) {
98
98
  const i = items.findIndex(it => {
@@ -126,11 +126,11 @@ async function promptNewProvider() {
126
126
  if (!displayName)
127
127
  return null;
128
128
  const id = slugify(displayName);
129
- const baseURLRaw = await ask(`\n ${chalk.cyan("Base URL")} ${chalk.dim("(e.g. https://dashscope-intl.aliyuncs.com/api/v2/apps/claude-code-proxy):")} `);
129
+ const baseURLRaw = await ask(`\n ${chalk.cyan("Base URL")} ${chalk.dim("(e.g. https://dashscope-intl.aliyuncs.com/apps/anthropic for Qwen 3.6 Plus):")} `);
130
130
  if (!baseURLRaw)
131
131
  return null;
132
132
  const baseURL = normalizeBaseURL(baseURLRaw);
133
- const model = await ask(`\n ${chalk.cyan("Model id")} ${chalk.dim("(e.g. qwen3-coder-plus):")} `);
133
+ const model = await ask(`\n ${chalk.cyan("Model id")} ${chalk.dim("(e.g. qwen3.6-plus):")} `);
134
134
  if (!model)
135
135
  return null;
136
136
  const keyMode = await select(` ${chalk.cyan("API key source")}:`, [
package/dist/render.d.ts CHANGED
@@ -1,9 +1,40 @@
1
1
  import type { Swarm } from "./swarm.js";
2
2
  import type { RateLimitWindow } from "./types.js";
3
3
  import type { RunInfo, SteeringContext, SteeringEvent } from "./ui.js";
4
+ export interface Section {
5
+ title: string;
6
+ rows: string[];
7
+ scrollable?: boolean;
8
+ highlightKey?: string;
9
+ }
10
+ export interface ContentRenderer {
11
+ /** Returns an array of sections to render in the content area */
12
+ sections(): Section[];
13
+ }
4
14
  export declare function truncate(s: string, max: number): string;
5
15
  export declare function fmtTokens(n: number): string;
6
16
  export declare function fmtDur(ms: number): string;
17
+ export declare function renderUnifiedFrame(params: {
18
+ model?: string;
19
+ phase: string;
20
+ barPct: number;
21
+ barLabel: string;
22
+ active?: number;
23
+ blocked?: number;
24
+ queued?: number;
25
+ startedAt: number;
26
+ totalIn: number;
27
+ totalOut: number;
28
+ totalCost: number;
29
+ waveNum: number;
30
+ sessionsUsed: number;
31
+ sessionsBudget: number;
32
+ remaining: number;
33
+ usageBarRender?: (out: string[], w: number) => void;
34
+ content: ContentRenderer;
35
+ hotkeyRow?: string;
36
+ extraFooterRows?: string[];
37
+ }): string;
7
38
  type RLGetter = () => {
8
39
  utilization: number;
9
40
  isUsingOverage: boolean;
package/dist/render.js CHANGED
@@ -138,9 +138,57 @@ function renderUsageBars(out, w, swarm) {
138
138
  out.push(` ${chalk.dim("Extra ")}${barStr} ${label}`);
139
139
  }
140
140
  }
141
- export function renderFrame(swarm, showHotkeys, runInfo, selectedAgentId) {
141
+ // ── Unified frame renderer ──
142
+ export function renderUnifiedFrame(params) {
142
143
  const w = Math.max((process.stdout.columns ?? 80) || 80, 60);
143
144
  const out = [];
145
+ // Header
146
+ renderHeader(out, w, {
147
+ model: params.model,
148
+ phase: params.phase,
149
+ barPct: params.barPct,
150
+ barLabel: params.barLabel,
151
+ active: params.active ?? 0,
152
+ blocked: params.blocked,
153
+ queued: params.queued ?? 0,
154
+ startedAt: params.startedAt,
155
+ totalIn: params.totalIn,
156
+ totalOut: params.totalOut,
157
+ totalCost: params.totalCost,
158
+ waveNum: params.waveNum,
159
+ sessionsUsed: params.sessionsUsed,
160
+ sessionsBudget: params.sessionsBudget,
161
+ remaining: params.remaining,
162
+ });
163
+ // Usage bar
164
+ if (params.usageBarRender) {
165
+ params.usageBarRender(out, w);
166
+ }
167
+ out.push("");
168
+ // Content sections
169
+ const sections = params.content.sections();
170
+ for (const sec of sections) {
171
+ if (sec.title) {
172
+ section(out, w, sec.title);
173
+ }
174
+ for (const row of sec.rows) {
175
+ out.push(row);
176
+ }
177
+ }
178
+ // Footer
179
+ out.push("");
180
+ if (params.hotkeyRow) {
181
+ out.push(params.hotkeyRow);
182
+ }
183
+ if (params.extraFooterRows) {
184
+ for (const row of params.extraFooterRows) {
185
+ out.push(row);
186
+ }
187
+ }
188
+ out.push("");
189
+ return out.join("\n");
190
+ }
191
+ export function renderFrame(swarm, showHotkeys, runInfo, selectedAgentId) {
144
192
  const stoppingTag = swarm.aborted ? chalk.yellow("STOPPING") : "";
145
193
  const pausedTag = swarm.paused ? chalk.yellow("PAUSED") : "";
146
194
  const stallTag = swarm.stallLevel >= 3 ? chalk.red("STALL") : swarm.stallLevel > 0 ? chalk.yellow(`STALL L${swarm.stallLevel}`) : "";
@@ -149,93 +197,90 @@ export function renderFrame(swarm, showHotkeys, runInfo, selectedAgentId) {
149
197
  : swarm.rateLimitPaused > 0 ? chalk.yellow("COOLING") : "";
150
198
  const phase = [phaseLabel, pausedTag, stallTag, stoppingTag].filter(Boolean).join(" ");
151
199
  const waveUsed = swarm.completed + swarm.failed;
152
- renderHeader(out, w, {
153
- model: runInfo?.model ?? swarm.model,
154
- phase,
155
- barPct: swarm.total > 0 ? swarm.completed / swarm.total : 0,
156
- barLabel: `${swarm.completed}/${swarm.total}`,
157
- active: swarm.active, blocked: swarm.blocked, queued: swarm.pending,
158
- startedAt: runInfo?.startedAt ?? swarm.startedAt,
159
- totalIn: (runInfo?.accIn ?? 0) + swarm.totalInputTokens,
160
- totalOut: (runInfo?.accOut ?? 0) + swarm.totalOutputTokens,
161
- totalCost: (runInfo?.accCost ?? swarm.baseCostUsd) + swarm.totalCostUsd,
162
- waveNum: runInfo?.waveNum ?? -1,
163
- sessionsUsed: (runInfo ? runInfo.accCompleted + runInfo.accFailed : 0) + waveUsed,
164
- sessionsBudget: runInfo?.sessionsBudget ?? swarm.total,
165
- remaining: Math.max(0, (runInfo?.remaining ?? swarm.total) - waveUsed),
166
- });
167
- renderUsageBars(out, w, swarm);
168
- out.push("");
169
- // Agent table
170
200
  const running = swarm.agents.filter(a => a.status === "running");
171
201
  const finished = swarm.agents.filter(a => a.status !== "running");
172
202
  const showFinished = finished.slice(-Math.max(2, 12 - running.length));
173
203
  const show = [...running, ...showFinished];
174
- if (show.length > 0) {
175
- out.push(chalk.gray(" # Status Task" + " ".repeat(Math.max(1, w - 56)) + "Action"));
176
- out.push(chalk.gray(" " + "\u2500".repeat(Math.min(w - 4, 100))));
177
- for (const a of show)
178
- out.push(fmtRow(a, w, a.id === (selectedAgentId ?? -1)));
179
- if (swarm.pending > 0)
180
- out.push(chalk.gray(` ... + ${swarm.pending} queued`));
181
- }
182
- // ── Agent detail (progressive discovery) ──
183
204
  const detailAgent = selectedAgentId != null
184
205
  ? swarm.agents.find(a => a.id === selectedAgentId)
185
206
  : undefined;
186
- if (detailAgent) {
187
- out.push("");
188
- section(out, w, `Agent ${detailAgent.id} detail \u00b7 [d] next \u00b7 [Esc] close`);
189
- const taskLines = detailAgent.task.prompt.split("\n");
190
- const maxTaskLines = Math.min(6, taskLines.length);
191
- for (let i = 0; i < maxTaskLines; i++) {
192
- out.push(` ${chalk.dim(truncate(taskLines[i].trim(), w - 6))}`);
193
- }
194
- if (taskLines.length > maxTaskLines)
195
- out.push(chalk.dim(` \u2026 + ${taskLines.length - maxTaskLines} more lines`));
196
- const meta = [];
197
- if (detailAgent.currentTool)
198
- meta.push(chalk.yellow(`tool: ${detailAgent.currentTool}`));
199
- if (detailAgent.lastText)
200
- meta.push(chalk.dim(truncate(detailAgent.lastText, 60)));
201
- if (detailAgent.filesChanged != null)
202
- meta.push(chalk.dim(`${detailAgent.filesChanged} files`));
203
- if (detailAgent.costUsd != null)
204
- meta.push(chalk.yellow(`$${detailAgent.costUsd.toFixed(3)}`));
205
- if (detailAgent.toolCalls > 0)
206
- meta.push(chalk.dim(`${detailAgent.toolCalls} tools`));
207
- if (meta.length > 0)
208
- out.push(` ${meta.join(chalk.dim(" \u00b7 "))}`);
209
- }
210
- // Merge results
211
- if (swarm.mergeResults.length > 0) {
212
- out.push("");
213
- out.push(chalk.gray(" \u2500\u2500\u2500 Merges " + "\u2500".repeat(Math.min(w - 16, 90))));
214
- for (const mr of swarm.mergeResults) {
215
- const icon = mr.ok ? chalk.green("\u2713") : chalk.red("\u2717");
216
- const info = mr.ok ? chalk.dim(`${mr.filesChanged} file(s)`) : chalk.red(truncate(mr.error || "conflict", 40));
217
- out.push(` ${icon} ${mr.branch} ${info}`);
218
- }
219
- }
220
- // Event log
221
- out.push("");
222
- out.push(chalk.gray(" \u2500\u2500\u2500 Events " + "\u2500".repeat(Math.min(w - 16, 90))));
223
- const logN = Math.min(12, swarm.logs.length);
224
- for (const entry of swarm.logs.slice(-logN)) {
225
- const t = new Date(entry.time).toLocaleTimeString("en", { hour12: false });
226
- const tag = entry.agentId < 0 ? chalk.magenta("[sys]") : chalk.cyan(`[${entry.agentId}]`);
227
- // Tool-use events with target get a secondary detail line
228
- const arrowIdx = entry.text.indexOf(" \u2192 ");
229
- if (arrowIdx > 0 && arrowIdx < 20) {
230
- const toolName = entry.text.slice(0, arrowIdx);
231
- const target = entry.text.slice(arrowIdx + 3);
232
- out.push(chalk.gray(` ${t} `) + tag + ` ${chalk.yellow(toolName)}`);
233
- out.push(chalk.dim(` ${truncate(target, w - 10)}`));
234
- }
235
- else {
236
- out.push(chalk.gray(` ${t} `) + tag + ` ${colorEvent(truncate(entry.text, w - 22))}`);
237
- }
238
- }
207
+ const content = {
208
+ sections() {
209
+ const secs = [];
210
+ // Agent table (undecorated — raw header + rows)
211
+ if (show.length > 0) {
212
+ const rows = [
213
+ chalk.gray(" # Status Task" + " ".repeat(Math.max(1, (process.stdout.columns ?? 80) || 80, 60) - 56)) + "Action",
214
+ chalk.gray(" " + "\u2500".repeat(Math.min(Math.max((process.stdout.columns ?? 80) || 80, 60) - 4, 100))),
215
+ ];
216
+ for (const a of show)
217
+ rows.push(fmtRow(a, (process.stdout.columns ?? 80) || 80, a.id === (selectedAgentId ?? -1)));
218
+ if (swarm.pending > 0)
219
+ rows.push(chalk.gray(` ... + ${swarm.pending} queued`));
220
+ secs.push({ title: "", rows });
221
+ }
222
+ // Agent detail (decorated)
223
+ if (detailAgent) {
224
+ const rows = [];
225
+ const taskLines = detailAgent.task.prompt.split("\n");
226
+ const maxTaskLines = Math.min(6, taskLines.length);
227
+ const ww = Math.max((process.stdout.columns ?? 80) || 80, 60);
228
+ for (let i = 0; i < maxTaskLines; i++) {
229
+ rows.push(` ${chalk.dim(truncate(taskLines[i].trim(), ww - 6))}`);
230
+ }
231
+ if (taskLines.length > maxTaskLines)
232
+ rows.push(chalk.dim(` \u2026 + ${taskLines.length - maxTaskLines} more lines`));
233
+ const meta = [];
234
+ if (detailAgent.currentTool)
235
+ meta.push(chalk.yellow(`tool: ${detailAgent.currentTool}`));
236
+ if (detailAgent.lastText)
237
+ meta.push(chalk.dim(truncate(detailAgent.lastText, 60)));
238
+ if (detailAgent.filesChanged != null)
239
+ meta.push(chalk.dim(`${detailAgent.filesChanged} files`));
240
+ if (detailAgent.costUsd != null)
241
+ meta.push(chalk.yellow(`$${detailAgent.costUsd.toFixed(3)}`));
242
+ if (detailAgent.toolCalls > 0)
243
+ meta.push(chalk.dim(`${detailAgent.toolCalls} tools`));
244
+ if (meta.length > 0)
245
+ rows.push(` ${meta.join(chalk.dim(" \u00b7 "))}`);
246
+ secs.push({ title: `Agent ${detailAgent.id} detail \u00b7 [d] next \u00b7 [Esc] close`, rows });
247
+ }
248
+ // Merge results (undecorated)
249
+ if (swarm.mergeResults.length > 0) {
250
+ const ww = Math.max((process.stdout.columns ?? 80) || 80, 60);
251
+ const rows = [chalk.gray(" \u2500\u2500\u2500 Merges " + "\u2500".repeat(Math.min(ww - 16, 90)))];
252
+ for (const mr of swarm.mergeResults) {
253
+ const icon = mr.ok ? chalk.green("\u2713") : chalk.red("\u2717");
254
+ const info = mr.ok ? chalk.dim(`${mr.filesChanged} file(s)`) : chalk.red(truncate(mr.error || "conflict", 40));
255
+ rows.push(` ${icon} ${mr.branch} ${info}`);
256
+ }
257
+ secs.push({ title: "", rows });
258
+ }
259
+ // Event log (undecorated)
260
+ const ww = Math.max((process.stdout.columns ?? 80) || 80, 60);
261
+ const eventRows = [chalk.gray(" \u2500\u2500\u2500 Events " + "\u2500".repeat(Math.min(ww - 16, 90)))];
262
+ const logN = Math.min(12, swarm.logs.length);
263
+ for (const entry of swarm.logs.slice(-logN)) {
264
+ const t = new Date(entry.time).toLocaleTimeString("en", { hour12: false });
265
+ const tag = entry.agentId < 0 ? chalk.magenta("[sys]") : chalk.cyan(`[${entry.agentId}]`);
266
+ const arrowIdx = entry.text.indexOf(" \u2192 ");
267
+ if (arrowIdx > 0 && arrowIdx < 20) {
268
+ const toolName = entry.text.slice(0, arrowIdx);
269
+ const target = entry.text.slice(arrowIdx + 3);
270
+ eventRows.push(chalk.gray(` ${t} `) + tag + ` ${chalk.yellow(toolName)}`);
271
+ eventRows.push(chalk.dim(` ${truncate(target, ww - 10)}`));
272
+ }
273
+ else {
274
+ eventRows.push(chalk.gray(` ${t} `) + tag + ` ${colorEvent(truncate(entry.text, ww - 22))}`);
275
+ }
276
+ }
277
+ secs.push({ title: "", rows: eventRows });
278
+ return secs;
279
+ },
280
+ };
281
+ // Build footer
282
+ let hotkeyRow;
283
+ const extraFooterRows = [];
239
284
  if (showHotkeys) {
240
285
  const pending = runInfo?.pendingSteer ?? 0;
241
286
  const chip = pending > 0 ? chalk.cyan(` \u270E ${pending} steer queued`) : "";
@@ -244,13 +289,32 @@ export function renderFrame(swarm, showHotkeys, runInfo, selectedAgentId) {
244
289
  const pauseLabel = swarm.paused ? "[p] resume" : "[p] pause";
245
290
  const detailChip = swarm.active > 0 ? chalk.dim(" [d] detail") : "";
246
291
  const selectChip = swarm.active > 0 && running.length <= 10 ? chalk.dim(" [0-9] select") : "";
247
- out.push(chalk.dim(` [b] budget [t] cap [c] conc [e] extra ${pauseLabel} [s] steer [?] ask [q] stop`) + fixChip + retryChip + chip + detailChip + selectChip);
292
+ hotkeyRow = chalk.dim(` [b] budget [t] cap [c] conc [e] extra ${pauseLabel} [s] steer [?] ask [q] stop`) + fixChip + retryChip + chip + detailChip + selectChip;
248
293
  if (swarm.blocked > 0 && swarm.blocked === swarm.active) {
249
- out.push(chalk.yellow(` all workers rate-limited — [r] retry-now, [c] reduce concurrency, [p] pause, [q] quit`));
294
+ extraFooterRows.push(chalk.yellow(` all workers rate-limited — [r] retry-now, [c] reduce concurrency, [p] pause, [q] quit`));
250
295
  }
251
296
  }
252
- out.push("");
253
- return out.join("\n");
297
+ return renderUnifiedFrame({
298
+ model: runInfo?.model ?? swarm.model,
299
+ phase,
300
+ barPct: swarm.total > 0 ? swarm.completed / swarm.total : 0,
301
+ barLabel: `${swarm.completed}/${swarm.total}`,
302
+ active: swarm.active,
303
+ blocked: swarm.blocked,
304
+ queued: swarm.pending,
305
+ startedAt: runInfo?.startedAt ?? swarm.startedAt,
306
+ totalIn: (runInfo?.accIn ?? 0) + swarm.totalInputTokens,
307
+ totalOut: (runInfo?.accOut ?? 0) + swarm.totalOutputTokens,
308
+ totalCost: (runInfo?.accCost ?? swarm.baseCostUsd) + swarm.totalCostUsd,
309
+ waveNum: runInfo?.waveNum ?? -1,
310
+ sessionsUsed: (runInfo ? runInfo.accCompleted + runInfo.accFailed : 0) + waveUsed,
311
+ sessionsBudget: runInfo?.sessionsBudget ?? swarm.total,
312
+ remaining: Math.max(0, (runInfo?.remaining ?? swarm.total) - waveUsed),
313
+ usageBarRender: (out, w) => renderUsageBars(out, w, swarm),
314
+ content,
315
+ hotkeyRow,
316
+ extraFooterRows,
317
+ });
254
318
  }
255
319
  function section(out, w, title) {
256
320
  const inner = ` ${title} `;
@@ -324,70 +388,99 @@ function renderStatusBlock(out, w, status) {
324
388
  out.push(` ${chalk.dim(truncate(ln.trim(), w - 4))}`);
325
389
  }
326
390
  export function renderSteeringFrame(runInfo, data, showHotkeys, rlGetter) {
327
- const w = Math.max((process.stdout.columns ?? 80) || 80, 60);
328
- const out = [];
329
391
  const totalUsed = runInfo.accCompleted + runInfo.accFailed;
330
- renderHeader(out, w, {
331
- model: runInfo.model,
332
- phase: chalk.magenta("STEERING"),
333
- barPct: runInfo.sessionsBudget > 0 ? totalUsed / runInfo.sessionsBudget : 0,
334
- barLabel: `${totalUsed}/${runInfo.sessionsBudget}`,
335
- active: 0, queued: 0,
336
- startedAt: runInfo.startedAt,
337
- totalIn: runInfo.accIn, totalOut: runInfo.accOut, totalCost: runInfo.accCost,
338
- waveNum: runInfo.waveNum,
339
- sessionsUsed: totalUsed, sessionsBudget: runInfo.sessionsBudget, remaining: runInfo.remaining,
340
- });
341
- const rl = rlGetter?.();
342
- if (rl && (rl.utilization > 0 || rl.windows.size > 0))
343
- renderSteeringUsageBar(out, w, rl);
344
- out.push("");
345
392
  const ctx = data.context;
346
- if (ctx?.objective) {
347
- const obj = ctx.objective.replace(/\s+/g, " ").trim();
348
- out.push(` ${chalk.bold.white("Objective")} ${chalk.dim(truncate(obj, w - 15))}`);
349
- out.push("");
350
- }
351
- if (ctx?.lastWave && ctx.lastWave.tasks.length > 0) {
352
- renderLastWave(out, w, ctx.lastWave);
353
- out.push("");
354
- }
355
- if (ctx?.status) {
356
- renderStatusBlock(out, w, ctx.status);
357
- out.push("");
358
- }
359
- section(out, w, "Planner activity");
360
- const events = data.events.slice(-15);
361
- if (events.length === 0) {
362
- out.push(chalk.dim(" (waiting for planner\u2026)"));
363
- }
364
- else {
365
- for (const e of events) {
366
- const t = new Date(e.time).toLocaleTimeString("en", { hour12: false });
367
- // Tool-use events with target get a secondary detail line
368
- const arrowIdx = e.text.indexOf(" \u2192 ");
369
- if (arrowIdx > 0 && arrowIdx < 30) {
370
- const toolName = e.text.slice(0, arrowIdx);
371
- const target = e.text.slice(arrowIdx + 3);
372
- out.push(chalk.gray(` ${t} `) + chalk.magenta("[plan] ") + chalk.yellow(toolName));
373
- out.push(chalk.dim(` ${truncate(target, w - 10)}`));
393
+ const content = {
394
+ sections() {
395
+ const secs = [];
396
+ const ww = Math.max((process.stdout.columns ?? 80) || 80, 60);
397
+ // Objective (undecorated — raw line)
398
+ if (ctx?.objective) {
399
+ const obj = ctx.objective.replace(/\s+/g, " ").trim();
400
+ secs.push({ title: "", rows: [
401
+ ` ${chalk.bold.white("Objective")} ${chalk.dim(truncate(obj, ww - 15))}`,
402
+ "",
403
+ ] });
404
+ }
405
+ // Status (decorated via renderStatusBlock)
406
+ if (ctx?.status) {
407
+ const statusRows = [];
408
+ renderStatusBlock(statusRows, ww, ctx.status);
409
+ if (statusRows.length > 0) {
410
+ statusRows.push("");
411
+ secs.push({ title: "", rows: statusRows });
412
+ }
413
+ }
414
+ // Last wave (decorated via renderLastWave)
415
+ if (ctx?.lastWave && ctx.lastWave.tasks.length > 0) {
416
+ const lwRows = [];
417
+ renderLastWave(lwRows, ww, ctx.lastWave);
418
+ lwRows.push("");
419
+ secs.push({ title: "", rows: lwRows });
420
+ }
421
+ // Planner activity (decorated)
422
+ const plannerRows = [];
423
+ const events = data.events.slice(-15);
424
+ if (events.length === 0) {
425
+ plannerRows.push(chalk.dim(" (waiting for planner\u2026)"));
374
426
  }
375
427
  else {
376
- out.push(chalk.gray(` ${t} `) + chalk.magenta("[plan] ") + colorEvent(truncate(e.text, w - 22)));
428
+ for (const e of events) {
429
+ const t = new Date(e.time).toLocaleTimeString("en", { hour12: false });
430
+ const arrowIdx = e.text.indexOf(" \u2192 ");
431
+ if (arrowIdx > 0 && arrowIdx < 30) {
432
+ const toolName = e.text.slice(0, arrowIdx);
433
+ const target = e.text.slice(arrowIdx + 3);
434
+ plannerRows.push(chalk.gray(` ${t} `) + chalk.magenta("[plan] ") + chalk.yellow(toolName));
435
+ plannerRows.push(chalk.dim(` ${truncate(target, ww - 10)}`));
436
+ }
437
+ else {
438
+ plannerRows.push(chalk.gray(` ${t} `) + chalk.magenta("[plan] ") + colorEvent(truncate(e.text, ww - 22)));
439
+ }
440
+ }
441
+ }
442
+ secs.push({ title: "Planner activity", rows: plannerRows });
443
+ // Status line (undecorated)
444
+ const liveClean = data.statusLine.replace(/\n/g, " ");
445
+ secs.push({ title: "", rows: [` ${chalk.cyan("\u25B6")} ${chalk.dim(truncate(liveClean, ww - 6))}`] });
446
+ return secs;
447
+ },
448
+ };
449
+ // Usage bar
450
+ const usageBarRender = rlGetter
451
+ ? (out, w) => {
452
+ const rl = rlGetter();
453
+ if (rl && (rl.utilization > 0 || rl.windows.size > 0)) {
454
+ renderSteeringUsageBar(out, w, rl);
377
455
  }
378
456
  }
379
- }
380
- out.push("");
381
- const liveClean = data.statusLine.replace(/\n/g, " ");
382
- out.push(` ${chalk.cyan("\u25B6")} ${chalk.dim(truncate(liveClean, w - 6))}`);
383
- out.push("");
457
+ : undefined;
458
+ // Footer
459
+ let hotkeyRow;
384
460
  if (showHotkeys) {
385
461
  const pending = runInfo?.pendingSteer ?? 0;
386
462
  const chip = pending > 0 ? chalk.cyan(` \u270E ${pending} steer queued`) : "";
387
- out.push(chalk.dim(" [b] budget [s] steer [q] stop") + chip);
463
+ hotkeyRow = chalk.dim(" [b] budget [s] steer [q] stop") + chip;
388
464
  }
389
- out.push("");
390
- return out.join("\n");
465
+ return renderUnifiedFrame({
466
+ model: runInfo.model,
467
+ phase: chalk.magenta(`STEERING \u2192 wave ${runInfo.waveNum + 2}`),
468
+ barPct: runInfo.sessionsBudget > 0 ? totalUsed / runInfo.sessionsBudget : 0,
469
+ barLabel: `${totalUsed}/${runInfo.sessionsBudget}`,
470
+ active: 0,
471
+ queued: 0,
472
+ startedAt: runInfo.startedAt,
473
+ totalIn: runInfo.accIn,
474
+ totalOut: runInfo.accOut,
475
+ totalCost: runInfo.accCost,
476
+ waveNum: runInfo.waveNum,
477
+ sessionsUsed: totalUsed,
478
+ sessionsBudget: runInfo.sessionsBudget,
479
+ remaining: runInfo.remaining,
480
+ usageBarRender,
481
+ content,
482
+ hotkeyRow,
483
+ });
391
484
  }
392
485
  export function renderSummary(swarm) {
393
486
  const w = Math.max((process.stdout.columns ?? 80) || 80, 60);
package/dist/run.js CHANGED
@@ -205,8 +205,35 @@ export async function executeRun(cfg) {
205
205
  };
206
206
  process.on("SIGINT", gracefulStop);
207
207
  process.on("SIGTERM", gracefulStop);
208
- process.on("uncaughtException", (err) => { currentSwarm?.abort(); currentSwarm?.cleanup(); display.stop(); restore(); console.error(chalk.red(`\n Uncaught: ${err.message}`)); process.exit(1); });
209
- process.on("unhandledRejection", (reason) => { currentSwarm?.abort(); currentSwarm?.cleanup(); display.stop(); restore(); console.error(chalk.red(`\n Unhandled: ${reason instanceof Error ? reason.message : reason}`)); process.exit(1); });
208
+ const crashHandler = (label, detail) => {
209
+ // Save run state with currentTasks so resume can pick up where we left off.
210
+ try {
211
+ saveRunState(runDir, {
212
+ id: `run-${new Date().toISOString().slice(0, 19)}`, objective: objective ?? "", budget: cfg.budget,
213
+ remaining, workerModel, plannerModel,
214
+ workerProviderId: cfg.workerProvider?.id, plannerProviderId: cfg.plannerProvider?.id,
215
+ concurrency, permissionMode,
216
+ usageCap, allowExtraUsage: cfg.allowExtraUsage, extraUsageBudget: cfg.extraUsageBudget,
217
+ flex, useWorktrees, mergeStrategy, waveNum, currentTasks,
218
+ accCost, accCompleted, accFailed, accIn, accOut, accTools,
219
+ branches, phase: "stopped", startedAt: new Date(cfg.runStartedAt).toISOString(), cwd,
220
+ });
221
+ }
222
+ catch { }
223
+ // Save partial wave session if swarm was running.
224
+ if (currentSwarm?.agents.length) {
225
+ try {
226
+ saveWaveSession(runDir, waveNum, currentSwarm.agents, currentSwarm.totalCostUsd);
227
+ }
228
+ catch { }
229
+ }
230
+ display.stop();
231
+ restore();
232
+ console.error(chalk.red(`\n ${label}: ${detail}`));
233
+ process.exit(1);
234
+ };
235
+ process.on("uncaughtException", (err) => { currentSwarm?.abort(); currentSwarm?.cleanup(); crashHandler("Uncaught", err instanceof Error ? err.message : String(err)); });
236
+ process.on("unhandledRejection", (reason) => { currentSwarm?.abort(); currentSwarm?.cleanup(); crashHandler("Unhandled", reason instanceof Error ? reason.message : String(reason)); });
210
237
  // Shared steering logic used by both resume-steering and in-loop steering
211
238
  const runSteering = async () => {
212
239
  let steered = false;
@@ -300,19 +327,33 @@ export async function executeRun(cfg) {
300
327
  while (runAnotherRound) {
301
328
  runAnotherRound = false;
302
329
  while (remaining > 0 && currentTasks.length > 0 && !stopping) {
330
+ // Health check: runs once per process start to fix a broken build before
331
+ // any real work begins. Only triggers when there's nothing else queued —
332
+ // it must NEVER override tasks that steering has already planned.
303
333
  if (!lastHealed) {
334
+ lastHealed = true;
335
+ // Only replace tasks with health fix if the queue was essentially empty.
336
+ // Steering-planned tasks always take priority.
304
337
  const healTask = checkProjectHealth(cwd);
305
- if (healTask && remaining > 0) {
306
- lastHealed = true;
338
+ if (healTask && remaining > 0 && currentTasks.length <= 1) {
307
339
  currentTasks = [healTask];
308
340
  }
309
341
  }
310
- else {
311
- lastHealed = false;
312
- }
313
342
  if (currentTasks.length > remaining)
314
343
  currentTasks = currentTasks.slice(0, remaining);
315
344
  syncRunInfo();
345
+ // Save run state BEFORE the swarm — if the process crashes mid-swarm,
346
+ // resume picks up currentTasks. This is the critical resilience checkpoint.
347
+ saveRunState(runDir, {
348
+ id: `run-${new Date().toISOString().slice(0, 19)}`, objective: objective ?? "", budget: cfg.budget,
349
+ remaining, workerModel, plannerModel,
350
+ workerProviderId: cfg.workerProvider?.id, plannerProviderId: cfg.plannerProvider?.id,
351
+ concurrency, permissionMode,
352
+ usageCap, allowExtraUsage: cfg.allowExtraUsage, extraUsageBudget: cfg.extraUsageBudget,
353
+ flex, useWorktrees, mergeStrategy, waveNum, currentTasks,
354
+ accCost, accCompleted, accFailed, accIn, accOut, accTools,
355
+ branches, phase: "steering", startedAt: new Date(cfg.runStartedAt).toISOString(), cwd,
356
+ });
316
357
  const swarm = new Swarm({
317
358
  tasks: currentTasks, concurrency, cwd, model: workerModel, permissionMode, allowedTools,
318
359
  useWorktrees, mergeStrategy: waveMerge, agentTimeoutMs: cfg.agentTimeoutMs,
@@ -332,6 +373,15 @@ export async function executeRun(cfg) {
332
373
  console.error(chalk.red(`\n Authentication failed — check your API key or run: claude auth\n`));
333
374
  process.exit(1);
334
375
  }
376
+ // Swarm crashed mid-execution — save partial results before propagating.
377
+ // The pre-swarm saveRunState already preserved currentTasks for resume.
378
+ // Also save the wave session with whatever agents completed.
379
+ if (swarm.agents.length > 0) {
380
+ try {
381
+ saveWaveSession(runDir, waveNum, swarm.agents, swarm.totalCostUsd);
382
+ }
383
+ catch { }
384
+ }
335
385
  throw err;
336
386
  }
337
387
  display.pause();
package/dist/ui.d.ts CHANGED
@@ -71,6 +71,7 @@ export declare class RunDisplay {
71
71
  private askTempFile?;
72
72
  /** ID of the agent whose detail panel is open; undefined = no detail shown. */
73
73
  private selectedAgentId?;
74
+ private navState;
74
75
  private onSteer?;
75
76
  private onAsk?;
76
77
  constructor(runInfo: RunInfo, liveConfig?: LiveConfig, callbacks?: {
@@ -87,6 +88,15 @@ export declare class RunDisplay {
87
88
  selectAgent(id: number): void;
88
89
  /** Clear the agent detail panel. */
89
90
  clearSelectedAgent(): void;
91
+ /** Arrow-key navigation dispatched by the demux in handleTyped(). */
92
+ navigate(direction: "up" | "down" | "left" | "right" | "enter"): boolean;
93
+ /** Get the agents visible in the table (running + last N finished). */
94
+ private getVisibleAgents;
95
+ /** Discover sections from the current render state for navigation boundaries. */
96
+ private getSections;
97
+ private clampNavState;
98
+ /** Returns the unique highlight key for the currently focused row, used by renderer. */
99
+ getHighlightKey(): string | undefined;
90
100
  private clearAskTempFile;
91
101
  /** Get the currently selected agent's ID for rendering. */
92
102
  getSelectedAgentId(): number | undefined;
@@ -112,7 +122,19 @@ export declare class RunDisplay {
112
122
  private setupHotkeys;
113
123
  /** Handle a pasted block. Returns true if the frame needs a redraw. */
114
124
  private handlePaste;
115
- /** Handle a typed (non-pasted) chunk. Returns true if the frame needs a redraw. */
125
+ /** Handle a typed (non-pasted) chunk. Returns true if the frame needs a redraw.
126
+ *
127
+ * Demux pipeline — routes arrow keys and ESC BEFORE hotkey matching:
128
+ * Raw stdin chunk → splitPaste
129
+ * ├─ paste → handlePaste (existing, fine)
130
+ * └─ typed → demux
131
+ * ├─ ESC + [A/B/C/D → this.navigate("up"/"down"/"right"/"left")
132
+ * ├─ ESC → cancel input / close detail / dismiss panel
133
+ * ├─ Enter → submit / reveal / select
134
+ * ├─ Ctrl+C → abort
135
+ * ├─ Backspace → delete
136
+ * └─ printable → hotkey matching (b, t, c, e, p, s, q, ?, d, 0-9)
137
+ */
116
138
  private handleTyped;
117
139
  private plainTick;
118
140
  }
package/dist/ui.js CHANGED
@@ -31,6 +31,7 @@ export class RunDisplay {
31
31
  askTempFile;
32
32
  /** ID of the agent whose detail panel is open; undefined = no detail shown. */
33
33
  selectedAgentId;
34
+ navState = { focusSection: 0, focusRow: 0, scrollOffset: 0 };
34
35
  onSteer;
35
36
  onAsk;
36
37
  constructor(runInfo, liveConfig, callbacks) {
@@ -86,6 +87,160 @@ export class RunDisplay {
86
87
  }
87
88
  /** Clear the agent detail panel. */
88
89
  clearSelectedAgent() { this.selectedAgentId = undefined; }
90
+ /** Arrow-key navigation dispatched by the demux in handleTyped(). */
91
+ navigate(direction) {
92
+ const sections = this.getSections();
93
+ const nav = this.navState;
94
+ const section = sections[Math.min(nav.focusSection, sections.length - 1)];
95
+ let changed = false;
96
+ switch (direction) {
97
+ case "up":
98
+ if (nav.focusRow > 0) {
99
+ nav.focusRow--;
100
+ nav.scrollOffset = Math.max(0, nav.scrollOffset - 1);
101
+ changed = true;
102
+ }
103
+ else if (nav.focusSection > 0) {
104
+ nav.focusSection--;
105
+ const prevSection = sections[nav.focusSection];
106
+ nav.focusRow = Math.max(0, prevSection.rowCount - 1);
107
+ changed = true;
108
+ }
109
+ break;
110
+ case "down":
111
+ if (nav.focusRow < section.rowCount - 1) {
112
+ nav.focusRow++;
113
+ changed = true;
114
+ }
115
+ else if (nav.focusSection < sections.length - 1) {
116
+ nav.focusSection++;
117
+ nav.focusRow = 0;
118
+ changed = true;
119
+ }
120
+ break;
121
+ case "left":
122
+ if (this.selectedAgentId != null) {
123
+ this.clearSelectedAgent();
124
+ changed = true;
125
+ }
126
+ else if (nav.focusSection > 0) {
127
+ nav.focusSection--;
128
+ nav.focusRow = 0;
129
+ changed = true;
130
+ }
131
+ break;
132
+ case "right":
133
+ if (this.swarm && this.selectedAgentId == null) {
134
+ const agents = this.getVisibleAgents();
135
+ const agent = agents[nav.focusRow];
136
+ if (agent && agent.status === "running") {
137
+ this.selectAgent(agent.id);
138
+ changed = true;
139
+ }
140
+ }
141
+ else if (nav.focusSection < sections.length - 1) {
142
+ nav.focusSection++;
143
+ nav.focusRow = 0;
144
+ changed = true;
145
+ }
146
+ break;
147
+ case "enter":
148
+ if (this.swarm) {
149
+ const agents = this.getVisibleAgents();
150
+ const agent = agents[nav.focusRow];
151
+ if (agent) {
152
+ if (this.selectedAgentId === agent.id) {
153
+ this.clearSelectedAgent();
154
+ }
155
+ else {
156
+ this.selectAgent(agent.id);
157
+ }
158
+ changed = true;
159
+ }
160
+ }
161
+ break;
162
+ }
163
+ this.clampNavState(sections);
164
+ return changed;
165
+ }
166
+ /** Get the agents visible in the table (running + last N finished). */
167
+ getVisibleAgents() {
168
+ if (!this.swarm)
169
+ return [];
170
+ const running = this.swarm.agents.filter(a => a.status === "running");
171
+ const finished = this.swarm.agents.filter(a => a.status !== "running");
172
+ const showFinished = finished.slice(-Math.max(2, 12 - running.length));
173
+ return [...running, ...showFinished];
174
+ }
175
+ /** Discover sections from the current render state for navigation boundaries. */
176
+ getSections() {
177
+ const sections = [];
178
+ if (this.swarm) {
179
+ // Agent table section
180
+ const show = this.getVisibleAgents();
181
+ sections.push({
182
+ title: "Agents",
183
+ rowCount: show.length,
184
+ highlightKeyForRow: (row) => show[row]?.id != null ? `agent-${show[row].id}` : undefined,
185
+ });
186
+ // Agent detail section
187
+ if (this.selectedAgentId != null) {
188
+ sections.push({
189
+ title: "Detail",
190
+ rowCount: 1,
191
+ highlightKeyForRow: () => "detail",
192
+ });
193
+ }
194
+ // Merge results section
195
+ if (this.swarm.mergeResults.length > 0) {
196
+ sections.push({
197
+ title: "Merges",
198
+ rowCount: this.swarm.mergeResults.length,
199
+ highlightKeyForRow: (row) => `merge-${row}`,
200
+ });
201
+ }
202
+ // Event log section
203
+ sections.push({
204
+ title: "Events",
205
+ rowCount: Math.min(12, this.swarm.logs.length),
206
+ highlightKeyForRow: (row) => `event-${row}`,
207
+ });
208
+ }
209
+ else if (this.steeringActive) {
210
+ // Steering mode sections
211
+ if (this.steeringContext?.objective) {
212
+ sections.push({ title: "Objective", rowCount: 1, highlightKeyForRow: () => "objective" });
213
+ }
214
+ if (this.steeringContext?.status) {
215
+ sections.push({ title: "Status", rowCount: 1, highlightKeyForRow: () => "status" });
216
+ }
217
+ if (this.steeringContext?.lastWave) {
218
+ sections.push({ title: "LastWave", rowCount: Math.min(6, this.steeringContext.lastWave.tasks.length + 1), highlightKeyForRow: (row) => `wave-task-${row}` });
219
+ }
220
+ sections.push({ title: "PlannerActivity", rowCount: Math.min(15, this.steeringEvents.length), highlightKeyForRow: (row) => `steer-event-${row}` });
221
+ sections.push({ title: "StatusLine", rowCount: 1, highlightKeyForRow: () => "status-line" });
222
+ }
223
+ // Ensure at least one section
224
+ if (sections.length === 0) {
225
+ sections.push({ title: "Content", rowCount: 1, highlightKeyForRow: () => "content" });
226
+ }
227
+ return sections;
228
+ }
229
+ clampNavState(sections) {
230
+ const nav = this.navState;
231
+ nav.focusSection = Math.min(Math.max(0, nav.focusSection), sections.length - 1);
232
+ const s = sections[nav.focusSection];
233
+ if (s) {
234
+ nav.focusRow = Math.min(Math.max(0, nav.focusRow), Math.max(0, s.rowCount - 1));
235
+ }
236
+ }
237
+ /** Returns the unique highlight key for the currently focused row, used by renderer. */
238
+ getHighlightKey() {
239
+ const sections = this.getSections();
240
+ const nav = this.navState;
241
+ const section = sections[Math.min(nav.focusSection, sections.length - 1)];
242
+ return section?.highlightKeyForRow?.(nav.focusRow);
243
+ }
89
244
  clearAskTempFile() {
90
245
  if (this.askTempFile) {
91
246
  try {
@@ -325,9 +480,62 @@ export class RunDisplay {
325
480
  }
326
481
  return false;
327
482
  }
328
- /** Handle a typed (non-pasted) chunk. Returns true if the frame needs a redraw. */
483
+ /** Handle a typed (non-pasted) chunk. Returns true if the frame needs a redraw.
484
+ *
485
+ * Demux pipeline — routes arrow keys and ESC BEFORE hotkey matching:
486
+ * Raw stdin chunk → splitPaste
487
+ * ├─ paste → handlePaste (existing, fine)
488
+ * └─ typed → demux
489
+ * ├─ ESC + [A/B/C/D → this.navigate("up"/"down"/"right"/"left")
490
+ * ├─ ESC → cancel input / close detail / dismiss panel
491
+ * ├─ Enter → submit / reveal / select
492
+ * ├─ Ctrl+C → abort
493
+ * ├─ Backspace → delete
494
+ * └─ printable → hotkey matching (b, t, c, e, p, s, q, ?, d, 0-9)
495
+ */
329
496
  handleTyped(s) {
330
497
  const lc = this.liveConfig;
498
+ // ── 1. Arrow keys: \x1B[A = up, \x1B[B = down, \x1B[C = right, \x1B[D = left ──
499
+ if (s.startsWith("\x1B[")) {
500
+ const dir = s[2];
501
+ if (dir === "A") {
502
+ this.navigate("up");
503
+ return true;
504
+ }
505
+ if (dir === "B") {
506
+ this.navigate("down");
507
+ return true;
508
+ }
509
+ if (dir === "C") {
510
+ this.navigate("right");
511
+ return true;
512
+ }
513
+ if (dir === "D") {
514
+ this.navigate("left");
515
+ return true;
516
+ }
517
+ // Other ANSI sequences — swallow silently
518
+ return true;
519
+ }
520
+ // ── 2. Standalone ESC ──
521
+ if (s === "\x1B") {
522
+ if (this.inputMode !== "none") {
523
+ this.inputMode = "none";
524
+ this.inputSegs = [];
525
+ return true;
526
+ }
527
+ if (this.selectedAgentId != null) {
528
+ this.clearSelectedAgent();
529
+ return true;
530
+ }
531
+ if (this.askState && !this.askState.streaming) {
532
+ this.askState = undefined;
533
+ this.clearAskTempFile();
534
+ return true;
535
+ }
536
+ return false;
537
+ }
538
+ // ── 3. Input mode: budget / threshold / concurrency / extra ──
331
539
  if (this.inputMode === "budget" || this.inputMode === "threshold" || this.inputMode === "concurrency" || this.inputMode === "extra") {
332
540
  let dirty = false;
333
541
  for (const ch of s) {
@@ -366,12 +574,6 @@ export class RunDisplay {
366
574
  this.inputSegs = [];
367
575
  return true;
368
576
  }
369
- // ESC cancels input mode
370
- if (ch === "\x1B") {
371
- this.inputMode = "none";
372
- this.inputSegs = [];
373
- return true;
374
- }
375
577
  if (ch === "\x7F") {
376
578
  backspaceSegments(this.inputSegs);
377
579
  dirty = true;
@@ -384,6 +586,7 @@ export class RunDisplay {
384
586
  }
385
587
  return dirty;
386
588
  }
589
+ // ── 4. Input mode: steer / ask ──
387
590
  if (this.inputMode === "steer" || this.inputMode === "ask") {
388
591
  let dirty = false;
389
592
  for (let ci = 0; ci < s.length; ci++) {
@@ -406,18 +609,11 @@ export class RunDisplay {
406
609
  this.inputSegs = [];
407
610
  return true;
408
611
  }
409
- // ESC cancels consume this byte and any following ANSI sequence bytes
612
+ // ESC cancels input mode (no ANSI-byte consumption loop arrows arrive
613
+ // as "\x1B[A" in a single call and are caught by step 1 above)
410
614
  if (ch === "\x1B") {
411
615
  this.inputMode = "none";
412
616
  this.inputSegs = [];
413
- // Skip any remaining ANSI sequence bytes (e.g. [A for arrow keys)
414
- while (ci + 1 < s.length) {
415
- const next = s[ci + 1];
416
- const nc = next.charCodeAt(0);
417
- ci++;
418
- if ((nc >= 0x40 && nc <= 0x7E) || nc === 0x7F)
419
- break; // final byte
420
- }
421
617
  return true;
422
618
  }
423
619
  if (ch === "\x7F" || ch === "\b") {
@@ -427,9 +623,9 @@ export class RunDisplay {
427
623
  }
428
624
  const code = ch.charCodeAt(0);
429
625
  if (code < 0x20)
430
- continue; // control chars
626
+ continue;
431
627
  if (code >= 0x7F && code < 0xA0)
432
- continue; // DEL + C1 controls
628
+ continue;
433
629
  if (code >= 0x20 && code <= 0x7E && segmentsToString(this.inputSegs).length < MAX_INPUT_LEN) {
434
630
  appendCharToSegments(this.inputSegs, ch);
435
631
  dirty = true;
@@ -437,16 +633,9 @@ export class RunDisplay {
437
633
  }
438
634
  return dirty;
439
635
  }
440
- // Hotkey mode — only accept single printable ASCII characters
441
- // Skip ESC and ANSI sequences entirely
442
- if (s.length > 1 && (s[0] === "\x1B" || s.charCodeAt(0) < 0x20))
443
- return false;
444
- if (s.length !== 1)
445
- return false;
446
- const key = s[0];
447
- const code = key.charCodeAt(0);
448
- // Allow \r / \n through for Enter-to-reveal
449
- if (code === 0x0D || code === 0x0A) {
636
+ // ── 5. Hotkey mode ──
637
+ // Enter
638
+ if (s === "\r" || s === "\n") {
450
639
  if (this.askTempFile) {
451
640
  try {
452
641
  execSync(`open -R ${JSON.stringify(this.askTempFile)}`);
@@ -455,12 +644,21 @@ export class RunDisplay {
455
644
  }
456
645
  return true;
457
646
  }
458
- // ESC clears ask answer panel when in hotkey mode (check before the control-char filter below)
459
- if (key === "\x1B" && this.askState && !this.askState.streaming) {
460
- this.askState = undefined;
461
- this.clearAskTempFile();
462
- return false;
647
+ // Ctrl+C
648
+ if (s === "\x03") {
649
+ if (this.swarm && !this.swarm.aborted) {
650
+ this.swarm.abort();
651
+ }
652
+ else {
653
+ process.exit(0);
654
+ }
655
+ return true;
463
656
  }
657
+ // Only single printable ASCII characters reach hotkey matching
658
+ if (s.length !== 1)
659
+ return false;
660
+ const key = s[0];
661
+ const code = key.charCodeAt(0);
464
662
  if (code < 0x20 || code > 0x7E)
465
663
  return false;
466
664
  if (key === "b" || key === "B") {
@@ -527,15 +725,7 @@ export class RunDisplay {
527
725
  }
528
726
  // [d] cycle agent detail panel
529
727
  if ((key === "d" || key === "D") && this.swarm && this.swarm.active > 0) {
530
- if (this.selectedAgentId != null)
531
- this.cycleSelectedAgent();
532
- else
533
- this.cycleSelectedAgent();
534
- return true;
535
- }
536
- // ESC closes detail panel
537
- if (key === "\x1B" && this.selectedAgentId != null) {
538
- this.clearSelectedAgent();
728
+ this.cycleSelectedAgent();
539
729
  return true;
540
730
  }
541
731
  // Number keys 0-9 select a specific agent by row index in the visible table
@@ -547,7 +737,7 @@ export class RunDisplay {
547
737
  return true;
548
738
  }
549
739
  }
550
- if (key === "q" || key === "Q" || key === "\x03") {
740
+ if (key === "q" || key === "Q") {
551
741
  if (this.swarm) {
552
742
  if (this.swarm.aborted)
553
743
  process.exit(0);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-overnight",
3
- "version": "1.16.12",
3
+ "version": "1.16.16",
4
4
  "description": "Background lane for your Claude Max plan. Parallel Claude Agent SDK sessions in git worktrees with a usage cap that reserves headroom for your interactive Claude Code. Crash-safe resume. Opus/Sonnet/Haiku + Qwen/OpenRouter.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -10,6 +10,7 @@
10
10
  "build": "tsc",
11
11
  "dev": "tsc --watch",
12
12
  "start": "node dist/bin.js",
13
+ "test": "node --test dist/__tests__/*.test.js",
13
14
  "prepublishOnly": "node scripts/sync-plugin-version.js"
14
15
  },
15
16
  "dependencies": {
@@ -18,6 +19,8 @@
18
19
  },
19
20
  "devDependencies": {
20
21
  "@types/node": "^22.0.0",
22
+ "node-pty": "^1.1.0",
23
+ "strip-ansi": "^7.1.0",
21
24
  "typescript": "^5.7.0"
22
25
  },
23
26
  "license": "MIT",
@@ -65,6 +68,8 @@
65
68
  "files": [
66
69
  "dist",
67
70
  "!dist/__tests__",
71
+ "plugins",
72
+ "QUICKSHEET_PLAYWRIGHT.md",
68
73
  "README.md",
69
74
  "LICENSE"
70
75
  ]
@@ -0,0 +1,10 @@
1
+ {
2
+ "name": "claude-overnight",
3
+ "version": "1.16.16",
4
+ "description": "Claude Code skill for understanding, installing, and inspecting claude-overnight runs — parallel Claude agents in git worktrees with thinking waves, multi-wave steering, and crash-safe resume.",
5
+ "author": {
6
+ "name": "Francesco Fornace"
7
+ },
8
+ "homepage": "https://github.com/Fornace/claude-overnight",
9
+ "license": "MIT"
10
+ }
@@ -0,0 +1,92 @@
1
+ ---
2
+ name: claude-overnight
3
+ description: >
4
+ Understand, install, and inspect claude-overnight runs — a CLI that
5
+ launches parallel Claude agents in git worktrees with thinking waves,
6
+ multi-wave steering, and crash-safe resume. Use when the user mentions
7
+ claude-overnight, a `.claude-overnight/` folder, an "overnight" or
8
+ "swarm" run, or asks to check status / resume / continue a
9
+ multi-phase plan. Not for Vercel Workflow DevKit.
10
+ ---
11
+
12
+ # What it is
13
+
14
+ `claude-overnight` is a CLI (npm: `claude-overnight`, bin: `claude-overnight`) that takes an objective + budget and launches many Claude agent sessions in parallel, each in an isolated git worktree. It's a local multi-session orchestrator built on top of the Claude Agent SDK — not itself an agent harness, but a layer that plans, dispatches, and steers many sessions that run on the SDK's harness. A "thinking wave" of architect sessions explores the codebase, an orchestrator synthesizes concrete tasks, executor waves run them in parallel, and steering decides between more execution, reflection, or declaring done. Rate limits, crashes, and usage caps are all resumable — nothing is lost.
15
+
16
+ Repo: https://github.com/Fornace/claude-overnight
17
+
18
+ # Install / run
19
+
20
+ ```bash
21
+ npm install -g claude-overnight # Node >= 20; needs Claude auth or ANTHROPIC_API_KEY (or Qwen 3.6 Plus — see repo README)
22
+ claude-overnight # interactive in cwd
23
+ claude-overnight tasks.json # task file mode
24
+ claude-overnight "task a" "task b" # inline
25
+ ```
26
+
27
+ Common flags: `--budget=N`, `--concurrency=N`, `--model=<name>`, `--usage-cap=N`, `--allow-extra-usage`, `--extra-usage-budget=N`, `--timeout=SECONDS`, `--no-flex`, `--dry-run`.
28
+
29
+ Live keys while running: `b` change budget · `t` change usage cap · `q` graceful stop (twice = force).
30
+
31
+ Exit codes: `0` all ok · `1` some failed · `2` all/none.
32
+
33
+ # On-disk layout (this is how you inspect status)
34
+
35
+ Every run lives at `<repo>/.claude-overnight/runs/<ISO-timestamp>/`:
36
+
37
+ | File / dir | What it tells you |
38
+ |----------------------|-----------------------------------------------------------------------------------|
39
+ | `run.json` | Machine state: objective, model, budget, cost, waves done, branches, done flag. |
40
+ | `status.md` | **Living project snapshot**, rewritten by steering every wave. First line = short status. |
41
+ | `goal.md` | Evolving "north star" — what the run currently thinks "amazing" means. |
42
+ | `milestones/*.md` | Strategic snapshots archived ~every 5 waves. Long-term memory of the run. |
43
+ | `designs/*.md` | Architect outputs from the thinking wave. Deleted once the objective is complete. |
44
+ | `sessions/wave-N.json` | Per-wave agent records: prompt, status, cost, files changed, branch, error. |
45
+
46
+ The newest subfolder under `runs/` is the current/last run. A run that never reached "done" is **resumable** — `run.json` will not be marked complete and `designs/` may still be present.
47
+
48
+ To assess status of a run from scratch, read in this order: `goal.md` → `status.md` → newest file in `milestones/` → newest `sessions/wave-*.json` → `run.json`. Five reads and you know exactly where it stands.
49
+
50
+ **Durable run history (committed, survives cleanup):** `claude-overnight.log.md` at the repo root is updated on every run with a block per run ID — original objective, start/finish times, cost, outcome, branch. If the user asks "what was my prompt" or "what did last night's run do" and `.claude-overnight/runs/` is empty, this file is the canonical recovery path.
51
+
52
+ # Resume / continue
53
+
54
+ Just run `claude-overnight` again in the same repo. It auto-detects the unfinished run and shows a **Resume / Fresh / Quit** prompt. On resume: unmerged `swarm/task-*` branches auto-merge, the wave loop continues, status/goal/milestones/designs are preserved. If orchestration crashed after the thinking wave, surviving `designs/*.md` are reused — no re-paying for architects.
55
+
56
+ For **multi-phase plans** (task file with `objective` + `flexiblePlan: true`), resuming picks up at the next wave with full steering context. Don't hand-edit `run.json` to "fix" a stuck run unless something is demonstrably corrupt — prefer re-running and letting steering re-assess.
57
+
58
+ Merged branches from prior runs are not re-run. Knowledge carries forward across runs: new runs see what completed runs built.
59
+
60
+ # Diagnosing a stuck / failed run
61
+
62
+ 1. Read `status.md` (living snapshot) and the newest `sessions/wave-*.json`.
63
+ 2. Check for `swarm/task-*` branches left behind (`git branch --list 'swarm/*'`) — these are unmerged worktree outputs, usually from a conflict or crash.
64
+ 3. Look at `run.json` for `done`, `lastError`, usage/cost fields.
65
+ 4. If the thinking wave succeeded but orchestration crashed, `designs/` will still contain the architect docs — a resume will reuse them.
66
+ 5. If rate-limited, the tool waits and retries on its own — do not kill it unless the user asks.
67
+
68
+ # What NOT to do
69
+
70
+ - Don't micromanage the tool. It has its own planner, steering, and goal refinement — trust them.
71
+ - Don't invent a resume procedure. The CLI handles resume itself; the correct action is almost always "re-run `claude-overnight` in the repo".
72
+ - Don't delete `.claude-overnight/` to "clean up" — it holds the only record of what the run learned. It should be in `.gitignore`.
73
+ - Don't truncate or summarize agent output files when reading them back — never discard expensive agent output.
74
+ - Don't confuse this with Vercel Workflow DevKit — unrelated despite the word "workflow".
75
+
76
+ # Playwright Parallel Usage
77
+
78
+ When agents use the Playwright MCP server for testing, parallel instances conflict on browser locks and cookie state. See `QUICKSHEET_PLAYWRIGHT.md` at the repo root for the full reference.
79
+
80
+ **Quick rules:**
81
+ - **Headless by default** — prevents focus stealing on macOS. Only use headed when anti-bot detection (CAPTCHA, Cloudflare) forces it
82
+ - **Isolated agents (no login):** Each MCP server needs `--isolated --headless`
83
+ - **Isolated agents (with saved login):** Each needs its own `userDataDir` or `--storage-state` file, plus `--headless`
84
+ - Multiple MCP entries in `settings.json` — one per concurrency slot, or use a single `--isolated` entry if cookies don't need to persist
85
+
86
+ **Context7 (ctx7) docs:** Requires authentication (`npx ctx7@latest login` or `CONTEXT7_API_KEY`). Pre-flight check:
87
+
88
+ ```bash
89
+ npx ctx7@latest library playwright "parallel browser instances"
90
+ ```
91
+
92
+ If this fails with a quota/auth error, fall back to training data — don't block the run.