claude-overnight 1.16.12 → 1.16.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/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.15",
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",