@pellux/goodvibes-tui 1.1.0 → 1.7.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 (194) hide show
  1. package/CHANGELOG.md +85 -21
  2. package/README.md +20 -11
  3. package/docs/foundation-artifacts/operator-contract.json +1 -1
  4. package/package.json +2 -2
  5. package/src/core/alert-gating.ts +67 -0
  6. package/src/core/approval-alert.ts +72 -0
  7. package/src/core/budget-breach-notifier.ts +106 -0
  8. package/src/core/conversation-rendering.ts +19 -0
  9. package/src/core/conversation.ts +18 -0
  10. package/src/core/focus-tracker.ts +41 -0
  11. package/src/core/long-task-notifier.ts +33 -6
  12. package/src/core/system-message-router.ts +37 -51
  13. package/src/core/turn-cancellation.ts +20 -0
  14. package/src/core/turn-event-wiring.ts +64 -3
  15. package/src/export/cost-utils.ts +36 -0
  16. package/src/input/command-registry.ts +38 -1
  17. package/src/input/commands/checkpoint-runtime.ts +280 -0
  18. package/src/input/commands/codebase-runtime.ts +192 -0
  19. package/src/input/commands/eval.ts +1 -1
  20. package/src/input/commands/health-runtime.ts +1 -1
  21. package/src/input/commands/image-runtime.ts +112 -0
  22. package/src/input/commands/intelligence-runtime.ts +11 -1
  23. package/src/input/commands/local-auth-runtime.ts +4 -1
  24. package/src/input/commands/local-runtime.ts +9 -3
  25. package/src/input/commands/marketplace-runtime.ts +2 -2
  26. package/src/input/commands/memory.ts +6 -1
  27. package/src/input/commands/operator-panel-runtime.ts +40 -24
  28. package/src/input/commands/planning-runtime.ts +26 -3
  29. package/src/input/commands/platform-sandbox-runtime.ts +1 -6
  30. package/src/input/commands/plugin-runtime.ts +2 -2
  31. package/src/input/commands/policy-dispatch.ts +21 -0
  32. package/src/input/commands/provider-accounts-runtime.ts +1 -1
  33. package/src/input/commands/qrcode-runtime.ts +3 -3
  34. package/src/input/commands/recall-review.ts +35 -0
  35. package/src/input/commands/remote-runtime.ts +3 -3
  36. package/src/input/commands/runtime-services.ts +54 -13
  37. package/src/input/commands/services-runtime.ts +1 -1
  38. package/src/input/commands/session-workflow.ts +16 -1
  39. package/src/input/commands/settings-sync-runtime.ts +1 -1
  40. package/src/input/commands/shell-core.ts +15 -7
  41. package/src/input/commands/skills-runtime.ts +2 -8
  42. package/src/input/commands/subscription-runtime.ts +2 -2
  43. package/src/input/commands/test-runtime.ts +277 -0
  44. package/src/input/commands/websearch-runtime.ts +101 -0
  45. package/src/input/commands/work-plan-runtime.ts +36 -10
  46. package/src/input/commands/workstream-runtime.ts +338 -0
  47. package/src/input/commands.ts +12 -0
  48. package/src/input/config-modal-types.ts +161 -0
  49. package/src/input/config-modal.ts +377 -0
  50. package/src/input/feed-context-factory.ts +7 -8
  51. package/src/input/handler-command-route.ts +27 -17
  52. package/src/input/handler-feed-routes.ts +61 -23
  53. package/src/input/handler-feed.ts +47 -23
  54. package/src/input/handler-interactions.ts +1 -3
  55. package/src/input/handler-modal-routes.ts +65 -0
  56. package/src/input/handler-modal-stack.ts +3 -5
  57. package/src/input/handler-modal-token-routes.ts +16 -49
  58. package/src/input/handler-picker-routes.ts +11 -94
  59. package/src/input/handler-shortcuts.ts +24 -14
  60. package/src/input/handler-types.ts +2 -6
  61. package/src/input/handler-ui-state.ts +10 -22
  62. package/src/input/handler.ts +6 -28
  63. package/src/input/keybindings.ts +22 -8
  64. package/src/input/model-picker-types.ts +24 -6
  65. package/src/input/model-picker.ts +58 -0
  66. package/src/input/panel-integration-actions.ts +9 -170
  67. package/src/input/settings-modal-data.ts +95 -0
  68. package/src/input/settings-modal-mutations.ts +6 -4
  69. package/src/main.ts +24 -27
  70. package/src/panels/agent-inspector-shared.ts +2 -1
  71. package/src/panels/base-panel.ts +22 -1
  72. package/src/panels/builtin/agent.ts +21 -107
  73. package/src/panels/builtin/development.ts +15 -61
  74. package/src/panels/builtin/knowledge.ts +8 -32
  75. package/src/panels/builtin/operations.ts +84 -428
  76. package/src/panels/builtin/session.ts +21 -112
  77. package/src/panels/builtin/shared.ts +9 -43
  78. package/src/panels/builtin-modals.ts +218 -0
  79. package/src/panels/builtin-panels.ts +5 -0
  80. package/src/panels/cost-tracker-panel.ts +36 -10
  81. package/src/panels/diff-panel.ts +12 -43
  82. package/src/panels/eval-registry.ts +60 -0
  83. package/src/panels/fleet-panel.ts +800 -0
  84. package/src/panels/fleet-read-model.ts +477 -0
  85. package/src/panels/fleet-steer.ts +92 -0
  86. package/src/panels/fleet-stop.ts +125 -0
  87. package/src/panels/fleet-tabs.ts +230 -0
  88. package/src/panels/fleet-transcript.ts +356 -0
  89. package/src/panels/index.ts +7 -31
  90. package/src/panels/modals/hooks-modal.ts +187 -0
  91. package/src/panels/modals/keybindings-modal.ts +151 -0
  92. package/src/panels/modals/knowledge-modal.ts +158 -0
  93. package/src/panels/modals/local-auth-modal.ts +132 -0
  94. package/src/panels/modals/marketplace-modal.ts +218 -0
  95. package/src/panels/modals/memory-modal.ts +180 -0
  96. package/src/panels/modals/modal-surface-helpers.ts +54 -0
  97. package/src/panels/modals/modal-theme.ts +36 -0
  98. package/src/panels/modals/pairing-modal.ts +144 -0
  99. package/src/panels/modals/planning-modal.ts +282 -0
  100. package/src/panels/modals/plugins-modal.ts +174 -0
  101. package/src/panels/modals/policy-modal.ts +256 -0
  102. package/src/panels/modals/provider-health-modal.ts +136 -0
  103. package/src/panels/modals/remote-modal.ts +115 -0
  104. package/src/panels/modals/sandbox-modal.ts +150 -0
  105. package/src/panels/modals/security-modal.ts +217 -0
  106. package/src/panels/modals/services-modal.ts +179 -0
  107. package/src/panels/modals/settings-sync-modal.ts +142 -0
  108. package/src/panels/modals/skills-modal.ts +189 -0
  109. package/src/panels/modals/subscription-modal.ts +172 -0
  110. package/src/panels/modals/work-plan-modal.ts +149 -0
  111. package/src/panels/panel-confirm-overlay.ts +72 -0
  112. package/src/panels/panel-manager.ts +108 -5
  113. package/src/panels/project-planning-answer-actions.ts +4 -2
  114. package/src/panels/skills-panel.ts +7 -51
  115. package/src/panels/types.ts +13 -0
  116. package/src/permissions/hunk-selection.ts +179 -0
  117. package/src/permissions/prompt.ts +60 -4
  118. package/src/renderer/compaction-history-modal.ts +19 -3
  119. package/src/renderer/compaction-preview.ts +13 -2
  120. package/src/renderer/compaction-quality.ts +100 -0
  121. package/src/renderer/config-modal.ts +81 -0
  122. package/src/renderer/conversation-overlays.ts +10 -17
  123. package/src/renderer/fleet-tab-strip.ts +56 -0
  124. package/src/renderer/footer-tips.ts +10 -2
  125. package/src/renderer/git-status.ts +40 -0
  126. package/src/renderer/help-overlay.ts +2 -2
  127. package/src/renderer/model-workspace.ts +44 -1
  128. package/src/renderer/panel-workspace-bar.ts +8 -2
  129. package/src/renderer/turn-injection.ts +114 -0
  130. package/src/runtime/bootstrap-command-context.ts +21 -1
  131. package/src/runtime/bootstrap-command-parts.ts +34 -7
  132. package/src/runtime/bootstrap-core.ts +14 -4
  133. package/src/runtime/bootstrap-shell.ts +29 -16
  134. package/src/runtime/bootstrap.ts +11 -17
  135. package/src/runtime/code-index-services.ts +112 -0
  136. package/src/runtime/orchestrator-core-services.ts +49 -0
  137. package/src/runtime/process-lifecycle.ts +3 -1
  138. package/src/runtime/services.ts +91 -43
  139. package/src/runtime/ui-services.ts +8 -0
  140. package/src/runtime/workstream-services.ts +195 -0
  141. package/src/shell/blocking-input.ts +22 -1
  142. package/src/shell/ui-openers.ts +129 -17
  143. package/src/utils/format-duration.ts +8 -18
  144. package/src/utils/splash-lines.ts +1 -1
  145. package/src/version.ts +1 -1
  146. package/src/panels/agent-inspector-panel.ts +0 -786
  147. package/src/panels/approval-panel.ts +0 -252
  148. package/src/panels/automation-control-panel.ts +0 -479
  149. package/src/panels/cockpit-panel.ts +0 -481
  150. package/src/panels/cockpit-read-model.ts +0 -238
  151. package/src/panels/communication-panel.ts +0 -256
  152. package/src/panels/control-plane-panel.ts +0 -470
  153. package/src/panels/debug-panel.ts +0 -615
  154. package/src/panels/docs-panel.ts +0 -384
  155. package/src/panels/eval-panel.ts +0 -627
  156. package/src/panels/file-explorer-panel.ts +0 -673
  157. package/src/panels/file-preview-panel.ts +0 -517
  158. package/src/panels/hooks-panel.ts +0 -401
  159. package/src/panels/incident-review-panel.ts +0 -406
  160. package/src/panels/intelligence-panel.ts +0 -383
  161. package/src/panels/knowledge-graph-panel.ts +0 -515
  162. package/src/panels/marketplace-panel.ts +0 -399
  163. package/src/panels/memory-panel.ts +0 -558
  164. package/src/panels/ops-control-panel.ts +0 -329
  165. package/src/panels/ops-strategy-panel.ts +0 -321
  166. package/src/panels/orchestration-panel.ts +0 -465
  167. package/src/panels/panel-list-panel.ts +0 -566
  168. package/src/panels/plan-dashboard-panel.ts +0 -707
  169. package/src/panels/policy-panel.ts +0 -517
  170. package/src/panels/project-planning-panel.ts +0 -731
  171. package/src/panels/provider-health-panel.ts +0 -678
  172. package/src/panels/provider-health-tracker.ts +0 -310
  173. package/src/panels/provider-health-views.ts +0 -567
  174. package/src/panels/qr-panel.ts +0 -280
  175. package/src/panels/remote-panel.ts +0 -534
  176. package/src/panels/routes-panel.ts +0 -241
  177. package/src/panels/sandbox-panel.ts +0 -456
  178. package/src/panels/security-panel.ts +0 -447
  179. package/src/panels/services-panel.ts +0 -329
  180. package/src/panels/session-browser-panel.ts +0 -496
  181. package/src/panels/settings-sync-panel.ts +0 -398
  182. package/src/panels/subscription-panel.ts +0 -342
  183. package/src/panels/symbol-outline-panel.ts +0 -619
  184. package/src/panels/system-messages-panel.ts +0 -364
  185. package/src/panels/tasks-panel.ts +0 -608
  186. package/src/panels/thinking-panel.ts +0 -333
  187. package/src/panels/tool-inspector-panel.ts +0 -537
  188. package/src/panels/work-plan-panel.ts +0 -540
  189. package/src/panels/worktree-panel.ts +0 -360
  190. package/src/panels/wrfc-panel.ts +0 -790
  191. package/src/renderer/agent-detail-modal.ts +0 -466
  192. package/src/renderer/live-tail-modal.ts +0 -156
  193. package/src/renderer/process-modal.ts +0 -671
  194. package/src/renderer/qr-renderer.ts +0 -120
@@ -0,0 +1,477 @@
1
+ // ---------------------------------------------------------------------------
2
+ // fleet-read-model.ts
3
+ //
4
+ // W2.2 (Fleet tree panel) — pure, testable read-model over the SDK's live
5
+ // process registry (@pellux/goodvibes-sdk/platform/runtime/fleet, landed on
6
+ // SDK main as W2.1). No BasePanel/rendering dependency: this module owns the
7
+ // flat ProcessNode[] -> tree transformation, the honest cost/token
8
+ // aggregates, and the state->glyph/tone mapping that FleetPanel renders.
9
+ //
10
+ // Design notes:
11
+ // - The W2.2 brief predates the real SDK API and sketched a bespoke
12
+ // FleetRegistryNode/FleetRegistry adapter pair. The real
13
+ // createProcessRegistry() ProcessNode already carries elapsedMs,
14
+ // usage, costUsd, costState, and currentActivity directly, so no
15
+ // adapter layer is needed here — this module consumes ProcessNode
16
+ // straight from the registry and only owns the tree-walk + aggregate
17
+ // + presentation-mapping logic the SDK does not provide.
18
+ // - Tree-walk shape (connectors, cycle guard, leftover pass) is ported
19
+ // from renderer/process-modal.ts's appendAgentSubtree/
20
+ // appendAgentGroupEntries, generalized from AgentRecord to ProcessNode
21
+ // and from WRFC-role ordering to plain startedAt ordering (ProcessNode
22
+ // has no role concept — parentId alone expresses the hierarchy, and the
23
+ // SDK guarantees every parentId either resolves or the node is a root).
24
+ // - Cost/token honesty mirrors agent-inspector-shared.ts's
25
+ // hasReportedUsage convention: an all-zero-but-present usage object is
26
+ // treated as "no data" (never a fabricated 0/$0.00), and aggregates are
27
+ // null only when NO node in the snapshot has real data.
28
+ // - Two-factory shape (live/static) mirrors cockpit-read-model.ts.
29
+ // ---------------------------------------------------------------------------
30
+
31
+ import type {
32
+ ProcessCostState,
33
+ ProcessKind,
34
+ ProcessNode,
35
+ ProcessRegistry,
36
+ ProcessState,
37
+ ProcessUsage,
38
+ SteerResult,
39
+ } from '@pellux/goodvibes-sdk/platform/runtime/fleet';
40
+ import type { RuntimeEventBus } from '@/runtime/index.ts';
41
+
42
+ // ---------------------------------------------------------------------------
43
+ // Public types
44
+ // ---------------------------------------------------------------------------
45
+
46
+ /** One row in the flattened, depth-first fleet tree. */
47
+ export interface FleetTreeRow {
48
+ readonly node: ProcessNode;
49
+ readonly depth: number;
50
+ /** Tree-drawing prefix ('├─ ' / '└─ ' / '│ ' / ' ' segments), '' at depth 0. */
51
+ readonly treePrefix: string;
52
+ readonly isLastChild: boolean;
53
+ readonly hasChildren: boolean;
54
+ }
55
+
56
+ /** Aggregate snapshot the fleet panel renders from. */
57
+ export interface FleetSnapshot {
58
+ readonly rows: readonly FleetTreeRow[];
59
+ /** Sum of costUsd across nodes with a real (non-unpriced) reading; null when none do. */
60
+ readonly totalCost: number | null;
61
+ /** Sum of reported usage tokens across nodes with real usage; null when none do. */
62
+ readonly totalTokens: number | null;
63
+ /** Count of nodes in an actively-working state (see isRunningProcessState). */
64
+ readonly runningCount: number;
65
+ readonly capturedAt: number;
66
+ }
67
+
68
+ // ---------------------------------------------------------------------------
69
+ // State classification — glyph/tone mapping (pure, unit-testable)
70
+ // ---------------------------------------------------------------------------
71
+
72
+ export type FleetStateTone = 'active' | 'success' | 'failure' | 'warn' | 'muted';
73
+
74
+ const STATE_GLYPHS: Record<ProcessState, string> = {
75
+ thinking: '◔',
76
+ 'executing-tool': '●',
77
+ 'awaiting-approval': '◐',
78
+ streaming: '◕',
79
+ stalled: '◒',
80
+ retrying: '↻',
81
+ done: '✓',
82
+ failed: '✗',
83
+ killed: '⊘',
84
+ // Wave-3 verb formalization: a distinct TERMINAL outcome from 'killed' —
85
+ // both come from AgentManager.cancel(), but a graceful interrupt request
86
+ // ('the operator asked nicely') is display-distinguishable from a hard
87
+ // kill. '◌' verified free against every other glyph in this table.
88
+ interrupted: '◌',
89
+ idle: '·',
90
+ queued: '…',
91
+ // Wave-6 SDK: schedules/triggers/automation jobs report 'paused' when
92
+ // disabled (previously mislabeled 'killed'). NOT terminal — resumable via
93
+ // ProcessRegistry.resume() (W6.2 d2 wires the full pause/resume UI in
94
+ // fleet-stop.ts). '❚' verified free against every other glyph in this table.
95
+ paused: '❚',
96
+ };
97
+
98
+ const STATE_TONES: Record<ProcessState, FleetStateTone> = {
99
+ thinking: 'active',
100
+ 'executing-tool': 'active',
101
+ streaming: 'active',
102
+ 'awaiting-approval': 'warn',
103
+ stalled: 'warn',
104
+ retrying: 'warn',
105
+ done: 'success',
106
+ failed: 'failure',
107
+ killed: 'muted',
108
+ // 'warn' (amber), not 'muted' like killed — an interrupt is a deliberate
109
+ // operator action worth noticing, not a passive/neutral outcome.
110
+ interrupted: 'warn',
111
+ idle: 'muted',
112
+ queued: 'muted',
113
+ // Deliberately parked by the operator: neutral, not alarming.
114
+ paused: 'muted',
115
+ };
116
+
117
+ /** Terminal states — interrupt/kill are not offered; not counted as running. */
118
+ const TERMINAL_STATES = new Set<ProcessState>(['done', 'failed', 'killed', 'interrupted']);
119
+
120
+ /** States representing actively-working nodes (drives runningCount + follow target). */
121
+ const RUNNING_STATES = new Set<ProcessState>([
122
+ 'thinking', 'executing-tool', 'awaiting-approval', 'streaming', 'stalled', 'retrying',
123
+ ]);
124
+
125
+ const KIND_TAGS: Record<ProcessKind, string> = {
126
+ agent: 'agent',
127
+ 'wrfc-chain': 'wrfc',
128
+ 'wrfc-subtask': 'wrfc·sub',
129
+ workflow: 'flow',
130
+ trigger: 'trig',
131
+ schedule: 'sched',
132
+ watcher: 'watch',
133
+ 'background-process': 'exec',
134
+ // Wave 4 (wo703): orchestration-engine kinds (adapters/orchestration.ts).
135
+ workstream: 'stream',
136
+ phase: 'phase',
137
+ 'work-item': 'item',
138
+ // Wave 5 (wo804): the repo source-tree code index (adapters/code-index.ts).
139
+ // A single leaf node — never a rollup of other flat-list nodes (see
140
+ // ROLLUP_KINDS below) — so no other list in this module needs updating.
141
+ 'code-index': 'index',
142
+ };
143
+
144
+ export function fleetStateGlyph(state: ProcessState): string {
145
+ return STATE_GLYPHS[state] ?? '?';
146
+ }
147
+
148
+ export function fleetStateTone(state: ProcessState): FleetStateTone {
149
+ return STATE_TONES[state] ?? 'muted';
150
+ }
151
+
152
+ export function fleetKindTag(kind: ProcessKind): string {
153
+ return KIND_TAGS[kind] ?? kind;
154
+ }
155
+
156
+ export function isTerminalProcessState(state: ProcessState): boolean {
157
+ return TERMINAL_STATES.has(state);
158
+ }
159
+
160
+ export function isRunningProcessState(state: ProcessState): boolean {
161
+ return RUNNING_STATES.has(state);
162
+ }
163
+
164
+ // ---------------------------------------------------------------------------
165
+ // Honest usage/cost helpers (W0.9 convention, ported from agent-inspector-shared.ts)
166
+ // ---------------------------------------------------------------------------
167
+
168
+ /**
169
+ * True when a ProcessUsage carries real reported token data rather than
170
+ * being present-but-all-zero. Mirrors hasReportedUsage() in
171
+ * agent-inspector-shared.ts, applied to the SDK's ProcessUsage shape.
172
+ */
173
+ export function hasFleetUsage(usage: ProcessUsage | undefined): usage is ProcessUsage {
174
+ if (!usage) return false;
175
+ return usage.inputTokens > 0 || usage.outputTokens > 0
176
+ || usage.cacheReadTokens > 0 || usage.cacheWriteTokens > 0;
177
+ }
178
+
179
+ /** Total token count (input + output + cache) for a node's usage, or null when no real usage has landed. */
180
+ export function fleetUsageTokens(usage: ProcessUsage | undefined): number | null {
181
+ if (!hasFleetUsage(usage)) return null;
182
+ return usage.inputTokens + usage.outputTokens + usage.cacheReadTokens + usage.cacheWriteTokens;
183
+ }
184
+
185
+ /** Whether a node's costUsd is a real reading, never a fabricated placeholder. */
186
+ export function hasFleetCost(costUsd: number | null | undefined, costState: ProcessCostState): boolean {
187
+ return costState !== 'unpriced' && typeof costUsd === 'number';
188
+ }
189
+
190
+ // ---------------------------------------------------------------------------
191
+ // Tree builder — pure, testable (ported tree-walk shape from process-modal.ts)
192
+ // ---------------------------------------------------------------------------
193
+
194
+ function compareNodes(a: ProcessNode, b: ProcessNode): number {
195
+ const delta = (a.startedAt ?? Infinity) - (b.startedAt ?? Infinity);
196
+ if (delta !== 0) return delta;
197
+ return a.id.localeCompare(b.id);
198
+ }
199
+
200
+ function appendSubtree(
201
+ rows: FleetTreeRow[],
202
+ node: ProcessNode,
203
+ childrenByParent: Map<string, ProcessNode[]>,
204
+ depth: number,
205
+ ancestorPrefix: string,
206
+ isLast: boolean,
207
+ visited: Set<string>,
208
+ ): void {
209
+ if (visited.has(node.id)) return; // cycle guard
210
+ visited.add(node.id);
211
+
212
+ const children = (childrenByParent.get(node.id) ?? []).slice().sort(compareNodes);
213
+ const treePrefix = depth === 0 ? '' : `${ancestorPrefix}${isLast ? '└─ ' : '├─ '}`;
214
+ rows.push({ node, depth, treePrefix, isLastChild: isLast, hasChildren: children.length > 0 });
215
+
216
+ const descendantPrefix = depth === 0 ? '' : `${ancestorPrefix}${isLast ? ' ' : '│ '}`;
217
+ children.forEach((child, index) => {
218
+ appendSubtree(rows, child, childrenByParent, depth + 1, descendantPrefix, index === children.length - 1, visited);
219
+ });
220
+ }
221
+
222
+ /**
223
+ * Flatten a ProcessNode[] into a stable depth-first FleetTreeRow[] list.
224
+ * Root-level rows (depth 0) carry an empty treePrefix (multi-root forest —
225
+ * each top-level process family renders without a connector to its
226
+ * unrelated siblings); connectors appear starting at depth 1. Defensive
227
+ * cycle guard: a self-referencing or looping parentId chain is walked once
228
+ * via the leftover pass below rather than recursing forever (the SDK
229
+ * guarantees every parentId resolves or the node is a root, so this is
230
+ * belt-and-suspenders, not a supported shape).
231
+ */
232
+ export function buildFleetRows(nodes: readonly ProcessNode[]): FleetTreeRow[] {
233
+ const byId = new Map(nodes.map((n) => [n.id, n]));
234
+ const childrenByParent = new Map<string, ProcessNode[]>();
235
+ const roots: ProcessNode[] = [];
236
+
237
+ for (const node of nodes) {
238
+ if (node.parentId && byId.has(node.parentId)) {
239
+ const siblings = childrenByParent.get(node.parentId) ?? [];
240
+ siblings.push(node);
241
+ childrenByParent.set(node.parentId, siblings);
242
+ } else {
243
+ roots.push(node);
244
+ }
245
+ }
246
+
247
+ const rows: FleetTreeRow[] = [];
248
+ const visited = new Set<string>();
249
+
250
+ for (const root of roots.slice().sort(compareNodes)) {
251
+ appendSubtree(rows, root, childrenByParent, 0, '', true, visited);
252
+ }
253
+
254
+ // Defensive: nodes never reached because their entire ancestor chain forms
255
+ // a cycle with no true root. Walked as pseudo-roots so they still render.
256
+ const leftovers = nodes.filter((n) => !visited.has(n.id)).sort(compareNodes);
257
+ for (const node of leftovers) {
258
+ appendSubtree(rows, node, childrenByParent, 0, '', true, visited);
259
+ }
260
+
261
+ return rows;
262
+ }
263
+
264
+ /**
265
+ * Kinds whose usage/costUsd (and, for 'running', whose derived state) are the
266
+ * SDK's OWN rollup of *other* nodes that ALSO appear individually in the flat
267
+ * list — summing across every flat node would therefore double-count them.
268
+ * Per registry.ts assemble() + wrfc.ts adaptChain/adaptSubtask:
269
+ * - 'wrfc-chain': usage/costUsd = sum over the chain's member agents
270
+ * (chain.allAgentIds minus the owner) via adaptChain's aggregateCost/
271
+ * sumUsage — those member agents are pushed onto the flat node list as
272
+ * their own 'agent' nodes too. The chain's derived ProcessState ('running'
273
+ * while any phase is active) likewise just mirrors its members' states.
274
+ * - 'wrfc-subtask': never carries usage/cost (adaptSubtask always sets
275
+ * usage: undefined, costUsd: null, costState: 'unpriced') and its
276
+ * 'running' phase state mirrors whichever agent is currently working the
277
+ * subtask. Excluded for the same "grouping construct, not an actor"
278
+ * reason as the chain, even though today it can never contribute a
279
+ * nonzero cost/token reading (defense in depth against a future adapter
280
+ * change, not load-bearing).
281
+ *
282
+ * Wave 4 (wo703) additions — verified against adapters/orchestration.ts:
283
+ * - 'workstream': adaptWorkstream SUMS every work-item's usage/costUsd
284
+ * exactly once (sumWorkItemUsage/aggregateWorkItemCost), the same
285
+ * "rollup of nodes that also appear individually" shape as adaptChain —
286
+ * each work item ALSO appears as its own 'work-item' node in the flat
287
+ * list, so summing over every flat node would double-count. Its derived
288
+ * state likewise mirrors its items' states, so it is excluded from
289
+ * runningCount for the same non-double-counting reason as 'wrfc-chain'.
290
+ * - 'phase': adaptPhase reports NO usage/cost (mirrors adaptSubtask's
291
+ * "report nothing" choice exactly — usage: undefined, costUsd: null,
292
+ * costState: 'unpriced') and its 'running' state mirrors whichever
293
+ * work-item currently occupies it. Same defense-in-depth inclusion as
294
+ * 'wrfc-subtask': never contributes today, excluded anyway in case a
295
+ * future adapter change gives it one.
296
+ * - 'work-item' is deliberately NOT in this set: unlike a phase, a work
297
+ * item carries its OWN direct usage/cost (item.usage, cumulative across
298
+ * every phase it has visited) — it is the leaf contributor, the
299
+ * 'wrfc-subtask'-for-capabilities analogue but the 'agent'-for-usage
300
+ * analogue. Excluding it would silently zero out real cost/token totals.
301
+ *
302
+ * Wave 5 (wo804): 'code-index' is deliberately NOT in this set either —
303
+ * adaptCodeIndex yields exactly one standalone ProcessNode per registry
304
+ * (never a parent whose children ALSO appear individually in the flat
305
+ * list), so it is a leaf like 'agent'/'work-item', not a grouping construct.
306
+ * It reports no usage/cost (an index build has no LLM turn), so it never
307
+ * contributes to totalCost/totalTokens regardless — its running state DOES
308
+ * count toward runningCount while building, which is correct: it is a real,
309
+ * distinct unit of work, not an arithmetic sum of other rows.
310
+ */
311
+ const ROLLUP_KINDS = new Set<ProcessKind>(['wrfc-chain', 'wrfc-subtask', 'workstream', 'phase']);
312
+
313
+ /**
314
+ * True for the WRFC owner agent's node specifically: an 'agent'-kind node
315
+ * whose source AgentRecord has wrfcRole === 'owner'. The owner runs no LLM
316
+ * turn itself — at chain completion the SDK backfills owner.usage from
317
+ * aggregateChainUsage(chain) (wrfc-controller.ts completeOwnerAgent), which
318
+ * is the SAME phase-children total already carried by the chain node AND by
319
+ * each phase-child agent's own node. Detected via the opaque `raw` field
320
+ * (the AgentRecord) since ProcessNode itself has no wrfcRole — this is the
321
+ * one place fleet-read-model reaches into `raw` for aggregation honesty
322
+ * rather than drill-down display.
323
+ */
324
+ function isWrfcOwnerAgentNode(node: ProcessNode): boolean {
325
+ if (node.kind !== 'agent') return false;
326
+ const raw = node.raw;
327
+ return typeof raw === 'object' && raw !== null && (raw as { wrfcRole?: unknown }).wrfcRole === 'owner';
328
+ }
329
+
330
+ /**
331
+ * Nodes whose usage/costUsd would double-count against other nodes already
332
+ * in the same flat list (see ROLLUP_KINDS / isWrfcOwnerAgentNode above).
333
+ * Excluded from totalCost/totalTokens. NOT excluded from runningCount by
334
+ * this predicate alone — see isFleetRunningLeaf.
335
+ */
336
+ function isFleetAggregateRollupNode(node: ProcessNode): boolean {
337
+ return ROLLUP_KINDS.has(node.kind) || isWrfcOwnerAgentNode(node);
338
+ }
339
+
340
+ /**
341
+ * Nodes that count toward runningCount. Excludes ROLLUP_KINDS (a running
342
+ * wrfc-chain/wrfc-subtask reflects the very same unit of work as its running
343
+ * member agent, which is already counted) but — unlike the cost/token
344
+ * exclusion above — does NOT exclude the WRFC owner agent: the owner is a
345
+ * real, distinct supervising process (not an arithmetic sum of other rows),
346
+ * so counting it as "running" alongside its phase children is not a double
347
+ * count in the way summing its rolled-up usage/cost would be.
348
+ */
349
+ function isFleetRunningLeaf(node: ProcessNode): boolean {
350
+ return !ROLLUP_KINDS.has(node.kind) && isRunningProcessState(node.state);
351
+ }
352
+
353
+ /** Build the full FleetSnapshot (rows + honest aggregates) from a flat node list. */
354
+ export function buildFleetSnapshot(nodes: readonly ProcessNode[], capturedAt: number = Date.now()): FleetSnapshot {
355
+ const rows = buildFleetRows(nodes);
356
+
357
+ let totalCost: number | null = null;
358
+ let totalTokens: number | null = null;
359
+ let runningCount = 0;
360
+
361
+ for (const node of nodes) {
362
+ if (!isFleetAggregateRollupNode(node)) {
363
+ if (hasFleetCost(node.costUsd, node.costState)) {
364
+ totalCost = (totalCost ?? 0) + (node.costUsd as number);
365
+ }
366
+ const tokens = fleetUsageTokens(node.usage);
367
+ if (tokens !== null) {
368
+ totalTokens = (totalTokens ?? 0) + tokens;
369
+ }
370
+ }
371
+ if (isFleetRunningLeaf(node)) runningCount++;
372
+ }
373
+
374
+ return { rows, totalCost, totalTokens, runningCount, capturedAt };
375
+ }
376
+
377
+ // ---------------------------------------------------------------------------
378
+ // Read-model — two-factory shape (live / static), mirrors cockpit-read-model.ts
379
+ // ---------------------------------------------------------------------------
380
+
381
+ /** Payload of a `COMMUNICATION_CONSUMED` runtime-bus event, narrowed for FleetReadModel consumers. */
382
+ export interface SteerConsumedEvent {
383
+ readonly messageId: string;
384
+ readonly agentId: string;
385
+ readonly turn: number;
386
+ }
387
+
388
+ export interface FleetReadModel {
389
+ getSnapshot(): FleetSnapshot;
390
+ /** Subscribe to fleet changes. Returns an unsubscribe function. */
391
+ subscribe(listener: () => void): () => void;
392
+ /** Graceful interruption where the source supports one. Returns true when accepted. */
393
+ interrupt(id: string): boolean;
394
+ /**
395
+ * Wave-6 (wo-F item d2): re-arm a `paused` node (a disabled trigger/schedule,
396
+ * or an automation job). The inverse of interrupt()'s pause. Returns true when
397
+ * accepted; false for a node that is not currently `paused` or whose kind has
398
+ * no resume path (honest refusal).
399
+ */
400
+ resume(id: string): boolean;
401
+ /** Hard stop, optionally cascading to descendants. Returns the node ids acted on. */
402
+ kill(id: string, opts?: { readonly cascade?: boolean }): readonly string[];
403
+ /**
404
+ * Wave-3 (W3.2): queue a human message for a live in-process agent (or a
405
+ * wrfc-subtask's current live member), delivered at its next turn
406
+ * boundary. Honest refusal (`{queued:false,reason}`) for anything that
407
+ * cannot take mid-run input — see ProcessRegistry.steer's doc comment.
408
+ */
409
+ steer(id: string, text: string): SteerResult;
410
+ /**
411
+ * Wave-3 (W3.2): subscribe to the honest "the agent actually consumed this
412
+ * steer at its turn boundary" signal (COMMUNICATION_CONSUMED on the
413
+ * runtime bus's 'communication' domain). Returns an unsubscribe function.
414
+ * A read-model constructed without a runtimeBus (e.g. the static factory,
415
+ * or a test double) never invokes the listener — graceful no-op, matching
416
+ * the steer()/steerable degrade-without-a-dep convention.
417
+ */
418
+ subscribeConsumed(listener: (event: SteerConsumedEvent) => void): () => void;
419
+ }
420
+
421
+ /** Narrow surface of ProcessRegistry this read-model depends on. */
422
+ export type FleetRegistryLike = Pick<ProcessRegistry, 'query' | 'subscribe' | 'interrupt' | 'resume' | 'kill' | 'steer'>;
423
+
424
+ /**
425
+ * Create a live FleetReadModel backed by the SDK's ProcessRegistry.
426
+ * `runtimeBus` is optional (narrowed to just `onDomain`) — without it,
427
+ * `subscribeConsumed` degrades to a no-op, same shape as the registry's own
428
+ * messageBus-optional degrade for steer()/steerable.
429
+ */
430
+ export function createFleetReadModel(
431
+ registry: FleetRegistryLike,
432
+ runtimeBus?: Pick<RuntimeEventBus, 'onDomain'>,
433
+ ): FleetReadModel {
434
+ return {
435
+ getSnapshot(): FleetSnapshot {
436
+ const snapshot = registry.query();
437
+ return buildFleetSnapshot(snapshot.nodes, snapshot.capturedAt);
438
+ },
439
+ subscribe(listener: () => void): () => void {
440
+ return registry.subscribe(() => listener());
441
+ },
442
+ interrupt(id: string): boolean {
443
+ return registry.interrupt(id);
444
+ },
445
+ resume(id: string): boolean {
446
+ return registry.resume(id);
447
+ },
448
+ kill(id: string, opts?: { readonly cascade?: boolean }): readonly string[] {
449
+ return registry.kill(id, opts);
450
+ },
451
+ steer(id: string, text: string): SteerResult {
452
+ return registry.steer(id, text);
453
+ },
454
+ subscribeConsumed(listener: (event: SteerConsumedEvent) => void): () => void {
455
+ if (!runtimeBus) return () => {};
456
+ return runtimeBus.onDomain('communication', (envelope) => {
457
+ const event = envelope.payload;
458
+ if (event.type === 'COMMUNICATION_CONSUMED') {
459
+ listener({ messageId: event.messageId, agentId: event.agentId, turn: event.turn });
460
+ }
461
+ });
462
+ },
463
+ };
464
+ }
465
+
466
+ /** Create a static FleetReadModel for tests/goldens — no live registry, no timers. */
467
+ export function createStaticFleetReadModel(snapshot: FleetSnapshot): FleetReadModel {
468
+ return {
469
+ getSnapshot: () => snapshot,
470
+ subscribe: () => () => {},
471
+ interrupt: () => false,
472
+ resume: () => false,
473
+ kill: () => [],
474
+ steer: () => ({ queued: false, reason: 'no live registry' }),
475
+ subscribeConsumed: () => () => {},
476
+ };
477
+ }
@@ -0,0 +1,92 @@
1
+ // ---------------------------------------------------------------------------
2
+ // fleet-steer.ts
3
+ //
4
+ // Wave-3 (W3.2) — steer-badge rendering helpers and the "dropped inference"
5
+ // reconciliation pass, split out of fleet-panel.ts to keep that file under
6
+ // the architecture line cap (see check-architecture.ts's 800-line gate).
7
+ // Pure functions only; FleetPanel still owns the mutable FleetTab.steerBadge
8
+ // state itself (see fleet-tabs.ts's SteerBadge doc) and calls into this
9
+ // module rather than duplicating the logic inline.
10
+ // ---------------------------------------------------------------------------
11
+
12
+ import { STEER_TTL_MS, type ProcessNode } from '@pellux/goodvibes-sdk/platform/runtime/fleet';
13
+ import { isTerminalProcessState } from './fleet-read-model.ts';
14
+ import type { FleetTab, SteerBadgeStatus } from './fleet-tabs.ts';
15
+ import { DEFAULT_PANEL_PALETTE, type PanelPalette } from './polish.ts';
16
+
17
+ /** Linger before an auto-resolved (consumed/dropped) steer badge is cleared from the tab. */
18
+ export const STEER_BADGE_LINGER_MS = 4_000;
19
+
20
+ export function steerBadgeGlyph(status: SteerBadgeStatus): string {
21
+ switch (status) {
22
+ case 'queued': return '⧗';
23
+ case 'consumed': return '✓';
24
+ case 'dropped': return '⚠';
25
+ }
26
+ }
27
+
28
+ export function steerBadgeTone(status: SteerBadgeStatus, palette: PanelPalette): string {
29
+ switch (status) {
30
+ case 'queued': return palette.warn ?? DEFAULT_PANEL_PALETTE.warn;
31
+ case 'consumed': return palette.good ?? DEFAULT_PANEL_PALETTE.good;
32
+ case 'dropped': return palette.bad ?? DEFAULT_PANEL_PALETTE.bad;
33
+ }
34
+ }
35
+
36
+ /**
37
+ * Wave-3 (W3.2, risk #2 — "dropped inference"): the SDK emits no
38
+ * cancelled/expired signal for a queued steer, so a badge left `queued`
39
+ * after its target node goes terminal (done/failed/killed/interrupted)
40
+ * would hang forever with no honest resolution. Resolves any such badge to
41
+ * `dropped`, and clears any already-resolved (consumed/dropped) badge past
42
+ * its short linger so a tab doesn't accumulate stale indicators.
43
+ *
44
+ * Mutates `tab.steerBadge` in place (same mutable-slot convention as
45
+ * `FleetTab.ledgerEntries` — see fleet-panel.ts's ensureLedgerLoaded).
46
+ * Returns true when anything changed, so the caller knows whether to mark
47
+ * itself dirty.
48
+ */
49
+ export function reconcileSteerBadges(
50
+ tabs: readonly FleetTab[],
51
+ findLiveNode: (nodeId: string) => ProcessNode | null,
52
+ now: number,
53
+ ): boolean {
54
+ let changed = false;
55
+ for (const tab of tabs) {
56
+ const badge = tab.steerBadge;
57
+ if (!badge) continue;
58
+ if (badge.status === 'queued') {
59
+ const node = findLiveNode(tab.nodeId);
60
+ if (!node || isTerminalProcessState(node.state)) {
61
+ tab.steerBadge = {
62
+ ...badge,
63
+ status: 'dropped',
64
+ note: node
65
+ ? `the ${node.kind} went ${node.state} before the steer was delivered`
66
+ : 'the target is no longer tracked',
67
+ resolvedAt: now,
68
+ };
69
+ changed = true;
70
+ } else if (badge.queuedAt !== undefined && now - badge.queuedAt > STEER_TTL_MS) {
71
+ // Long-tool-call case: the target is still healthy and non-terminal,
72
+ // but the underlying steer message's own TTL (the SDK's MessageBus —
73
+ // see registry.js's steer(), which stamps every steer with
74
+ // STEER_TTL_MS) has lapsed without a COMMUNICATION_CONSUMED ever
75
+ // arriving. The SDK gives no explicit expiry signal, so without this
76
+ // the badge would show 'queued' forever even though the message is
77
+ // provably gone from the bus.
78
+ tab.steerBadge = {
79
+ ...badge,
80
+ status: 'dropped',
81
+ note: 'expired undelivered',
82
+ resolvedAt: now,
83
+ };
84
+ changed = true;
85
+ }
86
+ } else if (badge.resolvedAt !== undefined && now - badge.resolvedAt > STEER_BADGE_LINGER_MS) {
87
+ tab.steerBadge = null;
88
+ changed = true;
89
+ }
90
+ }
91
+ return changed;
92
+ }