@pellux/goodvibes-tui 1.17.0 → 1.19.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.
Files changed (104) hide show
  1. package/CHANGELOG.md +208 -0
  2. package/README.md +1 -1
  3. package/docs/foundation-artifacts/operator-contract.json +5025 -1636
  4. package/package.json +7 -7
  5. package/src/cli/endpoints.ts +50 -4
  6. package/src/cli/help.ts +29 -2
  7. package/src/cli/management-commands.ts +99 -23
  8. package/src/cli/management.ts +0 -2
  9. package/src/cli/service-posture.ts +5 -3
  10. package/src/cli/status.ts +27 -6
  11. package/src/cli/surface-command.ts +29 -15
  12. package/src/core/consolidation-receipt.ts +41 -0
  13. package/src/core/conversation-rendering.ts +14 -5
  14. package/src/core/memory-diagnostics-gateway.ts +99 -0
  15. package/src/core/memory-provenance.ts +67 -0
  16. package/src/core/memory-status.ts +118 -0
  17. package/src/core/pairing-devices.ts +49 -0
  18. package/src/core/pairing-handoff.ts +101 -0
  19. package/src/core/pairing-offers.ts +83 -0
  20. package/src/core/pairing-origin.ts +78 -0
  21. package/src/core/pairing-tailscale-gateway.ts +56 -0
  22. package/src/core/power-chip-source.ts +91 -0
  23. package/src/core/power-status.ts +95 -0
  24. package/src/core/stable-host.ts +171 -0
  25. package/src/core/stream-event-wiring.ts +21 -0
  26. package/src/core/turn-budget-outcome.ts +60 -0
  27. package/src/core/turn-cancellation.ts +39 -0
  28. package/src/core/turn-event-wiring.ts +6 -0
  29. package/src/core/voice-provision-gateway.ts +202 -0
  30. package/src/core/voice-provision-status.ts +197 -0
  31. package/src/daemon/cli.ts +127 -60
  32. package/src/input/command-registry.ts +21 -0
  33. package/src/input/commands/acp-runtime.ts +72 -0
  34. package/src/input/commands/devices-runtime.ts +109 -0
  35. package/src/input/commands/experience-runtime.ts +25 -4
  36. package/src/input/commands/git-runtime.ts +28 -4
  37. package/src/input/commands/graph-runtime.ts +45 -0
  38. package/src/input/commands/health-runtime.ts +15 -1
  39. package/src/input/commands/operator-rpc.ts +32 -11
  40. package/src/input/commands/power-runtime.ts +56 -0
  41. package/src/input/commands/qrcode-runtime.ts +14 -19
  42. package/src/input/commands/queue-runtime.ts +70 -0
  43. package/src/input/commands/workstream-attempts.ts +6 -1
  44. package/src/input/commands/workstream-runtime.ts +44 -25
  45. package/src/input/commands/worktree-runtime.ts +35 -8
  46. package/src/input/commands.ts +10 -0
  47. package/src/input/config-modal-types.ts +11 -0
  48. package/src/input/config-modal.ts +65 -2
  49. package/src/input/handler-shortcuts.ts +23 -0
  50. package/src/input/keybindings.ts +19 -1
  51. package/src/input/settings-modal-data.ts +31 -37
  52. package/src/input/settings-modal-types.ts +5 -2
  53. package/src/input/tts-settings-actions.ts +27 -8
  54. package/src/input/voice-local-settings.ts +52 -0
  55. package/src/main.ts +42 -42
  56. package/src/panels/builtin/operations.ts +54 -1
  57. package/src/panels/builtin/shared.ts +12 -0
  58. package/src/panels/builtin-modals.ts +49 -32
  59. package/src/panels/diff-panel.ts +34 -1
  60. package/src/panels/fleet-acts.ts +446 -0
  61. package/src/panels/fleet-gateway.ts +117 -0
  62. package/src/panels/fleet-observed-render.ts +128 -0
  63. package/src/panels/fleet-panel-format.ts +120 -4
  64. package/src/panels/fleet-panel-worktree-detail.ts +14 -0
  65. package/src/panels/fleet-panel.ts +60 -44
  66. package/src/panels/fleet-read-model.ts +34 -3
  67. package/src/panels/fleet-spawn.ts +266 -0
  68. package/src/panels/fleet-stop.ts +38 -2
  69. package/src/panels/fleet-tabs.ts +2 -2
  70. package/src/panels/memory-consolidation-gateway.ts +94 -0
  71. package/src/panels/modals/devices-modal.ts +161 -0
  72. package/src/panels/modals/memory-modal.ts +149 -5
  73. package/src/panels/modals/pairing-modal.ts +198 -68
  74. package/src/panels/modals/security-modal.ts +6 -0
  75. package/src/panels/workstream-graph-render.ts +123 -0
  76. package/src/renderer/composer-fragments.ts +69 -0
  77. package/src/renderer/settings-modal-helpers.ts +9 -0
  78. package/src/renderer/settings-modal.ts +4 -2
  79. package/src/renderer/shell-surface.ts +3 -0
  80. package/src/renderer/tool-call.ts +12 -6
  81. package/src/renderer/ui-factory.ts +32 -0
  82. package/src/runtime/bootstrap-command-context.ts +3 -1
  83. package/src/runtime/bootstrap-command-parts.ts +3 -1
  84. package/src/runtime/bootstrap-core.ts +6 -6
  85. package/src/runtime/bootstrap-shell.ts +7 -0
  86. package/src/runtime/code-index-services.ts +15 -1
  87. package/src/runtime/durability-services.ts +36 -1
  88. package/src/runtime/fleet-services.ts +41 -0
  89. package/src/runtime/idle-power-services.ts +85 -0
  90. package/src/runtime/index.ts +2 -0
  91. package/src/runtime/interaction-seams.ts +109 -0
  92. package/src/runtime/knowledge-services.ts +94 -0
  93. package/src/runtime/legacy-daemon-migration.ts +38 -6
  94. package/src/runtime/legacy-daemon-reconcile.ts +439 -0
  95. package/src/runtime/memory-governance-services.ts +79 -0
  96. package/src/runtime/notification-dispatch.ts +28 -0
  97. package/src/runtime/power-keepawake-remote.ts +75 -0
  98. package/src/runtime/process-lifecycle.ts +3 -1
  99. package/src/runtime/services.ts +105 -107
  100. package/src/runtime/ui-services.ts +3 -0
  101. package/src/runtime/voice-setup-services.ts +104 -0
  102. package/src/shell/blocking-input.ts +21 -141
  103. package/src/shell/recovery-input-helpers.ts +53 -166
  104. package/src/version.ts +11 -2
@@ -0,0 +1,128 @@
1
+ // ---------------------------------------------------------------------------
2
+ // fleet-observed-render.ts — the fleet-pane rows for observed FOREIGN coding
3
+ // agents (kind 'observed-external').
4
+ //
5
+ // goodvibes did NOT spawn these — they are externally-launched Claude Code /
6
+ // Codex / opencode sessions, detected read-only from OS signals. The row's
7
+ // FIRST job is visibility ("it's in claude or codex"): goodvibes never presents
8
+ // itself as the foreign session's cockpit. So an observed row:
9
+ // - shows its honest external kind + a liveness-derived state (active/quiet),
10
+ // - is NEVER counted in any of the panel's own-fleet counts,
11
+ // - carries NO stop affordance, ever,
12
+ // - offers steer as a DRILL-IN only (in the detail view), and only where a
13
+ // real channel exists; where steer.kind is 'none' the detail states why.
14
+ // ---------------------------------------------------------------------------
15
+
16
+ import type { ProcessNode, ProcessObserved, ObservedAgentKind } from '@pellux/goodvibes-sdk/platform/runtime/fleet';
17
+ import type { Line } from '../types/grid.ts';
18
+ import { buildPanelLine, buildSearchInputLine, DEFAULT_PANEL_PALETTE, type PanelPalette } from './polish.ts';
19
+ import { truncateDisplay, wrapText } from '../utils/terminal-width.ts';
20
+
21
+ /** An observed-external node carries its foreign-agent facts on `observed`. */
22
+ export type ObservedNode = ProcessNode & { readonly observed: ProcessObserved };
23
+
24
+ /** True for a foreign observed-agent node (never one of our own fleet rows). */
25
+ export function isObservedExternalNode(node: ProcessNode): node is ObservedNode {
26
+ return node.kind === 'observed-external' && node.observed !== undefined;
27
+ }
28
+
29
+ /** The honest plain-language label for an observed foreign agent's kind. */
30
+ export function observedKindLabel(kind: ObservedAgentKind): string {
31
+ switch (kind) {
32
+ case 'claude-code': return 'Claude Code';
33
+ case 'codex': return 'Codex';
34
+ case 'opencode': return 'opencode';
35
+ default: return 'unknown agent';
36
+ }
37
+ }
38
+
39
+ /** Liveness glyph: a filled ring while CPU is advancing, a hollow one while quiet. */
40
+ function livenessGlyph(state: ProcessObserved['liveness']['state']): string {
41
+ return state === 'active' ? '◉' : '○';
42
+ }
43
+
44
+ /**
45
+ * One observed-external row. Deliberately NOT the standard fleet row: no cost/
46
+ * token/steer-badge columns (a foreign session has no LLM turn we account for),
47
+ * no stop marker — just the glyph, an "observed" tag, the session label, its
48
+ * external kind, and its liveness state.
49
+ */
50
+ export function renderObservedRowLine(node: ObservedNode, width: number, palette: PanelPalette = DEFAULT_PANEL_PALETTE): Line {
51
+ const C = palette;
52
+ const observed = node.observed;
53
+ const glyph = livenessGlyph(observed.liveness.state);
54
+ const kindLabel = observedKindLabel(observed.externalKind);
55
+ // The label is width-budgeted so the trailing "kind · state" tell never
56
+ // overflows the row (the tell is the honesty signal — it must survive).
57
+ const tell = `${kindLabel} · ${observed.liveness.state}`;
58
+ const labelBudget = Math.max(4, width - tell.length - 14);
59
+ return buildPanelLine(width, [
60
+ [' ', C.dim],
61
+ [glyph, observed.liveness.state === 'active' ? (C.info ?? DEFAULT_PANEL_PALETTE.info) : C.dim],
62
+ [' observed ', C.dim],
63
+ [truncateDisplay(node.label, labelBudget), C.value],
64
+ [' ', C.dim],
65
+ [tell, C.dim],
66
+ ]);
67
+ }
68
+
69
+ /**
70
+ * The detail block for a selected observed-external row. States the foreign
71
+ * facts (external kind, pid, cwd, liveness meaning), then the steer affordance —
72
+ * a DRILL-IN: where a real channel exists it names the action; where steer.kind
73
+ * is 'none' it states the honest reason. It NEVER shows a stop affordance —
74
+ * observing a foreign session is not owning its lifecycle.
75
+ */
76
+ /**
77
+ * Render the observed-row detail. When `steerDraft` is a string the drill-in
78
+ * steer composer is open on THIS row: an active input line replaces the passive
79
+ * "s: steer" hint. A channel-less row never shows an input (owner ruling) — its
80
+ * detail states the honest reason and stops there.
81
+ */
82
+ export function renderObservedDetailLines(node: ObservedNode, width: number, palette: PanelPalette = DEFAULT_PANEL_PALETTE, steerDraft: string | null = null): Line[] {
83
+ const C = palette;
84
+ const observed = node.observed;
85
+ const lines: Line[] = [];
86
+ lines.push(buildPanelLine(width, [
87
+ [' ', C.dim],
88
+ [livenessGlyph(observed.liveness.state), observed.liveness.state === 'active' ? (C.info ?? DEFAULT_PANEL_PALETTE.info) : C.dim],
89
+ [' observed ', C.dim],
90
+ [observedKindLabel(observed.externalKind), C.value],
91
+ [' pid ', C.label],
92
+ [String(observed.pid), C.value],
93
+ [' state ', C.label],
94
+ [observed.liveness.state, C.value],
95
+ ]));
96
+ if (observed.cwd) {
97
+ lines.push(buildPanelLine(width, [[' cwd ', C.label], [truncateDisplay(observed.cwd, Math.max(0, width - 6)), C.dim]]));
98
+ }
99
+ // Liveness meaning, verbatim — honest about what 'quiet' can and cannot tell.
100
+ for (const segment of wrapText(observed.liveness.detail, Math.max(1, width - 2))) {
101
+ lines.push(buildPanelLine(width, [[' ', C.dim], [segment, C.dim]]));
102
+ }
103
+ // Steer is a drill-in: a real channel is named here (a message sent from this
104
+ // detail travels over it — fleet.observed.steer), and where none exists the
105
+ // honest reason is stated instead of a dead action. Wrapped so a long reason
106
+ // is never clipped at a narrow width.
107
+ const steerText = observed.steer.kind === 'tmux'
108
+ ? `available via tmux pane ${observed.steer.paneId} (send-keys)`
109
+ : `unavailable — ${observed.steer.reason}`;
110
+ const steerFg = observed.steer.kind === 'tmux' ? (C.info ?? DEFAULT_PANEL_PALETTE.info) : C.dim;
111
+ const steerSegments = wrapText(steerText, Math.max(1, width - 8));
112
+ steerSegments.forEach((segment, i) => {
113
+ lines.push(buildPanelLine(width, [[i === 0 ? ' steer ' : ' ', C.label], [segment, steerFg]]));
114
+ });
115
+ // Drill-in compose input: only where a real channel exists. An active draft
116
+ // renders the input line + send/cancel hint; otherwise a discoverability hint.
117
+ if (observed.steer.kind === 'tmux') {
118
+ if (steerDraft !== null) {
119
+ lines.push(buildSearchInputLine(width, ' send ', `${steerDraft}_`, C, { active: true, bg: C.inputBg, valueColor: C.info }));
120
+ lines.push(buildPanelLine(width, [[' ', C.label], ['Enter: send Esc: cancel', C.dim]]));
121
+ } else {
122
+ lines.push(buildPanelLine(width, [[' ', C.label], ['s: steer this session', C.dim]]));
123
+ }
124
+ }
125
+ // Visibility, not a cockpit: there is no stop here, by design.
126
+ lines.push(buildPanelLine(width, [[' stop ', C.label], ['not offered — goodvibes only observes this foreign session', C.dim]]));
127
+ return lines;
128
+ }
@@ -7,7 +7,7 @@
7
7
  // renderItem/renderDetail call, with no `this` dependency.
8
8
  // ---------------------------------------------------------------------------
9
9
 
10
- import type { ProcessCostState, ProcessNode, ProcessUsage } from '@pellux/goodvibes-sdk/platform/runtime/fleet';
10
+ import type { ProcessCostState, ProcessNode, ProcessReviewSummary, ProcessUsage } from '@pellux/goodvibes-sdk/platform/runtime/fleet';
11
11
  import type { Line } from '../types/grid.ts';
12
12
  import { formatAgentCost } from './agent-inspector-shared.ts';
13
13
  import { buildPanelLine, DEFAULT_PANEL_PALETTE, type ColumnSpec, type PanelPalette } from './polish.ts';
@@ -15,12 +15,15 @@ import { fleetAttentionText, fleetNodeAttention, fleetStallMarker, fleetUsageTok
15
15
  import { fleetStateDisplay } from './fleet-stop.ts';
16
16
  import { formatElapsed } from '../utils/format-elapsed.ts';
17
17
  import { truncateDisplay } from '../utils/terminal-width.ts';
18
- import { formatWorkItemIsolationDetailFromRaw } from './fleet-panel-worktree-detail.ts';
18
+ import { conflictFilesFromRaw, formatWorkItemIsolationDetailFromRaw } from './fleet-panel-worktree-detail.ts';
19
+ import { wrapText } from '../utils/terminal-width.ts';
19
20
  import { buildAlignedRow } from './polish.ts';
20
21
  import { fleetKindTag } from './fleet-read-model.ts';
21
22
  import { steerBadgeGlyph, steerBadgeTone } from './fleet-steer.ts';
22
23
  import type { SteerBadge } from './fleet-tabs.ts';
23
24
  import type { FleetTreeRow } from './fleet-read-model.ts';
25
+ import { isObservedExternalNode, renderObservedDetailLines, renderObservedRowLine } from './fleet-observed-render.ts';
26
+ import { renderPoolSummary, type WorkstreamGraphSnapshot } from './workstream-graph-render.ts';
24
27
 
25
28
  // Column widths for the tree row layout. `label` absorbs whatever width is
26
29
  // left over after the fixed columns + gaps; on hostile (narrow) widths the
@@ -88,6 +91,9 @@ export function renderFleetRowLine(
88
91
  ): Line {
89
92
  const C = palette;
90
93
  const node = row.node;
94
+ // Observed foreign agents render as their own visibility row (no cost/steer
95
+ // columns, no stop marker) — see fleet-observed-render.ts.
96
+ if (isObservedExternalNode(node)) return renderObservedRowLine(node, width, palette);
91
97
  const disp = fleetStateDisplay(node.state, stopping, blocked);
92
98
  const color = toneColor(disp.tone, C);
93
99
  // Best-of-N grouping: badge a sibling attempt so the N candidates read as one
@@ -153,16 +159,103 @@ export function renderFleetRowLine(
153
159
  * 'state' text never claims a past-tense outcome mid-write, and a
154
160
  * blocked-on-user node reads 'blocked on you' here too.
155
161
  */
162
+ /**
163
+ * The reviewer's acceptance checklist + verdict, from the fleet node's served
164
+ * `review` field (ProcessReviewSummary, rides fleet.snapshot/list). Rendered
165
+ * only when a review has completed — never an empty shell. Each item shows
166
+ * whether it was verified, the evidence, and (when present) how it was
167
+ * exercised, matching what the webui's review detail surfaces.
168
+ */
169
+ export function renderReviewLines(review: ProcessReviewSummary, width: number, palette: PanelPalette = DEFAULT_PANEL_PALETTE): Line[] {
170
+ const C = palette;
171
+ const passTone = C.good ?? C.info;
172
+ const failTone = C.bad ?? C.warn ?? DEFAULT_PANEL_PALETTE.warn;
173
+ const cyclesLabel = review.cycles === 1 ? '1 cycle' : `${review.cycles} cycles`;
174
+ const lines: Line[] = [
175
+ buildPanelLine(width, [
176
+ [' review ', C.label],
177
+ [review.passed ? '✓ passed' : '✗ not passed', review.passed ? passTone : failTone],
178
+ [' score ', C.label], [String(review.score), C.value],
179
+ [' ', C.dim], [cyclesLabel, C.dim],
180
+ ]),
181
+ ];
182
+ if (review.checklist.length === 0) {
183
+ // Empty checklist is itself a gate failure — say so honestly, don't hide it.
184
+ lines.push(buildPanelLine(width, [[' ', C.dim], ['(the reviewer emitted no acceptance checklist — a gate failure)', failTone]]));
185
+ return lines;
186
+ }
187
+ for (const it of review.checklist) {
188
+ const mark = it.verified ? '[verified] ' : '[unverified]';
189
+ const markTone = it.verified ? passTone : failTone;
190
+ wrapText(it.item, Math.max(1, width - 16)).forEach((seg, i) => {
191
+ lines.push(buildPanelLine(width, i === 0
192
+ ? [[' ', C.dim], [`${mark} `, markTone], [seg, C.value]]
193
+ : [[' ', C.dim], [seg, C.value]]));
194
+ });
195
+ if (it.evidence) {
196
+ for (const seg of wrapText(`evidence: ${it.evidence}`, Math.max(1, width - 6))) {
197
+ lines.push(buildPanelLine(width, [[' ', C.dim], [seg, C.dim]]));
198
+ }
199
+ }
200
+ if (it.howExercised) {
201
+ for (const seg of wrapText(`exercised: ${it.howExercised}`, Math.max(1, width - 6))) {
202
+ lines.push(buildPanelLine(width, [[' ', C.dim], [seg, C.dim]]));
203
+ }
204
+ }
205
+ }
206
+ return lines;
207
+ }
208
+
209
+ /**
210
+ * The task graph's edges + elastic-pool posture for a workstream row, rendered
211
+ * IN the fleet detail (the tree shows nodes; edges/pool used to be reachable
212
+ * only via /graph — /graph still works). Fetched lazily and cached by fleet-acts;
213
+ * rendered only once a snapshot is in hand. An empty graph states so honestly.
214
+ */
215
+ export function renderGraphPostureLines(graph: WorkstreamGraphSnapshot, width: number, palette: PanelPalette = DEFAULT_PANEL_PALETTE): Line[] {
216
+ const C = palette;
217
+ const lines: Line[] = [
218
+ buildPanelLine(width, [[' graph ', C.label], [renderPoolSummary(graph.pool), C.value]]),
219
+ ];
220
+ if (graph.edges.length === 0) {
221
+ lines.push(buildPanelLine(width, [[' ', C.dim], ['no dependency edges', C.dim]]));
222
+ return lines;
223
+ }
224
+ const titleById = new Map(graph.nodes.map((n) => [n.id, n.title]));
225
+ lines.push(buildPanelLine(width, [[' edges ', C.label], [`${graph.edges.length} dependency link(s)`, C.value]]));
226
+ for (const edge of graph.edges) {
227
+ const from = titleById.get(edge.from) ?? edge.from;
228
+ const to = titleById.get(edge.to) ?? edge.to;
229
+ for (const seg of wrapText(`${from} → ${to}`, Math.max(1, width - 5))) {
230
+ lines.push(buildPanelLine(width, [[' ', C.dim], [seg, C.dim]]));
231
+ }
232
+ }
233
+ return lines;
234
+ }
235
+
156
236
  export function renderFleetDetailLines(
157
237
  node: ProcessNode,
158
238
  width: number,
159
239
  stopping: boolean,
160
240
  blocked: boolean,
161
241
  palette: PanelPalette = DEFAULT_PANEL_PALETTE,
242
+ observedSteerDraft: string | null = null,
243
+ graphSnapshot: WorkstreamGraphSnapshot | null = null,
162
244
  ): Line[] {
163
245
  const C = palette;
246
+ // Observed foreign agents drill into their own detail (facts + steer-or-reason,
247
+ // never a stop) — see fleet-observed-render.ts. The drill-in steer draft (when
248
+ // the composer is open on this row) renders an input line in that detail.
249
+ if (isObservedExternalNode(node)) return renderObservedDetailLines(node, width, palette, observedSteerDraft);
164
250
  const disp = fleetStateDisplay(node.state, stopping, blocked);
165
251
  const color = toneColor(disp.tone, C);
252
+ // A blocked node's state text names WHY in the reason's own words
253
+ // ('needs your pick' / 'merge conflict waiting on you' / …) so the detail
254
+ // block never contradicts the row's reason-specific badge; the shared ⚑
255
+ // glyph/tone (disp) is unchanged. 'stopping' still wins over the reason.
256
+ const stateLabel = blocked && !stopping
257
+ ? fleetAttentionText(fleetNodeAttention(node) ?? { reason: 'approval' })
258
+ : disp.label;
166
259
 
167
260
  const line1 = buildPanelLine(width, [
168
261
  [' ', C.dim],
@@ -171,7 +264,7 @@ export function renderFleetDetailLines(
171
264
  [' id ', C.label],
172
265
  [node.id, C.value],
173
266
  [' state ', C.label],
174
- [disp.label, color],
267
+ [stateLabel, color],
175
268
  [' elapsed ', C.label],
176
269
  [formatElapsed(node.elapsedMs), C.value],
177
270
  ]);
@@ -206,5 +299,28 @@ export function renderFleetDetailLines(
206
299
  // Approval history attaches here once session tabs land.
207
300
  const line4 = buildPanelLine(width, [[' approvals ', C.label], ['—', C.dim]]);
208
301
  const isolationDetail = node.kind === 'work-item' ? formatWorkItemIsolationDetailFromRaw(node.raw) : null;
209
- return [line1, line2, ...headlineLine, line3, line4, ...(isolationDetail ? [buildPanelLine(width, [[' isolation ', C.label], [isolationDetail, C.dim]])] : [])];
302
+ // A merge-conflict row shows its STRUCTURED conflicting-path list, wrapped so a
303
+ // long path is fully readable — never clipped (STEP 4: the conflict row acts,
304
+ // and the operator sees exactly which files need resolving before pressing Enter).
305
+ const conflictFiles = node.kind === 'work-item' ? conflictFilesFromRaw(node.raw) : null;
306
+ const conflictLines: Line[] = conflictFiles
307
+ ? [
308
+ buildPanelLine(width, [[' conflicts ', C.label], [`${conflictFiles.length} file(s) — press Enter to resolve`, C.warn ?? DEFAULT_PANEL_PALETTE.warn]]),
309
+ // Prefix is 3 spaces of indent + a 2-col bullet/continuation marker, so
310
+ // wrap the path at width-5 to keep the composed line within `width`
311
+ // (a hard-wrapped long path is fully readable, never clipped).
312
+ ...conflictFiles.flatMap((file) =>
313
+ wrapText(file, Math.max(1, width - 5)).map((segment, i) =>
314
+ buildPanelLine(width, [[' ', C.dim], [i === 0 ? `• ${segment}` : ` ${segment}`, C.value]]),
315
+ ),
316
+ ),
317
+ ]
318
+ : [];
319
+ // The reviewer's acceptance checklist + verdict, when a review has completed
320
+ // (served on node.review; absent before any review — never an empty shell).
321
+ const reviewLines = node.review ? renderReviewLines(node.review, width, C) : [];
322
+ // The task graph's edges/pool posture for a workstream row (fetched + cached
323
+ // by fleet-acts); rendered in-panel under the chain, /graph still available.
324
+ const graphLines = graphSnapshot ? renderGraphPostureLines(graphSnapshot, width, C) : [];
325
+ return [line1, line2, ...headlineLine, line3, line4, ...(isolationDetail ? [buildPanelLine(width, [[' isolation ', C.label], [isolationDetail, C.dim]])] : []), ...conflictLines, ...reviewLines, ...graphLines];
210
326
  }
@@ -46,3 +46,17 @@ export function formatWorkItemIsolationDetailFromRaw(raw: unknown): string | nul
46
46
  const item = (raw as { item?: WorkItem } | undefined)?.item;
47
47
  return item ? formatWorkItemIsolationDetail(item) : null;
48
48
  }
49
+
50
+ /**
51
+ * The STRUCTURED conflicting-path list a merge-conflict work item recorded
52
+ * (WorkItem.conflictFiles), for the Fleet detail block to show a human exactly
53
+ * which files need resolving. Null when the node is not a conflicted work item
54
+ * or recorded no files. The caller wraps these (never clips) so a long path is
55
+ * always fully readable.
56
+ */
57
+ export function conflictFilesFromRaw(raw: unknown): readonly string[] | null {
58
+ const item = (raw as { item?: WorkItem } | undefined)?.item;
59
+ if (!item || item.mergeState !== 'conflict') return null;
60
+ const files = item.conflictFiles;
61
+ return files && files.length > 0 ? files : null;
62
+ }
@@ -27,6 +27,8 @@ import {
27
27
  type FleetReadModel,
28
28
  type FleetTreeRow,
29
29
  } from './fleet-read-model.ts';
30
+ import type { FleetActs } from './fleet-acts.ts';
31
+ import type { FleetSpawn } from './fleet-spawn.ts';
30
32
  import {
31
33
  activeFleetTab,
32
34
  appendSteerText,
@@ -40,7 +42,7 @@ import {
40
42
  type SteerBadge,
41
43
  } from './fleet-tabs.ts';
42
44
  import { liveSteerableLabels, reconcileSteerBadges as reconcileSteerBadgesPure, renderSteerBadgeLine, steerRefusalMessage } from './fleet-steer.ts';
43
- import { FleetStopTracker, toggleFleetPause, buildFleetTreeHints, countDescendantStats } from './fleet-stop.ts';
45
+ import { FleetStopTracker, toggleFleetPause, buildFleetTreeHints, fleetKillConfirmArgs } from './fleet-stop.ts';
44
46
  import { resolveFleetDeepLinkIndex } from './fleet-deep-link.ts';
45
47
  import { parseAgentLedger, renderFleetAgentTranscript, renderFleetChainSummary, renderFleetLedgerFallback, renderFleetTranscriptLoading } from './fleet-transcript.ts';
46
48
  import { renderFleetTabStrip } from '../renderer/fleet-tab-strip.ts';
@@ -95,13 +97,10 @@ export class FleetPanel extends ScrollableListPanel<FleetTreeRow> {
95
97
  private tickTimer: ReturnType<typeof setInterval> | null = null;
96
98
  /**
97
99
  * The node id the cursor is anchored to, independent of its row index.
98
- * `selectedIndex` is a plain offset into `getItems()`'s current order —
99
- * every snapshot rebuilds that array from scratch (new nodes, completed
100
- * nodes pruned, tree re-sorted), so a stale index silently points at
101
- * whatever row now happens to occupy that slot. `reanchorSelection()`
102
- * re-locates this id in every fresh snapshot and repoints selectedIndex
103
- * there; null means "no anchor yet" (follows the base class's index-0
104
- * default until the first navigation/tick establishes one).
100
+ * `selectedIndex` is a plain offset into `getItems()`'s current (rebuilt-every-
101
+ * snapshot) order, so a stale index silently drifts; `reanchorSelection()`
102
+ * re-locates this id each snapshot and repoints selectedIndex. null means "no
103
+ * anchor yet" (base class index-0 default until first navigation/tick).
105
104
  */
106
105
  private selectedNodeId: string | null = null;
107
106
 
@@ -112,6 +111,11 @@ export class FleetPanel extends ScrollableListPanel<FleetTreeRow> {
112
111
  private readonly readModel: FleetReadModel,
113
112
  actions: Partial<FleetActionCallbacks> = {},
114
113
  private readonly configManager: ConfigManager | null = null,
114
+ // The waiting-on-human acts (pick / conflict / discard). Null in a runtime
115
+ // with no daemon gateway wired — those keys then fall through honestly.
116
+ private readonly acts: FleetActs | null = null,
117
+ // The ACP spawn affordance ('n' hosts a discovered third-party agent); null with no gateway wired.
118
+ private readonly spawn: FleetSpawn | null = null,
115
119
  ) {
116
120
  super('fleet', 'Fleet', '⊟', 'runtime-ops');
117
121
  this.showSelectionGutter = true; // visible ▸ focus indicator
@@ -232,11 +236,10 @@ export class FleetPanel extends ScrollableListPanel<FleetTreeRow> {
232
236
  }
233
237
 
234
238
  /**
235
- * (cross-restart honesty): a restart always starts from an empty
236
- * `AgentManager` Map (no bridge from a prior TUI/daemon registry), so a
237
- * completed process from a previous session can never reappear here — a
238
- * real, permanent limitation (brief point 5), documented rather than
239
- * silently implying "nothing has ever run" or that a restart brings it back.
239
+ * (cross-restart honesty): a restart starts from an empty `AgentManager` Map
240
+ * (no bridge from a prior registry), so a completed process from a previous
241
+ * session can never reappear here — a real, permanent limitation, documented
242
+ * rather than implying "nothing has ever run" or that a restart brings it back.
240
243
  */
241
244
  protected override getEmptyStateActions(): Array<{ command: string; summary: string }> {
242
245
  return [
@@ -275,15 +278,11 @@ export class FleetPanel extends ScrollableListPanel<FleetTreeRow> {
275
278
  }
276
279
 
277
280
  /**
278
- * Re-locate `selectedNodeId` in the current (possibly just-changed)
279
- * snapshot and repoint `selectedIndex` at its new position — this is what
280
- * makes the selection follow a specific process across adds/removes/
281
- * reorders instead of drifting onto whatever row now occupies the old
282
- * index. When the anchored node is no longer present (completed and
283
- * pruned, killed and pruned, etc.) — or no anchor has been established
284
- * yet — falls back to the base class's clampSelection() (nearest valid
285
- * index, never an out-of-bounds/vanished selection) and re-anchors to
286
- * whatever that lands on.
281
+ * Re-locate `selectedNodeId` in the current snapshot and repoint
282
+ * `selectedIndex` at it, so the selection follows a specific process across
283
+ * adds/removes/reorders instead of drifting onto whatever row occupies the old
284
+ * index. When the anchored node is gone (pruned) — or no anchor exists yet —
285
+ * falls back to clampSelection() (nearest valid index) and re-anchors there.
287
286
  */
288
287
  private reanchorSelection(): void {
289
288
  const rows = this.getItems();
@@ -317,12 +316,10 @@ export class FleetPanel extends ScrollableListPanel<FleetTreeRow> {
317
316
 
318
317
  /**
319
318
  * Focus the next node blocked on the operator (awaiting approval/input),
320
- * cycling top-to-bottom in display order and wrapping. Always resolves
321
- * against the LIVE fleet (blocked nodes never live in the archive), and
322
- * returns to the tree view from any attached tab so the reveal is visible —
323
- * mirrors receiveDeepLink's "make it visible" contract. Honest no-op with a
324
- * message when nothing is currently waiting on the operator. Returns true
325
- * (the 'b' key is always consumed by the panel).
319
+ * cycling in display order and wrapping. Resolves against the LIVE fleet
320
+ * (blocked nodes never live in the archive) and returns to the tree view from
321
+ * any attached tab so the reveal is visible (receiveDeepLink's contract).
322
+ * Honest no-op message when nothing waits; always consumes 'b' (returns true).
326
323
  */
327
324
  private jumpToNextBlocked(): boolean {
328
325
  const snapshot = this.readModel.getSnapshot();
@@ -384,6 +381,13 @@ export class FleetPanel extends ScrollableListPanel<FleetTreeRow> {
384
381
  public override handleInput(key: string): boolean {
385
382
  if (this.lastError !== null) this.clearError();
386
383
 
384
+ // The candidate picker (a flagged pick decision) owns the view + input while active.
385
+ if (this.acts?.pickModeActive()) return this.acts.handlePickInput(key);
386
+ // The observed-agent steer composer (a drill-in) likewise owns EVERY key until Enter/Esc.
387
+ if (this.acts?.observedSteerActive()) return this.acts.handleObservedSteerInput(key);
388
+ // The ACP spawn picker (host a third-party agent) likewise owns view + input.
389
+ if (this.spawn?.spawnModeActive()) return this.spawn.handleSpawnInput(key);
390
+
387
391
  // Confirm overlay owns input first (K-armed kill confirm).
388
392
  if (this.confirmOverlay.handleInput(key)) return true;
389
393
 
@@ -425,6 +429,18 @@ export class FleetPanel extends ScrollableListPanel<FleetTreeRow> {
425
429
 
426
430
  const selected = this.getSelectedItem();
427
431
 
432
+ // Waiting-on-human acts on the selected row: Enter on a flagged pick/conflict
433
+ // row, D on a worktree-owning row. Returns false (falls through to attach /
434
+ // ordinary handling) for any other row, so Enter still opens an agent tab.
435
+ if (this.acts && selected && this.acts.handleTreeKey(key, selected.node)) {
436
+ this.captureSelectionAnchor();
437
+ return true;
438
+ }
439
+
440
+ // Host a third-party ACP agent: lists discovered agents, then a known dir,
441
+ // then spawns it as an acp-agent row. Quiet when none are discovered.
442
+ if (key === 'n' && this.spawn) { void this.spawn.begin(); return true; }
443
+
428
444
  // Archive controls: v toggles the live fleet <-> session archive view;
429
445
  // a archives the selected finished subtree (or restores it from the
430
446
  // archive view); A archives every fully-finished root subtree.
@@ -479,25 +495,16 @@ export class FleetPanel extends ScrollableListPanel<FleetTreeRow> {
479
495
 
480
496
  if (key === 'K') {
481
497
  if (!selected || isTerminalProcessState(selected.node.state)) return false;
498
+ // Observed foreign agents have killable:false — the refusal below reads
499
+ // honestly; goodvibes only observes them, so stop is never offered.
482
500
  if (!selected.node.capabilities.killable) {
483
501
  this.setError(`${selected.node.kind} does not support kill.`);
484
502
  return true;
485
503
  }
486
- const node = selected.node;
487
- const shortId = node.id.length > 8 ? node.id.slice(-8) : node.id;
488
- const stats = countDescendantStats(this.getItems(), node.id);
489
- const suffix = stats.total > 0 ? ` (+${stats.total} descendant${stats.total === 1 ? '' : 's'}, ${stats.active} active)` : '';
490
- this.confirmOverlay.arm({
491
- id: node.id,
492
- label: `${node.kind} ${shortId}${suffix}`,
493
- verb: 'Kill',
494
- // d1: mark 'stopping…' only once the kill is confirmed, not while
495
- // the confirm overlay is merely armed.
496
- onConfirm: () => {
497
- this.actions.kill(node.id, { cascade: true });
498
- this.stopTracker.mark(node.id);
499
- },
500
- });
504
+ this.confirmOverlay.arm(fleetKillConfirmArgs(selected.node, this.getItems(), {
505
+ kill: (id) => this.actions.kill(id, { cascade: true }),
506
+ markStopping: (id) => this.stopTracker.mark(id),
507
+ }));
501
508
  return true;
502
509
  }
503
510
 
@@ -539,6 +546,9 @@ export class FleetPanel extends ScrollableListPanel<FleetTreeRow> {
539
546
  // one press for a steerable node; honest refusal otherwise.
540
547
  if (!selected) return false;
541
548
  const node = selected.node;
549
+ // Observed foreign agents steer as a DRILL-IN only; that 's' is handled by
550
+ // acts.handleTreeKey above (opens the composer in the row's detail), so it
551
+ // never reaches this attach path.
542
552
  // Closure replay note: a node that FINISHED in the hint→keypress race
543
553
  // window must say so — "does not support steer" is misleading for an
544
554
  // agent whose only problem is being done.
@@ -659,7 +669,11 @@ export class FleetPanel extends ScrollableListPanel<FleetTreeRow> {
659
669
  // so the detail's literal state text never contradicts the row glyph.
660
670
  const stopping = this.stopTracker.isStopping(node.id);
661
671
  const blocked = !stopping && isBlockedOnUserNode(node);
662
- return renderFleetDetailLines(node, width, stopping, blocked, C);
672
+ // The drill-in observed-steer draft (when the composer is open on this row)
673
+ // renders an input line in the observed detail. For a workstream row, lazily
674
+ // fetch its task graph so the edges/pool posture renders in-panel.
675
+ this.acts?.ensureGraphFor(node);
676
+ return renderFleetDetailLines(node, width, stopping, blocked, C, this.acts?.observedSteerDraftFor(node.id) ?? null, this.acts?.graphFor(node.id) ?? null);
663
677
  }
664
678
 
665
679
  /** Renders the active session tab's content (transcript / chain summary / ledger fallback) in place of the tree. */
@@ -777,6 +791,8 @@ export class FleetPanel extends ScrollableListPanel<FleetTreeRow> {
777
791
  }
778
792
 
779
793
  public override render(width: number, height: number): Line[] {
794
+ if (this.acts?.pickModeActive()) return this.acts.renderPickMode(width, height, C);
795
+ if (this.spawn?.spawnModeActive()) return this.spawn.renderSpawnMode(width, height, C);
780
796
  if (this.tabsState.activeTabIndex > 0) return this.renderTabView(width, height);
781
797
  return this.renderTreeView(width, height);
782
798
  }
@@ -167,6 +167,17 @@ const KIND_TAGS: Record<ProcessKind, string> = {
167
167
  // A single leaf node — never a rollup of other flat-list nodes (see
168
168
  // ROLLUP_KINDS below) — so no other list in this module needs updating.
169
169
  'code-index': 'index',
170
+ // A hosted third-party coding agent (Claude Code / Codex / opencode) spawned
171
+ // over ACP (adapters/acp-host.ts). A plain leaf row — steer/stop/attention
172
+ // ride on its node capabilities and the SDK's needsAttention projection, so
173
+ // this glyph/label is the only read-model special-casing it needs.
174
+ 'acp-agent': 'acp',
175
+ // An externally-launched foreign coding agent (Claude Code / Codex / opencode)
176
+ // that goodvibes did NOT spawn — observed read-only from OS signals. The row's
177
+ // first job is visibility ("it's in claude or codex"); it is never counted in
178
+ // any our-fleet count, never carries a stop affordance, and steer is drill-in
179
+ // only. See ObservedProcess handling in fleet-panel-format.ts.
180
+ 'observed-external': 'observed',
170
181
  };
171
182
 
172
183
  export function fleetStateGlyph(state: ProcessState): string {
@@ -213,9 +224,27 @@ export function isBlockedOnUserNode(node: ProcessNode): boolean {
213
224
  return fleetNodeAttention(node) !== null;
214
225
  }
215
226
 
216
- /** The literal badge text for a waiting-on-human node, by reason. */
227
+ /**
228
+ * The literal badge text for a waiting-on-human node, by reason. Every reason
229
+ * is a first-class member of the ONE waiting-on-human state class (same ⚑
230
+ * glyph, same jump key, same count) but names WHY in its own words:
231
+ * - 'approval' — a tool call is held for an approve/deny decision.
232
+ * - 'input' — the node is otherwise blocked on operator input.
233
+ * - 'pick' — a best-of-N attempt group is ready and only a human's
234
+ * winner pick advances it.
235
+ * - 'conflict' — a merge conflict needs a human resolution before the work
236
+ * can land.
237
+ * The pick/conflict wording is deliberately DISTINCT from the bare
238
+ * 'blocked on you' so an operator reads the required act, not just the state.
239
+ */
217
240
  export function fleetAttentionText(attention: ProcessAttention): string {
218
- return attention.reason === 'input' ? 'needs your input' : 'blocked on you';
241
+ switch (attention.reason) {
242
+ case 'input': return 'needs your input';
243
+ case 'pick': return 'needs your pick';
244
+ case 'conflict': return 'merge conflict waiting on you';
245
+ case 'approval': return 'blocked on you';
246
+ default: return 'blocked on you';
247
+ }
219
248
  }
220
249
 
221
250
  /**
@@ -470,7 +499,9 @@ function isFleetAggregateRollupNode(node: ProcessNode): boolean {
470
499
  * count in the way summing its rolled-up usage/cost would be.
471
500
  */
472
501
  function isFleetRunningLeaf(node: ProcessNode): boolean {
473
- return !ROLLUP_KINDS.has(node.kind) && isRunningProcessState(node.state);
502
+ // Observed foreign agents are NEVER counted in our own fleet counts — they
503
+ // are externally-launched sessions goodvibes only watches, not work it runs.
504
+ return node.kind !== 'observed-external' && !ROLLUP_KINDS.has(node.kind) && isRunningProcessState(node.state);
474
505
  }
475
506
 
476
507
  /** Build the full FleetSnapshot (rows + honest aggregates) from a flat node list. */