@viraatdas/rudder 0.5.12 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/tui.js CHANGED
@@ -4,9 +4,10 @@ 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 { permissionAttentionFromOutput } from "./agent-attention.js";
7
8
  import { currentBranch, findRepoRoot, hasChanges } 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";
11
12
  import { pathExists, shortenHome } from "./util.js";
12
13
  const INTERACTIVE_BACKENDS = ["claude", "codex"];
@@ -58,9 +59,11 @@ function RudderTui({ defaults }) {
58
59
  const [commandMenuIndex, setCommandMenuIndex] = useState(0);
59
60
  const [discoveredModels, setDiscoveredModels] = useState([]);
60
61
  const [deletePrompt, setDeletePrompt] = useState(null);
62
+ const [mergePrompt, setMergePrompt] = useState(null);
63
+ const [conflictPrompt, setConflictPrompt] = useState(null);
61
64
  const [submitting, setSubmitting] = useState(false);
62
65
  const [preferencesLoaded, setPreferencesLoaded] = useState(false);
63
- const notifiedFinishedRuns = useRef(null);
66
+ const notifiedAlerts = useRef(null);
64
67
  const refresh = useCallback(async () => {
65
68
  const root = findRepoRoot();
66
69
  const [nextConfig, nextBranch, nextRuns] = await Promise.all([
@@ -71,7 +74,7 @@ function RudderTui({ defaults }) {
71
74
  setRepoRoot(root);
72
75
  setConfig(nextConfig);
73
76
  setBranch(nextBranch);
74
- notifyFinishedRuns(nextRuns, notifiedFinishedRuns);
77
+ notifyRunAlerts(nextRuns, notifiedAlerts);
75
78
  setRuns(nextRuns);
76
79
  if (!preferencesLoaded) {
77
80
  setBackend(toInteractiveBackend(defaults.backend ?? nextConfig.lastUsedBackend ?? nextConfig.defaultBackend));
@@ -199,6 +202,107 @@ function RudderTui({ defaults }) {
199
202
  setNotice(error instanceof Error ? error.message : String(error));
200
203
  }
201
204
  }, [deletePrompt, refresh]);
205
+ const requestMergeRun = useCallback((runOverride, allowDirty = false) => {
206
+ const run = runOverride ?? selectedRun;
207
+ if (!run) {
208
+ setNotice("No agent selected");
209
+ return;
210
+ }
211
+ if (!canMerge(run)) {
212
+ setNotice(`${shortId(run.id)} is not ready to merge`);
213
+ return;
214
+ }
215
+ setDeletePrompt(null);
216
+ setConflictPrompt(null);
217
+ setMergePrompt({
218
+ kind: "selected",
219
+ runId: run.id,
220
+ label: truncate(run.task, 48),
221
+ allowDirty,
222
+ });
223
+ setNotice(`Merge ${shortId(run.id)}? press y to confirm or n to cancel`);
224
+ }, [selectedRun]);
225
+ const requestMergeAll = useCallback((allowDirty = false) => {
226
+ const ready = runs.filter(canMerge);
227
+ if (ready.length === 0) {
228
+ setNotice("No completed worktree runs ready to merge");
229
+ return;
230
+ }
231
+ setDeletePrompt(null);
232
+ setConflictPrompt(null);
233
+ setMergePrompt({ kind: "all", runIds: ready.map((run) => run.id), allowDirty });
234
+ setNotice(`Merge ${ready.length} run${ready.length === 1 ? "" : "s"}? press y to confirm or n to cancel`);
235
+ }, [runs]);
236
+ const confirmMerge = useCallback(async () => {
237
+ if (!mergePrompt) {
238
+ return;
239
+ }
240
+ const prompt = mergePrompt;
241
+ setMergePrompt(null);
242
+ try {
243
+ if (prompt.kind === "selected") {
244
+ const merged = await mergeRun(prompt.runId, prompt.allowDirty, { silent: true });
245
+ if (merged.merge?.status === "conflict") {
246
+ const files = merged.merge.conflictedFiles ?? [];
247
+ setConflictPrompt({ runId: prompt.runId, files });
248
+ setNotice(`Merge conflict in ${files.length || "unknown"} file${files.length === 1 ? "" : "s"}; press y for AI help or n for manual`);
249
+ }
250
+ else {
251
+ setNotice(`Merged ${shortId(prompt.runId)}`);
252
+ }
253
+ await refresh();
254
+ return;
255
+ }
256
+ let mergedCount = 0;
257
+ for (const runId of prompt.runIds) {
258
+ const merged = await mergeRun(runId, prompt.allowDirty, { silent: true });
259
+ if (merged.merge?.status === "conflict") {
260
+ const files = merged.merge.conflictedFiles ?? [];
261
+ setConflictPrompt({ runId, files });
262
+ setNotice(`Merge all stopped after ${mergedCount}: conflict in ${shortId(runId)}; press y for AI help or n for manual`);
263
+ await refresh();
264
+ return;
265
+ }
266
+ mergedCount += 1;
267
+ }
268
+ setNotice(`Merged ${mergedCount} run${mergedCount === 1 ? "" : "s"}`);
269
+ await refresh();
270
+ }
271
+ catch (error) {
272
+ setNotice(error instanceof Error ? error.message : String(error));
273
+ await refresh();
274
+ }
275
+ }, [mergePrompt, refresh]);
276
+ const startConflictResolver = useCallback(async () => {
277
+ if (!conflictPrompt) {
278
+ return;
279
+ }
280
+ const files = conflictPrompt.files.length ? conflictPrompt.files.join("\n") : "(git did not report conflicted files)";
281
+ const task = [
282
+ "Read RUDDER.md first. A git merge stopped with conflicts in this checkout.",
283
+ `Conflicted files:\n${files}`,
284
+ "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.",
285
+ ].join("\n\n");
286
+ try {
287
+ const run = await startRun({
288
+ task,
289
+ backend,
290
+ model,
291
+ detach: true,
292
+ worktree: false,
293
+ silent: true,
294
+ view: "shell",
295
+ });
296
+ setConflictPrompt(null);
297
+ setSelectedRunId(run.id);
298
+ setExpandedRunIds((current) => new Set(current).add(run.id));
299
+ setNotice(`Started AI merge-conflict resolver ${shortId(run.id)}`);
300
+ await refresh();
301
+ }
302
+ catch (error) {
303
+ setNotice(error instanceof Error ? error.message : String(error));
304
+ }
305
+ }, [backend, conflictPrompt, model, refresh]);
202
306
  const copySelectedTranscript = useCallback(async (runOverride) => {
203
307
  const run = runOverride ?? selectedRun;
204
308
  if (!run) {
@@ -229,7 +333,9 @@ function RudderTui({ defaults }) {
229
333
  return;
230
334
  case "backend":
231
335
  if (isInteractiveBackend(args[0])) {
232
- chooseBackend(args[0], setBackend, setModel, setNotice);
336
+ const nextBackend = args[0];
337
+ chooseBackend(nextBackend, setBackend, setModel, setNotice);
338
+ setConfig(await rememberBackendSelection({ backend: nextBackend }));
233
339
  }
234
340
  else {
235
341
  setNotice("Usage: /backend claude|codex");
@@ -267,6 +373,11 @@ function RudderTui({ defaults }) {
267
373
  else {
268
374
  const nextModel = args.join(" ");
269
375
  setModel(nextModel);
376
+ setConfig(await rememberBackendSelection({
377
+ backend,
378
+ model: nextModel,
379
+ updateModel: true,
380
+ }));
270
381
  setNotice(`Model ${nextModel}`);
271
382
  }
272
383
  setInput("");
@@ -289,11 +400,11 @@ function RudderTui({ defaults }) {
289
400
  setInput("");
290
401
  return;
291
402
  case "merge":
292
- await runAction(args[0] ?? selectedRun?.id, async (id) => mergeRun(id, args.includes("--allow-dirty"), { silent: true }), "Merged", setNotice, refresh);
403
+ requestMergeRun(resolveUiRun(runs, args[0] ?? selectedRun?.id), args.includes("--allow-dirty"));
293
404
  setInput("");
294
405
  return;
295
406
  case "merge-all":
296
- await mergeReadyRuns(runs, args.includes("--allow-dirty"), setNotice, refresh);
407
+ requestMergeAll(args.includes("--allow-dirty"));
297
408
  setInput("");
298
409
  return;
299
410
  case "clear":
@@ -305,7 +416,7 @@ function RudderTui({ defaults }) {
305
416
  setNotice(`Unknown command: /${command}`);
306
417
  setInput("");
307
418
  }
308
- }, [app, backend, copySelectedTranscript, refresh, requestDeleteSelectedRun, runs, selectedRun?.id]);
419
+ }, [app, backend, copySelectedTranscript, refresh, requestDeleteSelectedRun, requestMergeAll, requestMergeRun, runs, selectedRun?.id]);
309
420
  const selectModelOption = useCallback((index) => {
310
421
  const option = modelOptions[index];
311
422
  if (!option) {
@@ -315,7 +426,14 @@ function RudderTui({ defaults }) {
315
426
  setModelMenuOpen(false);
316
427
  setModelMenuIndex(index);
317
428
  setNotice(option.value ? `Model ${option.value}` : "Using backend default model");
318
- }, [modelOptions]);
429
+ void rememberBackendSelection({
430
+ backend,
431
+ model: option.value,
432
+ updateModel: true,
433
+ }).then(setConfig).catch((error) => {
434
+ setNotice(error instanceof Error ? error.message : String(error));
435
+ });
436
+ }, [backend, modelOptions]);
319
437
  const selectCommandOption = useCallback((index) => {
320
438
  const option = commandOptions[index];
321
439
  if (!option) {
@@ -355,6 +473,30 @@ function RudderTui({ defaults }) {
355
473
  }
356
474
  return;
357
475
  }
476
+ if (mergePrompt) {
477
+ if (key.escape || value === "n" || value === "N") {
478
+ setMergePrompt(null);
479
+ setNotice("Merge cancelled");
480
+ return;
481
+ }
482
+ if (value === "y" || value === "Y") {
483
+ void confirmMerge();
484
+ return;
485
+ }
486
+ return;
487
+ }
488
+ if (conflictPrompt) {
489
+ if (key.escape || value === "n" || value === "N") {
490
+ setConflictPrompt(null);
491
+ setNotice("Resolve the merge conflicts manually, then commit");
492
+ return;
493
+ }
494
+ if (value === "y" || value === "Y") {
495
+ void startConflictResolver();
496
+ return;
497
+ }
498
+ return;
499
+ }
358
500
  if (deletePrompt) {
359
501
  if (key.escape) {
360
502
  setDeletePrompt(null);
@@ -532,11 +674,11 @@ function RudderTui({ defaults }) {
532
674
  return;
533
675
  }
534
676
  if (input.length === 0 && value === "m" && selectedRun) {
535
- void runAction(selectedRun.id, async (id) => mergeRun(id, false, { silent: true }), "Merged", setNotice, refresh);
677
+ requestMergeRun(selectedRun);
536
678
  return;
537
679
  }
538
680
  if (input.length === 0 && value === "M") {
539
- void mergeReadyRuns(runs, false, setNotice, refresh);
681
+ requestMergeAll();
540
682
  return;
541
683
  }
542
684
  if (input.length === 0 && value === "d") {
@@ -565,7 +707,7 @@ function RudderTui({ defaults }) {
565
707
  const railWidth = Math.min(42, Math.max(30, Math.floor(width * 0.34)));
566
708
  const detailWidth = Math.max(30, width - railWidth - 1);
567
709
  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"
710
+ 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
711
  ? _jsx(StatusDock, { notice: notice })
570
712
  : _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
713
  }
@@ -579,13 +721,13 @@ function RunRail(props) {
579
721
  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
722
  }
581
723
  function RunCard(props) {
582
- const tone = statusColor(props.run.status);
724
+ const tone = runStatusColor(props.run);
583
725
  const label = props.selected ? (props.targeted ? ">>" : "> ") : " ";
584
726
  const task = truncate(props.run.task, Math.max(12, props.width - 14));
585
727
  const progress = completionPercent(props.run);
586
728
  const summary = truncate(agentRailSummary(props.run), Math.max(12, props.width - 7));
587
729
  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] })] }));
730
+ 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
731
  }
590
732
  function DetailPane(props) {
591
733
  if (!props.run) {
@@ -594,7 +736,7 @@ function DetailPane(props) {
594
736
  const composerHeight = props.focused ? 3 : 0;
595
737
  const outputHeight = Math.max(5, props.height - 6 - composerHeight);
596
738
  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] }));
739
+ 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
740
  }
599
741
  function WorkerComposer(props) {
600
742
  const active = isActive(props.run.status);
@@ -642,6 +784,18 @@ function DeletePromptBox(props) {
642
784
  : "d delete run, Esc cancel";
643
785
  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
786
  }
787
+ function MergePromptBox(props) {
788
+ const contentWidth = Math.max(24, props.width - 4);
789
+ const subject = props.prompt.kind === "selected"
790
+ ? `merge ${shortId(props.prompt.runId)} ${props.prompt.label}`
791
+ : `merge ${props.prompt.runIds.length} completed run${props.prompt.runIds.length === 1 ? "" : "s"}`;
792
+ 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) }), _jsx(Text, { color: "gray", children: fitLine("press y to merge, n to cancel", contentWidth) })] }));
793
+ }
794
+ function MergeConflictPromptBox(props) {
795
+ const contentWidth = Math.max(24, props.width - 4);
796
+ const files = props.prompt.files.length ? props.prompt.files.join(", ") : "unknown files";
797
+ 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) })] }));
798
+ }
645
799
  function PromptDock(props) {
646
800
  const label = props.targetRun ? `${isActive(props.targetRun.status) ? "interrupt" : "agent"} ${shortId(props.targetRun.id)}` : "task";
647
801
  const showTextLabel = !props.focused || Boolean(props.targetRun);
@@ -665,6 +819,7 @@ async function loadUiRuns(repoRoot) {
665
819
  output,
666
820
  events,
667
821
  work: buildWork(events, run),
822
+ attention: permissionAttentionFromOutput(output),
668
823
  };
669
824
  }));
670
825
  }
@@ -853,25 +1008,32 @@ function modelForBackend(backend, config) {
853
1008
  return undefined;
854
1009
  }
855
1010
  if (backend === "claude") {
856
- return config.backends.claude?.model === "opus" ? "sonnet" : config.backends.claude?.model;
1011
+ return config.backends.claude?.model;
857
1012
  }
858
1013
  if (backend === "codex") {
859
1014
  return config.backends.codex?.model;
860
1015
  }
861
1016
  return config.backends.acpx?.model;
862
1017
  }
863
- function notifyFinishedRuns(runs, ref) {
864
- const finished = new Set(runs.filter((run) => isTerminal(run.status)).map((run) => run.id));
1018
+ function notifyRunAlerts(runs, ref) {
1019
+ const terminal = new Set(runs.filter((run) => isTerminal(run.status)).map((run) => run.id));
1020
+ const permission = new Set(runs.filter((run) => runNeedsPermission(run)).map((run) => run.id));
865
1021
  if (!ref.current) {
866
- ref.current = finished;
1022
+ ref.current = { terminal, permission };
867
1023
  return;
868
1024
  }
869
1025
  for (const run of runs) {
870
- if (isTerminal(run.status) && !ref.current.has(run.id)) {
1026
+ if (isTerminal(run.status) && !ref.current.terminal.has(run.id)) {
871
1027
  playCompletionSound();
872
- ref.current.add(run.id);
1028
+ ref.current.terminal.add(run.id);
1029
+ }
1030
+ if (runNeedsPermission(run) && !ref.current.permission.has(run.id)) {
1031
+ playCompletionSound();
1032
+ ref.current.permission.add(run.id);
873
1033
  }
874
1034
  }
1035
+ ref.current.terminal = terminal;
1036
+ ref.current.permission = permission;
875
1037
  }
876
1038
  function playCompletionSound() {
877
1039
  try {
@@ -953,6 +1115,12 @@ function statusColor(status) {
953
1115
  }
954
1116
  return "cyan";
955
1117
  }
1118
+ function runStatusColor(run) {
1119
+ return runNeedsPermission(run) ? "yellow" : statusColor(run.status);
1120
+ }
1121
+ function runNeedsPermission(run) {
1122
+ return isActive(run.status) && run.attention.needsPermission;
1123
+ }
956
1124
  function toneColor(tone) {
957
1125
  if (tone === "success") {
958
1126
  return "green";
@@ -984,6 +1152,9 @@ function workGlyph(tone) {
984
1152
  return "--";
985
1153
  }
986
1154
  function completionPercent(run) {
1155
+ if (runNeedsPermission(run)) {
1156
+ return 90;
1157
+ }
987
1158
  if (run.status === "merged") {
988
1159
  return 100;
989
1160
  }
@@ -1027,6 +1198,9 @@ function canMerge(run) {
1027
1198
  return run.status === "completed" && run.worktree.enabled;
1028
1199
  }
1029
1200
  function runSummary(run) {
1201
+ if (runNeedsPermission(run)) {
1202
+ return run.attention.summary ? `needs permission: ${run.attention.summary}` : "needs permission";
1203
+ }
1030
1204
  const latestWork = run.work.at(-1);
1031
1205
  if (run.status === "steering") {
1032
1206
  return "auto-steering after completion";
@@ -1041,6 +1215,9 @@ function runSummary(run) {
1041
1215
  return run.status;
1042
1216
  }
1043
1217
  function workerStateLabel(run) {
1218
+ if (runNeedsPermission(run)) {
1219
+ return "needs permission";
1220
+ }
1044
1221
  if (run.status === "completed") {
1045
1222
  return canMerge(run) ? "done m merge" : "done";
1046
1223
  }
@@ -1059,6 +1236,9 @@ function workerStateLabel(run) {
1059
1236
  return "running";
1060
1237
  }
1061
1238
  function agentRailSummary(run) {
1239
+ if (runNeedsPermission(run)) {
1240
+ return run.attention.summary ?? "waiting for permission";
1241
+ }
1062
1242
  const output = summarizeOutput(run.output);
1063
1243
  if (output) {
1064
1244
  return output;
@@ -1077,7 +1257,11 @@ function summarizeOutput(output) {
1077
1257
  const sentence = normalized.match(/^.{24,180}?[.!?](?:\s|$)/)?.[0]?.trim();
1078
1258
  return sentence || normalized.slice(0, 180);
1079
1259
  }
1080
- function statusWord(status) {
1260
+ function statusWord(run) {
1261
+ if (runNeedsPermission(run)) {
1262
+ return "permission:";
1263
+ }
1264
+ const status = run.status;
1081
1265
  if (status === "completed" || status === "merged") {
1082
1266
  return "done:";
1083
1267
  }