parallel-codex-tui 0.1.4 → 0.1.6

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 (41) hide show
  1. package/.parallel-codex/config.example.toml +46 -0
  2. package/README.md +96 -19
  3. package/dist/cli-startup-preflight.js +18 -0
  4. package/dist/cli-startup-recovery.js +13 -1
  5. package/dist/cli.js +19 -7
  6. package/dist/core/clipboard.js +97 -0
  7. package/dist/core/collaboration-timeline.js +9 -2
  8. package/dist/core/config.js +161 -103
  9. package/dist/core/router.js +1 -1
  10. package/dist/core/session-index.js +234 -61
  11. package/dist/core/session-manager.js +25 -1
  12. package/dist/core/task-session-details.js +175 -0
  13. package/dist/core/task-state-machine.js +10 -9
  14. package/dist/doctor.js +58 -39
  15. package/dist/domain/schemas.js +15 -1
  16. package/dist/orchestrator/collaboration-channel.js +35 -3
  17. package/dist/orchestrator/final-acceptance.js +86 -0
  18. package/dist/orchestrator/orchestrator.js +405 -69
  19. package/dist/orchestrator/prompts.js +42 -3
  20. package/dist/orchestrator/workspace-sandbox.js +16 -0
  21. package/dist/tui/App.js +514 -56
  22. package/dist/tui/AppShell.js +9 -3
  23. package/dist/tui/FeatureBoardView.js +7 -2
  24. package/dist/tui/InputBar.js +87 -15
  25. package/dist/tui/StatusBar.js +1 -1
  26. package/dist/tui/StatusDetailView.js +164 -0
  27. package/dist/tui/TaskSessionDetailView.js +222 -0
  28. package/dist/tui/TaskSessionsView.js +5 -2
  29. package/dist/tui/WorkerOutputView.js +6 -1
  30. package/dist/tui/WorkerOverviewView.js +23 -4
  31. package/dist/tui/keyboard.js +6 -0
  32. package/dist/tui/status-line.js +42 -0
  33. package/dist/version.js +1 -1
  34. package/dist/workers/capabilities.js +4 -3
  35. package/dist/workers/live-probe.js +4 -3
  36. package/dist/workers/mock-adapter.js +37 -5
  37. package/dist/workers/native-attach.js +32 -17
  38. package/dist/workers/process-adapter.js +2 -0
  39. package/dist/workers/provider.js +26 -0
  40. package/dist/workers/registry.js +12 -22
  41. package/package.json +7 -1
package/dist/tui/App.js CHANGED
@@ -3,9 +3,10 @@ import { useEffect, useMemo, useRef, useState } from "react";
3
3
  import { basename } from "node:path";
4
4
  import { Box, Text, useApp, useInput, useStdin } from "ink";
5
5
  import { Lexer } from "marked";
6
+ import { copyTextToClipboard } from "../core/clipboard.js";
6
7
  import { readJson } from "../core/file-store.js";
7
8
  import { WorkerStatusSchema } from "../domain/schemas.js";
8
- import { effectiveWorkerWatchdog, formatRoutePendingStatus, formatSelectedWorkerStatus, formatRouteStatus, formatStatusLine, formatWorkerRuntimeStatus } from "./status-line.js";
9
+ import { effectiveWorkerWatchdog, formatRoutePendingStatus, formatRoutePendingSummaryStatus, formatRouteStatus, formatRouteSummaryStatus, formatStatusLine, formatWorkerRuntimeStatus } from "./status-line.js";
9
10
  import { applyChatInputChunk, insertChatPaste } from "./chat-input.js";
10
11
  import { chatRequestHistory, navigateChatDraftHistory } from "./chat-history.js";
11
12
  import { createChatPasteDecoder } from "./chat-paste.js";
@@ -18,7 +19,7 @@ import { TerminalOutput } from "./TerminalOutput.js";
18
19
  import { NativeTerminalScreen } from "./terminal-screen.js";
19
20
  import { WorkerOutputView } from "./WorkerOutputView.js";
20
21
  import { compactEndByDisplayWidth, displayWidth, wrapByDisplayWidth } from "./display-width.js";
21
- import { isAttachShortcut, isExitShortcut, isLogsShortcut, isNewTaskShortcut, isRouterDiagnosticsShortcut, isTaskResultShortcut, isTaskSessionsShortcut, isWorkerOverviewShortcut, isWorkerSearchShortcut, isWorkspaceShortcut, mouseScrollDelta, rawHistoryDelta, rawPageScrollDelta, scrollDelta, workerLogJumpKind } from "./keyboard.js";
22
+ import { isAttachShortcut, isCopyShortcut, isExitShortcut, isLogsShortcut, isNewTaskShortcut, isRouterDiagnosticsShortcut, isStatusDetailsShortcut, isTaskResultShortcut, isTaskSessionsShortcut, isWorkerOverviewShortcut, isWorkerSearchShortcut, isWorkspaceShortcut, mouseScrollDelta, rawHistoryDelta, rawPageScrollDelta, scrollDelta, workerLogJumpKind } from "./keyboard.js";
22
23
  import { createRawInputDecoder, tokenizeRawInput } from "./raw-input-decoder.js";
23
24
  import { decodeHtmlEntities } from "./markdown-text.js";
24
25
  import { configureTuiTheme, TUI_THEME } from "./theme.js";
@@ -28,8 +29,11 @@ import { moveWorkerSelection, WorkerOverviewView } from "./WorkerOverviewView.js
28
29
  import { FeatureBoardView, moveFeatureBoardSelection } from "./FeatureBoardView.js";
29
30
  import { CollaborationTimelineView, collaborationSelectionScrollOffset, collaborationTimelineEvents, moveCollaborationEventSelection, nextCollaborationFeatureIndex } from "./CollaborationTimelineView.js";
30
31
  import { moveTaskSessionSelection, TaskSessionsView } from "./TaskSessionsView.js";
32
+ import { moveTaskSessionDetailSelection, TaskSessionDetailView } from "./TaskSessionDetailView.js";
31
33
  import { latestTaskResultMessageIndex, parseTaskResultSummary } from "./task-result.js";
32
- import { buildNativeAttachLaunch, startNativeAttachProcess } from "../workers/native-attach.js";
34
+ import { buildNativeAttachLaunch, buildNativeForkLaunch, startNativeAttachProcess } from "../workers/native-attach.js";
35
+ import { assignableWorkerProviderIds, workerProviderLabel, workerProviders } from "../workers/provider.js";
36
+ import { StatusDetailView } from "./StatusDetailView.js";
33
37
  export function routeDecisionChatMessage(route) {
34
38
  return `route · ${route.mode} · ${route.source ?? "router"}\n${route.reason.trim()}`;
35
39
  }
@@ -44,7 +48,7 @@ const EMPTY_WORKER_NAVIGATION_TARGETS = {
44
48
  errorOffsets: [],
45
49
  diffOffsets: []
46
50
  };
47
- export function App({ config, orchestrator, cwd, initialTaskId = null, initialRoute = null, initialWorkers, initialCanRetryTask = false, initialMessages = [], persistChatMessage, workspaceChoices = [], switchWorkspace, loadRouterDiagnostics, loadTaskSessions, renameTaskSession, setTaskSessionArchived, deleteTaskSession, exportTaskSession, loadCollaborationTimeline, activateTaskSession, prepareNativeAttach, startNativeAttach, shutdownSignal }) {
51
+ export function App({ config, orchestrator, cwd, initialTaskId = null, initialRoute = null, initialWorkers, initialCanRetryTask = false, initialMessages = [], persistChatMessage, workspaceChoices = [], switchWorkspace, loadRouterDiagnostics, loadTaskSessions, loadTaskSessionDetails, renameTaskSession, setTaskSessionArchived, deleteTaskSession, exportTaskSession, loadCollaborationTimeline, activateTaskSession, prepareNativeAttach, prepareNativeFork, startNativeAttach, copyToClipboard = copyTextToClipboard, shutdownSignal }) {
48
52
  configureTuiTheme({
49
53
  theme: config.ui.theme,
50
54
  colors: config.ui.colors
@@ -70,6 +74,7 @@ export function App({ config, orchestrator, cwd, initialTaskId = null, initialRo
70
74
  const [activeMode, setActiveMode] = useState(initialTaskId ? "complex" : null);
71
75
  const [canRetryTask, setCanRetryTask] = useState(initialCanRetryTask);
72
76
  const [attachError, setAttachError] = useState(null);
77
+ const [clipboardNotice, setClipboardNotice] = useState(null);
73
78
  const [nativeInput, setNativeInput] = useState("");
74
79
  const [workerScrollOffset, setWorkerScrollOffset] = useState(0);
75
80
  const [workerSearch, setWorkerSearch] = useState({
@@ -95,9 +100,13 @@ export function App({ config, orchestrator, cwd, initialTaskId = null, initialRo
95
100
  const [taskSessionsNotice, setTaskSessionsNotice] = useState(null);
96
101
  const [taskSessionsIncludeArchived, setTaskSessionsIncludeArchived] = useState(false);
97
102
  const [taskSessionAction, setTaskSessionAction] = useState(null);
103
+ const [taskSessionDetails, setTaskSessionDetails] = useState(null);
104
+ const [taskSessionDetailSelectedWorkerIndex, setTaskSessionDetailSelectedWorkerIndex] = useState(0);
105
+ const [taskSessionDetailNotice, setTaskSessionDetailNotice] = useState(null);
98
106
  const [collaborationTimeline, setCollaborationTimeline] = useState(null);
99
107
  const [featureBoardSelectedIndex, setFeatureBoardSelectedIndex] = useState(0);
100
108
  const [featureCancelPrompt, setFeatureCancelPrompt] = useState(null);
109
+ const [featureAssignmentPrompt, setFeatureAssignmentPrompt] = useState(null);
101
110
  const [featureBoardNotice, setFeatureBoardNotice] = useState(null);
102
111
  const [collaborationLoading, setCollaborationLoading] = useState(false);
103
112
  const [collaborationError, setCollaborationError] = useState(null);
@@ -113,6 +122,8 @@ export function App({ config, orchestrator, cwd, initialTaskId = null, initialRo
113
122
  const mountedRef = useRef(true);
114
123
  const nativeAttachRef = useRef(nativeAttach);
115
124
  const nativeAttachRequestSequenceRef = useRef(0);
125
+ const clipboardNoticeTimerRef = useRef(null);
126
+ const copyTextByViewRef = useRef({ chat: "", worker: "" });
116
127
  const messagesRef = useRef([...initialMessages]);
117
128
  const activeRunControllerRef = useRef(null);
118
129
  const activeTaskIdRef = useRef(initialTaskId);
@@ -121,6 +132,7 @@ export function App({ config, orchestrator, cwd, initialTaskId = null, initialRo
121
132
  const inputCursorRef = useRef(inputCursor);
122
133
  const viewRef = useRef(view);
123
134
  const busyRef = useRef(busy);
135
+ const canRetryTaskRef = useRef(initialCanRetryTask);
124
136
  const routeFallbackPromptRef = useRef(null);
125
137
  const routeFallbackResolverRef = useRef(null);
126
138
  const workersRef = useRef(workers);
@@ -137,9 +149,12 @@ export function App({ config, orchestrator, cwd, initialTaskId = null, initialRo
137
149
  const taskSessionsLoadingRef = useRef(taskSessionsLoading);
138
150
  const taskSessionsIncludeArchivedRef = useRef(taskSessionsIncludeArchived);
139
151
  const taskSessionActionRef = useRef(taskSessionAction);
152
+ const taskSessionDetailsRef = useRef(null);
153
+ const taskSessionDetailSelectedWorkerIndexRef = useRef(0);
140
154
  const collaborationTimelineRef = useRef(null);
141
155
  const featureBoardSelectedIndexRef = useRef(0);
142
156
  const featureCancelPromptRef = useRef(null);
157
+ const featureAssignmentPromptRef = useRef(null);
143
158
  const collaborationFeatureIndexRef = useRef(-1);
144
159
  const collaborationSelectedEventIdRef = useRef(null);
145
160
  const collaborationDetailOpenRef = useRef(false);
@@ -157,6 +172,7 @@ export function App({ config, orchestrator, cwd, initialTaskId = null, initialRo
157
172
  const openTaskSessionsRef = useRef(openTaskSessions);
158
173
  const openFeatureBoardRef = useRef(openFeatureBoard);
159
174
  const confirmFeatureCancellationRef = useRef(confirmFeatureCancellation);
175
+ const reassignSelectedFeatureRef = useRef(reassignSelectedFeature);
160
176
  const openCollaborationTimelineRef = useRef(openCollaborationTimeline);
161
177
  const refreshCollaborationTimelineRef = useRef(refreshCollaborationTimeline);
162
178
  const activateSelectedTaskSessionRef = useRef(activateSelectedTaskSession);
@@ -165,11 +181,16 @@ export function App({ config, orchestrator, cwd, initialTaskId = null, initialRo
165
181
  const archiveSelectedTaskSessionRef = useRef(archiveSelectedTaskSession);
166
182
  const deleteSelectedTaskSessionRef = useRef(deleteSelectedTaskSession);
167
183
  const exportSelectedTaskSessionRef = useRef(exportSelectedTaskSession);
184
+ const openSelectedTaskSessionDetailsRef = useRef(openSelectedTaskSessionDetails);
185
+ const refreshTaskSessionDetailsRef = useRef(refreshTaskSessionDetails);
186
+ const openTaskSessionDetailWorkerRef = useRef(openTaskSessionDetailWorker);
187
+ const copyVisibleViewRef = useRef(copyVisibleView);
168
188
  const workspaceReturnViewRef = useRef("chat");
169
189
  const routerReturnViewRef = useRef("chat");
170
190
  const workerOverviewReturnViewRef = useRef("chat");
171
191
  const taskSessionsReturnViewRef = useRef("chat");
172
192
  const collaborationReturnViewRef = useRef("workers");
193
+ const statusReturnViewRef = useRef("chat");
173
194
  const collaborationLoadSequenceRef = useRef(0);
174
195
  const routerLoadSequenceRef = useRef(0);
175
196
  const taskSessionsLoadSequenceRef = useRef(0);
@@ -183,34 +204,31 @@ export function App({ config, orchestrator, cwd, initialTaskId = null, initialRo
183
204
  const contentHeight = appContentHeight(terminalSize.rows, Boolean(attachError), config.ui.showStatusBar);
184
205
  const outputHeight = Math.max(1, contentHeight);
185
206
  const terminalWidth = terminalSize.columns;
186
- const workerActivityPolicies = useMemo(() => ({
187
- codex: {
188
- timeoutMs: config.workers.codex.timeoutMs,
189
- idleTimeoutMs: config.workers.codex.idleTimeoutMs,
190
- firstOutputTimeoutMs: config.workers.codex.firstOutputTimeoutMs
191
- },
192
- claude: {
193
- timeoutMs: config.workers.claude.timeoutMs,
194
- idleTimeoutMs: config.workers.claude.idleTimeoutMs,
195
- firstOutputTimeoutMs: config.workers.claude.firstOutputTimeoutMs
196
- },
197
- mock: {
198
- timeoutMs: config.workers.mock.timeoutMs,
199
- idleTimeoutMs: config.workers.mock.idleTimeoutMs,
200
- firstOutputTimeoutMs: config.workers.mock.firstOutputTimeoutMs
201
- }
202
- }), [config.workers.claude, config.workers.codex, config.workers.mock]);
207
+ const workerActivityPolicies = useMemo(() => Object.fromEntries(workerProviders(config).map(({ id, config: provider }) => [id, {
208
+ timeoutMs: provider.timeoutMs,
209
+ idleTimeoutMs: provider.idleTimeoutMs,
210
+ firstOutputTimeoutMs: provider.firstOutputTimeoutMs
211
+ }])), [config.workers]);
212
+ const assignableProviderIds = useMemo(() => assignableWorkerProviderIds(config), [config.workers]);
203
213
  const selectedBoardFeature = collaborationTimeline?.features[featureBoardSelectedIndex];
214
+ const selectedTaskSessionDetailWorker = taskSessionDetails?.workers[taskSessionDetailSelectedWorkerIndex];
215
+ const taskSessionDetailHasNative = Boolean(selectedTaskSessionDetailWorker?.nativeSession && !taskSessionDetails?.task.archived_at);
216
+ const taskSessionDetailCanFork = Boolean(taskSessionDetailHasNative
217
+ && selectedTaskSessionDetailWorker
218
+ && (config.workers[selectedTaskSessionDetailWorker.engine]?.interactive.forkArgs.length ?? 0) > 0);
204
219
  const featureCanCancel = busy && (selectedBoardFeature?.state === "actor_running"
205
220
  || selectedBoardFeature?.state === "critic_running");
221
+ const featureCanReassign = assignableProviderIds.length > 1
222
+ && !busy
223
+ && canRetryTask
224
+ && selectedBoardFeature?.state !== "approved";
206
225
  const visibleStatus = statusLineWithWorkerRefs(status, workers);
207
- const selectedWorkerStatus = formatSelectedWorkerStatus(visibleStatus, selectedWorkerIndex);
208
- const visibleWorkerStatus = view === "chat" || view === "router" || view === "sessions" || view === "features" || view === "collaboration"
209
- ? ""
210
- : selectedWorkerStatus;
211
226
  const visibleRouteStatus = routePending
212
227
  ? formatRoutePendingStatus(routePending, routeElapsedMs)
213
228
  : formatRouteStatus(lastRoute);
229
+ const visibleRouteSummary = routePending
230
+ ? formatRoutePendingSummaryStatus(routePending, routeElapsedMs)
231
+ : formatRouteSummaryStatus(lastRoute);
214
232
  const visibleTaskStatus = routePending && !activeTaskId ? "" : formatStatusLine(visibleStatus);
215
233
  const workerRefreshKey = workers.map((worker) => `${worker.id}\u0000${worker.statusPath}`).join("\u0001");
216
234
  const taskResultMessageIndex = useMemo(() => activeTaskId ? latestTaskResultMessageIndex(messages, activeTaskId) : -1, [activeTaskId, messages]);
@@ -245,6 +263,9 @@ export function App({ config, orchestrator, cwd, initialTaskId = null, initialRo
245
263
  useEffect(() => {
246
264
  busyRef.current = busy;
247
265
  }, [busy]);
266
+ useEffect(() => {
267
+ canRetryTaskRef.current = canRetryTask;
268
+ }, [canRetryTask]);
248
269
  useEffect(() => {
249
270
  taskResultExpandedRef.current = taskResultExpanded;
250
271
  }, [taskResultExpanded]);
@@ -281,6 +302,12 @@ export function App({ config, orchestrator, cwd, initialTaskId = null, initialRo
281
302
  useEffect(() => {
282
303
  taskSessionActionRef.current = taskSessionAction;
283
304
  }, [taskSessionAction]);
305
+ useEffect(() => {
306
+ taskSessionDetailsRef.current = taskSessionDetails;
307
+ }, [taskSessionDetails]);
308
+ useEffect(() => {
309
+ taskSessionDetailSelectedWorkerIndexRef.current = taskSessionDetailSelectedWorkerIndex;
310
+ }, [taskSessionDetailSelectedWorkerIndex]);
284
311
  useEffect(() => {
285
312
  featureBoardSelectedIndexRef.current = featureBoardSelectedIndex;
286
313
  }, [featureBoardSelectedIndex]);
@@ -341,6 +368,10 @@ export function App({ config, orchestrator, cwd, initialTaskId = null, initialRo
341
368
  archiveSelectedTaskSessionRef.current = archiveSelectedTaskSession;
342
369
  deleteSelectedTaskSessionRef.current = deleteSelectedTaskSession;
343
370
  exportSelectedTaskSessionRef.current = exportSelectedTaskSession;
371
+ openSelectedTaskSessionDetailsRef.current = openSelectedTaskSessionDetails;
372
+ refreshTaskSessionDetailsRef.current = refreshTaskSessionDetails;
373
+ openTaskSessionDetailWorkerRef.current = openTaskSessionDetailWorker;
374
+ copyVisibleViewRef.current = copyVisibleView;
344
375
  });
345
376
  useEffect(() => {
346
377
  if ((view !== "collaboration" && view !== "features") || !activeTaskId || !loadCollaborationTimeline) {
@@ -560,7 +591,7 @@ export function App({ config, orchestrator, cwd, initialTaskId = null, initialRo
560
591
  }, [config.ui.autoOpenFailedWorker, status?.taskId, workerActivityPolicies, workerRefreshKey]);
561
592
  useEffect(() => {
562
593
  setRawMode(true);
563
- process.stdout.write("\x1b[?2004h\x1b[?1000h\x1b[?1002h\x1b[?1006h");
594
+ process.stdout.write("\x1b[?2004h\x1b[?1000h\x1b[?1006h");
564
595
  const commitChatInputUpdate = (update, previousValue, previousCursor) => {
565
596
  if (update.exit) {
566
597
  activeRunControllerRef.current?.abort();
@@ -608,11 +639,18 @@ export function App({ config, orchestrator, cwd, initialTaskId = null, initialRo
608
639
  setTaskSessionsError(null);
609
640
  setSelectedTaskSessionIndex(nextIndex);
610
641
  };
642
+ const moveSelectedTaskSessionDetailWorker = (delta, wrap = false) => {
643
+ const nextIndex = moveTaskSessionDetailSelection(taskSessionDetailSelectedWorkerIndexRef.current, delta, taskSessionDetailsRef.current?.workers.length ?? 0, wrap);
644
+ taskSessionDetailSelectedWorkerIndexRef.current = nextIndex;
645
+ setTaskSessionDetailNotice(null);
646
+ setTaskSessionDetailSelectedWorkerIndex(nextIndex);
647
+ };
611
648
  const moveSelectedFeature = (delta, wrap = false) => {
612
649
  const nextIndex = moveFeatureBoardSelection(featureBoardSelectedIndexRef.current, delta, collaborationTimelineRef.current?.features.length ?? 0, wrap);
613
650
  featureBoardSelectedIndexRef.current = nextIndex;
614
651
  setCollaborationError(null);
615
652
  updateFeatureCancelPrompt(null);
653
+ updateFeatureAssignmentPrompt(null);
616
654
  setFeatureBoardNotice(null);
617
655
  setFeatureBoardSelectedIndex(nextIndex);
618
656
  };
@@ -650,6 +688,10 @@ export function App({ config, orchestrator, cwd, initialTaskId = null, initialRo
650
688
  return;
651
689
  }
652
690
  const currentView = viewRef.current;
691
+ if (currentView !== "workspace" && tokenizeRawInput(chunk).some((inputChunk) => isCopyShortcut(inputChunk, {}))) {
692
+ void copyVisibleViewRef.current();
693
+ return;
694
+ }
653
695
  if (currentView === "chat" && routeFallbackPromptRef.current) {
654
696
  const fallbackChunks = tokenizeRawInput(chunk);
655
697
  if (fallbackChunks.some((fallbackChunk) => isExitShortcut(fallbackChunk, {}))) {
@@ -657,6 +699,13 @@ export function App({ config, orchestrator, cwd, initialTaskId = null, initialRo
657
699
  exitRef.current();
658
700
  return;
659
701
  }
702
+ if (fallbackChunks.some((fallbackChunk) => isStatusDetailsShortcut(fallbackChunk, {}))) {
703
+ statusReturnViewRef.current = "chat";
704
+ viewRef.current = "status";
705
+ setAttachError(null);
706
+ setView("status");
707
+ return;
708
+ }
660
709
  for (const fallbackChunk of fallbackChunks) {
661
710
  if (fallbackChunk === "\x1b") {
662
711
  settleRouteFallbackChoice("cancel");
@@ -680,12 +729,72 @@ export function App({ config, orchestrator, cwd, initialTaskId = null, initialRo
680
729
  if (currentView === "workspace") {
681
730
  return;
682
731
  }
732
+ if (currentView === "status") {
733
+ if (isExitShortcut(chunk, {})) {
734
+ activeRunControllerRef.current?.abort();
735
+ exitRef.current();
736
+ return;
737
+ }
738
+ if (isStatusDetailsShortcut(chunk, {}) || chunk === "\x1b") {
739
+ viewRef.current = statusReturnViewRef.current;
740
+ setView(statusReturnViewRef.current);
741
+ }
742
+ return;
743
+ }
744
+ if (isStatusDetailsShortcut(chunk, {})) {
745
+ statusReturnViewRef.current = currentView;
746
+ viewRef.current = "status";
747
+ setAttachError(null);
748
+ setView("status");
749
+ return;
750
+ }
683
751
  if (currentView === "sessions") {
684
752
  if (isExitShortcut(chunk, {})) {
685
753
  activeRunControllerRef.current?.abort();
686
754
  exitRef.current();
687
755
  return;
688
756
  }
757
+ if (taskSessionDetailsRef.current) {
758
+ if (isTaskSessionsShortcut(chunk, {}) || chunk === "\x1b") {
759
+ taskSessionDetailsRef.current = null;
760
+ taskSessionDetailSelectedWorkerIndexRef.current = 0;
761
+ setTaskSessionDetails(null);
762
+ setTaskSessionDetailSelectedWorkerIndex(0);
763
+ setTaskSessionDetailNotice(null);
764
+ setTaskSessionsError(null);
765
+ return;
766
+ }
767
+ if (taskSessionsLoadingRef.current) {
768
+ return;
769
+ }
770
+ if (chunk === "r" || chunk === "R") {
771
+ void refreshTaskSessionDetailsRef.current();
772
+ return;
773
+ }
774
+ if (chunk === "c" || chunk === "C" || chunk === "\u000f") {
775
+ void openTaskSessionDetailWorkerRef.current("resume");
776
+ return;
777
+ }
778
+ if (chunk === "b" || chunk === "B") {
779
+ void openTaskSessionDetailWorkerRef.current("fork");
780
+ return;
781
+ }
782
+ if (chunk === "\r" || chunk === "\n") {
783
+ void openTaskSessionDetailWorkerRef.current("logs");
784
+ return;
785
+ }
786
+ if (chunk === "\t") {
787
+ moveSelectedTaskSessionDetailWorker(1, true);
788
+ return;
789
+ }
790
+ const detailSelectionDelta = -(rawHistoryDelta(chunk)
791
+ + rawPageScrollDelta(chunk, Math.max(1, outputHeight - 2))
792
+ + mouseScrollDelta(chunk, 1));
793
+ if (detailSelectionDelta !== 0) {
794
+ moveSelectedTaskSessionDetailWorker(detailSelectionDelta);
795
+ }
796
+ return;
797
+ }
689
798
  const sessionAction = taskSessionActionRef.current;
690
799
  if (sessionAction) {
691
800
  if (chunk === "\x1b") {
@@ -787,6 +896,10 @@ export function App({ config, orchestrator, cwd, initialTaskId = null, initialRo
787
896
  void exportSelectedTaskSessionRef.current();
788
897
  return;
789
898
  }
899
+ if (chunk === "i" || chunk === "I") {
900
+ void openSelectedTaskSessionDetailsRef.current();
901
+ return;
902
+ }
790
903
  if (chunk === "h" || chunk === "H") {
791
904
  const includeArchived = !taskSessionsIncludeArchivedRef.current;
792
905
  taskSessionsIncludeArchivedRef.current = includeArchived;
@@ -826,13 +939,33 @@ export function App({ config, orchestrator, cwd, initialTaskId = null, initialRo
826
939
  setFeatureBoardNotice(null);
827
940
  return;
828
941
  }
829
- if (featureChunk === "x" || featureChunk === "X") {
942
+ if ((pendingCancel.action === "cancel" && (featureChunk === "x" || featureChunk === "X"))
943
+ || (pendingCancel.action === "pause" && (featureChunk === "p" || featureChunk === "P"))) {
830
944
  void confirmFeatureCancellationRef.current(pendingCancel);
831
945
  return;
832
946
  }
833
947
  }
834
948
  return;
835
949
  }
950
+ const pendingAssignment = featureAssignmentPromptRef.current;
951
+ if (pendingAssignment) {
952
+ for (const featureChunk of featureChunks) {
953
+ if (featureChunk === "\x1b" || featureChunk === "m" || featureChunk === "M") {
954
+ updateFeatureAssignmentPrompt(null);
955
+ setFeatureBoardNotice(null);
956
+ return;
957
+ }
958
+ if (featureChunk === "a" || featureChunk === "A") {
959
+ void reassignSelectedFeatureRef.current(pendingAssignment, "actor");
960
+ return;
961
+ }
962
+ if (featureChunk === "c" || featureChunk === "C") {
963
+ void reassignSelectedFeatureRef.current(pendingAssignment, "critic");
964
+ return;
965
+ }
966
+ }
967
+ return;
968
+ }
836
969
  for (const featureChunk of featureChunks) {
837
970
  if (featureChunk === "\x1b" || isWorkerOverviewShortcut(featureChunk, {})) {
838
971
  setCollaborationError(null);
@@ -855,11 +988,43 @@ export function App({ config, orchestrator, cwd, initialTaskId = null, initialRo
855
988
  setFeatureBoardNotice("Selected feature has no active Actor/Critic to cancel.");
856
989
  return;
857
990
  }
858
- const prompt = { taskId, featureId: feature.id, title: feature.title };
991
+ const prompt = { action: "cancel", taskId, featureId: feature.id, title: feature.title };
859
992
  updateFeatureCancelPrompt(prompt);
860
993
  setFeatureBoardNotice(`Cancel ${feature.title}? Active peers will finish; integration stays blocked.`);
861
994
  return;
862
995
  }
996
+ if (featureChunk === "p" || featureChunk === "P") {
997
+ const taskId = activeTaskIdRef.current;
998
+ const feature = collaborationTimelineRef.current?.features[featureBoardSelectedIndexRef.current];
999
+ const pausable = busyRef.current
1000
+ && (feature?.state === "actor_running" || feature?.state === "critic_running");
1001
+ if (!taskId || !feature || !pausable) {
1002
+ setFeatureBoardNotice("Selected feature has no active Actor/Critic to pause.");
1003
+ return;
1004
+ }
1005
+ const prompt = { action: "pause", taskId, featureId: feature.id, title: feature.title };
1006
+ updateFeatureCancelPrompt(prompt);
1007
+ setFeatureBoardNotice(`Pause ${feature.title}? Completed peer checkpoints will be kept.`);
1008
+ return;
1009
+ }
1010
+ if (featureChunk === "m" || featureChunk === "M") {
1011
+ const taskId = activeTaskIdRef.current;
1012
+ const feature = collaborationTimelineRef.current?.features[featureBoardSelectedIndexRef.current];
1013
+ const reassignable = !busyRef.current
1014
+ && canRetryTaskRef.current
1015
+ && feature?.state !== "approved";
1016
+ if (!taskId || !feature || !reassignable) {
1017
+ setFeatureBoardNotice("Pause or stop the task before reassigning this Feature.");
1018
+ return;
1019
+ }
1020
+ updateFeatureAssignmentPrompt({
1021
+ taskId,
1022
+ featureId: feature.id,
1023
+ title: feature.title
1024
+ });
1025
+ setFeatureBoardNotice(`Reassign ${feature.title} · A Actor (${feature.actorEngine ?? config.pairing.actor}) · C Critic (${feature.criticEngine ?? config.pairing.critic})`);
1026
+ return;
1027
+ }
863
1028
  if (featureChunk === "r" || featureChunk === "R") {
864
1029
  setFeatureBoardNotice(null);
865
1030
  void refreshCollaborationTimelineRef.current(false);
@@ -1322,7 +1487,7 @@ export function App({ config, orchestrator, cwd, initialTaskId = null, initialRo
1322
1487
  stdinEvents.removeListener("input", handleRawInput);
1323
1488
  rawInputDecoderRef.current.end();
1324
1489
  chatPasteDecoderRef.current.reset();
1325
- process.stdout.write("\x1b[?1006l\x1b[?1002l\x1b[?1000l\x1b[?2004l");
1490
+ process.stdout.write("\x1b[?1006l\x1b[?1000l\x1b[?2004l");
1326
1491
  setRawMode(false);
1327
1492
  };
1328
1493
  }, [outputHeight, setRawMode, stdinEvents]);
@@ -1337,6 +1502,10 @@ export function App({ config, orchestrator, cwd, initialTaskId = null, initialRo
1337
1502
  collaborationLoadSequenceRef.current += 1;
1338
1503
  routerLoadSequenceRef.current += 1;
1339
1504
  taskSessionsLoadSequenceRef.current += 1;
1505
+ if (clipboardNoticeTimerRef.current) {
1506
+ clearTimeout(clipboardNoticeTimerRef.current);
1507
+ clipboardNoticeTimerRef.current = null;
1508
+ }
1340
1509
  };
1341
1510
  }, []);
1342
1511
  useEffect(() => {
@@ -1354,6 +1523,93 @@ export function App({ config, orchestrator, cwd, initialTaskId = null, initialRo
1354
1523
  shutdownSignal.addEventListener("abort", shutdown, { once: true });
1355
1524
  return () => shutdownSignal.removeEventListener("abort", shutdown);
1356
1525
  }, [shutdownSignal]);
1526
+ async function copyVisibleView() {
1527
+ const payload = visibleClipboardPayload(viewRef.current);
1528
+ if (!payload?.text.trim()) {
1529
+ setAttachError("No visible text to copy in this view");
1530
+ return;
1531
+ }
1532
+ if (clipboardNoticeTimerRef.current) {
1533
+ clearTimeout(clipboardNoticeTimerRef.current);
1534
+ clipboardNoticeTimerRef.current = null;
1535
+ }
1536
+ setAttachError(null);
1537
+ setClipboardNotice({ state: "copying", text: `copying visible ${payload.label}` });
1538
+ try {
1539
+ await copyToClipboard(payload.text);
1540
+ setClipboardNotice({ state: "copied", text: `copied visible ${payload.label} · wheel still active` });
1541
+ clipboardNoticeTimerRef.current = setTimeout(() => {
1542
+ clipboardNoticeTimerRef.current = null;
1543
+ setClipboardNotice(null);
1544
+ }, 1800);
1545
+ }
1546
+ catch (error) {
1547
+ setClipboardNotice(null);
1548
+ setAttachError(`Copy failed · ${error instanceof Error ? error.message : String(error)}`);
1549
+ }
1550
+ }
1551
+ function visibleClipboardPayload(currentView) {
1552
+ if (currentView === "chat") {
1553
+ return { label: "chat", text: copyTextByViewRef.current.chat };
1554
+ }
1555
+ if (currentView === "worker") {
1556
+ return { label: "logs", text: copyTextByViewRef.current.worker };
1557
+ }
1558
+ if (currentView === "native") {
1559
+ return { label: "native output", text: nativeAttachRef.current?.screen.snapshot() ?? "" };
1560
+ }
1561
+ if (currentView === "status") {
1562
+ return {
1563
+ label: "status",
1564
+ text: [cwd, activeTaskIdRef.current, visibleTaskStatus, visibleRouteSummary]
1565
+ .filter(Boolean)
1566
+ .join("\n")
1567
+ };
1568
+ }
1569
+ if (currentView === "workers") {
1570
+ return {
1571
+ label: "workers",
1572
+ text: workersRef.current.map((worker) => [
1573
+ worker.label,
1574
+ worker.runtimeStatus?.state,
1575
+ worker.runtimeStatus?.phase,
1576
+ worker.runtimeStatus?.summary
1577
+ ].filter(Boolean).join(" · ")).join("\n")
1578
+ };
1579
+ }
1580
+ if (currentView === "features") {
1581
+ return {
1582
+ label: "features",
1583
+ text: (collaborationTimelineRef.current?.features ?? []).map((feature) => [
1584
+ feature.id,
1585
+ feature.title,
1586
+ feature.state
1587
+ ].filter(Boolean).join(" · ")).join("\n")
1588
+ };
1589
+ }
1590
+ if (currentView === "sessions") {
1591
+ const details = taskSessionDetailsRef.current;
1592
+ return {
1593
+ label: "sessions",
1594
+ text: details
1595
+ ? details.workers.map((worker) => [worker.id, worker.role, worker.engine, worker.state].join(" · ")).join("\n")
1596
+ : taskSessionsRef.current.map((task) => [task.id, task.title, task.status].join(" · ")).join("\n")
1597
+ };
1598
+ }
1599
+ if (currentView === "router") {
1600
+ return { label: "routes", text: routerRecords.map((record) => JSON.stringify(record)).join("\n") };
1601
+ }
1602
+ if (currentView === "collaboration") {
1603
+ const timeline = collaborationTimelineRef.current;
1604
+ return {
1605
+ label: "timeline",
1606
+ text: timeline
1607
+ ? collaborationTimelineEvents(timeline, collaborationFeatureIndexRef.current, collaborationUnresolvedOnlyRef.current).map((event) => JSON.stringify(event)).join("\n")
1608
+ : ""
1609
+ };
1610
+ }
1611
+ return null;
1612
+ }
1357
1613
  useInput((inputKey, key) => {
1358
1614
  if (view === "worker") {
1359
1615
  if (isExitShortcut(inputKey, key)) {
@@ -1396,18 +1652,19 @@ export function App({ config, orchestrator, cwd, initialTaskId = null, initialRo
1396
1652
  void attachSelectedWorker(worker);
1397
1653
  }
1398
1654
  }, { isActive: view === "worker" && !workerSearch.open });
1399
- async function attachSelectedWorker(worker) {
1655
+ async function attachSelectedWorker(worker, mode = "resume") {
1400
1656
  const requestSequence = nativeAttachRequestSequenceRef.current + 1;
1401
1657
  nativeAttachRequestSequenceRef.current = requestSequence;
1402
1658
  const attachIsCurrent = () => (mountedRef.current && nativeAttachRequestSequenceRef.current === requestSequence);
1403
1659
  setAttachError(null);
1404
1660
  try {
1405
- const launch = await (prepareNativeAttach
1406
- ? prepareNativeAttach(worker)
1407
- : buildNativeAttachLaunch({
1408
- config,
1409
- worker
1410
- }));
1661
+ const launch = await (mode === "fork"
1662
+ ? prepareNativeFork
1663
+ ? prepareNativeFork(worker)
1664
+ : buildNativeForkLaunch({ config, worker })
1665
+ : prepareNativeAttach
1666
+ ? prepareNativeAttach(worker)
1667
+ : buildNativeAttachLaunch({ config, worker }));
1411
1668
  if (!attachIsCurrent()) {
1412
1669
  return;
1413
1670
  }
@@ -1445,7 +1702,12 @@ export function App({ config, orchestrator, cwd, initialTaskId = null, initialRo
1445
1702
  if (!attachIsCurrent()) {
1446
1703
  return;
1447
1704
  }
1448
- void screen.write(`\r\n${nativeAttachExitLine(code, screen.dimensions().cols)}\r\n`).then(() => {
1705
+ const failureHint = nativeAttachFailureHint(screen.snapshot(), code, worker.engine);
1706
+ const closeOutput = [
1707
+ nativeAttachExitLine(code, screen.dimensions().cols),
1708
+ ...(failureHint ? [failureHint] : [])
1709
+ ].join("\r\n");
1710
+ void screen.write(`\r\n${closeOutput}\r\n`).then(() => {
1449
1711
  if (!attachIsCurrent()) {
1450
1712
  return;
1451
1713
  }
@@ -1705,11 +1967,20 @@ export function App({ config, orchestrator, cwd, initialTaskId = null, initialRo
1705
1967
  setRouteAnnouncement(null);
1706
1968
  setLastRoute(null);
1707
1969
  try {
1708
- const result = await orchestrator.retryTask({
1970
+ const selectedPausedFeature = viewRef.current === "features"
1971
+ ? collaborationTimelineRef.current?.features[featureBoardSelectedIndexRef.current]
1972
+ : null;
1973
+ const retryInput = {
1709
1974
  taskId,
1710
1975
  cwd,
1711
1976
  ...createRunCallbacks(controller, { announceRoute: false })
1712
- });
1977
+ };
1978
+ const result = selectedPausedFeature?.state === "paused"
1979
+ ? await orchestrator.resumeFeature({
1980
+ ...retryInput,
1981
+ featureId: selectedPausedFeature.id
1982
+ })
1983
+ : await orchestrator.retryTask(retryInput);
1713
1984
  activeTaskIdRef.current = taskId;
1714
1985
  setActiveTaskId(taskId);
1715
1986
  setActiveMode("complex");
@@ -1833,6 +2104,11 @@ export function App({ config, orchestrator, cwd, initialTaskId = null, initialRo
1833
2104
  setAttachError(null);
1834
2105
  setTaskSessionsError(null);
1835
2106
  setTaskSessionsNotice(null);
2107
+ taskSessionDetailsRef.current = null;
2108
+ taskSessionDetailSelectedWorkerIndexRef.current = 0;
2109
+ setTaskSessionDetails(null);
2110
+ setTaskSessionDetailSelectedWorkerIndex(0);
2111
+ setTaskSessionDetailNotice(null);
1836
2112
  updateTaskSessionAction(null);
1837
2113
  await refreshTaskSessions(activeTaskIdRef.current);
1838
2114
  }
@@ -1943,7 +2219,7 @@ export function App({ config, orchestrator, cwd, initialTaskId = null, initialRo
1943
2219
  return exportTaskSession(selected.id);
1944
2220
  }, (path) => `Exported · ${path}`, selected.id);
1945
2221
  }
1946
- async function activateSelectedTaskSession() {
2222
+ async function openSelectedTaskSessionDetails() {
1947
2223
  if (busyRef.current || taskSessionsLoadingRef.current) {
1948
2224
  return;
1949
2225
  }
@@ -1951,9 +2227,103 @@ export function App({ config, orchestrator, cwd, initialTaskId = null, initialRo
1951
2227
  if (!selected) {
1952
2228
  return;
1953
2229
  }
2230
+ taskSessionsLoadingRef.current = true;
2231
+ setTaskSessionsLoading(true);
2232
+ setTaskSessionsError(null);
2233
+ setTaskSessionDetailNotice(null);
2234
+ const sequence = taskSessionsLoadSequenceRef.current + 1;
2235
+ taskSessionsLoadSequenceRef.current = sequence;
2236
+ try {
2237
+ if (!loadTaskSessionDetails) {
2238
+ throw new Error("Task session details are unavailable");
2239
+ }
2240
+ const details = await loadTaskSessionDetails(selected);
2241
+ if (taskSessionsLoadSequenceRef.current !== sequence) {
2242
+ return;
2243
+ }
2244
+ taskSessionDetailsRef.current = details;
2245
+ taskSessionDetailSelectedWorkerIndexRef.current = 0;
2246
+ setTaskSessionDetails(details);
2247
+ setTaskSessionDetailSelectedWorkerIndex(0);
2248
+ }
2249
+ catch (error) {
2250
+ if (taskSessionsLoadSequenceRef.current === sequence) {
2251
+ setTaskSessionsError(error instanceof Error ? error.message : String(error));
2252
+ }
2253
+ }
2254
+ finally {
2255
+ if (taskSessionsLoadSequenceRef.current === sequence) {
2256
+ taskSessionsLoadingRef.current = false;
2257
+ setTaskSessionsLoading(false);
2258
+ }
2259
+ }
2260
+ }
2261
+ async function refreshTaskSessionDetails() {
2262
+ const current = taskSessionDetailsRef.current;
2263
+ if (!current || taskSessionsLoadingRef.current || !loadTaskSessionDetails) {
2264
+ return;
2265
+ }
2266
+ const selectedWorkerId = current.workers[taskSessionDetailSelectedWorkerIndexRef.current]?.id;
2267
+ taskSessionsLoadingRef.current = true;
2268
+ setTaskSessionsLoading(true);
2269
+ setTaskSessionsError(null);
2270
+ const sequence = taskSessionsLoadSequenceRef.current + 1;
2271
+ taskSessionsLoadSequenceRef.current = sequence;
2272
+ try {
2273
+ const details = await loadTaskSessionDetails(current.task);
2274
+ if (taskSessionsLoadSequenceRef.current !== sequence) {
2275
+ return;
2276
+ }
2277
+ const selectedIndex = selectedWorkerId
2278
+ ? Math.max(0, details.workers.findIndex((worker) => worker.id === selectedWorkerId))
2279
+ : 0;
2280
+ taskSessionDetailsRef.current = details;
2281
+ taskSessionDetailSelectedWorkerIndexRef.current = selectedIndex;
2282
+ setTaskSessionDetails(details);
2283
+ setTaskSessionDetailSelectedWorkerIndex(selectedIndex);
2284
+ setTaskSessionDetailNotice("Session hierarchy refreshed");
2285
+ }
2286
+ catch (error) {
2287
+ if (taskSessionsLoadSequenceRef.current === sequence) {
2288
+ setTaskSessionsError(error instanceof Error ? error.message : String(error));
2289
+ }
2290
+ }
2291
+ finally {
2292
+ if (taskSessionsLoadSequenceRef.current === sequence) {
2293
+ taskSessionsLoadingRef.current = false;
2294
+ setTaskSessionsLoading(false);
2295
+ }
2296
+ }
2297
+ }
2298
+ async function openTaskSessionDetailWorker(mode) {
2299
+ const details = taskSessionDetailsRef.current;
2300
+ const detailWorker = details?.workers[taskSessionDetailSelectedWorkerIndexRef.current];
2301
+ if (!details || !detailWorker || taskSessionsLoadingRef.current) {
2302
+ return;
2303
+ }
2304
+ if (mode !== "logs" && !detailWorker.nativeSession) {
2305
+ setTaskSessionDetailNotice(`No native session recorded for ${sessionDetailWorkerLabel(detailWorker)}`);
2306
+ return;
2307
+ }
2308
+ const worker = await activateTaskSessionRecord(details.task, detailWorker.id, "worker");
2309
+ if (worker && mode !== "logs") {
2310
+ await attachSelectedWorkerRef.current(worker, mode === "fork" ? "fork" : "resume");
2311
+ }
2312
+ }
2313
+ async function activateSelectedTaskSession() {
2314
+ const selected = taskSessionsRef.current[selectedTaskSessionIndexRef.current];
2315
+ if (!selected) {
2316
+ return;
2317
+ }
2318
+ await activateTaskSessionRecord(selected, null, "chat");
2319
+ }
2320
+ async function activateTaskSessionRecord(selected, preferredWorkerId, targetView) {
2321
+ if (busyRef.current || taskSessionsLoadingRef.current) {
2322
+ return null;
2323
+ }
1954
2324
  if (selected.archived_at) {
1955
2325
  setTaskSessionsError(`Unarchive ${selected.title} before restoring it`);
1956
- return;
2326
+ return null;
1957
2327
  }
1958
2328
  taskSessionsLoadingRef.current = true;
1959
2329
  setTaskSessionsLoading(true);
@@ -1966,7 +2336,7 @@ export function App({ config, orchestrator, cwd, initialTaskId = null, initialRo
1966
2336
  }
1967
2337
  const restored = await activateTaskSession(selected.id);
1968
2338
  if (taskSessionsLoadSequenceRef.current !== sequence) {
1969
- return;
2339
+ return null;
1970
2340
  }
1971
2341
  if (!restored) {
1972
2342
  throw new Error(`Task session not found: ${selected.id}`);
@@ -1981,19 +2351,28 @@ export function App({ config, orchestrator, cwd, initialTaskId = null, initialRo
1981
2351
  setTaskResultExpanded(latestTaskResultMessageIndex(messagesRef.current, restored.taskId) >= 0);
1982
2352
  workersRef.current = restored.workers;
1983
2353
  setWorkers(restored.workers);
1984
- selectedWorkerIndexRef.current = 0;
1985
- setSelectedWorkerIndex(0);
2354
+ const preferredWorkerIndex = preferredWorkerId
2355
+ ? restored.workers.findIndex((worker) => worker.id === preferredWorkerId)
2356
+ : -1;
2357
+ if (preferredWorkerId && preferredWorkerIndex < 0) {
2358
+ throw new Error(`Worker is no longer available: ${preferredWorkerId}`);
2359
+ }
2360
+ const nextWorkerIndex = preferredWorkerIndex >= 0 ? preferredWorkerIndex : 0;
2361
+ selectedWorkerIndexRef.current = nextWorkerIndex;
2362
+ setSelectedWorkerIndex(nextWorkerIndex);
1986
2363
  setWorkerScrollOffset(0);
1987
2364
  autoSelectedFailedWorkerRef.current = false;
1988
2365
  userSelectedWorkerRef.current = false;
1989
2366
  setAttachError(null);
1990
- viewRef.current = "chat";
1991
- setView("chat");
2367
+ viewRef.current = targetView;
2368
+ setView(targetView);
2369
+ return restored.workers[nextWorkerIndex] ?? null;
1992
2370
  }
1993
2371
  catch (error) {
1994
2372
  if (taskSessionsLoadSequenceRef.current === sequence) {
1995
2373
  setTaskSessionsError(error instanceof Error ? error.message : String(error));
1996
2374
  }
2375
+ return null;
1997
2376
  }
1998
2377
  finally {
1999
2378
  if (taskSessionsLoadSequenceRef.current === sequence) {
@@ -2006,17 +2385,51 @@ export function App({ config, orchestrator, cwd, initialTaskId = null, initialRo
2006
2385
  featureCancelPromptRef.current = next;
2007
2386
  setFeatureCancelPrompt(next);
2008
2387
  }
2388
+ function updateFeatureAssignmentPrompt(next) {
2389
+ featureAssignmentPromptRef.current = next;
2390
+ setFeatureAssignmentPrompt(next);
2391
+ }
2392
+ async function reassignSelectedFeature(prompt, role) {
2393
+ const feature = collaborationTimelineRef.current?.features.find((item) => item.id === prompt.featureId);
2394
+ if (!feature) {
2395
+ setFeatureBoardNotice(`Feature no longer exists: ${prompt.featureId}`);
2396
+ updateFeatureAssignmentPrompt(null);
2397
+ return;
2398
+ }
2399
+ const current = role === "actor"
2400
+ ? feature.actorEngine ?? config.pairing.actor
2401
+ : feature.criticEngine ?? config.pairing.critic;
2402
+ const engine = nextAssignableFeatureEngine(current, assignableProviderIds);
2403
+ try {
2404
+ await orchestrator.reassignFeature({
2405
+ taskId: prompt.taskId,
2406
+ featureId: prompt.featureId,
2407
+ role,
2408
+ engine
2409
+ });
2410
+ setFeatureBoardNotice(`${role === "actor" ? "Actor" : "Critic"} reassigned to ${featureEngineDisplay(config, engine)} · Ctrl+R resumes the task.`);
2411
+ await refreshCollaborationTimeline(false);
2412
+ }
2413
+ catch (error) {
2414
+ setFeatureBoardNotice(`Reassign failed for ${prompt.title}: ${error instanceof Error ? error.message : String(error)}`);
2415
+ }
2416
+ }
2009
2417
  async function confirmFeatureCancellation(prompt) {
2010
2418
  updateFeatureCancelPrompt(null);
2011
2419
  setAttachError(null);
2012
2420
  try {
2013
- const result = await orchestrator.cancelFeature(prompt.taskId, prompt.featureId);
2421
+ const result = prompt.action === "pause"
2422
+ ? await orchestrator.pauseFeature(prompt.taskId, prompt.featureId)
2423
+ : await orchestrator.cancelFeature(prompt.taskId, prompt.featureId);
2014
2424
  setFeatureBoardNotice(result.requested
2015
- ? `Cancellation requested for ${prompt.title} · ${result.role ?? "worker"} stopping; active peers will finish.`
2425
+ ? prompt.action === "pause"
2426
+ ? `Pause requested for ${prompt.title} · ${result.role ?? "worker"} stopping; checkpoints stay resumable.`
2427
+ : `Cancellation requested for ${prompt.title} · ${result.role ?? "worker"} stopping; active peers will finish.`
2016
2428
  : `No active Actor/Critic for ${prompt.title}; refresh and try again.`);
2017
2429
  }
2018
2430
  catch (error) {
2019
- setFeatureBoardNotice(`Cancel failed for ${prompt.title}: ${error instanceof Error ? error.message : String(error)}`);
2431
+ const action = prompt.action === "pause" ? "Pause" : "Cancel";
2432
+ setFeatureBoardNotice(`${action} failed for ${prompt.title}: ${error instanceof Error ? error.message : String(error)}`);
2020
2433
  }
2021
2434
  }
2022
2435
  async function openFeatureBoard() {
@@ -2032,6 +2445,7 @@ export function App({ config, orchestrator, cwd, initialTaskId = null, initialRo
2032
2445
  setAttachError(null);
2033
2446
  setCollaborationError(null);
2034
2447
  updateFeatureCancelPrompt(null);
2448
+ updateFeatureAssignmentPrompt(null);
2035
2449
  setFeatureBoardNotice(null);
2036
2450
  collaborationTimelineRef.current = null;
2037
2451
  setCollaborationTimeline(null);
@@ -2219,19 +2633,19 @@ export function App({ config, orchestrator, cwd, initialTaskId = null, initialRo
2219
2633
  if (view === "workspace") {
2220
2634
  return (_jsx(Box, { flexDirection: "column", height: terminalSize.rows, children: _jsx(WorkspacePicker, { cwd: cwd, choices: workspaceChoices, terminalHeight: terminalSize.rows, terminalWidth: terminalWidth, onCancel: closeWorkspacePicker, onSelect: (workspace) => void selectWorkspace(workspace) }) }));
2221
2635
  }
2222
- return (_jsx(AppShell, { view: view, cwd: cwd, taskId: activeTaskId, statusText: [visibleTaskStatus, visibleWorkerStatus, visibleRouteStatus].filter(Boolean).join(" | "), contentHeight: contentHeight, showStatusBar: config.ui.showStatusBar, input: _jsx(InputBar, { mode: view === "worker" && workerSearch.open ? "worker-search" : view, ready: inputReady, busy: busy, routeFallback: Boolean(routeFallbackPrompt), collaborationDetail: collaborationDetailOpen, collaborationUnresolved: collaborationUnresolvedOnly, collaborationBack: collaborationReturnViewRef.current, featureCanCancel: featureCanCancel, featureCancelConfirm: Boolean(featureCancelPrompt), taskSessionAction: taskSessionAction?.type === "rename"
2636
+ return (_jsx(AppShell, { view: view, cwd: cwd, taskId: activeTaskId, statusText: [visibleTaskStatus, visibleRouteSummary].filter(Boolean).join(" | "), contentHeight: contentHeight, showStatusBar: config.ui.showStatusBar, input: _jsx(InputBar, { mode: view === "worker" && workerSearch.open ? "worker-search" : view, ready: inputReady, busy: busy, routeFallback: Boolean(routeFallbackPrompt), collaborationDetail: collaborationDetailOpen, collaborationUnresolved: collaborationUnresolvedOnly, collaborationBack: collaborationReturnViewRef.current, featureCanCancel: featureCanCancel, featureCanPause: featureCanCancel, featureCanReassign: featureCanReassign, featureCancelConfirm: featureCancelPrompt?.action === "cancel", featurePauseConfirm: featureCancelPrompt?.action === "pause", featureAssignment: Boolean(featureAssignmentPrompt), taskSessionAction: taskSessionAction?.type === "rename"
2223
2637
  ? { type: "rename", value: taskSessionAction.value, cursor: taskSessionAction.cursor }
2224
2638
  : taskSessionAction?.type === "delete"
2225
2639
  ? { type: "delete", title: taskSessionAction.title }
2226
- : null, taskSessionsIncludeArchived: taskSessionsIncludeArchived, canRetry: canRetryTask, hasWorkers: workers.length > 0, hasActiveTask: Boolean(activeTaskId), hasTaskResult: hasTaskResult, taskResultExpanded: taskResultExpanded, chatScrollOffset: chatScrollOffset, chatMaxScrollOffset: chatMaxScrollOffset, nativeClosed: view === "native" && nativeAttach?.closedCode !== null, searchMatchIndex: workerSearch.matchIndex, searchMatchCount: workerNavigationTargets.searchOffsets.length, value: workerSearch.open && view === "worker"
2640
+ : null, taskSessionsIncludeArchived: taskSessionsIncludeArchived, taskSessionDetail: Boolean(taskSessionDetails), taskSessionDetailHasNative: taskSessionDetailHasNative, taskSessionDetailCanFork: taskSessionDetailCanFork, canRetry: canRetryTask, hasWorkers: workers.length > 0, hasActiveTask: Boolean(activeTaskId), hasTaskResult: hasTaskResult, taskResultExpanded: taskResultExpanded, chatScrollOffset: chatScrollOffset, chatMaxScrollOffset: chatMaxScrollOffset, nativeClosed: view === "native" && nativeAttach?.closedCode !== null, searchMatchIndex: workerSearch.matchIndex, searchMatchCount: workerNavigationTargets.searchOffsets.length, clipboardNotice: clipboardNotice, value: workerSearch.open && view === "worker"
2227
2641
  ? workerSearch.query
2228
- : view === "native" || view === "router" || view === "workers" || view === "features" || view === "sessions" || view === "collaboration" ? "" : input, cursor: workerSearch.open && view === "worker"
2642
+ : view === "native" || view === "router" || view === "workers" || view === "features" || view === "sessions" || view === "collaboration" || view === "status" ? "" : input, cursor: workerSearch.open && view === "worker"
2229
2643
  ? workerSearch.cursor
2230
- : view === "chat" ? inputCursor : undefined, terminalWidth: terminalWidth, onChange: view === "native" ? setNativeInput : setInput, onSubmit: view === "native" ? undefined : submit }), error: attachError, children: view === "native" ? (_jsx(NativeAttachView, { attach: nativeAttach, viewportHeight: contentHeight })) : view === "sessions" ? (_jsx(TaskSessionsView, { tasks: taskSessions, activeTaskId: activeTaskId, selectedIndex: selectedTaskSessionIndex, includeArchived: taskSessionsIncludeArchived, notice: taskSessionsNotice, action: taskSessionAction?.type === "rename"
2644
+ : view === "chat" ? inputCursor : undefined, terminalWidth: terminalWidth, onChange: view === "native" ? setNativeInput : setInput, onSubmit: view === "native" ? undefined : submit }), error: attachError, children: view === "status" ? (_jsx(StatusDetailView, { cwd: cwd, taskId: activeTaskId, mode: activeMode, busy: busy, canRetry: canRetryTask, taskStatus: visibleTaskStatus, routeStatus: visibleRouteStatus, routeReason: routePending ? undefined : lastRoute?.reason, workers: workers, selectedWorkerIndex: selectedWorkerIndex, height: contentHeight, terminalWidth: terminalWidth })) : view === "native" ? (_jsx(NativeAttachView, { attach: nativeAttach, viewportHeight: contentHeight })) : view === "sessions" ? (taskSessionDetails ? (_jsx(TaskSessionDetailView, { details: taskSessionDetails, selectedWorkerIndex: taskSessionDetailSelectedWorkerIndex, loading: taskSessionsLoading, error: taskSessionsError, notice: taskSessionDetailNotice, height: contentHeight, terminalWidth: terminalWidth })) : (_jsx(TaskSessionsView, { tasks: taskSessions, activeTaskId: activeTaskId, selectedIndex: selectedTaskSessionIndex, includeArchived: taskSessionsIncludeArchived, notice: taskSessionsNotice, action: taskSessionAction?.type === "rename"
2231
2645
  ? { type: "rename", title: taskSessionAction.title }
2232
2646
  : taskSessionAction?.type === "delete"
2233
2647
  ? { type: "delete", title: taskSessionAction.title }
2234
- : null, loading: taskSessionsLoading, error: taskSessionsError, height: contentHeight, terminalWidth: terminalWidth })) : view === "features" ? (_jsx(FeatureBoardView, { timeline: collaborationTimeline, selectedIndex: featureBoardSelectedIndex, loading: collaborationLoading, error: collaborationError, notice: featureBoardNotice, height: contentHeight, terminalWidth: terminalWidth })) : view === "collaboration" ? (_jsx(CollaborationTimelineView, { timeline: collaborationTimeline, featureIndex: collaborationFeatureIndex, selectedEventId: collaborationSelectedEventId, detailOpen: collaborationDetailOpen, unresolvedOnly: collaborationUnresolvedOnly, loading: collaborationLoading, error: collaborationError, scrollOffset: collaborationScrollOffset, height: contentHeight, terminalWidth: terminalWidth, onViewportChange: ({ offset, maxOffset }) => {
2648
+ : null, loading: taskSessionsLoading, error: taskSessionsError, height: contentHeight, terminalWidth: terminalWidth }))) : view === "features" ? (_jsx(FeatureBoardView, { timeline: collaborationTimeline, selectedIndex: featureBoardSelectedIndex, loading: collaborationLoading, error: collaborationError, notice: featureBoardNotice, height: contentHeight, terminalWidth: terminalWidth })) : view === "collaboration" ? (_jsx(CollaborationTimelineView, { timeline: collaborationTimeline, featureIndex: collaborationFeatureIndex, selectedEventId: collaborationSelectedEventId, detailOpen: collaborationDetailOpen, unresolvedOnly: collaborationUnresolvedOnly, loading: collaborationLoading, error: collaborationError, scrollOffset: collaborationScrollOffset, height: contentHeight, terminalWidth: terminalWidth, onViewportChange: ({ offset, maxOffset }) => {
2235
2649
  collaborationMaxScrollOffsetRef.current = maxOffset;
2236
2650
  setCollaborationMaxScrollOffset(maxOffset);
2237
2651
  if (offset !== collaborationScrollOffset) {
@@ -2250,7 +2664,11 @@ export function App({ config, orchestrator, cwd, initialTaskId = null, initialRo
2250
2664
  chatScrollOffsetRef.current = offset;
2251
2665
  setChatScrollOffset(offset);
2252
2666
  }
2253
- } })) : (_jsx(WorkerOutputView, { title: workerTitle(workers, selectedWorkerIndex), role: workers[selectedWorkerIndex]?.role, featureId: workers[selectedWorkerIndex]?.featureId, logPath: workers[selectedWorkerIndex]?.logPath ?? null, scrollOffset: workerScrollOffset, searchQuery: workerSearch.open ? workerSearch.query : "", searchMatchIndex: workerSearch.matchIndex, height: Math.max(1, outputHeight - 1), terminalWidth: terminalWidth, onNavigationChange: updateWorkerNavigationTargets, onViewportChange: ({ offset }) => {
2667
+ }, onCopyTextChange: (text) => {
2668
+ copyTextByViewRef.current.chat = text;
2669
+ } })) : (_jsx(WorkerOutputView, { title: workerTitle(workers, selectedWorkerIndex), role: workers[selectedWorkerIndex]?.role, featureId: workers[selectedWorkerIndex]?.featureId, logPath: workers[selectedWorkerIndex]?.logPath ?? null, scrollOffset: workerScrollOffset, searchQuery: workerSearch.open ? workerSearch.query : "", searchMatchIndex: workerSearch.matchIndex, height: Math.max(1, outputHeight - 1), terminalWidth: terminalWidth, onNavigationChange: updateWorkerNavigationTargets, onCopyTextChange: (text) => {
2670
+ copyTextByViewRef.current.worker = text;
2671
+ }, onViewportChange: ({ offset }) => {
2254
2672
  if (offset !== workerScrollOffset) {
2255
2673
  setWorkerScrollOffset(offset);
2256
2674
  }
@@ -2262,7 +2680,7 @@ function readTerminalSize() {
2262
2680
  rows: Math.max(1, Math.trunc(process.stdout.rows || 30))
2263
2681
  };
2264
2682
  }
2265
- export function ChatView({ messages, cwd, activeTaskId, terminalWidth = process.stdout.columns || 120, viewportHeight, scrollOffset = 0, expandedTaskResult = false, onViewportChange }) {
2683
+ export function ChatView({ messages, cwd, activeTaskId, terminalWidth = process.stdout.columns || 120, viewportHeight, scrollOffset = 0, expandedTaskResult = false, onViewportChange, onCopyTextChange }) {
2266
2684
  const height = viewportHeight ? Math.max(1, viewportHeight) : undefined;
2267
2685
  const viewport = useMemo(() => chatMessageViewport(messages, terminalWidth, height ?? 12, scrollOffset, {
2268
2686
  expandedTaskResult,
@@ -2274,6 +2692,10 @@ export function ChatView({ messages, cwd, activeTaskId, terminalWidth = process.
2274
2692
  maxOffset: viewport.maxOffset
2275
2693
  });
2276
2694
  }, [onViewportChange, viewport.clampedOffset, viewport.maxOffset]);
2695
+ const visibleCopyText = viewport.lines.map((line) => line.text).join("\n");
2696
+ useEffect(() => {
2697
+ onCopyTextChange?.(visibleCopyText);
2698
+ }, [onCopyTextChange, visibleCopyText]);
2277
2699
  if (messages.length === 0) {
2278
2700
  const spacerLines = chatViewportSpacerLineCount(1, height);
2279
2701
  return (_jsxs(Box, { flexDirection: "column", height: height, children: [_jsx(ChatViewportSpacerLines, { count: spacerLines, terminalWidth: terminalWidth }), _jsx(ChatEmptyState, { cwd: cwd, activeTaskId: activeTaskId, terminalWidth: terminalWidth })] }));
@@ -3046,6 +3468,21 @@ function compactChatTaskId(taskId) {
3046
3468
  }
3047
3469
  return taskId.startsWith("task-") ? taskId.slice("task-".length) : taskId;
3048
3470
  }
3471
+ export function nextAssignableFeatureEngine(current, providers = ["codex", "claude"]) {
3472
+ const available = [...new Set(providers)];
3473
+ if (available.length === 0) {
3474
+ return current;
3475
+ }
3476
+ const currentIndex = available.indexOf(current);
3477
+ return available[(currentIndex + 1 + available.length) % available.length] ?? current;
3478
+ }
3479
+ function sessionDetailWorkerLabel(worker) {
3480
+ const role = `${worker.role.slice(0, 1).toUpperCase()}${worker.role.slice(1)}`;
3481
+ return `${role} (${worker.engine})${worker.featureTitle ? ` · ${worker.featureTitle}` : ""}`;
3482
+ }
3483
+ function featureEngineDisplay(config, engine) {
3484
+ return workerProviderLabel(config, engine);
3485
+ }
3049
3486
  function compactChatText(text, maxLength) {
3050
3487
  if (maxLength <= 5) {
3051
3488
  return takeChatTextStartByDisplayWidth(text, maxLength);
@@ -3364,6 +3801,27 @@ export function nativeAttachExitLine(code, nativeTerminalCols) {
3364
3801
  ];
3365
3802
  return firstNativeTitleThatFits(candidates, contentWidth);
3366
3803
  }
3804
+ export function nativeAttachFailureHint(output, code, providerId = "codex") {
3805
+ if (code === 0) {
3806
+ return null;
3807
+ }
3808
+ if (/effective permissions do not allow additional writable roots|error adding directories|read-only sandbox/i.test(output)) {
3809
+ return `fix · set workers.${providerId}.interactive.args sandbox to workspace-write or danger-full-access, then reattach`;
3810
+ }
3811
+ if (/not inside a trusted directory|trusted directory/i.test(output)) {
3812
+ return "fix · open Codex once in this workspace and approve directory trust, then reattach";
3813
+ }
3814
+ if (/unexpected argument ['\"]?--skip-git-repo-check|--skip-git-repo-check.*not found/i.test(output)) {
3815
+ return `fix · remove --skip-git-repo-check from workers.${providerId}.interactive.args, then reattach`;
3816
+ }
3817
+ if (/stdin is not a terminal/i.test(output)) {
3818
+ return "fix · native attach requires its embedded PTY; reopen it with Ctrl+O instead of piping codex resume";
3819
+ }
3820
+ if (/permission denied|\bEACCES\b|\bEPERM\b/i.test(output)) {
3821
+ return "fix · grant the terminal read/write access to the workspace and .parallel-codex directories, then reattach";
3822
+ }
3823
+ return null;
3824
+ }
3367
3825
  function withNativeTitleSuffix(candidates, suffix) {
3368
3826
  const cleanSuffix = suffix?.trim();
3369
3827
  if (!cleanSuffix) {