@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.
- package/CHANGELOG.md +208 -0
- package/README.md +1 -1
- package/docs/foundation-artifacts/operator-contract.json +5025 -1636
- package/package.json +7 -7
- package/src/cli/endpoints.ts +50 -4
- package/src/cli/help.ts +29 -2
- package/src/cli/management-commands.ts +99 -23
- package/src/cli/management.ts +0 -2
- package/src/cli/service-posture.ts +5 -3
- package/src/cli/status.ts +27 -6
- package/src/cli/surface-command.ts +29 -15
- package/src/core/consolidation-receipt.ts +41 -0
- package/src/core/conversation-rendering.ts +14 -5
- package/src/core/memory-diagnostics-gateway.ts +99 -0
- package/src/core/memory-provenance.ts +67 -0
- package/src/core/memory-status.ts +118 -0
- package/src/core/pairing-devices.ts +49 -0
- package/src/core/pairing-handoff.ts +101 -0
- package/src/core/pairing-offers.ts +83 -0
- package/src/core/pairing-origin.ts +78 -0
- package/src/core/pairing-tailscale-gateway.ts +56 -0
- package/src/core/power-chip-source.ts +91 -0
- package/src/core/power-status.ts +95 -0
- package/src/core/stable-host.ts +171 -0
- package/src/core/stream-event-wiring.ts +21 -0
- package/src/core/turn-budget-outcome.ts +60 -0
- package/src/core/turn-cancellation.ts +39 -0
- package/src/core/turn-event-wiring.ts +6 -0
- package/src/core/voice-provision-gateway.ts +202 -0
- package/src/core/voice-provision-status.ts +197 -0
- package/src/daemon/cli.ts +127 -60
- package/src/input/command-registry.ts +21 -0
- package/src/input/commands/acp-runtime.ts +72 -0
- package/src/input/commands/devices-runtime.ts +109 -0
- package/src/input/commands/experience-runtime.ts +25 -4
- package/src/input/commands/git-runtime.ts +28 -4
- package/src/input/commands/graph-runtime.ts +45 -0
- package/src/input/commands/health-runtime.ts +15 -1
- package/src/input/commands/operator-rpc.ts +32 -11
- package/src/input/commands/power-runtime.ts +56 -0
- package/src/input/commands/qrcode-runtime.ts +14 -19
- package/src/input/commands/queue-runtime.ts +70 -0
- package/src/input/commands/workstream-attempts.ts +6 -1
- package/src/input/commands/workstream-runtime.ts +44 -25
- package/src/input/commands/worktree-runtime.ts +35 -8
- package/src/input/commands.ts +10 -0
- package/src/input/config-modal-types.ts +11 -0
- package/src/input/config-modal.ts +65 -2
- package/src/input/handler-shortcuts.ts +23 -0
- package/src/input/keybindings.ts +19 -1
- package/src/input/settings-modal-data.ts +31 -37
- package/src/input/settings-modal-types.ts +5 -2
- package/src/input/tts-settings-actions.ts +27 -8
- package/src/input/voice-local-settings.ts +52 -0
- package/src/main.ts +42 -42
- package/src/panels/builtin/operations.ts +54 -1
- package/src/panels/builtin/shared.ts +12 -0
- package/src/panels/builtin-modals.ts +49 -32
- package/src/panels/diff-panel.ts +34 -1
- package/src/panels/fleet-acts.ts +446 -0
- package/src/panels/fleet-gateway.ts +117 -0
- package/src/panels/fleet-observed-render.ts +128 -0
- package/src/panels/fleet-panel-format.ts +120 -4
- package/src/panels/fleet-panel-worktree-detail.ts +14 -0
- package/src/panels/fleet-panel.ts +60 -44
- package/src/panels/fleet-read-model.ts +34 -3
- package/src/panels/fleet-spawn.ts +266 -0
- package/src/panels/fleet-stop.ts +38 -2
- package/src/panels/fleet-tabs.ts +2 -2
- package/src/panels/memory-consolidation-gateway.ts +94 -0
- package/src/panels/modals/devices-modal.ts +161 -0
- package/src/panels/modals/memory-modal.ts +149 -5
- package/src/panels/modals/pairing-modal.ts +198 -68
- package/src/panels/modals/security-modal.ts +6 -0
- package/src/panels/workstream-graph-render.ts +123 -0
- package/src/renderer/composer-fragments.ts +69 -0
- package/src/renderer/settings-modal-helpers.ts +9 -0
- package/src/renderer/settings-modal.ts +4 -2
- package/src/renderer/shell-surface.ts +3 -0
- package/src/renderer/tool-call.ts +12 -6
- package/src/renderer/ui-factory.ts +32 -0
- package/src/runtime/bootstrap-command-context.ts +3 -1
- package/src/runtime/bootstrap-command-parts.ts +3 -1
- package/src/runtime/bootstrap-core.ts +6 -6
- package/src/runtime/bootstrap-shell.ts +7 -0
- package/src/runtime/code-index-services.ts +15 -1
- package/src/runtime/durability-services.ts +36 -1
- package/src/runtime/fleet-services.ts +41 -0
- package/src/runtime/idle-power-services.ts +85 -0
- package/src/runtime/index.ts +2 -0
- package/src/runtime/interaction-seams.ts +109 -0
- package/src/runtime/knowledge-services.ts +94 -0
- package/src/runtime/legacy-daemon-migration.ts +38 -6
- package/src/runtime/legacy-daemon-reconcile.ts +439 -0
- package/src/runtime/memory-governance-services.ts +79 -0
- package/src/runtime/notification-dispatch.ts +28 -0
- package/src/runtime/power-keepawake-remote.ts +75 -0
- package/src/runtime/process-lifecycle.ts +3 -1
- package/src/runtime/services.ts +105 -107
- package/src/runtime/ui-services.ts +3 -0
- package/src/runtime/voice-setup-services.ts +104 -0
- package/src/shell/blocking-input.ts +21 -141
- package/src/shell/recovery-input-helpers.ts +53 -166
- 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
|
-
[
|
|
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
|
-
|
|
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,
|
|
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
|
|
99
|
-
*
|
|
100
|
-
*
|
|
101
|
-
*
|
|
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
|
|
236
|
-
*
|
|
237
|
-
*
|
|
238
|
-
*
|
|
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
|
|
279
|
-
*
|
|
280
|
-
*
|
|
281
|
-
*
|
|
282
|
-
*
|
|
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
|
|
321
|
-
*
|
|
322
|
-
*
|
|
323
|
-
*
|
|
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
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
-
|
|
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. */
|