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