@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,446 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// fleet-acts.ts
|
|
3
|
+
//
|
|
4
|
+
// The Fleet panel's waiting-on-human ACTS: the flagged pick row, the flagged
|
|
5
|
+
// conflict row, and the worktree discard all act from the panel selection with
|
|
6
|
+
// no id ever typed. This controller owns that flow so fleet-panel.ts stays under
|
|
7
|
+
// the 800-line architecture cap — the panel delegates the trigger keys, the
|
|
8
|
+
// pick-mode input, and the pick-mode render here.
|
|
9
|
+
//
|
|
10
|
+
// • Pick — the flagged workstream row (needsAttention 'pick') opens a
|
|
11
|
+
// candidate picker (best-of-N held attempts, from fleet.attempts.list);
|
|
12
|
+
// ↑↓ chooses the winner, its diff shows in the shared DiffPanel, and Enter
|
|
13
|
+
// drives fleet.attempts.pick preview (confirm:false) -> confirm (confirm:true)
|
|
14
|
+
// through the DiffPanel's existing confirm overlay. No group/candidate id is
|
|
15
|
+
// ever typed — the panel derives them from the node and the selection.
|
|
16
|
+
// • Conflict — the flagged work-item row (needsAttention 'conflict') runs
|
|
17
|
+
// fleet.conflicts.resolve and hands the STAMPED resolution session id to the
|
|
18
|
+
// shared one-key jump/attach affordance (the CI fix-session machinery). On
|
|
19
|
+
// resolution the SDK reclaims the tree and the row clears on the next tick.
|
|
20
|
+
// • Discard — a worktree-owning work-item row runs worktrees.discard behind a
|
|
21
|
+
// confirm and renders the honest receipt (branch KEPT, dirty state preserved
|
|
22
|
+
// as a commit) — no path retyping.
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
|
|
25
|
+
import type { ProcessNode } from '@pellux/goodvibes-sdk/platform/runtime/fleet';
|
|
26
|
+
import type { WorkItem } from '@pellux/goodvibes-sdk/platform/orchestration';
|
|
27
|
+
import { summarizeError } from '@pellux/goodvibes-sdk/platform/utils';
|
|
28
|
+
import type { Line } from '../types/grid.ts';
|
|
29
|
+
import { isPanelSearchBackspace, isPanelSearchCancel, isPanelSearchCommit, isPanelSearchPrintable } from './search-focus.ts';
|
|
30
|
+
import { appendSteerText } from './fleet-tabs.ts';
|
|
31
|
+
import { isObservedExternalNode, observedKindLabel, type ObservedNode } from './fleet-observed-render.ts';
|
|
32
|
+
import {
|
|
33
|
+
buildKeyboardHints,
|
|
34
|
+
buildPanelWorkspace,
|
|
35
|
+
DEFAULT_PANEL_PALETTE,
|
|
36
|
+
type PanelPalette,
|
|
37
|
+
} from './polish.ts';
|
|
38
|
+
import { buildPanelLine } from './polish.ts';
|
|
39
|
+
import { formatAgentCost } from './agent-inspector-shared.ts';
|
|
40
|
+
import { fleetNodeAttention } from './fleet-read-model.ts';
|
|
41
|
+
import {
|
|
42
|
+
workItemIdFromNodeId,
|
|
43
|
+
workstreamIdFromNodeId,
|
|
44
|
+
type FleetAttemptCandidate,
|
|
45
|
+
type FleetGateway,
|
|
46
|
+
type FleetGatewayResolution,
|
|
47
|
+
type FleetGraphSnapshot,
|
|
48
|
+
type FleetHeldMergeGroup,
|
|
49
|
+
} from './fleet-gateway.ts';
|
|
50
|
+
|
|
51
|
+
/** The DiffPanel-backed surface the pick act reuses: show a diff, arm its confirm overlay, close it. */
|
|
52
|
+
export interface FleetDiffSurface {
|
|
53
|
+
/** Open + focus the diff panel showing this unified diff (title labels the candidate). */
|
|
54
|
+
show(title: string, unifiedDiff: string): void;
|
|
55
|
+
/** Arm the diff panel's existing confirm overlay (Enter/y merges, n/Esc cancels). */
|
|
56
|
+
armConfirm(opts: {
|
|
57
|
+
readonly id: string;
|
|
58
|
+
readonly label: string;
|
|
59
|
+
readonly verb: string;
|
|
60
|
+
readonly onConfirm: () => void | Promise<void>;
|
|
61
|
+
readonly onCancel?: () => void;
|
|
62
|
+
}): void;
|
|
63
|
+
/** Close the diff panel and return focus to the prompt. */
|
|
64
|
+
close(): void;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface FleetActsDeps {
|
|
68
|
+
/** Resolve a live gateway per act (so a daemon that comes up mid-session is seen); honest unavailable reason otherwise. */
|
|
69
|
+
readonly resolveGateway: () => FleetGatewayResolution;
|
|
70
|
+
/** The shared DiffPanel surface (reused for candidate diffs + the pick confirm). */
|
|
71
|
+
readonly diffSurface: FleetDiffSurface;
|
|
72
|
+
/** Surface a result/receipt/error line to the operator (system message, high priority). */
|
|
73
|
+
readonly notify: (message: string) => void;
|
|
74
|
+
/** Repaint request when the controller's own state (pick mode) changes. */
|
|
75
|
+
readonly markDirty: () => void;
|
|
76
|
+
/** The live node for an id in the current snapshot, for reading raw work-item fields; null when pruned. */
|
|
77
|
+
readonly findNode: (nodeId: string) => ProcessNode | null;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Live pick-mode state: the group being decided + the currently-highlighted candidate. */
|
|
81
|
+
interface PickMode {
|
|
82
|
+
readonly workstreamNodeId: string;
|
|
83
|
+
readonly group: FleetHeldMergeGroup;
|
|
84
|
+
/** Index into the group's HELD (pickable) candidates. */
|
|
85
|
+
selectedHeldIndex: number;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const C = DEFAULT_PANEL_PALETTE;
|
|
89
|
+
|
|
90
|
+
function shortId(id: string): string {
|
|
91
|
+
return id.length > 10 ? `${id.slice(0, 10)}…` : id;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** The held (pick-ready) candidates of a group, in attempt order — the only ones a winner can be chosen from. */
|
|
95
|
+
export function heldCandidates(group: FleetHeldMergeGroup): FleetAttemptCandidate[] {
|
|
96
|
+
return group.candidates.filter((c) => c.state === 'held-merge').slice().sort((a, b) => a.attemptIndex - b.attemptIndex);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** The raw WorkItem behind a work-item node, or null (non-work-item / no raw). */
|
|
100
|
+
export function workItemFromNode(node: ProcessNode): WorkItem | null {
|
|
101
|
+
const item = (node.raw as { item?: WorkItem } | undefined)?.item;
|
|
102
|
+
return item ?? null;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export class FleetActs {
|
|
106
|
+
private pick: PickMode | null = null;
|
|
107
|
+
/** Live observed-agent steer composer: the row being steered + its draft. Drill-in only. */
|
|
108
|
+
private observedSteer: { readonly nodeId: string; draft: string } | null = null;
|
|
109
|
+
/** Per-workstream-node graph snapshot cache (null = fetched, unavailable). undefined = not fetched. */
|
|
110
|
+
private readonly graphCache = new Map<string, FleetGraphSnapshot | null>();
|
|
111
|
+
private readonly graphInFlight = new Set<string>();
|
|
112
|
+
|
|
113
|
+
public constructor(private readonly deps: FleetActsDeps) {}
|
|
114
|
+
|
|
115
|
+
/** True while the candidate picker owns the fleet view + input. */
|
|
116
|
+
public pickModeActive(): boolean {
|
|
117
|
+
return this.pick !== null;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Dispatch an act-trigger key pressed on the tree while `node` is selected.
|
|
122
|
+
* Returns true when consumed (an act fired), false to fall through to the
|
|
123
|
+
* panel's ordinary key handling (so Enter still attaches an agent tab, etc.).
|
|
124
|
+
*/
|
|
125
|
+
public handleTreeKey(key: string, node: ProcessNode): boolean {
|
|
126
|
+
if (key === 'enter' || key === 'return') {
|
|
127
|
+
const attention = fleetNodeAttention(node);
|
|
128
|
+
if (attention?.reason === 'pick') { void this.beginPick(node); return true; }
|
|
129
|
+
if (attention?.reason === 'conflict') { void this.resolveConflict(node); return true; }
|
|
130
|
+
return false; // not an act row — let attach handle it
|
|
131
|
+
}
|
|
132
|
+
if (key === 'D') {
|
|
133
|
+
return this.discardWorktree(node);
|
|
134
|
+
}
|
|
135
|
+
// Observed foreign agents steer as a DRILL-IN only: 's' on the selected row
|
|
136
|
+
// opens the composer in its detail (never an attach, never a list verb).
|
|
137
|
+
if (key === 's' && isObservedExternalNode(node)) {
|
|
138
|
+
return this.openObservedSteer(node);
|
|
139
|
+
}
|
|
140
|
+
return false;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// ── Observed-agent steer (drill-in composer) ──────────────────────────────
|
|
144
|
+
|
|
145
|
+
/** True while the observed-steer composer owns input. */
|
|
146
|
+
public observedSteerActive(): boolean {
|
|
147
|
+
return this.observedSteer !== null;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** The active observed-steer draft for `nodeId`, or null — the detail renderer shows the compose line only for the composing row. */
|
|
151
|
+
public observedSteerDraftFor(nodeId: string): string | null {
|
|
152
|
+
return this.observedSteer && this.observedSteer.nodeId === nodeId ? this.observedSteer.draft : null;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Open the drill-in steer composer for an observed foreign-agent row. A row
|
|
157
|
+
* with a live tmux channel opens an input; a channel-less row keeps NO input
|
|
158
|
+
* and states the honest reason (owner ruling: steer is drill-in-only, and stop
|
|
159
|
+
* is never offered on an observed row).
|
|
160
|
+
*/
|
|
161
|
+
public openObservedSteer(node: ObservedNode): boolean {
|
|
162
|
+
const channel = node.observed.steer;
|
|
163
|
+
if (channel.kind !== 'tmux') {
|
|
164
|
+
this.deps.notify(`Cannot steer this ${observedKindLabel(node.observed.externalKind)} session — ${channel.reason}.`);
|
|
165
|
+
return true;
|
|
166
|
+
}
|
|
167
|
+
this.observedSteer = { nodeId: node.id, draft: '' };
|
|
168
|
+
this.deps.markDirty();
|
|
169
|
+
return true;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** Input while the observed-steer composer is open (mirrors the tab steer composer). */
|
|
173
|
+
public handleObservedSteerInput(key: string): boolean {
|
|
174
|
+
if (!this.observedSteer) return false;
|
|
175
|
+
if (isPanelSearchCancel(key)) { this.observedSteer = null; this.deps.markDirty(); return true; }
|
|
176
|
+
if (isPanelSearchCommit(key)) { void this.submitObservedSteer(); return true; }
|
|
177
|
+
if (isPanelSearchBackspace(key)) { this.observedSteer.draft = this.observedSteer.draft.slice(0, -1); this.deps.markDirty(); return true; }
|
|
178
|
+
if (key.length === 1 && (isPanelSearchPrintable(key) || key === '\r' || key === '\n')) {
|
|
179
|
+
this.observedSteer.draft = appendSteerText(this.observedSteer.draft, key);
|
|
180
|
+
this.deps.markDirty();
|
|
181
|
+
return true;
|
|
182
|
+
}
|
|
183
|
+
return true; // absorb every other key while composing
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** Drive fleet.observed.steer over the daemon; the row's own channel routes the send-keys server-side. */
|
|
187
|
+
private async submitObservedSteer(): Promise<void> {
|
|
188
|
+
if (!this.observedSteer) return;
|
|
189
|
+
const { nodeId, draft } = this.observedSteer;
|
|
190
|
+
const text = draft.trim();
|
|
191
|
+
this.observedSteer = null;
|
|
192
|
+
this.deps.markDirty();
|
|
193
|
+
if (text.length === 0) return; // empty submit just closes the composer
|
|
194
|
+
const gateway = this.requireGateway();
|
|
195
|
+
if (!gateway) return;
|
|
196
|
+
try {
|
|
197
|
+
const result = await gateway.steerObserved({ id: nodeId, text });
|
|
198
|
+
this.deps.notify(result.queued
|
|
199
|
+
? '[Fleet] Steer delivered to the foreign session (tmux send-keys).'
|
|
200
|
+
: `[Fleet] Steer refused: ${result.reason ?? 'the foreign session exposes no channel'}.`);
|
|
201
|
+
} catch (err) {
|
|
202
|
+
this.deps.notify(`Observed steer failed: ${summarizeError(err)}`);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// ── Task-graph posture (in-panel edges/pool under a workstream) ───────────
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Lazily fetch the task graph (fleet.graph.get) for a selected workstream row
|
|
210
|
+
* so the in-panel detail can render its edges/pool posture WITHOUT opening
|
|
211
|
+
* /graph. Idempotent: fetches once per node (cache + in-flight guard), quiet
|
|
212
|
+
* on an unavailable daemon (caches null rather than nagging every frame). A
|
|
213
|
+
* no-op for any non-workstream node.
|
|
214
|
+
*/
|
|
215
|
+
public ensureGraphFor(node: ProcessNode): void {
|
|
216
|
+
const workstreamId = workstreamIdFromNodeId(node.id);
|
|
217
|
+
if (workstreamId === null) return;
|
|
218
|
+
if (this.graphCache.has(node.id) || this.graphInFlight.has(node.id)) return;
|
|
219
|
+
const resolution = this.deps.resolveGateway();
|
|
220
|
+
if (!resolution.available) { this.graphCache.set(node.id, null); return; }
|
|
221
|
+
this.graphInFlight.add(node.id);
|
|
222
|
+
void resolution.gateway.getGraph(workstreamId)
|
|
223
|
+
.then((snapshot) => { this.graphCache.set(node.id, snapshot); })
|
|
224
|
+
.catch(() => { this.graphCache.set(node.id, null); })
|
|
225
|
+
.finally(() => { this.graphInFlight.delete(node.id); this.deps.markDirty(); });
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** The cached graph for a node (null = fetched/unavailable, undefined = not yet fetched). */
|
|
229
|
+
public graphFor(nodeId: string): FleetGraphSnapshot | null | undefined {
|
|
230
|
+
return this.graphCache.get(nodeId);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// ── Pick (STEP 3) ─────────────────────────────────────────────────────────
|
|
234
|
+
|
|
235
|
+
/** Open the candidate picker for a flagged pick row (the workstream node). */
|
|
236
|
+
public async beginPick(node: ProcessNode): Promise<void> {
|
|
237
|
+
const workstreamId = workstreamIdFromNodeId(node.id);
|
|
238
|
+
if (workstreamId === null) { this.deps.notify('This row is not a workstream with a ready pick.'); return; }
|
|
239
|
+
const gateway = this.requireGateway();
|
|
240
|
+
if (!gateway) return;
|
|
241
|
+
let group: FleetHeldMergeGroup | undefined;
|
|
242
|
+
try {
|
|
243
|
+
const { groups } = await gateway.listAttempts(workstreamId);
|
|
244
|
+
group = groups.find((g) => g.ready && heldCandidates(g).length > 0);
|
|
245
|
+
} catch (err) {
|
|
246
|
+
this.deps.notify(`Could not read the best-of-N candidates: ${summarizeError(err)}`);
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
if (!group) { this.deps.notify('No ready best-of-N group on this workstream — every attempt must settle first.'); return; }
|
|
250
|
+
this.pick = { workstreamNodeId: node.id, group, selectedHeldIndex: 0 };
|
|
251
|
+
this.showSelectedDiff();
|
|
252
|
+
this.deps.markDirty();
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/** Input while the candidate picker is active. */
|
|
256
|
+
public handlePickInput(key: string): boolean {
|
|
257
|
+
if (!this.pick) return false;
|
|
258
|
+
const held = heldCandidates(this.pick.group);
|
|
259
|
+
if (key === 'escape' || key === 'esc') { this.pick = null; this.deps.markDirty(); return true; }
|
|
260
|
+
if (key === 'up' || key === 'k') {
|
|
261
|
+
this.pick.selectedHeldIndex = (this.pick.selectedHeldIndex - 1 + held.length) % held.length;
|
|
262
|
+
this.showSelectedDiff();
|
|
263
|
+
this.deps.markDirty();
|
|
264
|
+
return true;
|
|
265
|
+
}
|
|
266
|
+
if (key === 'down' || key === 'j') {
|
|
267
|
+
this.pick.selectedHeldIndex = (this.pick.selectedHeldIndex + 1) % held.length;
|
|
268
|
+
this.showSelectedDiff();
|
|
269
|
+
this.deps.markDirty();
|
|
270
|
+
return true;
|
|
271
|
+
}
|
|
272
|
+
if (key === 'enter' || key === 'return') { void this.confirmSelectedPick(); return true; }
|
|
273
|
+
return true; // absorb every other key while the picker owns the view
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
private showSelectedDiff(): void {
|
|
277
|
+
if (!this.pick) return;
|
|
278
|
+
const cand = heldCandidates(this.pick.group)[this.pick.selectedHeldIndex];
|
|
279
|
+
if (!cand) return;
|
|
280
|
+
const diff = cand.diff?.unifiedDiff?.trim();
|
|
281
|
+
if (diff) this.deps.diffSurface.show(cand.title, cand.diff!.unifiedDiff);
|
|
282
|
+
else this.deps.diffSurface.show(cand.title, '@@ pick @@\n (no diff to preview for this candidate)');
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Drive fleet.attempts.pick preview (confirm:false) then, behind the DiffPanel
|
|
287
|
+
* confirm overlay, confirm (confirm:true). No id is typed — the group id and
|
|
288
|
+
* the winner item id both come from the picker state.
|
|
289
|
+
*/
|
|
290
|
+
private async confirmSelectedPick(): Promise<void> {
|
|
291
|
+
if (!this.pick) return;
|
|
292
|
+
const { group } = this.pick;
|
|
293
|
+
const cand = heldCandidates(group)[this.pick.selectedHeldIndex];
|
|
294
|
+
if (!cand) return;
|
|
295
|
+
const gateway = this.requireGateway();
|
|
296
|
+
if (!gateway) return;
|
|
297
|
+
// Preview: confirm:false returns the group WITHOUT applying (the honest
|
|
298
|
+
// "here is what you are about to merge"). A refusal to even preview surfaces.
|
|
299
|
+
try {
|
|
300
|
+
await gateway.pick({ groupId: group.groupId, winnerItemId: cand.itemId, confirm: false });
|
|
301
|
+
} catch (err) {
|
|
302
|
+
this.deps.notify(`Pick preview failed: ${summarizeError(err)}`);
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
const losers = heldCandidates(group).length - 1;
|
|
306
|
+
this.deps.diffSurface.show(cand.title, cand.diff?.unifiedDiff?.trim() ? cand.diff.unifiedDiff : '@@ pick @@\n (no diff to preview for this candidate)');
|
|
307
|
+
this.deps.diffSurface.armConfirm({
|
|
308
|
+
id: `${group.groupId}:${cand.itemId}`,
|
|
309
|
+
verb: 'Pick',
|
|
310
|
+
label: `Pick attempt ${cand.attemptIndex + 1} ("${cand.title}") — merge it, clean the ${losers} other worktree(s)`,
|
|
311
|
+
onConfirm: async () => {
|
|
312
|
+
try {
|
|
313
|
+
const result = await gateway.pick({ groupId: group.groupId, winnerItemId: cand.itemId, confirm: true });
|
|
314
|
+
if (result.applied) {
|
|
315
|
+
const losersCleaned = result.loserItemIds?.length ?? losers;
|
|
316
|
+
this.deps.notify(`[Fleet] Winner picked for group ${shortId(group.groupId)}: attempt ${cand.attemptIndex + 1} merged, ${losersCleaned} loser worktree(s) cleaned.`);
|
|
317
|
+
} else {
|
|
318
|
+
this.deps.notify(`[Fleet] Pick not applied for group ${shortId(group.groupId)} — the daemon still requires confirmation.`);
|
|
319
|
+
}
|
|
320
|
+
} catch (err) {
|
|
321
|
+
this.deps.notify(`Pick failed: ${summarizeError(err)}`);
|
|
322
|
+
}
|
|
323
|
+
this.deps.diffSurface.close();
|
|
324
|
+
this.pick = null;
|
|
325
|
+
this.deps.markDirty();
|
|
326
|
+
},
|
|
327
|
+
onCancel: () => {
|
|
328
|
+
this.deps.diffSurface.close();
|
|
329
|
+
this.pick = null;
|
|
330
|
+
this.deps.notify('Pick cancelled — nothing merged, no worktree cleaned.');
|
|
331
|
+
this.deps.markDirty();
|
|
332
|
+
},
|
|
333
|
+
});
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// ── Conflict (STEP 4) ─────────────────────────────────────────────────────
|
|
337
|
+
|
|
338
|
+
/** Run fleet.conflicts.resolve on a flagged conflict row; on success arm the shared jump/attach on the stamped session. */
|
|
339
|
+
public async resolveConflict(node: ProcessNode): Promise<void> {
|
|
340
|
+
const itemId = workItemIdFromNodeId(node.id);
|
|
341
|
+
if (itemId === null) { this.deps.notify('This row is not a conflicted work item.'); return; }
|
|
342
|
+
const gateway = this.requireGateway();
|
|
343
|
+
if (!gateway) return;
|
|
344
|
+
try {
|
|
345
|
+
const result = await gateway.resolveConflict(itemId);
|
|
346
|
+
const files = result.files.length > 0 ? ` over ${result.files.length} conflicted file(s)` : '';
|
|
347
|
+
this.deps.notify(`[Fleet] Conflict resolution session started for ${shortId(itemId)}${files} — press j to jump to it.`);
|
|
348
|
+
// Reuse the CI fix-session machinery: hand the STAMPED session id to the
|
|
349
|
+
// shared one-key jump affordance. The kept tree is reclaimed by the SDK on
|
|
350
|
+
// a successful re-merge, and the flagged row clears on the next snapshot.
|
|
351
|
+
gateway.armFixSessionAttach(result.sessionId);
|
|
352
|
+
} catch (err) {
|
|
353
|
+
this.deps.notify(`Conflict resolution failed: ${summarizeError(err)}`);
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// ── Discard (STEP 5) ──────────────────────────────────────────────────────
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* Discard the worktree a work-item row owns, behind a confirm, rendering the
|
|
361
|
+
* honest receipt (branch KEPT, dirty state preserved as a commit). Returns
|
|
362
|
+
* true when the key is consumed (a worktree row), false to fall through.
|
|
363
|
+
*/
|
|
364
|
+
public discardWorktree(node: ProcessNode): boolean {
|
|
365
|
+
const item = workItemFromNode(node);
|
|
366
|
+
const path = item?.worktreePath;
|
|
367
|
+
if (!path) return false; // not a worktree-owning row — let the key fall through
|
|
368
|
+
const gateway = this.requireGateway();
|
|
369
|
+
if (!gateway) return true;
|
|
370
|
+
this.deps.diffSurface.armConfirm({
|
|
371
|
+
id: `discard:${path}`,
|
|
372
|
+
verb: 'Discard',
|
|
373
|
+
label: `Discard worktree ${path} — the branch is KEPT and dirty state preserved as a commit`,
|
|
374
|
+
onConfirm: async () => {
|
|
375
|
+
try {
|
|
376
|
+
const receipt = await gateway.discardWorktree(path);
|
|
377
|
+
if (receipt.ok) {
|
|
378
|
+
this.deps.notify(`[Fleet] Worktree discarded: ${receipt.path}\n branch kept: ${receipt.branch || '(unknown)'}\n preservation commit: ${receipt.preservedCommit || '(none — nothing to preserve)'}\n ${receipt.detail}`);
|
|
379
|
+
} else {
|
|
380
|
+
this.deps.notify(`[Fleet] Worktree discard refused for ${receipt.path}: ${receipt.detail}`);
|
|
381
|
+
}
|
|
382
|
+
} catch (err) {
|
|
383
|
+
this.deps.notify(`Worktree discard failed: ${summarizeError(err)}`);
|
|
384
|
+
}
|
|
385
|
+
this.deps.diffSurface.close();
|
|
386
|
+
this.deps.markDirty();
|
|
387
|
+
},
|
|
388
|
+
onCancel: () => {
|
|
389
|
+
this.deps.diffSurface.close();
|
|
390
|
+
this.deps.notify('Discard cancelled — the worktree is untouched.');
|
|
391
|
+
this.deps.markDirty();
|
|
392
|
+
},
|
|
393
|
+
});
|
|
394
|
+
return true;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
// ── Render (pick mode) ────────────────────────────────────────────────────
|
|
398
|
+
|
|
399
|
+
/** The candidate picker view (replaces the tree while pick mode is active). */
|
|
400
|
+
public renderPickMode(width: number, height: number, palette: PanelPalette = C): Line[] {
|
|
401
|
+
const P = palette;
|
|
402
|
+
if (!this.pick) return [];
|
|
403
|
+
const { group } = this.pick;
|
|
404
|
+
const held = heldCandidates(group);
|
|
405
|
+
const lines: Line[] = [];
|
|
406
|
+
lines.push(buildPanelLine(width, [
|
|
407
|
+
[' Best-of-N winner pick — ', P.label],
|
|
408
|
+
[group.sourceTitle, P.value],
|
|
409
|
+
]));
|
|
410
|
+
lines.push(buildPanelLine(width, [[' Choose the winner; its diff shows in the diff panel. No id is typed.', P.dim]]));
|
|
411
|
+
lines.push(buildPanelLine(width, [['', P.dim]]));
|
|
412
|
+
held.forEach((cand, index) => {
|
|
413
|
+
const selected = index === this.pick!.selectedHeldIndex;
|
|
414
|
+
const files = cand.diff ? `${cand.diff.files.length} file(s)` : 'no diff';
|
|
415
|
+
const cost = cand.usage.costUsd !== null && cand.usage.costState !== 'unpriced'
|
|
416
|
+
? formatAgentCost(cand.usage.costUsd)
|
|
417
|
+
: 'unpriced';
|
|
418
|
+
lines.push(buildPanelLine(width, [
|
|
419
|
+
[selected ? ' ▸ ' : ' ', selected ? P.info : P.dim],
|
|
420
|
+
[`${cand.attemptIndex + 1}. `, P.label],
|
|
421
|
+
[cand.title, selected ? P.value : P.dim],
|
|
422
|
+
[` — ${files}, ${cost}`, P.dim],
|
|
423
|
+
]));
|
|
424
|
+
});
|
|
425
|
+
if (group.judgment?.proposedWinnerItemId) {
|
|
426
|
+
const proposed = held.find((c) => c.itemId === group.judgment!.proposedWinnerItemId);
|
|
427
|
+
lines.push(buildPanelLine(width, [['', P.dim]]));
|
|
428
|
+
lines.push(buildPanelLine(width, [
|
|
429
|
+
[' judge proposal (model, advisory): ', P.label],
|
|
430
|
+
[proposed ? `attempt ${proposed.attemptIndex + 1}` : shortId(group.judgment.proposedWinnerItemId), P.info],
|
|
431
|
+
]));
|
|
432
|
+
}
|
|
433
|
+
const footerLines = [buildKeyboardHints(width, [
|
|
434
|
+
{ keys: '↑↓', label: 'choose' },
|
|
435
|
+
{ keys: 'Enter', label: 'pick (confirm)' },
|
|
436
|
+
{ keys: 'Esc', label: 'cancel' },
|
|
437
|
+
], P)];
|
|
438
|
+
return buildPanelWorkspace(width, height, { title: 'Fleet — Pick winner', sections: [{ lines }], footerLines, palette: P });
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
private requireGateway(): FleetGateway | null {
|
|
442
|
+
const resolution = this.deps.resolveGateway();
|
|
443
|
+
if (!resolution.available) { this.deps.notify(`[Fleet] ${resolution.reason}`); return null; }
|
|
444
|
+
return resolution.gateway;
|
|
445
|
+
}
|
|
446
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// fleet-gateway.ts
|
|
3
|
+
//
|
|
4
|
+
// The async, daemon-backed verb surface the Fleet panel's waiting-on-human acts
|
|
5
|
+
// drive — a best-of-N winner pick (fleet.attempts.list / fleet.attempts.pick),
|
|
6
|
+
// a merge-conflict resolution (fleet.conflicts.resolve), and a worktree discard
|
|
7
|
+
// (worktrees.discard). These are gateway verbs with no named facade on the
|
|
8
|
+
// in-process OperatorClient, so — exactly like /ci and /worktree setup — they
|
|
9
|
+
// go over the generic operator invoke path (operator-rpc.ts's resolveOperatorRpc
|
|
10
|
+
// -> sdk.operator.invoke), reaching the SAME daemon the command layer does.
|
|
11
|
+
//
|
|
12
|
+
// The panel never types an id: it derives the workstream/work-item id from the
|
|
13
|
+
// selected node's namespaced id (workstream:<id> / work-item:<id>, the SDK's own
|
|
14
|
+
// fleet-adapter id scheme) and drives the verbs from there. The interface is
|
|
15
|
+
// injectable so the panel's act flow round-trips against a mocked daemon in
|
|
16
|
+
// tests; the live builder (createFleetGateway) is wired in builtin/operations.ts.
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
|
|
19
|
+
import type { ConfigManager } from '@pellux/goodvibes-sdk/platform/config';
|
|
20
|
+
import type { OperatorMethodOutput } from '@pellux/goodvibes-sdk';
|
|
21
|
+
import { resolveOperatorRpc } from '../input/commands/operator-rpc.ts';
|
|
22
|
+
|
|
23
|
+
/** fleet.attempts.list output — the held-merge groups awaiting a winner pick. */
|
|
24
|
+
export type FleetAttemptsList = OperatorMethodOutput<'fleet.attempts.list'>;
|
|
25
|
+
/** One held-merge group (candidates + their diffs + optional model judgment). */
|
|
26
|
+
export type FleetHeldMergeGroup = FleetAttemptsList['groups'][number];
|
|
27
|
+
/** One candidate attempt within a group. */
|
|
28
|
+
export type FleetAttemptCandidate = FleetHeldMergeGroup['candidates'][number];
|
|
29
|
+
/** fleet.attempts.pick output — the confirm preview (applied:false) OR the applied receipt (applied:true). */
|
|
30
|
+
export type FleetPickResult = OperatorMethodOutput<'fleet.attempts.pick'>;
|
|
31
|
+
/** fleet.conflicts.resolve output — the seeded resolution session stamped on the kept tree. */
|
|
32
|
+
export type FleetConflictResolution = OperatorMethodOutput<'fleet.conflicts.resolve'>;
|
|
33
|
+
/** worktrees.discard output — the honest receipt: dir removed, branch KEPT, dirty state preserved as a commit. */
|
|
34
|
+
export type FleetWorktreeDiscardReceipt = OperatorMethodOutput<'worktrees.discard'>;
|
|
35
|
+
/** fleet.graph.get output — the surface-facing task graph of one workstream (nodes/edges/pool). */
|
|
36
|
+
export type FleetGraphSnapshot = OperatorMethodOutput<'fleet.graph.get'>;
|
|
37
|
+
/** fleet.observed.steer output — queued:true with a messageId, or queued:false with an honest reason. */
|
|
38
|
+
export type FleetObservedSteerResult = OperatorMethodOutput<'fleet.observed.steer'>;
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* The narrow async verb surface the Fleet panel's acts drive. Every method is a
|
|
42
|
+
* real daemon round-trip in production (createFleetGateway) and a mocked shape
|
|
43
|
+
* in tests. `armFixSessionAttach` is the ONLY sync member — it hands a spawned
|
|
44
|
+
* session id to the shared one-key jump affordance (the CI fix-session
|
|
45
|
+
* machinery), never a separate attach path.
|
|
46
|
+
*/
|
|
47
|
+
export interface FleetGateway {
|
|
48
|
+
/** List the held-merge groups (candidates + diffs) for one workstream. */
|
|
49
|
+
listAttempts(workstreamId: string): Promise<FleetAttemptsList>;
|
|
50
|
+
/**
|
|
51
|
+
* Drive fleet.attempts.pick. `confirm:false` returns the confirm PREVIEW
|
|
52
|
+
* (applied:false, the group carried for the surface to render); `confirm:true`
|
|
53
|
+
* merges the winner, cleans the losers, and returns the applied receipt.
|
|
54
|
+
*/
|
|
55
|
+
pick(input: { readonly groupId: string; readonly winnerItemId: string; readonly confirm: boolean }): Promise<FleetPickResult>;
|
|
56
|
+
/** Resolve a merge conflict: spawn the seeded resolution session inside the kept tree. */
|
|
57
|
+
resolveConflict(itemId: string): Promise<FleetConflictResolution>;
|
|
58
|
+
/** Discard a worktree directory (branch kept, dirty state preserved as a commit). */
|
|
59
|
+
discardWorktree(path: string): Promise<FleetWorktreeDiscardReceipt>;
|
|
60
|
+
/** Fetch the task graph (nodes/edges/pool) for one workstream — the observability graph view. */
|
|
61
|
+
getGraph(workstreamId: string): Promise<FleetGraphSnapshot>;
|
|
62
|
+
/** Steer an observed foreign agent over its own channel (tmux send-keys); the daemon honestly refuses a channel-less row. */
|
|
63
|
+
steerObserved(input: { readonly id: string; readonly text: string }): Promise<FleetObservedSteerResult>;
|
|
64
|
+
/** Route a spawned session through the shared one-key jump/attach affordance. */
|
|
65
|
+
armFixSessionAttach(sessionId: string): void;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const WORKSTREAM_NODE_PREFIX = 'workstream:';
|
|
69
|
+
const WORK_ITEM_NODE_PREFIX = 'work-item:';
|
|
70
|
+
|
|
71
|
+
/** The raw workstream id behind a `workstream:<id>` fleet node id, or null for any other node. */
|
|
72
|
+
export function workstreamIdFromNodeId(nodeId: string): string | null {
|
|
73
|
+
return nodeId.startsWith(WORKSTREAM_NODE_PREFIX) ? nodeId.slice(WORKSTREAM_NODE_PREFIX.length) : null;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** The raw work-item id behind a `work-item:<id>` fleet node id, or null for any other node. */
|
|
77
|
+
export function workItemIdFromNodeId(nodeId: string): string | null {
|
|
78
|
+
return nodeId.startsWith(WORK_ITEM_NODE_PREFIX) ? nodeId.slice(WORK_ITEM_NODE_PREFIX.length) : null;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Why the gateway could not be built (daemon disabled / no control-plane URL),
|
|
83
|
+
* surfaced verbatim so an act can print an honest "not available" line rather
|
|
84
|
+
* than guessing — mirrors OperatorRpcUnavailable.reason.
|
|
85
|
+
*/
|
|
86
|
+
export type FleetGatewayResolution =
|
|
87
|
+
| { readonly available: true; readonly gateway: FleetGateway }
|
|
88
|
+
| { readonly available: false; readonly reason: string };
|
|
89
|
+
|
|
90
|
+
export interface FleetGatewayDeps {
|
|
91
|
+
readonly configManager: ConfigManager;
|
|
92
|
+
readonly homeDirectory: string;
|
|
93
|
+
/** Hand a spawned session id to the shared one-key jump affordance (late-bound: the affordance is patched onto the command context after bootstrap). */
|
|
94
|
+
readonly armFixSessionAttach: (sessionId: string) => void;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Build the live Fleet gateway over the generic operator invoke path — the same
|
|
99
|
+
* daemon resolution the command layer uses (resolveOperatorRpc). Returns an
|
|
100
|
+
* honest unavailable reason when no daemon is reachable, so the panel acts can
|
|
101
|
+
* refuse cleanly instead of throwing into the render loop.
|
|
102
|
+
*/
|
|
103
|
+
export function createFleetGateway(deps: FleetGatewayDeps): FleetGatewayResolution {
|
|
104
|
+
const rpc = resolveOperatorRpc({ configManager: deps.configManager, homeDirectory: deps.homeDirectory });
|
|
105
|
+
if (!rpc.available) return { available: false, reason: rpc.reason };
|
|
106
|
+
const { sdk } = rpc;
|
|
107
|
+
const gateway: FleetGateway = {
|
|
108
|
+
listAttempts: (workstreamId) => sdk.operator.invoke('fleet.attempts.list', { workstreamId }),
|
|
109
|
+
pick: (input) => sdk.operator.invoke('fleet.attempts.pick', input),
|
|
110
|
+
resolveConflict: (itemId) => sdk.operator.invoke('fleet.conflicts.resolve', { itemId }),
|
|
111
|
+
discardWorktree: (path) => sdk.operator.invoke('worktrees.discard', { path }),
|
|
112
|
+
getGraph: (workstreamId) => sdk.operator.invoke('fleet.graph.get', { workstreamId }),
|
|
113
|
+
steerObserved: (input) => sdk.operator.invoke('fleet.observed.steer', input),
|
|
114
|
+
armFixSessionAttach: deps.armFixSessionAttach,
|
|
115
|
+
};
|
|
116
|
+
return { available: true, gateway };
|
|
117
|
+
}
|