@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,266 @@
1
+ // ---------------------------------------------------------------------------
2
+ // fleet-spawn.ts
3
+ //
4
+ // The Fleet panel's ACP spawn affordance: 'n' on the fleet surface lists the
5
+ // third-party coding agents the daemon discovered (acp.agents.list — read-only,
6
+ // quiet when none), you pick one, then pick a working directory from the known
7
+ // candidates (the registered workspaces + the current dir — no free-text path
8
+ // retyping where a known dir exists), and acp.sessions.create hosts it as a
9
+ // long-lived daemon session. The new row shows up on the next fleet snapshot as
10
+ // kind 'acp-agent' and steers/stops/flags-for-attention like any other row.
11
+ //
12
+ // A structured spawn failure ({binary, stage, message}) is surfaced verbatim —
13
+ // never a hung row: the daemon bounds the handshake and returns a 'failed'
14
+ // record, and this controller reports its three honest fields.
15
+ //
16
+ // The controller owns the fleet view + input while a pick is in flight (mirrors
17
+ // FleetActs' pick mode), so fleet-panel.ts stays a thin delegator under the
18
+ // architecture line cap. The gateway is injectable so the flow round-trips
19
+ // against a mocked daemon in tests; the live builder is createAcpSpawnGateway.
20
+ // ---------------------------------------------------------------------------
21
+
22
+ import type { ConfigManager } from '@pellux/goodvibes-sdk/platform/config';
23
+ import type { OperatorMethodOutput } from '@pellux/goodvibes-sdk';
24
+ import { summarizeError } from '@pellux/goodvibes-sdk/platform/utils';
25
+ import type { Line } from '../types/grid.ts';
26
+ import { resolveOperatorRpc } from '../input/commands/operator-rpc.ts';
27
+ import {
28
+ buildKeyboardHints,
29
+ buildPanelWorkspace,
30
+ buildPanelLine,
31
+ DEFAULT_PANEL_PALETTE,
32
+ type PanelPalette,
33
+ } from './polish.ts';
34
+
35
+ /** One discovered third-party ACP agent (acp.agents.list). */
36
+ export type AcpDiscoveredAgent = OperatorMethodOutput<'acp.agents.list'>['agents'][number];
37
+ /** acp.sessions.create output — the hosted record + whether it started. */
38
+ export type AcpSpawnResult = OperatorMethodOutput<'acp.sessions.create'>;
39
+ /** One registered workspace root (workspaces.registrations.list). */
40
+ export type AcpWorkspaceRegistration = OperatorMethodOutput<'workspaces.registrations.list'>['workspaces'][number];
41
+
42
+ /** A working-directory candidate offered in the picker: a known dir, never retyped. */
43
+ export interface AcpDirCandidate {
44
+ readonly path: string;
45
+ /** A short human label (a workspace label, or "current directory"). */
46
+ readonly label: string;
47
+ }
48
+
49
+ /**
50
+ * The async verb surface the spawn flow drives — real daemon round-trips in
51
+ * production (createAcpSpawnGateway), a mocked shape in tests.
52
+ */
53
+ export interface AcpSpawnGateway {
54
+ /** Discovered third-party agents (read-only; empty is a quiet, honest absence). */
55
+ listAgents(): Promise<readonly AcpDiscoveredAgent[]>;
56
+ /** The registered workspace roots — the known-dir candidates for the picker. */
57
+ listWorkspaces(): Promise<readonly AcpWorkspaceRegistration[]>;
58
+ /** Host a discovered agent in a directory as a long-lived daemon session. */
59
+ createSession(input: { readonly agentId: string; readonly cwd: string }): Promise<AcpSpawnResult>;
60
+ }
61
+
62
+ export type AcpSpawnGatewayResolution =
63
+ | { readonly available: true; readonly gateway: AcpSpawnGateway }
64
+ | { readonly available: false; readonly reason: string };
65
+
66
+ export interface AcpSpawnGatewayDeps {
67
+ readonly configManager: ConfigManager;
68
+ readonly homeDirectory: string | (() => string);
69
+ }
70
+
71
+ /**
72
+ * Build the live ACP spawn gateway over the generic operator invoke path — the
73
+ * same daemon resolution the fleet acts use. Honest unavailable reason when no
74
+ * daemon is reachable, so the spawn key refuses cleanly instead of throwing.
75
+ */
76
+ export function createAcpSpawnGateway(deps: AcpSpawnGatewayDeps): AcpSpawnGatewayResolution {
77
+ const rpc = resolveOperatorRpc(deps);
78
+ if (!rpc.available) return { available: false, reason: rpc.reason };
79
+ const { sdk } = rpc;
80
+ const gateway: AcpSpawnGateway = {
81
+ listAgents: async () => (await sdk.operator.invoke('acp.agents.list', {})).agents,
82
+ listWorkspaces: async () => (await sdk.operator.invoke('workspaces.registrations.list', {})).workspaces,
83
+ createSession: (input) => sdk.operator.invoke('acp.sessions.create', input),
84
+ };
85
+ return { available: true, gateway };
86
+ }
87
+
88
+ export interface FleetSpawnDeps {
89
+ /** Resolve a live gateway per action (a daemon that comes up mid-session is seen). */
90
+ readonly resolveGateway: () => AcpSpawnGatewayResolution;
91
+ /** The current working directory — always a candidate dir (labeled "current directory"). */
92
+ readonly currentDirectory: () => string;
93
+ /** Surface a result/receipt/error line to the operator (system message). */
94
+ readonly notify: (message: string) => void;
95
+ /** Repaint request when the controller's own mode state changes. */
96
+ readonly markDirty: () => void;
97
+ }
98
+
99
+ /** Pick a third-party agent to host. */
100
+ interface AgentStep {
101
+ readonly step: 'agent';
102
+ readonly agents: readonly AcpDiscoveredAgent[];
103
+ index: number;
104
+ }
105
+ /** Pick the working directory the hosted agent runs in. */
106
+ interface DirStep {
107
+ readonly step: 'dir';
108
+ readonly agent: AcpDiscoveredAgent;
109
+ readonly candidates: readonly AcpDirCandidate[];
110
+ index: number;
111
+ }
112
+ type SpawnMode = AgentStep | DirStep | null;
113
+
114
+ const C = DEFAULT_PANEL_PALETTE;
115
+
116
+ export class FleetSpawn {
117
+ private mode: SpawnMode = null;
118
+ /** True while a create round-trip is in flight (absorb keys, show a spinner line). */
119
+ private creating = false;
120
+
121
+ public constructor(private readonly deps: FleetSpawnDeps) {}
122
+
123
+ /** True while the spawn picker owns the fleet view + input. */
124
+ public spawnModeActive(): boolean {
125
+ return this.mode !== null;
126
+ }
127
+
128
+ /**
129
+ * Begin the spawn flow: list discovered agents. Quiet, honest absence when the
130
+ * daemon reports none (no mode entered) rather than an empty picker.
131
+ */
132
+ public async begin(): Promise<void> {
133
+ if (this.mode !== null || this.creating) return;
134
+ const gateway = this.requireGateway();
135
+ if (!gateway) return;
136
+ let agents: readonly AcpDiscoveredAgent[];
137
+ try {
138
+ agents = await gateway.listAgents();
139
+ } catch (err) {
140
+ this.deps.notify(`Could not list ACP agents: ${summarizeError(err)}`);
141
+ return;
142
+ }
143
+ if (agents.length === 0) {
144
+ this.deps.notify('No third-party ACP agents discovered (Claude Code, Codex, opencode). Install one on PATH to host it here.');
145
+ return;
146
+ }
147
+ this.mode = { step: 'agent', agents, index: 0 };
148
+ this.deps.markDirty();
149
+ }
150
+
151
+ /** Input while the spawn picker is active. Absorbs every key it owns. */
152
+ public handleSpawnInput(key: string): boolean {
153
+ if (!this.mode || this.creating) return true;
154
+ if (key === 'escape' || key === 'esc') { this.mode = null; this.deps.markDirty(); return true; }
155
+ const list = this.mode.step === 'agent' ? this.mode.agents : this.mode.candidates;
156
+ if (key === 'up' || key === 'k') { this.mode.index = (this.mode.index - 1 + list.length) % list.length; this.deps.markDirty(); return true; }
157
+ if (key === 'down' || key === 'j') { this.mode.index = (this.mode.index + 1) % list.length; this.deps.markDirty(); return true; }
158
+ if (key === 'enter' || key === 'return') { void this.advance(); return true; }
159
+ return true;
160
+ }
161
+
162
+ /** Enter on the current step: agent → dir picker; dir → create the session. */
163
+ private async advance(): Promise<void> {
164
+ if (!this.mode) return;
165
+ if (this.mode.step === 'agent') {
166
+ const agent = this.mode.agents[this.mode.index];
167
+ if (!agent) return;
168
+ const candidates = await this.resolveDirCandidates();
169
+ this.mode = { step: 'dir', agent, candidates, index: 0 };
170
+ this.deps.markDirty();
171
+ return;
172
+ }
173
+ const cwd = this.mode.candidates[this.mode.index]?.path;
174
+ const agentId = this.mode.agent.id;
175
+ if (!cwd) return;
176
+ await this.createSession(agentId, cwd);
177
+ }
178
+
179
+ /** The known-dir candidates: the current directory first, then registered workspaces (deduped). */
180
+ private async resolveDirCandidates(): Promise<AcpDirCandidate[]> {
181
+ const current = this.deps.currentDirectory();
182
+ const candidates: AcpDirCandidate[] = [{ path: current, label: 'current directory' }];
183
+ const gateway = this.requireGateway();
184
+ if (gateway) {
185
+ try {
186
+ for (const ws of await gateway.listWorkspaces()) {
187
+ if (ws.root === current) continue; // already offered as "current directory"
188
+ candidates.push({ path: ws.root, label: ws.label ?? 'workspace' });
189
+ }
190
+ } catch {
191
+ // A workspaces read failure is non-fatal: the current dir is still offered.
192
+ }
193
+ }
194
+ return candidates;
195
+ }
196
+
197
+ /** Drive acp.sessions.create and surface the honest outcome (success or structured failure). */
198
+ private async createSession(agentId: string, cwd: string): Promise<void> {
199
+ const gateway = this.requireGateway();
200
+ if (!gateway) { this.mode = null; this.deps.markDirty(); return; }
201
+ this.creating = true;
202
+ this.deps.markDirty();
203
+ try {
204
+ const result = await gateway.createSession({ agentId, cwd });
205
+ const hosted = result.hosted;
206
+ if (hosted.error) {
207
+ // Structured failure rendered verbatim — never a hung row.
208
+ this.deps.notify(`[Fleet] Could not host ${hosted.title || agentId}: ${hosted.error.stage} stage failed for ${hosted.error.binary} — ${hosted.error.message}`);
209
+ } else {
210
+ this.deps.notify(`[Fleet] Hosting ${hosted.title || agentId} in ${cwd} — it appears as an acp-agent row; steer and stop it like any agent.`);
211
+ }
212
+ } catch (err) {
213
+ this.deps.notify(`ACP session create failed: ${summarizeError(err)}`);
214
+ } finally {
215
+ this.creating = false;
216
+ this.mode = null;
217
+ this.deps.markDirty();
218
+ }
219
+ }
220
+
221
+ /** The spawn picker view (replaces the tree while a pick is in flight). */
222
+ public renderSpawnMode(width: number, height: number, palette: PanelPalette = C): Line[] {
223
+ const P = palette;
224
+ if (!this.mode) return [];
225
+ const lines: Line[] = [];
226
+ if (this.creating) {
227
+ lines.push(buildPanelLine(width, [[' Hosting the agent…', P.dim]]));
228
+ return buildPanelWorkspace(width, height, { title: 'Fleet — Host an agent', sections: [{ lines }], footerLines: [], palette: P });
229
+ }
230
+ if (this.mode.step === 'agent') {
231
+ lines.push(buildPanelLine(width, [[' Host a third-party coding agent — pick one, then a directory.', P.dim]]));
232
+ lines.push(buildPanelLine(width, [['', P.dim]]));
233
+ this.mode.agents.forEach((agent, i) => {
234
+ const selected = i === (this.mode as AgentStep).index;
235
+ lines.push(buildPanelLine(width, [
236
+ [selected ? ' ▸ ' : ' ', selected ? P.info : P.dim],
237
+ [agent.title, selected ? P.value : P.dim],
238
+ [` — ${agent.binaryPath}`, P.dim],
239
+ ]));
240
+ });
241
+ } else {
242
+ lines.push(buildPanelLine(width, [[` Directory for ${this.mode.agent.title} — a known dir, no path to type.`, P.dim]]));
243
+ lines.push(buildPanelLine(width, [['', P.dim]]));
244
+ this.mode.candidates.forEach((cand, i) => {
245
+ const selected = i === (this.mode as DirStep).index;
246
+ lines.push(buildPanelLine(width, [
247
+ [selected ? ' ▸ ' : ' ', selected ? P.info : P.dim],
248
+ [cand.label, selected ? P.value : P.dim],
249
+ [` — ${cand.path}`, P.dim],
250
+ ]));
251
+ });
252
+ }
253
+ const footerLines = [buildKeyboardHints(width, [
254
+ { keys: '↑↓', label: 'choose' },
255
+ { keys: 'Enter', label: this.mode.step === 'agent' ? 'pick agent' : 'host here' },
256
+ { keys: 'Esc', label: 'cancel' },
257
+ ], P)];
258
+ return buildPanelWorkspace(width, height, { title: 'Fleet — Host an agent', sections: [{ lines }], footerLines, palette: P });
259
+ }
260
+
261
+ private requireGateway(): AcpSpawnGateway | null {
262
+ const resolution = this.deps.resolveGateway();
263
+ if (!resolution.available) { this.deps.notify(`[Fleet] ${resolution.reason}`); return null; }
264
+ return resolution.gateway;
265
+ }
266
+ }
@@ -8,7 +8,13 @@
8
8
  // ---------------------------------------------------------------------------
9
9
 
10
10
  import type { ProcessNode, ProcessState } from '@pellux/goodvibes-sdk/platform/runtime/fleet';
11
- import { fleetStateGlyph, fleetStateTone, isTerminalProcessState, type FleetStateTone, type FleetTreeRow } from './fleet-read-model.ts';
11
+ import type { WorkItem } from '@pellux/goodvibes-sdk/platform/orchestration';
12
+ import { fleetNodeAttention, fleetStateGlyph, fleetStateTone, isTerminalProcessState, type FleetStateTone, type FleetTreeRow } from './fleet-read-model.ts';
13
+
14
+ /** True when a work-item node owns a worktree that worktrees.discard can act on (D from the tree). */
15
+ function ownsWorktree(node: ProcessNode): boolean {
16
+ return Boolean((node.raw as { item?: WorkItem } | undefined)?.item?.worktreePath);
17
+ }
12
18
 
13
19
  /** How long 'stopping…' lingers after a stop keypress before the true state is shown regardless (never masks a stuck kill). */
14
20
  export const STOP_SETTLE_MS = 1500;
@@ -123,9 +129,15 @@ export function buildFleetTreeHints(
123
129
  ): FleetHint[] {
124
130
  const live = selected !== undefined && !isTerminalProcessState(selected.state);
125
131
  const isPaused = selected !== undefined && selected.state === 'paused';
132
+ // Enter is context-sensitive: on a flagged pick/conflict row it acts, else it
133
+ // attaches. Name the act so the key is discoverable from the tree.
134
+ const attention = selected ? fleetNodeAttention(selected) : null;
135
+ const enterLabel = attention?.reason === 'pick' ? 'pick'
136
+ : attention?.reason === 'conflict' ? 'resolve conflict'
137
+ : 'attach';
126
138
  const hints: FleetHint[] = [
127
139
  { keys: 'j/k', label: 'navigate' },
128
- { keys: 'Enter', label: 'attach' },
140
+ { keys: 'Enter', label: enterLabel },
129
141
  ];
130
142
  // Jump-to-blocked is offered whenever something is waiting on the operator,
131
143
  // in either view (the blocked nodes live in the active fleet, and the jump
@@ -144,9 +156,11 @@ export function buildFleetTreeHints(
144
156
  if (live && selected.capabilities.killable) hints.push({ keys: 'K', label: 'kill' });
145
157
  if (live && !isPaused && selected.capabilities.pausable) hints.push({ keys: 'p', label: 'pause' });
146
158
  if (isPaused && selected.capabilities.resumable) hints.push({ keys: 'p', label: 'resume' });
159
+ if (selected && ownsWorktree(selected)) hints.push({ keys: 'D', label: 'discard worktree' });
147
160
  if (selected && !live) hints.push({ keys: 'a', label: 'archive' });
148
161
  hints.push({ keys: 'A', label: 'archive finished' });
149
162
  hints.push({ keys: 'v', label: 'archived' });
163
+ hints.push({ keys: 'n', label: 'host agent' });
150
164
  hints.push({ keys: 'f', label: follow ? 'follow:on' : 'follow' });
151
165
  if (hasTabs) hints.push({ keys: '[ ]', label: 'tabs' });
152
166
  return hints;
@@ -178,3 +192,25 @@ export function countDescendantStats(rows: readonly FleetTreeRow[], nodeId: stri
178
192
  }
179
193
  return { total, active };
180
194
  }
195
+
196
+ /**
197
+ * Build the confirm-overlay arm options for a cascade Kill of one node — the
198
+ * label carries the descendant count so the operator sees what a confirm takes
199
+ * down. Extracted from FleetPanel to keep that file under the 800-line cap.
200
+ */
201
+ export function fleetKillConfirmArgs(
202
+ node: ProcessNode,
203
+ items: readonly FleetTreeRow[],
204
+ deps: { readonly kill: (id: string) => void; readonly markStopping: (id: string) => void },
205
+ ): { id: string; label: string; verb: string; onConfirm: () => void } {
206
+ const shortId = node.id.length > 8 ? node.id.slice(-8) : node.id;
207
+ const stats = countDescendantStats(items, node.id);
208
+ const suffix = stats.total > 0 ? ` (+${stats.total} descendant${stats.total === 1 ? '' : 's'}, ${stats.active} active)` : '';
209
+ return {
210
+ id: node.id,
211
+ label: `${node.kind} ${shortId}${suffix}`,
212
+ verb: 'Kill',
213
+ // Mark 'stopping…' only once the kill is confirmed, not while merely armed.
214
+ onConfirm: () => { deps.kill(node.id); deps.markStopping(node.id); },
215
+ };
216
+ }
@@ -25,10 +25,10 @@ import { MessageLineCache } from '../core/conversation-line-cache.ts';
25
25
  import { fleetKindTag } from './fleet-read-model.ts';
26
26
 
27
27
  /** Node kinds that can be attached as a session tab. */
28
- export type FleetAttachableKind = 'agent' | 'wrfc-chain';
28
+ export type FleetAttachableKind = 'agent' | 'wrfc-chain' | 'acp-agent';
29
29
 
30
30
  export function isAttachableFleetKind(kind: ProcessKind): kind is FleetAttachableKind {
31
- return kind === 'agent' || kind === 'wrfc-chain';
31
+ return kind === 'agent' || kind === 'wrfc-chain' || kind === 'acp-agent';
32
32
  }
33
33
 
34
34
  /**
@@ -0,0 +1,94 @@
1
+ // ---------------------------------------------------------------------------
2
+ // memory-consolidation-gateway.ts
3
+ //
4
+ // The async, daemon-backed verb the Memory modal's Proposals tab reads:
5
+ // memory.consolidation.receipts (GET /api/memory/consolidation/receipts) — the
6
+ // retained memory-consolidation run receipts plus the flattened
7
+ // pendingProposals (contradiction / cross-scope-duplicate / stale-delete)
8
+ // awaiting human review. The records a proposal references are already
9
+ // marked into the review queue by the daemon (reviewState 'contradicted' with
10
+ // a staleReason, or 'fresh' for cross-scope duplicates); this gateway is only
11
+ // how the Memory modal learns WHAT was proposed and WHY, so it can show that
12
+ // reason next to the affected records and offer a jump to them.
13
+ //
14
+ // No named facade exists on the in-process OperatorClient for this verb, so —
15
+ // exactly like the Fleet gateway (fleet-gateway.ts) — it goes over the
16
+ // generic operator invoke path (operator-rpc.ts's resolveOperatorRpc ->
17
+ // sdk.operator.invoke), reaching the SAME daemon the command layer does. A
18
+ // runtime without the consolidation scheduler answers an honest 501; an older
19
+ // daemon that has not wired this route yet answers 404 — both are "verb
20
+ // unavailable", not "no proposals" (see classifyConsolidationFetchError).
21
+ //
22
+ // The interface is injectable so the modal's proposals fetch round-trips
23
+ // against a mocked daemon in tests; the live builder
24
+ // (createMemoryConsolidationGateway) is wired in builtin-modals.ts, resolved
25
+ // lazily (a fresh factory call per fetch) exactly like the Fleet gateway.
26
+ // ---------------------------------------------------------------------------
27
+
28
+ import type { ConfigManager } from '@pellux/goodvibes-sdk/platform/config';
29
+ import type { OperatorMethodOutput } from '@pellux/goodvibes-sdk';
30
+ import { GoodVibesSdkError } from '@pellux/goodvibes-sdk';
31
+ import { resolveOperatorRpc, describeOperatorRpcError } from '../input/commands/operator-rpc.ts';
32
+
33
+ /** memory.consolidation.receipts output — retained receipts + the flattened pending proposals. */
34
+ export type MemoryConsolidationReceiptsResult = OperatorMethodOutput<'memory.consolidation.receipts'>;
35
+ /** One pending judgment proposal (contradiction / cross-scope-duplicate / stale-delete) awaiting human review. */
36
+ export type MemoryConsolidationProposal = MemoryConsolidationReceiptsResult['pendingProposals'][number];
37
+
38
+ /** The narrow async verb surface the Memory modal's Proposals tab drives. */
39
+ export interface MemoryConsolidationGateway {
40
+ /** Fetch the retained consolidation receipts + flattened pending proposals. */
41
+ fetchReceipts(): Promise<MemoryConsolidationReceiptsResult>;
42
+ }
43
+
44
+ /**
45
+ * Why the gateway could not be built (daemon disabled / no control-plane URL),
46
+ * surfaced verbatim so the modal can print an honest "unavailable" line
47
+ * rather than guessing — mirrors FleetGatewayResolution.
48
+ */
49
+ export type MemoryConsolidationGatewayResolution =
50
+ | { readonly available: true; readonly gateway: MemoryConsolidationGateway }
51
+ | { readonly available: false; readonly reason: string };
52
+
53
+ export interface MemoryConsolidationGatewayDeps {
54
+ readonly configManager: ConfigManager;
55
+ readonly homeDirectory: string;
56
+ }
57
+
58
+ /**
59
+ * Build the live consolidation-receipts gateway over the generic operator
60
+ * invoke path — the same daemon resolution the command layer and the Fleet
61
+ * gateway use. Returns an honest unavailable reason when no daemon is
62
+ * reachable, so the modal can refuse cleanly instead of throwing mid-render.
63
+ */
64
+ export function createMemoryConsolidationGateway(deps: MemoryConsolidationGatewayDeps): MemoryConsolidationGatewayResolution {
65
+ const rpc = resolveOperatorRpc({ configManager: deps.configManager, homeDirectory: deps.homeDirectory });
66
+ if (!rpc.available) return { available: false, reason: rpc.reason };
67
+ const { sdk } = rpc;
68
+ return {
69
+ available: true,
70
+ gateway: { fetchReceipts: () => sdk.operator.invoke('memory.consolidation.receipts', {}) },
71
+ };
72
+ }
73
+
74
+ /** How a `fetchReceipts()` rejection should render in the Proposals tab. */
75
+ export type MemoryConsolidationFetchFailure =
76
+ | { readonly kind: 'unavailable'; readonly reason: string }
77
+ | { readonly kind: 'error'; readonly message: string };
78
+
79
+ /**
80
+ * Classify a `fetchReceipts()` rejection: a 501 (the documented "no
81
+ * consolidation scheduler on this runtime" response) or a 404 (an older
82
+ * daemon that has not wired this route yet) are both "verb unavailable" —
83
+ * distinct from a generic request failure (network error, 401/403, 500),
84
+ * which renders as an honest fetch error instead. Reuses
85
+ * describeOperatorRpcError's wording so the two call sites (command layer,
86
+ * Fleet gateway, this gateway) never diverge on how they describe the same
87
+ * daemon response.
88
+ */
89
+ export function classifyConsolidationFetchError(error: unknown): MemoryConsolidationFetchFailure {
90
+ if (error instanceof GoodVibesSdkError && (error.status === 501 || error.status === 404)) {
91
+ return { kind: 'unavailable', reason: describeOperatorRpcError(error) };
92
+ }
93
+ return { kind: 'error', message: describeOperatorRpcError(error) };
94
+ }
@@ -0,0 +1,161 @@
1
+ import { MODAL_TONES } from './modal-theme.ts';
2
+ import { infoRow } from './modal-surface-helpers.ts';
3
+ import type {
4
+ ConfigModalActionContext,
5
+ ConfigModalRow,
6
+ ConfigModalSurface,
7
+ ConfigModalView,
8
+ } from '../../input/config-modal-types.ts';
9
+ import type { MintedPairingToken, PublicPairingToken } from '@pellux/goodvibes-sdk/platform/pairing';
10
+ import { formatDeviceLine, shortTokenId } from '../../core/pairing-devices.ts';
11
+
12
+ // ---------------------------------------------------------------------------
13
+ // Paired Devices — the settings security-domain device/token management surface.
14
+ // Lists every per-device pairing token (name · created · last-seen · id) and
15
+ // mirrors the pairing.tokens.* gateway verbs: revoke a device, migrate off the
16
+ // legacy shared token, revoke the shared token. Rename needs a name, which the
17
+ // stable-layout modal cannot capture inline, so it routes to the /devices rename
18
+ // command with the row's id pre-filled in the guidance. buildView reads list()
19
+ // fresh every render, so a revoke/migrate reflects immediately.
20
+ // ---------------------------------------------------------------------------
21
+
22
+ /** The token-store capability this surface needs — satisfied by PairingTokenManager. */
23
+ export interface DevicesModalPairingTokens {
24
+ list(): PublicPairingToken[];
25
+ rename(id: string, name: string): boolean;
26
+ revoke(id: string): boolean;
27
+ mintForMigration(input: { readonly name: string }): MintedPairingToken;
28
+ isLegacyRevoked(): boolean;
29
+ revokeLegacyShared(): void;
30
+ }
31
+
32
+ export interface DevicesModalDeps {
33
+ readonly pairingTokens: DevicesModalPairingTokens;
34
+ /** Injectable clock so last-seen formatting is deterministic in tests. */
35
+ readonly now?: () => number;
36
+ readonly copyToClipboard?: (text: string) => void;
37
+ }
38
+
39
+ const DEVICE_ROW_PREFIX = 'device:';
40
+
41
+ function deviceIdOf(row: ConfigModalRow | null): string | null {
42
+ return row && row.id.startsWith(DEVICE_ROW_PREFIX) ? row.id.slice(DEVICE_ROW_PREFIX.length) : null;
43
+ }
44
+
45
+ class DevicesModalSurface implements ConfigModalSurface {
46
+ readonly name = 'devices-modal';
47
+ readonly title = 'Paired Devices';
48
+ private requestRender: () => void = () => {};
49
+
50
+ constructor(private readonly deps: DevicesModalDeps) {}
51
+
52
+ readonly actions = [
53
+ { key: 'r', id: 'rename', label: 'rename', enabledFor: (row: ConfigModalRow | null) => deviceIdOf(row) !== null },
54
+ { key: 'x', id: 'revoke', label: 'revoke', confirm: true, enabledFor: (row: ConfigModalRow | null) => deviceIdOf(row) !== null },
55
+ { key: 'm', id: 'migrateShared', label: 'migrate off shared token' },
56
+ { key: 's', id: 'revokeShared', label: 'revoke shared token', confirm: true, enabledFor: () => !this.deps.pairingTokens.isLegacyRevoked() },
57
+ ];
58
+
59
+ onOpen(requestRender: () => void): void { this.requestRender = requestRender; }
60
+
61
+ buildView(): ConfigModalView {
62
+ const now = this.deps.now?.() ?? Date.now();
63
+ const tokens = this.deps.pairingTokens.list();
64
+ const rows: ConfigModalRow[] = [];
65
+ rows.push(infoRow('intro', 'Devices paired to this daemon. Each has its own token — revoke one without affecting the others.', { dim: true }));
66
+
67
+ if (tokens.length === 0) {
68
+ rows.push(infoRow('empty', 'No devices paired yet — run /pair (or open the pairing QR) to add one.'));
69
+ } else {
70
+ for (const token of tokens) {
71
+ rows.push({ id: `${DEVICE_ROW_PREFIX}${token.id}`, label: formatDeviceLine(token, now) });
72
+ }
73
+ }
74
+
75
+ const legacyRevoked = this.deps.pairingTokens.isLegacyRevoked();
76
+ rows.push(infoRow(
77
+ 'legacy',
78
+ legacyRevoked
79
+ ? 'Legacy shared token: revoked.'
80
+ : 'Legacy shared token: active — migrate a device off it (m), then revoke it (s).',
81
+ legacyRevoked ? { dim: true } : { fg: MODAL_TONES.reasoning },
82
+ ));
83
+
84
+ return {
85
+ title: 'Paired Devices',
86
+ tabs: [{ id: 'devices', label: 'Devices', rows, hints: ['r rename', 'x revoke', 'm migrate off shared', 's revoke shared'] }],
87
+ };
88
+ }
89
+
90
+ onAction(id: string, ctx: ConfigModalActionContext): void {
91
+ const tokens = this.deps.pairingTokens;
92
+
93
+ if (id === 'rename') {
94
+ const deviceId = deviceIdOf(ctx.row);
95
+ if (!deviceId) { ctx.setStatus('Select a device row first.'); return; }
96
+ // The stable-layout modal cannot capture a name inline — route to the
97
+ // command with the id pre-filled so the user only types the new name.
98
+ ctx.print(`To rename this device, run: /devices rename ${shortTokenId(deviceId)} <new name>`);
99
+ return;
100
+ }
101
+
102
+ if (id === 'revoke') {
103
+ const deviceId = deviceIdOf(ctx.row);
104
+ if (!deviceId) { ctx.setStatus('Select a device row first.'); return; }
105
+ const record = tokens.list().find((t) => t.id === deviceId);
106
+ const revoked = tokens.revoke(deviceId);
107
+ ctx.setStatus(revoked ? `Revoked "${record?.name ?? shortTokenId(deviceId)}" — its token stops working now.` : 'That device was already absent.');
108
+ this.requestRender();
109
+ return;
110
+ }
111
+
112
+ if (id === 'migrateShared') {
113
+ const minted = tokens.mintForMigration({ name: 'migrated device' });
114
+ if (this.deps.copyToClipboard) {
115
+ this.deps.copyToClipboard(minted.token);
116
+ ctx.print(`Minted "${minted.name}" and copied its one-time token to the clipboard. Pair the device, then revoke the shared token (s).`);
117
+ } else {
118
+ ctx.print(`Minted "${minted.name}" (one-time token): ${minted.token}\nPair the device with it, then revoke the shared token (s).`);
119
+ }
120
+ this.requestRender();
121
+ return;
122
+ }
123
+
124
+ if (id === 'revokeShared') {
125
+ if (tokens.isLegacyRevoked()) { ctx.setStatus('The legacy shared token is already revoked.'); return; }
126
+ tokens.revokeLegacyShared();
127
+ ctx.setStatus('Revoked the legacy shared token — devices on it must re-pair with their own token.');
128
+ this.requestRender();
129
+ return;
130
+ }
131
+ }
132
+ }
133
+
134
+ export function createDevicesModalSurface(deps: DevicesModalDeps): ConfigModalSurface {
135
+ return new DevicesModalSurface(deps);
136
+ }
137
+
138
+ /**
139
+ * Deterministic golden/render fixture: frozen token list + clock so last-seen
140
+ * formatting is stable.
141
+ */
142
+ export function devicesModalGoldenSurface(): ConfigModalSurface {
143
+ const now = Date.UTC(2026, 6, 13, 12, 0, 0);
144
+ const tokens: PublicPairingToken[] = [
145
+ { id: 'aaaa1111bbbb2222', name: 'my laptop', createdAt: Date.UTC(2026, 6, 10, 9, 0, 0), lastSeenAt: now - 3 * 60_000 },
146
+ { id: 'cccc3333dddd4444', name: 'kitchen tablet', createdAt: Date.UTC(2026, 6, 1, 9, 0, 0), lastSeenAt: now - 5 * 60 * 60_000 },
147
+ { id: 'eeee5555ffff6666', name: 'phone (never scanned)', createdAt: Date.UTC(2026, 6, 13, 8, 0, 0) },
148
+ ];
149
+ let legacyRevoked = false;
150
+ return createDevicesModalSurface({
151
+ now: () => now,
152
+ pairingTokens: {
153
+ list: () => tokens,
154
+ rename: () => true,
155
+ revoke: () => true,
156
+ mintForMigration: () => ({ id: 'new', name: 'migrated device', token: 'tok', createdAt: now }),
157
+ isLegacyRevoked: () => legacyRevoked,
158
+ revokeLegacyShared: () => { legacyRevoked = true; },
159
+ },
160
+ });
161
+ }