@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
@@ -1,20 +1,28 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { spawn } from "node:child_process";
3
+ import fsp from "node:fs/promises";
3
4
  import { fileURLToPath } from "node:url";
4
5
  import { useCallback, useEffect, useMemo, useRef, useState } from "react";
5
6
  import { Box, Text, render, useInput, useWindowSize } from "ink";
7
+ import { permissionAttentionFromOutput } from "./agent-attention.js";
6
8
  import { discoverEffortOptions, fallbackEffortOptions } from "./effort.js";
7
- import { currentBranch, findRepoRoot, hasChanges } from "./git.js";
9
+ import { currentBranch, findRepoRoot } from "./git.js";
8
10
  import { discoverModelOptions, fallbackModelOptions } from "./models.js";
9
- import { startNativeRun, deleteRun, mergeRun, reconcileNativeTerminals, stopRun } from "./run-manager.js";
10
- import { listRuns, loadConfig, saveConfig } from "./state.js";
11
+ import { startNativePlan, startNativeRun, deleteRun, mergeRun, reconcileNativeTerminals, stopRun } from "./run-manager.js";
12
+ import { listRuns, loadConfig, outputPath, rememberBackendSelection } from "./state.js";
11
13
  import { loadTmuxDashboardState, updateTmuxDashboardState, } from "./tmux-state.js";
12
14
  import { detachClient, resizePane, selectPane } from "./tmux.js";
15
+ import { taskDisplayLabel } from "./task-summary.js";
13
16
  import { shortenHome } from "./util.js";
14
17
  const COMPLETION_SOUND = fileURLToPath(new URL("../assets/sounds/ping.mp3", import.meta.url));
18
+ const ATTENTION_TAIL_BYTES = 64 * 1024;
19
+ const TASK_HISTORY_LIMIT = 100;
15
20
  const SLASH_COMMANDS = [
16
21
  { label: "/backend claude", detail: "use Claude Code for new tasks", value: "/backend claude" },
17
22
  { label: "/backend codex", detail: "use Codex for new tasks", value: "/backend codex" },
23
+ { label: "/plan", detail: "toggle Rudder read-only plan mode", value: "/plan" },
24
+ { label: "/plan <task>", detail: "plan one task without toggling", value: "/plan ", complete: "/plan " },
25
+ { label: "/run <task>", detail: "start implementation even when plan mode is on", value: "/run ", complete: "/run " },
18
26
  { label: "/model", detail: "pick from available models", value: "/model" },
19
27
  { label: "/model <id>", detail: "set model for new tasks", value: "/model ", complete: "/model " },
20
28
  { label: "/clear", detail: "clear the task input", value: "/clear" },
@@ -51,14 +59,14 @@ function AgentPane({ defaults }) {
51
59
  const [selectedRunId, setSelectedRunId] = useState();
52
60
  const [notice, setNotice] = useState("");
53
61
  const [deleteIntent, setDeleteIntent] = useState(null);
54
- const finishedRef = useRef(null);
62
+ const alertRef = useRef(null);
55
63
  const refresh = useCallback(async () => {
56
64
  const root = findRepoRoot();
57
65
  await reconcileNativeTerminals(root).catch(() => undefined);
58
66
  const [nextBranch, nextConfig, nextRuns, state] = await Promise.all([
59
67
  currentBranch(root),
60
68
  loadConfig(),
61
- listRuns(root),
69
+ loadAgentPaneRuns(root),
62
70
  loadTmuxDashboardState(root, defaults.tmuxSessionName),
63
71
  ]);
64
72
  setRepoRoot(root);
@@ -73,7 +81,7 @@ function AgentPane({ defaults }) {
73
81
  return () => clearInterval(timer);
74
82
  }, [refresh]);
75
83
  useEffect(() => {
76
- notifyFinishedRuns(runs, finishedRef);
84
+ notifyRunAlerts(runs, alertRef);
77
85
  }, [runs]);
78
86
  const selectedIndex = Math.max(0, runs.findIndex((run) => run.id === selectedRunId));
79
87
  const selectedRun = runs[selectedIndex];
@@ -95,16 +103,6 @@ function AgentPane({ defaults }) {
95
103
  setNotice("");
96
104
  return;
97
105
  }
98
- if (chunk === "m" && deleteIntent.canMerge) {
99
- void deleteRun(deleteIntent.runId, { mergeFirst: true, force: true, silent: true })
100
- .then(() => {
101
- setDeleteIntent(null);
102
- setNotice(`deleted ${shortId(deleteIntent.runId)}`);
103
- return refresh();
104
- })
105
- .catch((error) => setNotice(error instanceof Error ? error.message : String(error)));
106
- return;
107
- }
108
106
  if (chunk === "d") {
109
107
  void deleteRun(deleteIntent.runId, { force: true, silent: true })
110
108
  .then(() => {
@@ -131,7 +129,12 @@ function AgentPane({ defaults }) {
131
129
  }
132
130
  if (chunk === "m" && selectedRun) {
133
131
  void mergeRun(selectedRun.id, false, { silent: true })
134
- .then(() => setNotice(`merged ${shortId(selectedRun.id)}`))
132
+ .then((merged) => {
133
+ setNotice(merged.merge?.status === "conflict"
134
+ ? `merge conflict ${shortId(selectedRun.id)}`
135
+ : `merged ${shortId(selectedRun.id)}`);
136
+ return refresh();
137
+ })
135
138
  .catch((error) => setNotice(error instanceof Error ? error.message : String(error)));
136
139
  return;
137
140
  }
@@ -140,14 +143,8 @@ function AgentPane({ defaults }) {
140
143
  return;
141
144
  }
142
145
  if (chunk === "d" && selectedRun) {
143
- void hasChanges(selectedRun.worktree.path)
144
- .then((canMerge) => {
145
- setDeleteIntent({ runId: selectedRun.id, canMerge: selectedRun.worktree.enabled && canMerge });
146
- setNotice(selectedRun.worktree.enabled && canMerge
147
- ? "delete? press m to merge then delete, d to delete, Esc cancel"
148
- : "delete? press d to delete, Esc cancel");
149
- })
150
- .catch((error) => setNotice(error instanceof Error ? error.message : String(error)));
146
+ setDeleteIntent({ runId: selectedRun.id });
147
+ setNotice("delete? press d to delete run + worktree, Esc cancel");
151
148
  return;
152
149
  }
153
150
  if (chunk === "q") {
@@ -157,7 +154,7 @@ function AgentPane({ defaults }) {
157
154
  const width = Math.max(24, size.columns);
158
155
  const maxRuns = Math.max(1, Math.floor((size.rows - 5) / 3));
159
156
  const visibleRuns = runs.slice(0, maxRuns);
160
- return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { bold: true, children: "rudder" }), _jsx(Text, { color: "gray", children: summarize(`${shortenHome(repoRoot)} ${branch}`, width) }), _jsxs(Text, { children: ["agents ", _jsxs(Text, { color: "gray", children: [runs.length, " runs"] })] }), visibleRuns.length === 0 ? _jsx(Text, { color: "gray", children: "No agents yet." }) : visibleRuns.map((run) => (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { color: run.id === selectedRun?.id ? "cyan" : taskColor(run), children: [run.id === selectedRun?.id ? "> " : " ", summarize(run.task, width - 3)] }), _jsxs(Text, { children: [_jsxs(Text, { color: statusColor(run), children: [" ", statusMark(run)] }), _jsxs(Text, { color: "gray", children: [" ", run.backend, " "] }), _jsx(Text, { color: "magenta", children: modelLabel(run, config) })] })] }, run.id))), notice ? _jsx(Text, { color: deleteIntent ? "red" : "yellow", children: summarize(notice, width) }) : null, _jsx(Text, { color: "gray", children: "j/k select Enter focus m merge d delete" })] }));
157
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { bold: true, children: "rudder" }), _jsx(Text, { color: "gray", children: summarize(`${shortenHome(repoRoot)} ${branch}`, width) }), _jsxs(Text, { children: ["agents ", _jsxs(Text, { color: "gray", children: [runs.length, " runs"] })] }), visibleRuns.length === 0 ? _jsx(Text, { color: "gray", children: "No agents yet." }) : visibleRuns.map((run) => (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { color: run.id === selectedRun?.id ? "cyan" : taskColor(run), children: [run.id === selectedRun?.id ? "> " : " ", summarize(taskDisplayLabel(run, 80), width - 3)] }), _jsxs(Text, { children: [_jsxs(Text, { color: runStatusColor(run), children: [" ", statusMark(run)] }), _jsxs(Text, { color: "gray", children: [" ", run.backend, " "] }), _jsx(Text, { color: "magenta", children: modelLabel(run, config) })] })] }, run.id))), notice ? _jsx(Text, { color: deleteIntent ? "red" : "yellow", children: summarize(notice, width) }) : null, _jsx(Text, { color: "gray", children: "j/k select Enter focus m merge dd delete" })] }));
161
158
  }
162
159
  function TaskPane({ defaults }) {
163
160
  const [repoRoot, setRepoRoot] = useState(() => findRepoRoot());
@@ -166,7 +163,11 @@ function TaskPane({ defaults }) {
166
163
  const [model, setModel] = useState(defaults.model);
167
164
  const [effort, setEffort] = useState();
168
165
  const [input, setInput] = useState("");
166
+ const [planMode, setPlanMode] = useState(false);
169
167
  const inputRef = useRef("");
168
+ const taskHistoryRef = useRef([]);
169
+ const taskHistoryIndexRef = useRef(null);
170
+ const taskHistoryDraftRef = useRef("");
170
171
  const [notice, setNotice] = useState("");
171
172
  const [submitting, setSubmitting] = useState(false);
172
173
  const [commandIndex, setCommandIndex] = useState(0);
@@ -220,6 +221,58 @@ function TaskPane({ defaults }) {
220
221
  inputRef.current = value;
221
222
  setInput(value);
222
223
  }, []);
224
+ const resetTaskHistoryNavigation = useCallback(() => {
225
+ taskHistoryIndexRef.current = null;
226
+ taskHistoryDraftRef.current = "";
227
+ }, []);
228
+ const editTaskInput = useCallback((next) => {
229
+ resetTaskHistoryNavigation();
230
+ setTaskInput(next);
231
+ }, [resetTaskHistoryNavigation, setTaskInput]);
232
+ const rememberTaskHistory = useCallback((value) => {
233
+ const trimmed = value.trim();
234
+ if (!trimmed) {
235
+ return;
236
+ }
237
+ const history = taskHistoryRef.current;
238
+ history.push(trimmed);
239
+ if (history.length > TASK_HISTORY_LIMIT) {
240
+ history.splice(0, history.length - TASK_HISTORY_LIMIT);
241
+ }
242
+ resetTaskHistoryNavigation();
243
+ }, [resetTaskHistoryNavigation]);
244
+ const showTaskHistory = useCallback((direction) => {
245
+ const history = taskHistoryRef.current;
246
+ if (!history.length) {
247
+ return false;
248
+ }
249
+ if (direction === "previous") {
250
+ const current = taskHistoryIndexRef.current;
251
+ if (current === null) {
252
+ taskHistoryDraftRef.current = inputRef.current;
253
+ taskHistoryIndexRef.current = history.length - 1;
254
+ }
255
+ else {
256
+ taskHistoryIndexRef.current = Math.max(0, Math.min(current, history.length - 1) - 1);
257
+ }
258
+ setTaskInput(history[taskHistoryIndexRef.current] ?? "");
259
+ return true;
260
+ }
261
+ const current = taskHistoryIndexRef.current;
262
+ if (current === null) {
263
+ return false;
264
+ }
265
+ if (current + 1 < history.length) {
266
+ taskHistoryIndexRef.current = current + 1;
267
+ setTaskInput(history[taskHistoryIndexRef.current] ?? "");
268
+ return true;
269
+ }
270
+ taskHistoryIndexRef.current = null;
271
+ const draft = taskHistoryDraftRef.current;
272
+ taskHistoryDraftRef.current = "";
273
+ setTaskInput(draft);
274
+ return true;
275
+ }, [setTaskInput]);
223
276
  useEffect(() => {
224
277
  setCommandIndex(0);
225
278
  }, [input]);
@@ -270,6 +323,7 @@ function TaskPane({ defaults }) {
270
323
  await submit(resolvedCommand.value);
271
324
  return;
272
325
  }
326
+ rememberTaskHistory(task);
273
327
  if (task === "/model") {
274
328
  setTaskInput("");
275
329
  setNotice("");
@@ -279,11 +333,42 @@ function TaskPane({ defaults }) {
279
333
  setPendingModel(null);
280
334
  return;
281
335
  }
336
+ if (task === "/plan") {
337
+ setPlanMode((current) => {
338
+ const next = !current;
339
+ setNotice(next ? "Plan mode on: Enter starts a read-only planner" : "Plan mode off");
340
+ return next;
341
+ });
342
+ setTaskInput("");
343
+ setModelPickerOpen(false);
344
+ return;
345
+ }
346
+ if (task.startsWith("/plan ")) {
347
+ const planTask = task.slice("/plan ".length).trim();
348
+ if (!planTask) {
349
+ setNotice("Usage: /plan <task>");
350
+ return;
351
+ }
352
+ await startPlanner(planTask);
353
+ return;
354
+ }
355
+ if (task.startsWith("/run ")) {
356
+ const runTask = task.slice("/run ".length).trim();
357
+ if (!runTask) {
358
+ setNotice("Usage: /run <task>");
359
+ return;
360
+ }
361
+ await startWorker(runTask);
362
+ return;
363
+ }
282
364
  if (task.startsWith("/model ")) {
283
365
  const nextModel = task.slice("/model ".length).trim() || undefined;
284
366
  setModel(nextModel);
285
367
  setEffort(undefined);
286
- await updateBackendDefaults(repoRoot, defaults.tmuxSessionName, backend, nextModel, undefined);
368
+ await updateBackendDefaults(repoRoot, defaults.tmuxSessionName, backend, nextModel, undefined, {
369
+ updateModel: true,
370
+ updateEffort: true,
371
+ });
287
372
  setTaskInput("");
288
373
  setNotice("");
289
374
  return;
@@ -294,7 +379,10 @@ function TaskPane({ defaults }) {
294
379
  setModel(undefined);
295
380
  const nextEffort = effortForBackend(nextBackend, config);
296
381
  setEffort(nextEffort);
297
- await updateBackendDefaults(repoRoot, defaults.tmuxSessionName, nextBackend, undefined, nextEffort);
382
+ await updateBackendDefaults(repoRoot, defaults.tmuxSessionName, nextBackend, undefined, nextEffort, {
383
+ updateModel: false,
384
+ updateEffort: false,
385
+ });
298
386
  setTaskInput("");
299
387
  setNotice("");
300
388
  setModelPickerOpen(false);
@@ -307,7 +395,7 @@ function TaskPane({ defaults }) {
307
395
  }
308
396
  if (task === "/help") {
309
397
  setTaskInput("");
310
- setNotice("/backend claude|codex, /model, /model <id>, /clear, /detach");
398
+ setNotice("/plan toggles read-only planning, /run bypasses it, /backend claude|codex, /model");
311
399
  return;
312
400
  }
313
401
  if (task === "/detach") {
@@ -318,6 +406,13 @@ function TaskPane({ defaults }) {
318
406
  setNotice("Unknown command. Type / to see commands.");
319
407
  return;
320
408
  }
409
+ if (planMode) {
410
+ await startPlanner(task);
411
+ return;
412
+ }
413
+ await startWorker(task);
414
+ }, [backend, config, defaults.tmuxSessionName, effort, effortOptionsFor, model, modelIndex, modelOptions, modelPickerStep, pendingModel, planMode, rememberTaskHistory, repoRoot, setTaskInput, submitting]);
415
+ async function startWorker(task) {
321
416
  const state = await loadTmuxDashboardState(repoRoot, defaults.tmuxSessionName);
322
417
  if (!state) {
323
418
  setNotice("Rudder tmux state is missing. Reopen rudder.");
@@ -345,7 +440,36 @@ function TaskPane({ defaults }) {
345
440
  finally {
346
441
  setSubmitting(false);
347
442
  }
348
- }, [backend, config, defaults.tmuxSessionName, effort, effortOptionsFor, model, modelIndex, modelOptions, modelPickerStep, pendingModel, repoRoot, setTaskInput, submitting]);
443
+ }
444
+ async function startPlanner(task) {
445
+ const state = await loadTmuxDashboardState(repoRoot, defaults.tmuxSessionName);
446
+ if (!state) {
447
+ setNotice("Rudder tmux state is missing. Reopen rudder.");
448
+ return;
449
+ }
450
+ setSubmitting(true);
451
+ try {
452
+ const run = await startNativePlan({
453
+ task,
454
+ backend,
455
+ model,
456
+ effort,
457
+ tmuxSessionName: defaults.tmuxSessionName,
458
+ workerPaneId: state.workerPaneId,
459
+ focus: true,
460
+ silent: true,
461
+ });
462
+ await updateTmuxDashboardState(repoRoot, defaults.tmuxSessionName, { selectedRunId: run.id, backend, model, effort });
463
+ setTaskInput("");
464
+ setNotice("Read-only planner started");
465
+ }
466
+ catch (error) {
467
+ setNotice(error instanceof Error ? error.message : String(error));
468
+ }
469
+ finally {
470
+ setSubmitting(false);
471
+ }
472
+ }
349
473
  useInput((chunk, key) => {
350
474
  if (key.ctrl && chunk === "c") {
351
475
  void detachClient(defaults.tmuxSessionName);
@@ -401,7 +525,10 @@ function TaskPane({ defaults }) {
401
525
  setBackend(nextBackend);
402
526
  setModel(nextModel);
403
527
  setEffort(nextEffort);
404
- void updateBackendDefaults(repoRoot, defaults.tmuxSessionName, nextBackend, nextModel, nextEffort);
528
+ void updateBackendDefaults(repoRoot, defaults.tmuxSessionName, nextBackend, nextModel, nextEffort, {
529
+ updateModel: true,
530
+ updateEffort: true,
531
+ });
405
532
  setModelPickerOpen(false);
406
533
  setModelPickerStep("model");
407
534
  setPendingModel(null);
@@ -415,18 +542,26 @@ function TaskPane({ defaults }) {
415
542
  if (returnIndex >= 0 && !key.ctrl && !key.meta) {
416
543
  const beforeReturn = stripControlInput(chunk.slice(0, returnIndex));
417
544
  if (beforeReturn) {
418
- setTaskInput((current) => current + beforeReturn);
545
+ editTaskInput((current) => current + beforeReturn);
419
546
  }
420
547
  void submit();
421
548
  return;
422
549
  }
423
550
  if (isLineClear(chunk, key)) {
424
- setTaskInput("");
551
+ editTaskInput("");
425
552
  setNotice("");
426
553
  return;
427
554
  }
428
555
  if (isWordDelete(chunk, key)) {
429
- setTaskInput((current) => deletePreviousWord(current));
556
+ editTaskInput((current) => deletePreviousWord(current));
557
+ return;
558
+ }
559
+ if (taskHistoryIndexRef.current !== null && key.upArrow) {
560
+ showTaskHistory("previous");
561
+ return;
562
+ }
563
+ if (taskHistoryIndexRef.current !== null && key.downArrow) {
564
+ showTaskHistory("next");
430
565
  return;
431
566
  }
432
567
  if (commandMenuOpen) {
@@ -439,7 +574,7 @@ function TaskPane({ defaults }) {
439
574
  return;
440
575
  }
441
576
  if (key.escape) {
442
- setTaskInput("");
577
+ editTaskInput("");
443
578
  setNotice("");
444
579
  return;
445
580
  }
@@ -448,7 +583,7 @@ function TaskPane({ defaults }) {
448
583
  if (commandMenuOpen) {
449
584
  const command = commandOptions[commandIndex];
450
585
  if (command?.complete) {
451
- setTaskInput(command.complete);
586
+ editTaskInput(command.complete);
452
587
  setNotice("");
453
588
  return;
454
589
  }
@@ -460,19 +595,28 @@ function TaskPane({ defaults }) {
460
595
  void submit();
461
596
  return;
462
597
  }
598
+ if (key.upArrow) {
599
+ showTaskHistory("previous");
600
+ return;
601
+ }
602
+ if (key.downArrow) {
603
+ showTaskHistory("next");
604
+ return;
605
+ }
463
606
  if (key.backspace || key.delete || chunk === "\u007f" || chunk === "\b") {
464
- setTaskInput((current) => current.slice(0, -1));
607
+ editTaskInput((current) => current.slice(0, -1));
465
608
  return;
466
609
  }
467
610
  if (chunk && !key.ctrl && !key.meta) {
468
- setTaskInput((current) => current + chunk);
611
+ editTaskInput((current) => current + chunk);
469
612
  }
470
613
  });
471
614
  const configured = model || (backend === "claude" ? config?.backends.claude?.model : config?.backends.codex?.model) || "default";
472
615
  const configuredEffort = effort || effortForBackend(backend, config) || "auto";
616
+ const entryMode = planMode ? "plan" : "run";
473
617
  return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { children: [_jsx(Text, { color: "cyan", bold: true, children: "TASK" }), " ", input, _jsx(Text, { color: "cyan", children: "_" }), submitting ? _jsx(Text, { color: "gray", children: " starting..." }) : null, !submitting && notice ? _jsxs(Text, { color: "yellow", children: [" ", notice] }) : null] }), commandMenuOpen ? (_jsx(CommandMenu, { commands: commandOptions, selected: commandIndex })) : modelPickerOpen ? (modelPickerStep === "effort"
474
618
  ? _jsx(EffortMenu, { option: pendingModel ?? modelOptions[modelIndex], selected: effortIndex, backend: toNativeBackend(pendingModel?.backend ?? backend), options: effortOptionsFor(toNativeBackend(pendingModel?.backend ?? backend)) })
475
- : _jsx(ModelMenu, { options: modelOptions, selected: modelIndex, backend: backend })) : (_jsxs(Text, { color: "gray", children: ["Enter start Tab focus pane / commands ", backend, " ", configured, " ", configuredEffort] }))] }));
619
+ : _jsx(ModelMenu, { options: modelOptions, selected: modelIndex, backend: backend })) : (_jsxs(Text, { color: "gray", children: ["Enter ", entryMode, " Up/Down history Tab focus pane /plan /run ", backend, " ", configured, " ", configuredEffort] }))] }));
476
620
  }
477
621
  function CommandMenu({ commands, selected }) {
478
622
  const command = commands[Math.max(0, Math.min(selected, commands.length - 1))];
@@ -497,6 +641,35 @@ function WorkerIdle(_props) {
497
641
  function toNativeBackend(backend) {
498
642
  return backend === "codex" ? "codex" : "claude";
499
643
  }
644
+ async function loadAgentPaneRuns(repoRoot) {
645
+ const runs = await listRuns(repoRoot);
646
+ return await Promise.all(runs.map(async (run) => {
647
+ const output = await readTailIfExists(outputPath(repoRoot, run.id));
648
+ return {
649
+ ...run,
650
+ attention: permissionAttentionFromOutput(output),
651
+ };
652
+ }));
653
+ }
654
+ async function readTailIfExists(file) {
655
+ const handle = await fsp.open(file, "r").catch(() => null);
656
+ if (!handle) {
657
+ return "";
658
+ }
659
+ try {
660
+ const stat = await handle.stat();
661
+ const length = Math.min(stat.size, ATTENTION_TAIL_BYTES);
662
+ const buffer = Buffer.alloc(length);
663
+ await handle.read(buffer, 0, length, Math.max(0, stat.size - length));
664
+ return buffer.toString("utf8");
665
+ }
666
+ catch {
667
+ return "";
668
+ }
669
+ finally {
670
+ await handle.close().catch(() => undefined);
671
+ }
672
+ }
500
673
  async function focusSelectedWorker(repoRoot, tmuxSessionName, run) {
501
674
  if (run?.terminal?.paneId) {
502
675
  await selectPane(run.terminal.paneId).catch(() => undefined);
@@ -507,22 +680,35 @@ async function focusSelectedWorker(repoRoot, tmuxSessionName, run) {
507
680
  await selectPane(state.workerPaneId).catch(() => undefined);
508
681
  }
509
682
  }
510
- function notifyFinishedRuns(runs, ref) {
511
- const finished = new Set(runs.filter((run) => isTerminalRun(run)).map((run) => run.id));
683
+ function notifyRunAlerts(runs, ref) {
684
+ const terminal = new Set(runs.filter((run) => isTerminalRun(run)).map((run) => run.id));
685
+ const permission = new Set(runs.filter((run) => runNeedsPermission(run)).map((run) => run.id));
512
686
  if (!ref.current) {
513
- ref.current = finished;
687
+ ref.current = { terminal, permission };
514
688
  return;
515
689
  }
516
690
  for (const run of runs) {
517
- if (isTerminalRun(run) && !ref.current.has(run.id)) {
691
+ if (isTerminalRun(run) && !ref.current.terminal.has(run.id)) {
518
692
  playCompletionSound();
519
- ref.current.add(run.id);
693
+ ref.current.terminal.add(run.id);
694
+ }
695
+ if (runNeedsPermission(run) && !ref.current.permission.has(run.id)) {
696
+ playCompletionSound();
697
+ ref.current.permission.add(run.id);
520
698
  }
521
699
  }
700
+ ref.current.terminal = terminal;
701
+ ref.current.permission = permission;
522
702
  }
523
703
  function isTerminalRun(run) {
524
704
  return ["completed", "failed", "cancelled", "merged", "merge-conflict"].includes(run.status);
525
705
  }
706
+ function isActiveRun(run) {
707
+ return ["created", "running", "steering", "verifying"].includes(run.status);
708
+ }
709
+ function runNeedsPermission(run) {
710
+ return isActiveRun(run) && run.attention.needsPermission;
711
+ }
526
712
  function playCompletionSound() {
527
713
  try {
528
714
  const player = process.platform === "darwin" ? "afplay" : "ffplay";
@@ -538,6 +724,10 @@ function playCompletionSound() {
538
724
  }
539
725
  }
540
726
  function statusMark(run) {
727
+ if (runNeedsPermission(run))
728
+ return "needs permission";
729
+ if (run.mode === "plan" && isActiveRun(run))
730
+ return "planning";
541
731
  if (run.status === "merged")
542
732
  return "merged";
543
733
  if (run.status === "completed")
@@ -550,6 +740,9 @@ function statusMark(run) {
550
740
  return "running";
551
741
  return "queued";
552
742
  }
743
+ function runStatusColor(run) {
744
+ return runNeedsPermission(run) ? "yellow" : statusColor(run);
745
+ }
553
746
  function statusColor(run) {
554
747
  if (run.status === "merged" || run.status === "completed")
555
748
  return "green";
@@ -571,7 +764,8 @@ function modelLabel(run, config) {
571
764
  : config?.backends.codex?.model)
572
765
  ?? "default";
573
766
  const effort = run.effort ?? effortForBackend(toNativeBackend(run.backend), config);
574
- return summarize(`${model} ${effort ?? "auto"}`, 18);
767
+ const mode = run.mode === "plan" ? "plan " : "";
768
+ return summarize(`${mode}${model} ${effort ?? "auto"}`, 18);
575
769
  }
576
770
  function modelForBackend(backend, config) {
577
771
  return backend === "claude" ? config?.backends.claude?.model : config?.backends.codex?.model;
@@ -582,25 +776,15 @@ function effortForBackend(backend, config) {
582
776
  }
583
777
  return config?.backends.codex?.reasoningEffort ?? config?.backends.codex?.effort;
584
778
  }
585
- async function updateBackendDefaults(repoRoot, tmuxSessionName, backend, model, effort) {
779
+ async function updateBackendDefaults(repoRoot, tmuxSessionName, backend, model, effort, options) {
586
780
  await updateTmuxDashboardState(repoRoot, tmuxSessionName, { backend, model, effort });
587
- const config = await loadConfig();
588
- config.lastUsedBackend = backend;
589
- if (backend === "claude") {
590
- config.backends.claude = {
591
- ...(config.backends.claude ?? {}),
592
- model,
593
- effort,
594
- };
595
- }
596
- else {
597
- config.backends.codex = {
598
- ...(config.backends.codex ?? {}),
599
- model,
600
- reasoningEffort: effort,
601
- };
602
- }
603
- await saveConfig(config);
781
+ await rememberBackendSelection({
782
+ backend,
783
+ model,
784
+ effort,
785
+ updateModel: options.updateModel,
786
+ updateEffort: options.updateEffort,
787
+ });
604
788
  }
605
789
  function withBackend(options, backend) {
606
790
  return options.map((option) => ({ ...option, backend }));
@@ -618,7 +802,7 @@ function isExactRunnableCommand(input) {
618
802
  return SLASH_COMMANDS.some((command) => !command.complete && command.value === trimmed);
619
803
  }
620
804
  function resolveSlashCommand(input) {
621
- if (!input.startsWith("/") || input.startsWith("/model ")) {
805
+ if (!input.startsWith("/") || input.startsWith("/model ") || input.startsWith("/plan ") || input.startsWith("/run ")) {
622
806
  return undefined;
623
807
  }
624
808
  if (isExactRunnableCommand(input)) {