@viraatdas/rudder 1.0.6 → 1.0.7

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.
Files changed (45) hide show
  1. package/README.md +364 -26
  2. package/dist/agent-attention.d.ts +5 -0
  3. package/dist/agent-attention.js +54 -0
  4. package/dist/agent-attention.js.map +1 -0
  5. package/dist/backends.js +58 -10
  6. package/dist/backends.js.map +1 -1
  7. package/dist/brain.js +3 -3
  8. package/dist/brain.js.map +1 -1
  9. package/dist/cloud.d.ts +9 -0
  10. package/dist/cloud.js +2255 -0
  11. package/dist/cloud.js.map +1 -0
  12. package/dist/main.js +112 -2
  13. package/dist/main.js.map +1 -1
  14. package/dist/migration.d.ts +82 -0
  15. package/dist/migration.js +174 -0
  16. package/dist/migration.js.map +1 -0
  17. package/dist/native/rudder-native +0 -0
  18. package/dist/native-agents.d.ts +1 -0
  19. package/dist/native-agents.js +126 -8
  20. package/dist/native-agents.js.map +1 -1
  21. package/dist/plan-mode.d.ts +2 -0
  22. package/dist/plan-mode.js +22 -0
  23. package/dist/plan-mode.js.map +1 -0
  24. package/dist/repl.js +11 -3
  25. package/dist/repl.js.map +1 -1
  26. package/dist/run-manager.d.ts +11 -1
  27. package/dist/run-manager.js +164 -19
  28. package/dist/run-manager.js.map +1 -1
  29. package/dist/state.d.ts +10 -1
  30. package/dist/state.js +51 -1
  31. package/dist/state.js.map +1 -1
  32. package/dist/task-summary.d.ts +5 -0
  33. package/dist/task-summary.js +128 -0
  34. package/dist/task-summary.js.map +1 -0
  35. package/dist/tmux-dashboard.js +248 -64
  36. package/dist/tmux-dashboard.js.map +1 -1
  37. package/dist/tmux.js +1 -0
  38. package/dist/tmux.js.map +1 -1
  39. package/dist/tui.js +228 -46
  40. package/dist/tui.js.map +1 -1
  41. package/dist/types.d.ts +23 -0
  42. package/dist/util.d.ts +1 -0
  43. package/dist/util.js +23 -1
  44. package/dist/util.js.map +1 -1
  45. package/package.json +4 -2
package/dist/tui.js CHANGED
@@ -4,10 +4,12 @@ import { spawn } from "node:child_process";
4
4
  import { fileURLToPath } from "node:url";
5
5
  import { useCallback, useEffect, useMemo, useRef, useState } from "react";
6
6
  import { Box, Text, render, useApp, useInput, useWindowSize } from "ink";
7
- import { currentBranch, findRepoRoot, hasChanges } from "./git.js";
7
+ import { permissionAttentionFromOutput } from "./agent-attention.js";
8
+ import { currentBranch, findRepoRoot } from "./git.js";
8
9
  import { discoverModelOptions, fallbackModelOptions } from "./models.js";
9
- import { eventsPath, listRuns, loadConfig, outputPath, } from "./state.js";
10
+ import { eventsPath, listRuns, loadConfig, outputPath, rememberBackendSelection, } from "./state.js";
10
11
  import { continueRun, deleteRun, mergeRun, startRun, stopRun } from "./run-manager.js";
12
+ import { taskDisplayLabel } from "./task-summary.js";
11
13
  import { pathExists, shortenHome } from "./util.js";
12
14
  const INTERACTIVE_BACKENDS = ["claude", "codex"];
13
15
  const COMPLETION_SOUND = fileURLToPath(new URL("../assets/sounds/ping.mp3", import.meta.url));
@@ -19,7 +21,7 @@ const COMMANDS = [
19
21
  { name: "new", detail: "return to new-agent mode", insert: "/new" },
20
22
  { name: "worktree", detail: "toggle worktree policy", insert: "/worktree " },
21
23
  { name: "stop", detail: "stop the selected run", insert: "/stop" },
22
- { name: "delete", detail: "delete selected run, offering merge first when relevant", insert: "/delete" },
24
+ { name: "delete", detail: "delete selected run and its worktree", insert: "/delete" },
23
25
  { name: "copy", detail: "copy selected worker transcript", insert: "/copy" },
24
26
  { name: "merge", detail: "merge the selected completed worktree", insert: "/merge" },
25
27
  { name: "merge-all", detail: "merge all completed worktrees", insert: "/merge-all" },
@@ -58,9 +60,11 @@ function RudderTui({ defaults }) {
58
60
  const [commandMenuIndex, setCommandMenuIndex] = useState(0);
59
61
  const [discoveredModels, setDiscoveredModels] = useState([]);
60
62
  const [deletePrompt, setDeletePrompt] = useState(null);
63
+ const [mergePrompt, setMergePrompt] = useState(null);
64
+ const [conflictPrompt, setConflictPrompt] = useState(null);
61
65
  const [submitting, setSubmitting] = useState(false);
62
66
  const [preferencesLoaded, setPreferencesLoaded] = useState(false);
63
- const notifiedFinishedRuns = useRef(null);
67
+ const notifiedAlerts = useRef(null);
64
68
  const refresh = useCallback(async () => {
65
69
  const root = findRepoRoot();
66
70
  const [nextConfig, nextBranch, nextRuns] = await Promise.all([
@@ -71,7 +75,7 @@ function RudderTui({ defaults }) {
71
75
  setRepoRoot(root);
72
76
  setConfig(nextConfig);
73
77
  setBranch(nextBranch);
74
- notifyFinishedRuns(nextRuns, notifiedFinishedRuns);
78
+ notifyRunAlerts(nextRuns, notifiedAlerts);
75
79
  setRuns(nextRuns);
76
80
  if (!preferencesLoaded) {
77
81
  setBackend(toInteractiveBackend(defaults.backend ?? nextConfig.lastUsedBackend ?? nextConfig.defaultBackend));
@@ -168,30 +172,26 @@ function RudderTui({ defaults }) {
168
172
  setSubmitting(false);
169
173
  }
170
174
  }, [backend, model, refresh, submitting, targetRun, worktreeMode]);
171
- const requestDeleteSelectedRun = useCallback(async (runOverride) => {
175
+ const requestDeleteSelectedRun = useCallback((runOverride) => {
172
176
  const run = runOverride ?? selectedRun;
173
177
  if (!run) {
174
178
  setNotice("No agent selected");
175
179
  return;
176
180
  }
177
- const changed = run.worktree.enabled && await hasChanges(run.worktree.path).catch(() => false);
178
- const mergeable = changed && run.status === "completed";
179
- setDeletePrompt({ runId: run.id, canMerge: mergeable });
180
- setNotice(mergeable
181
- ? `Delete ${shortId(run.id)}? press m to merge first, d to discard, Esc to cancel`
182
- : `Delete ${shortId(run.id)}? press d to confirm, Esc to cancel`);
181
+ setDeletePrompt({ runId: run.id });
182
+ setNotice(`Delete ${shortId(run.id)}? press d to confirm, Esc to cancel`);
183
183
  }, [selectedRun]);
184
- const confirmDelete = useCallback(async (mergeFirst) => {
184
+ const confirmDelete = useCallback(async () => {
185
185
  if (!deletePrompt) {
186
186
  return;
187
187
  }
188
188
  const runId = deletePrompt.runId;
189
189
  try {
190
- await deleteRun(runId, { mergeFirst, force: true, silent: true });
190
+ await deleteRun(runId, { force: true, silent: true });
191
191
  setDeletePrompt(null);
192
192
  setSelectedRunId(undefined);
193
193
  setTargetRunId(undefined);
194
- setNotice(`${mergeFirst ? "Merged and deleted" : "Deleted"} ${shortId(runId)}`);
194
+ setNotice(`Deleted ${shortId(runId)}`);
195
195
  await refresh();
196
196
  }
197
197
  catch (error) {
@@ -199,6 +199,107 @@ function RudderTui({ defaults }) {
199
199
  setNotice(error instanceof Error ? error.message : String(error));
200
200
  }
201
201
  }, [deletePrompt, refresh]);
202
+ const requestMergeRun = useCallback((runOverride, allowDirty = false) => {
203
+ const run = runOverride ?? selectedRun;
204
+ if (!run) {
205
+ setNotice("No agent selected");
206
+ return;
207
+ }
208
+ if (!canMerge(run)) {
209
+ setNotice(`${shortId(run.id)} is not ready to merge`);
210
+ return;
211
+ }
212
+ setDeletePrompt(null);
213
+ setConflictPrompt(null);
214
+ setMergePrompt({
215
+ kind: "selected",
216
+ runId: run.id,
217
+ label: truncate(taskDisplayLabel(run, 48), 48),
218
+ allowDirty,
219
+ });
220
+ setNotice(`Merge ${shortId(run.id)}? press y to confirm or n to cancel`);
221
+ }, [selectedRun]);
222
+ const requestMergeAll = useCallback((allowDirty = false) => {
223
+ const ready = runs.filter(canMerge);
224
+ if (ready.length === 0) {
225
+ setNotice("No completed worktree runs ready to merge");
226
+ return;
227
+ }
228
+ setDeletePrompt(null);
229
+ setConflictPrompt(null);
230
+ setMergePrompt({ kind: "all", runIds: ready.map((run) => run.id), allowDirty });
231
+ setNotice(`Merge ${ready.length} run${ready.length === 1 ? "" : "s"}? press y to confirm or n to cancel`);
232
+ }, [runs]);
233
+ const confirmMerge = useCallback(async () => {
234
+ if (!mergePrompt) {
235
+ return;
236
+ }
237
+ const prompt = mergePrompt;
238
+ setMergePrompt(null);
239
+ try {
240
+ if (prompt.kind === "selected") {
241
+ const merged = await mergeRun(prompt.runId, prompt.allowDirty, { silent: true });
242
+ if (merged.merge?.status === "conflict") {
243
+ const files = merged.merge.conflictedFiles ?? [];
244
+ setConflictPrompt({ runId: prompt.runId, files });
245
+ setNotice(`Merge conflict in ${files.length || "unknown"} file${files.length === 1 ? "" : "s"}; press y for AI help or n for manual`);
246
+ }
247
+ else {
248
+ setNotice(`Merged ${shortId(prompt.runId)}`);
249
+ }
250
+ await refresh();
251
+ return;
252
+ }
253
+ let mergedCount = 0;
254
+ for (const runId of prompt.runIds) {
255
+ const merged = await mergeRun(runId, prompt.allowDirty, { silent: true });
256
+ if (merged.merge?.status === "conflict") {
257
+ const files = merged.merge.conflictedFiles ?? [];
258
+ setConflictPrompt({ runId, files });
259
+ setNotice(`Merge all stopped after ${mergedCount}: conflict in ${shortId(runId)}; press y for AI help or n for manual`);
260
+ await refresh();
261
+ return;
262
+ }
263
+ mergedCount += 1;
264
+ }
265
+ setNotice(`Merged ${mergedCount} run${mergedCount === 1 ? "" : "s"}`);
266
+ await refresh();
267
+ }
268
+ catch (error) {
269
+ setNotice(error instanceof Error ? error.message : String(error));
270
+ await refresh();
271
+ }
272
+ }, [mergePrompt, refresh]);
273
+ const startConflictResolver = useCallback(async () => {
274
+ if (!conflictPrompt) {
275
+ return;
276
+ }
277
+ const files = conflictPrompt.files.length ? conflictPrompt.files.join("\n") : "(git did not report conflicted files)";
278
+ const task = [
279
+ "Read RUDDER.md first. A git merge stopped with conflicts in this checkout.",
280
+ `Conflicted files:\n${files}`,
281
+ "Resolve the merge conflicts, keep the intended changes from both sides where appropriate, run relevant checks if possible, and report what changed. Do not abort the merge unless resolving is impossible.",
282
+ ].join("\n\n");
283
+ try {
284
+ const run = await startRun({
285
+ task,
286
+ backend,
287
+ model,
288
+ detach: true,
289
+ worktree: false,
290
+ silent: true,
291
+ view: "shell",
292
+ });
293
+ setConflictPrompt(null);
294
+ setSelectedRunId(run.id);
295
+ setExpandedRunIds((current) => new Set(current).add(run.id));
296
+ setNotice(`Started AI merge-conflict resolver ${shortId(run.id)}`);
297
+ await refresh();
298
+ }
299
+ catch (error) {
300
+ setNotice(error instanceof Error ? error.message : String(error));
301
+ }
302
+ }, [backend, conflictPrompt, model, refresh]);
202
303
  const copySelectedTranscript = useCallback(async (runOverride) => {
203
304
  const run = runOverride ?? selectedRun;
204
305
  if (!run) {
@@ -229,7 +330,9 @@ function RudderTui({ defaults }) {
229
330
  return;
230
331
  case "backend":
231
332
  if (isInteractiveBackend(args[0])) {
232
- chooseBackend(args[0], setBackend, setModel, setNotice);
333
+ const nextBackend = args[0];
334
+ chooseBackend(nextBackend, setBackend, setModel, setNotice);
335
+ setConfig(await rememberBackendSelection({ backend: nextBackend }));
233
336
  }
234
337
  else {
235
338
  setNotice("Usage: /backend claude|codex");
@@ -267,6 +370,11 @@ function RudderTui({ defaults }) {
267
370
  else {
268
371
  const nextModel = args.join(" ");
269
372
  setModel(nextModel);
373
+ setConfig(await rememberBackendSelection({
374
+ backend,
375
+ model: nextModel,
376
+ updateModel: true,
377
+ }));
270
378
  setNotice(`Model ${nextModel}`);
271
379
  }
272
380
  setInput("");
@@ -289,11 +397,11 @@ function RudderTui({ defaults }) {
289
397
  setInput("");
290
398
  return;
291
399
  case "merge":
292
- await runAction(args[0] ?? selectedRun?.id, async (id) => mergeRun(id, args.includes("--allow-dirty"), { silent: true }), "Merged", setNotice, refresh);
400
+ requestMergeRun(resolveUiRun(runs, args[0] ?? selectedRun?.id), args.includes("--allow-dirty"));
293
401
  setInput("");
294
402
  return;
295
403
  case "merge-all":
296
- await mergeReadyRuns(runs, args.includes("--allow-dirty"), setNotice, refresh);
404
+ requestMergeAll(args.includes("--allow-dirty"));
297
405
  setInput("");
298
406
  return;
299
407
  case "clear":
@@ -305,7 +413,7 @@ function RudderTui({ defaults }) {
305
413
  setNotice(`Unknown command: /${command}`);
306
414
  setInput("");
307
415
  }
308
- }, [app, backend, copySelectedTranscript, refresh, requestDeleteSelectedRun, runs, selectedRun?.id]);
416
+ }, [app, backend, copySelectedTranscript, refresh, requestDeleteSelectedRun, requestMergeAll, requestMergeRun, runs, selectedRun?.id]);
309
417
  const selectModelOption = useCallback((index) => {
310
418
  const option = modelOptions[index];
311
419
  if (!option) {
@@ -315,7 +423,14 @@ function RudderTui({ defaults }) {
315
423
  setModelMenuOpen(false);
316
424
  setModelMenuIndex(index);
317
425
  setNotice(option.value ? `Model ${option.value}` : "Using backend default model");
318
- }, [modelOptions]);
426
+ void rememberBackendSelection({
427
+ backend,
428
+ model: option.value,
429
+ updateModel: true,
430
+ }).then(setConfig).catch((error) => {
431
+ setNotice(error instanceof Error ? error.message : String(error));
432
+ });
433
+ }, [backend, modelOptions]);
319
434
  const selectCommandOption = useCallback((index) => {
320
435
  const option = commandOptions[index];
321
436
  if (!option) {
@@ -355,18 +470,38 @@ function RudderTui({ defaults }) {
355
470
  }
356
471
  return;
357
472
  }
473
+ if (mergePrompt) {
474
+ if (key.escape || value === "n" || value === "N") {
475
+ setMergePrompt(null);
476
+ setNotice("Merge cancelled");
477
+ return;
478
+ }
479
+ if (value === "y" || value === "Y") {
480
+ void confirmMerge();
481
+ return;
482
+ }
483
+ return;
484
+ }
485
+ if (conflictPrompt) {
486
+ if (key.escape || value === "n" || value === "N") {
487
+ setConflictPrompt(null);
488
+ setNotice("Resolve the merge conflicts manually, then commit");
489
+ return;
490
+ }
491
+ if (value === "y" || value === "Y") {
492
+ void startConflictResolver();
493
+ return;
494
+ }
495
+ return;
496
+ }
358
497
  if (deletePrompt) {
359
498
  if (key.escape) {
360
499
  setDeletePrompt(null);
361
500
  setNotice("Delete cancelled");
362
501
  return;
363
502
  }
364
- if (value === "m" && deletePrompt.canMerge) {
365
- void confirmDelete(true);
366
- return;
367
- }
368
- if (value === "d" || key.delete || key.backspace) {
369
- void confirmDelete(false);
503
+ if (value === "d") {
504
+ void confirmDelete();
370
505
  return;
371
506
  }
372
507
  return;
@@ -532,11 +667,11 @@ function RudderTui({ defaults }) {
532
667
  return;
533
668
  }
534
669
  if (input.length === 0 && value === "m" && selectedRun) {
535
- void runAction(selectedRun.id, async (id) => mergeRun(id, false, { silent: true }), "Merged", setNotice, refresh);
670
+ requestMergeRun(selectedRun);
536
671
  return;
537
672
  }
538
673
  if (input.length === 0 && value === "M") {
539
- void mergeReadyRuns(runs, false, setNotice, refresh);
674
+ requestMergeAll();
540
675
  return;
541
676
  }
542
677
  if (input.length === 0 && value === "d") {
@@ -565,7 +700,7 @@ function RudderTui({ defaults }) {
565
700
  const railWidth = Math.min(42, Math.max(30, Math.floor(width * 0.34)));
566
701
  const detailWidth = Math.max(30, width - railWidth - 1);
567
702
  const detailHeight = Math.max(8, height - 8);
568
- return (_jsxs(Box, { flexDirection: "column", width: width, height: height, children: [_jsx(Header, { width: width, repoRoot: repoRoot, branch: branch, backend: backend, model: model ?? modelForBackend(backend, config), activeCount: activeCount, worktreeMode: worktreeMode }), _jsxs(Box, { flexGrow: 1, minHeight: 0, children: [_jsx(RunRail, { runs: runs, selectedRunId: selectedRun?.id, targetRunId: targetRun?.id, width: railWidth, expandedRunIds: expandedRunIds, focused: focusPane === "agents" }), _jsx(Box, { flexDirection: "column", flexGrow: 1, marginLeft: 1, children: _jsx(DetailPane, { run: selectedRun, width: detailWidth, height: detailHeight, expanded: selectedExpanded, transcriptExpanded: transcriptExpanded, focused: focusPane === "worker", input: focusPane === "worker" ? input : "", submitting: submitting }) })] }), helpOpen ? _jsx(Help, {}) : null, modelMenuOpen ? _jsx(ModelMenu, { backend: backend, options: modelOptions, selectedIndex: modelMenuIndex, currentModel: model, width: width }) : null, commandMenuOpen ? _jsx(CommandMenu, { options: commandOptions, selectedIndex: commandMenuIndex, width: width }) : null, deletePrompt ? _jsx(DeletePromptBox, { prompt: deletePrompt, width: width }) : null, focusPane === "worker"
703
+ return (_jsxs(Box, { flexDirection: "column", width: width, height: height, children: [_jsx(Header, { width: width, repoRoot: repoRoot, branch: branch, backend: backend, model: model ?? modelForBackend(backend, config), activeCount: activeCount, worktreeMode: worktreeMode }), _jsxs(Box, { flexGrow: 1, minHeight: 0, children: [_jsx(RunRail, { runs: runs, selectedRunId: selectedRun?.id, targetRunId: targetRun?.id, width: railWidth, expandedRunIds: expandedRunIds, focused: focusPane === "agents" }), _jsx(Box, { flexDirection: "column", flexGrow: 1, marginLeft: 1, children: _jsx(DetailPane, { run: selectedRun, width: detailWidth, height: detailHeight, expanded: selectedExpanded, transcriptExpanded: transcriptExpanded, focused: focusPane === "worker", input: focusPane === "worker" ? input : "", submitting: submitting }) })] }), helpOpen ? _jsx(Help, {}) : null, modelMenuOpen ? _jsx(ModelMenu, { backend: backend, options: modelOptions, selectedIndex: modelMenuIndex, currentModel: model, width: width }) : null, commandMenuOpen ? _jsx(CommandMenu, { options: commandOptions, selectedIndex: commandMenuIndex, width: width }) : null, mergePrompt ? _jsx(MergePromptBox, { prompt: mergePrompt, width: width }) : null, conflictPrompt ? _jsx(MergeConflictPromptBox, { prompt: conflictPrompt, width: width }) : null, deletePrompt ? _jsx(DeletePromptBox, { prompt: deletePrompt, width: width }) : null, focusPane === "worker"
569
704
  ? _jsx(StatusDock, { notice: notice })
570
705
  : _jsx(PromptDock, { input: input, backend: backend, model: model ?? modelForBackend(backend, config), notice: notice, submitting: submitting, targetRun: targetRun, focused: focusPane === "task" }), _jsx(Footer, { focusPane: focusPane })] }));
571
706
  }
@@ -579,13 +714,13 @@ function RunRail(props) {
579
714
  return (_jsxs(Box, { flexDirection: "column", width: props.width, borderStyle: props.focused ? "double" : "single", borderColor: props.focused ? "cyan" : "gray", paddingX: 1, children: [_jsxs(Box, { justifyContent: "space-between", children: [_jsxs(Box, { children: [props.focused ? _jsx(FocusPill, { label: "focus" }) : null, _jsx(Text, { bold: true, color: props.focused ? "cyan" : undefined, children: " agents" })] }), _jsxs(Text, { color: "gray", children: [props.runs.length, " runs"] })] }), visible.length === 0 ? (_jsx(Box, { marginTop: 1, children: _jsx(Text, { color: "gray", children: "No runs yet. Type a task below." }) })) : null, visible.map((run) => (_jsx(RunCard, { run: run, selected: run.id === props.selectedRunId, targeted: run.id === props.targetRunId, expanded: props.expandedRunIds.has(run.id), width: props.width - 4 }, run.id)))] }));
580
715
  }
581
716
  function RunCard(props) {
582
- const tone = statusColor(props.run.status);
717
+ const tone = runStatusColor(props.run);
583
718
  const label = props.selected ? (props.targeted ? ">>" : "> ") : " ";
584
- const task = truncate(props.run.task, Math.max(12, props.width - 14));
719
+ const task = truncate(taskDisplayLabel(props.run, 80), Math.max(12, props.width - 14));
585
720
  const progress = completionPercent(props.run);
586
721
  const summary = truncate(agentRailSummary(props.run), Math.max(12, props.width - 7));
587
722
  const meta = `${progressBar(progress)} ${progress}%`;
588
- return (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsxs(Text, { wrap: "truncate", color: props.selected ? "white" : "gray", bold: props.selected, children: [label, " ", meta, " ", statusGlyph(props.run.status), " ", props.run.backend, " ", task] }), _jsxs(Text, { wrap: "truncate", color: tone, children: [" ", statusWord(props.run.status), " ", props.targeted ? "editing " : "", summary] })] }));
723
+ return (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsxs(Text, { wrap: "truncate", color: props.selected ? "white" : "gray", bold: props.selected, children: [label, " ", meta, " ", statusGlyph(props.run.status), " ", props.run.backend, " ", task] }), _jsxs(Text, { wrap: "truncate", color: tone, children: [" ", statusWord(props.run), " ", props.targeted ? "editing " : "", summary] })] }));
589
724
  }
590
725
  function DetailPane(props) {
591
726
  if (!props.run) {
@@ -594,7 +729,7 @@ function DetailPane(props) {
594
729
  const composerHeight = props.focused ? 3 : 0;
595
730
  const outputHeight = Math.max(5, props.height - 6 - composerHeight);
596
731
  const contentWidth = Math.max(10, props.width - 4);
597
- return (_jsxs(Box, { width: props.width, height: props.height, borderStyle: props.focused ? "double" : "single", borderColor: props.focused ? "cyan" : statusColor(props.run.status), paddingX: 1, flexDirection: "column", children: [_jsxs(Box, { justifyContent: "space-between", children: [_jsxs(Box, { children: [props.focused ? _jsx(FocusPill, { label: "focus" }) : null, _jsx(Text, { bold: true, color: props.focused ? "cyan" : undefined, children: " worker" })] }), _jsx(Text, { color: statusColor(props.run.status), children: workerStateLabel(props.run) })] }), _jsx(Text, { wrap: "truncate", color: "gray", children: fitLine(props.run.task, contentWidth) }), _jsx(Box, { flexDirection: "column", marginTop: 1, minHeight: 0, children: _jsx(Box, { height: outputHeight, overflow: "hidden", flexDirection: "column", children: tailLines(props.run.output, outputHeight).map((line, index) => (_jsx(Text, { wrap: "truncate", children: line || " " }, index))) }) }), props.focused ? _jsx(WorkerComposer, { run: props.run, input: props.input, submitting: props.submitting, width: contentWidth }) : null] }));
732
+ return (_jsxs(Box, { width: props.width, height: props.height, borderStyle: props.focused ? "double" : "single", borderColor: props.focused ? "cyan" : runStatusColor(props.run), paddingX: 1, flexDirection: "column", children: [_jsxs(Box, { justifyContent: "space-between", children: [_jsxs(Box, { children: [props.focused ? _jsx(FocusPill, { label: "focus" }) : null, _jsx(Text, { bold: true, color: props.focused ? "cyan" : undefined, children: " worker" })] }), _jsx(Text, { color: runStatusColor(props.run), children: workerStateLabel(props.run) })] }), _jsx(Text, { wrap: "truncate", color: "gray", children: fitLine(props.run.task, contentWidth) }), _jsx(Box, { flexDirection: "column", marginTop: 1, minHeight: 0, children: _jsx(Box, { height: outputHeight, overflow: "hidden", flexDirection: "column", children: tailLines(props.run.output, outputHeight).map((line, index) => (_jsx(Text, { wrap: "truncate", children: line || " " }, index))) }) }), props.focused ? _jsx(WorkerComposer, { run: props.run, input: props.input, submitting: props.submitting, width: contentWidth }) : null] }));
598
733
  }
599
734
  function WorkerComposer(props) {
600
735
  const active = isActive(props.run.status);
@@ -604,7 +739,7 @@ function WorkerComposer(props) {
604
739
  return (_jsxs(Box, { flexDirection: "column", marginTop: 1, borderStyle: "single", borderColor: "cyan", paddingX: 1, children: [_jsxs(Box, { justifyContent: "space-between", children: [_jsxs(Text, { children: [_jsx(FocusPill, { label: label }), _jsxs(Text, { color: props.submitting ? "yellow" : "cyan", children: [" ", props.submitting ? "sending" : shortId(props.run.id)] }), _jsxs(Text, { children: [" ", truncate(value, Math.max(8, props.width - 28))] }), _jsx(Text, { color: "cyan", children: "_" })] }), _jsx(Text, { color: "gray", children: active ? "running" : "resumable" })] }), _jsx(Text, { color: "gray", children: fitLine(`${helper}. Tab changes pane, Esc returns to task.`, props.width) })] }));
605
740
  }
606
741
  function Help() {
607
- return (_jsxs(Box, { borderStyle: "single", borderColor: "yellow", paddingX: 1, flexDirection: "column", children: [_jsx(Text, { bold: true, color: "yellow", children: "keys" }), _jsxs(Text, { children: [_jsx(Text, { color: "cyan", children: "Tab" }), " focus agents/worker/task ", _jsx(Text, { color: "cyan", children: "Enter" }), " submit focused input ", _jsx(Text, { color: "cyan", children: "j/k" }), " select run"] }), _jsxs(Text, { children: [_jsx(Text, { color: "cyan", children: "worker focus" }), " type to selected agent; running agents are interrupted on Enter ", _jsx(Text, { color: "cyan", children: "n" }), " new task ", _jsx(Text, { color: "cyan", children: "x" }), " expand ", _jsx(Text, { color: "cyan", children: "l" }), " transcript"] }), _jsxs(Text, { children: [_jsx(Text, { color: "cyan", children: "o" }), " model picker ", _jsx(Text, { color: "cyan", children: "/" }), " command search ", _jsx(Text, { color: "cyan", children: "d" }), " delete ", _jsx(Text, { color: "cyan", children: "y" }), " copy transcript ", _jsx(Text, { color: "cyan", children: "s" }), " stop ", _jsx(Text, { color: "cyan", children: "m/M" }), " merge"] }), _jsx(Text, { color: "gray", children: "Slash: /backend claude|codex, /model, /model <name>, /agent, /interrupt, /new, /worktree, /stop, /delete, /copy, /merge, /merge-all, /exit" })] }));
742
+ return (_jsxs(Box, { borderStyle: "single", borderColor: "yellow", paddingX: 1, flexDirection: "column", children: [_jsx(Text, { bold: true, color: "yellow", children: "keys" }), _jsxs(Text, { children: [_jsx(Text, { color: "cyan", children: "Tab" }), " focus agents/worker/task ", _jsx(Text, { color: "cyan", children: "Enter" }), " submit focused input ", _jsx(Text, { color: "cyan", children: "j/k" }), " select run"] }), _jsxs(Text, { children: [_jsx(Text, { color: "cyan", children: "worker focus" }), " type to selected agent; running agents are interrupted on Enter ", _jsx(Text, { color: "cyan", children: "n" }), " new task ", _jsx(Text, { color: "cyan", children: "x" }), " expand ", _jsx(Text, { color: "cyan", children: "l" }), " transcript"] }), _jsxs(Text, { children: [_jsx(Text, { color: "cyan", children: "o" }), " model picker ", _jsx(Text, { color: "cyan", children: "/" }), " command search ", _jsx(Text, { color: "cyan", children: "dd" }), " delete ", _jsx(Text, { color: "cyan", children: "y" }), " copy transcript ", _jsx(Text, { color: "cyan", children: "s" }), " stop ", _jsx(Text, { color: "cyan", children: "m/M" }), " merge"] }), _jsx(Text, { color: "gray", children: "Slash: /backend claude|codex, /model, /model <name>, /agent, /interrupt, /new, /worktree, /stop, /delete, /copy, /merge, /merge-all, /exit" })] }));
608
743
  }
609
744
  function FocusPill(props) {
610
745
  return (_jsxs(Text, { backgroundColor: "cyan", color: "black", bold: true, children: [" ", props.label.toUpperCase(), " "] }));
@@ -637,11 +772,25 @@ function CommandMenu(props) {
637
772
  }
638
773
  function DeletePromptBox(props) {
639
774
  const contentWidth = Math.max(24, props.width - 4);
640
- const action = props.prompt.canMerge
641
- ? "m merge first, d discard run, Esc cancel"
642
- : "d delete run, Esc cancel";
775
+ const action = "d delete run + worktree, Esc cancel";
643
776
  return (_jsx(Box, { width: props.width, borderStyle: "double", borderColor: "yellow", paddingX: 1, children: _jsx(Text, { color: "yellow", bold: true, children: fitLine(`delete ${shortId(props.prompt.runId)}? ${action}`, contentWidth) }) }));
644
777
  }
778
+ function MergePromptBox(props) {
779
+ const contentWidth = Math.max(24, props.width - 4);
780
+ const subject = props.prompt.kind === "selected"
781
+ ? `merge ${shortId(props.prompt.runId)} ${props.prompt.label}`
782
+ : `merge ${props.prompt.runIds.length} completed run${props.prompt.runIds.length === 1 ? "" : "s"}`;
783
+ const prefix = "press ";
784
+ const action = "y to merge";
785
+ const suffix = ", n to cancel";
786
+ const hint = fitLine(`${prefix}${action}${suffix}`, contentWidth);
787
+ return (_jsxs(Box, { width: props.width, borderStyle: "double", borderColor: "yellow", paddingX: 1, flexDirection: "column", children: [_jsx(Text, { color: "yellow", bold: true, children: fitLine(subject, contentWidth) }), _jsxs(Text, { children: [_jsx(Text, { color: "gray", children: hint.slice(0, prefix.length) }), _jsx(Text, { color: "red", bold: true, children: hint.slice(prefix.length, prefix.length + action.length) }), _jsx(Text, { color: "gray", children: hint.slice(prefix.length + action.length) })] })] }));
788
+ }
789
+ function MergeConflictPromptBox(props) {
790
+ const contentWidth = Math.max(24, props.width - 4);
791
+ const files = props.prompt.files.length ? props.prompt.files.join(", ") : "unknown files";
792
+ return (_jsxs(Box, { width: props.width, borderStyle: "double", borderColor: "red", paddingX: 1, flexDirection: "column", children: [_jsx(Text, { color: "red", bold: true, children: fitLine(`merge conflict ${shortId(props.prompt.runId)}`, contentWidth) }), _jsx(Text, { color: "gray", children: fitLine(files, contentWidth) }), _jsx(Text, { color: "gray", children: fitLine("press y for AI help, n to handle manually", contentWidth) })] }));
793
+ }
645
794
  function PromptDock(props) {
646
795
  const label = props.targetRun ? `${isActive(props.targetRun.status) ? "interrupt" : "agent"} ${shortId(props.targetRun.id)}` : "task";
647
796
  const showTextLabel = !props.focused || Boolean(props.targetRun);
@@ -651,7 +800,7 @@ function StatusDock(props) {
651
800
  return (_jsxs(Box, { borderStyle: "single", borderColor: "gray", paddingX: 1, justifyContent: "space-between", children: [_jsx(Text, { color: "gray", children: "worker input is active inside the selected agent pane" }), _jsx(Text, { color: "gray", children: props.notice })] }));
652
801
  }
653
802
  function Footer(props) {
654
- return (_jsx(Box, { children: _jsxs(Text, { color: "gray", children: ["focus:", props.focusPane, " Tab focus / commands o model n new c worker d delete y copy m/M merge ? help"] }) }));
803
+ return (_jsx(Box, { children: _jsxs(Text, { color: "gray", children: ["focus:", props.focusPane, " Tab focus / commands o model n new c worker dd delete y copy m/M merge ? help"] }) }));
655
804
  }
656
805
  async function loadUiRuns(repoRoot) {
657
806
  const runs = await listRuns(repoRoot);
@@ -665,6 +814,7 @@ async function loadUiRuns(repoRoot) {
665
814
  output,
666
815
  events,
667
816
  work: buildWork(events, run),
817
+ attention: permissionAttentionFromOutput(output),
668
818
  };
669
819
  }));
670
820
  }
@@ -853,25 +1003,32 @@ function modelForBackend(backend, config) {
853
1003
  return undefined;
854
1004
  }
855
1005
  if (backend === "claude") {
856
- return config.backends.claude?.model === "opus" ? "sonnet" : config.backends.claude?.model;
1006
+ return config.backends.claude?.model;
857
1007
  }
858
1008
  if (backend === "codex") {
859
1009
  return config.backends.codex?.model;
860
1010
  }
861
1011
  return config.backends.acpx?.model;
862
1012
  }
863
- function notifyFinishedRuns(runs, ref) {
864
- const finished = new Set(runs.filter((run) => isTerminal(run.status)).map((run) => run.id));
1013
+ function notifyRunAlerts(runs, ref) {
1014
+ const terminal = new Set(runs.filter((run) => isTerminal(run.status)).map((run) => run.id));
1015
+ const permission = new Set(runs.filter((run) => runNeedsPermission(run)).map((run) => run.id));
865
1016
  if (!ref.current) {
866
- ref.current = finished;
1017
+ ref.current = { terminal, permission };
867
1018
  return;
868
1019
  }
869
1020
  for (const run of runs) {
870
- if (isTerminal(run.status) && !ref.current.has(run.id)) {
1021
+ if (isTerminal(run.status) && !ref.current.terminal.has(run.id)) {
1022
+ playCompletionSound();
1023
+ ref.current.terminal.add(run.id);
1024
+ }
1025
+ if (runNeedsPermission(run) && !ref.current.permission.has(run.id)) {
871
1026
  playCompletionSound();
872
- ref.current.add(run.id);
1027
+ ref.current.permission.add(run.id);
873
1028
  }
874
1029
  }
1030
+ ref.current.terminal = terminal;
1031
+ ref.current.permission = permission;
875
1032
  }
876
1033
  function playCompletionSound() {
877
1034
  try {
@@ -953,6 +1110,12 @@ function statusColor(status) {
953
1110
  }
954
1111
  return "cyan";
955
1112
  }
1113
+ function runStatusColor(run) {
1114
+ return runNeedsPermission(run) ? "yellow" : statusColor(run.status);
1115
+ }
1116
+ function runNeedsPermission(run) {
1117
+ return isActive(run.status) && run.attention.needsPermission;
1118
+ }
956
1119
  function toneColor(tone) {
957
1120
  if (tone === "success") {
958
1121
  return "green";
@@ -984,6 +1147,9 @@ function workGlyph(tone) {
984
1147
  return "--";
985
1148
  }
986
1149
  function completionPercent(run) {
1150
+ if (runNeedsPermission(run)) {
1151
+ return 90;
1152
+ }
987
1153
  if (run.status === "merged") {
988
1154
  return 100;
989
1155
  }
@@ -1027,6 +1193,9 @@ function canMerge(run) {
1027
1193
  return run.status === "completed" && run.worktree.enabled;
1028
1194
  }
1029
1195
  function runSummary(run) {
1196
+ if (runNeedsPermission(run)) {
1197
+ return run.attention.summary ? `needs permission: ${run.attention.summary}` : "needs permission";
1198
+ }
1030
1199
  const latestWork = run.work.at(-1);
1031
1200
  if (run.status === "steering") {
1032
1201
  return "auto-steering after completion";
@@ -1041,6 +1210,9 @@ function runSummary(run) {
1041
1210
  return run.status;
1042
1211
  }
1043
1212
  function workerStateLabel(run) {
1213
+ if (runNeedsPermission(run)) {
1214
+ return "needs permission";
1215
+ }
1044
1216
  if (run.status === "completed") {
1045
1217
  return canMerge(run) ? "done m merge" : "done";
1046
1218
  }
@@ -1059,6 +1231,9 @@ function workerStateLabel(run) {
1059
1231
  return "running";
1060
1232
  }
1061
1233
  function agentRailSummary(run) {
1234
+ if (runNeedsPermission(run)) {
1235
+ return run.attention.summary ?? "waiting for permission";
1236
+ }
1062
1237
  const output = summarizeOutput(run.output);
1063
1238
  if (output) {
1064
1239
  return output;
@@ -1077,8 +1252,15 @@ function summarizeOutput(output) {
1077
1252
  const sentence = normalized.match(/^.{24,180}?[.!?](?:\s|$)/)?.[0]?.trim();
1078
1253
  return sentence || normalized.slice(0, 180);
1079
1254
  }
1080
- function statusWord(status) {
1081
- if (status === "completed" || status === "merged") {
1255
+ function statusWord(run) {
1256
+ if (runNeedsPermission(run)) {
1257
+ return "permission:";
1258
+ }
1259
+ const status = run.status;
1260
+ if (status === "merged") {
1261
+ return "merged:";
1262
+ }
1263
+ if (status === "completed") {
1082
1264
  return "done:";
1083
1265
  }
1084
1266
  if (status === "failed" || status === "merge-conflict") {