@pellux/goodvibes-tui 1.0.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 (209) hide show
  1. package/CHANGELOG.md +130 -127
  2. package/README.md +32 -14
  3. package/docs/foundation-artifacts/operator-contract.json +1 -1
  4. package/package.json +2 -2
  5. package/src/cli/ensure-goodvibes-gitignore.ts +32 -0
  6. package/src/cli/entrypoint.ts +2 -0
  7. package/src/core/alert-gating.ts +67 -0
  8. package/src/core/approval-alert.ts +72 -0
  9. package/src/core/budget-breach-notifier.ts +106 -0
  10. package/src/core/conversation-rendering.ts +19 -0
  11. package/src/core/conversation.ts +58 -0
  12. package/src/core/focus-tracker.ts +41 -0
  13. package/src/core/long-task-notifier.ts +33 -6
  14. package/src/core/stream-event-wiring.ts +104 -2
  15. package/src/core/stream-stall-watchdog.ts +41 -17
  16. package/src/core/system-message-router.ts +37 -51
  17. package/src/core/turn-cancellation.ts +20 -0
  18. package/src/core/turn-event-wiring.ts +64 -3
  19. package/src/export/cost-utils.ts +126 -8
  20. package/src/input/command-registry.ts +54 -1
  21. package/src/input/commands/checkpoint-runtime.ts +280 -0
  22. package/src/input/commands/codebase-runtime.ts +192 -0
  23. package/src/input/commands/diff-runtime.ts +61 -30
  24. package/src/input/commands/eval.ts +1 -1
  25. package/src/input/commands/health-runtime.ts +1 -1
  26. package/src/input/commands/image-runtime.ts +112 -0
  27. package/src/input/commands/intelligence-runtime.ts +11 -1
  28. package/src/input/commands/local-auth-runtime.ts +4 -1
  29. package/src/input/commands/local-runtime.ts +9 -3
  30. package/src/input/commands/marketplace-runtime.ts +2 -2
  31. package/src/input/commands/memory.ts +6 -1
  32. package/src/input/commands/operator-panel-runtime.ts +40 -24
  33. package/src/input/commands/planning-runtime.ts +26 -3
  34. package/src/input/commands/platform-sandbox-runtime.ts +1 -6
  35. package/src/input/commands/plugin-runtime.ts +2 -2
  36. package/src/input/commands/policy-dispatch.ts +21 -0
  37. package/src/input/commands/provider-accounts-runtime.ts +1 -1
  38. package/src/input/commands/qrcode-runtime.ts +3 -3
  39. package/src/input/commands/recall-review.ts +35 -0
  40. package/src/input/commands/remote-runtime.ts +3 -3
  41. package/src/input/commands/runtime-services.ts +54 -13
  42. package/src/input/commands/services-runtime.ts +1 -1
  43. package/src/input/commands/session-content.ts +12 -0
  44. package/src/input/commands/session-workflow.ts +19 -1
  45. package/src/input/commands/settings-sync-runtime.ts +1 -1
  46. package/src/input/commands/share-runtime.ts +9 -2
  47. package/src/input/commands/shell-core.ts +15 -7
  48. package/src/input/commands/skills-runtime.ts +2 -8
  49. package/src/input/commands/subscription-runtime.ts +2 -2
  50. package/src/input/commands/test-runtime.ts +277 -0
  51. package/src/input/commands/websearch-runtime.ts +101 -0
  52. package/src/input/commands/work-plan-runtime.ts +36 -10
  53. package/src/input/commands/workstream-runtime.ts +338 -0
  54. package/src/input/commands.ts +12 -0
  55. package/src/input/config-modal-types.ts +161 -0
  56. package/src/input/config-modal.ts +377 -0
  57. package/src/input/feed-context-factory.ts +7 -8
  58. package/src/input/handler-command-route.ts +27 -17
  59. package/src/input/handler-content-actions.ts +22 -8
  60. package/src/input/handler-feed-routes.ts +107 -7
  61. package/src/input/handler-feed.ts +49 -13
  62. package/src/input/handler-interactions.ts +3 -3
  63. package/src/input/handler-modal-routes.ts +65 -0
  64. package/src/input/handler-modal-stack.ts +3 -5
  65. package/src/input/handler-modal-token-routes.ts +16 -49
  66. package/src/input/handler-picker-routes.ts +11 -94
  67. package/src/input/handler-shortcuts.ts +24 -14
  68. package/src/input/handler-types.ts +3 -6
  69. package/src/input/handler-ui-state.ts +10 -22
  70. package/src/input/handler.ts +10 -28
  71. package/src/input/keybindings.ts +22 -8
  72. package/src/input/model-picker-types.ts +24 -6
  73. package/src/input/model-picker.ts +58 -0
  74. package/src/input/panel-integration-actions.ts +9 -170
  75. package/src/input/settings-modal-data.ts +95 -0
  76. package/src/input/settings-modal-mutations.ts +6 -4
  77. package/src/main.ts +41 -44
  78. package/src/panels/agent-inspector-shared.ts +35 -11
  79. package/src/panels/base-panel.ts +22 -1
  80. package/src/panels/builtin/agent.ts +21 -107
  81. package/src/panels/builtin/development.ts +16 -62
  82. package/src/panels/builtin/knowledge.ts +8 -32
  83. package/src/panels/builtin/operations.ts +84 -428
  84. package/src/panels/builtin/session.ts +21 -112
  85. package/src/panels/builtin/shared.ts +9 -43
  86. package/src/panels/builtin-modals.ts +218 -0
  87. package/src/panels/builtin-panels.ts +5 -0
  88. package/src/panels/cost-tracker-panel.ts +63 -14
  89. package/src/panels/diff-panel.ts +123 -60
  90. package/src/panels/eval-registry.ts +60 -0
  91. package/src/panels/fleet-panel.ts +800 -0
  92. package/src/panels/fleet-read-model.ts +477 -0
  93. package/src/panels/fleet-steer.ts +92 -0
  94. package/src/panels/fleet-stop.ts +125 -0
  95. package/src/panels/fleet-tabs.ts +230 -0
  96. package/src/panels/fleet-transcript.ts +356 -0
  97. package/src/panels/git-panel.ts +9 -9
  98. package/src/panels/index.ts +7 -31
  99. package/src/panels/local-auth-panel.ts +10 -0
  100. package/src/panels/modals/hooks-modal.ts +187 -0
  101. package/src/panels/modals/keybindings-modal.ts +151 -0
  102. package/src/panels/modals/knowledge-modal.ts +158 -0
  103. package/src/panels/modals/local-auth-modal.ts +132 -0
  104. package/src/panels/modals/marketplace-modal.ts +218 -0
  105. package/src/panels/modals/memory-modal.ts +180 -0
  106. package/src/panels/modals/modal-surface-helpers.ts +54 -0
  107. package/src/panels/modals/modal-theme.ts +36 -0
  108. package/src/panels/modals/pairing-modal.ts +144 -0
  109. package/src/panels/modals/planning-modal.ts +282 -0
  110. package/src/panels/modals/plugins-modal.ts +174 -0
  111. package/src/panels/modals/policy-modal.ts +256 -0
  112. package/src/panels/modals/provider-health-modal.ts +136 -0
  113. package/src/panels/modals/remote-modal.ts +115 -0
  114. package/src/panels/modals/sandbox-modal.ts +150 -0
  115. package/src/panels/modals/security-modal.ts +217 -0
  116. package/src/panels/modals/services-modal.ts +179 -0
  117. package/src/panels/modals/settings-sync-modal.ts +142 -0
  118. package/src/panels/modals/skills-modal.ts +189 -0
  119. package/src/panels/modals/subscription-modal.ts +172 -0
  120. package/src/panels/modals/work-plan-modal.ts +149 -0
  121. package/src/panels/panel-confirm-overlay.ts +72 -0
  122. package/src/panels/panel-manager.ts +108 -5
  123. package/src/panels/project-planning-answer-actions.ts +4 -2
  124. package/src/panels/scrollable-list-panel.ts +9 -0
  125. package/src/panels/skills-panel.ts +7 -51
  126. package/src/panels/token-budget-panel.ts +5 -3
  127. package/src/panels/types.ts +26 -0
  128. package/src/permissions/hunk-selection.ts +179 -0
  129. package/src/permissions/prompt.ts +60 -4
  130. package/src/renderer/compaction-history-modal.ts +19 -3
  131. package/src/renderer/compaction-preview.ts +13 -2
  132. package/src/renderer/compaction-quality.ts +100 -0
  133. package/src/renderer/config-modal.ts +81 -0
  134. package/src/renderer/conversation-overlays.ts +10 -17
  135. package/src/renderer/fleet-tab-strip.ts +56 -0
  136. package/src/renderer/footer-tips.ts +10 -2
  137. package/src/renderer/git-status.ts +40 -0
  138. package/src/renderer/help-overlay.ts +2 -2
  139. package/src/renderer/model-workspace.ts +44 -1
  140. package/src/renderer/panel-workspace-bar.ts +8 -2
  141. package/src/renderer/shell-surface.ts +8 -1
  142. package/src/renderer/turn-injection.ts +114 -0
  143. package/src/renderer/ui-factory.ts +91 -7
  144. package/src/runtime/bootstrap-command-context.ts +24 -1
  145. package/src/runtime/bootstrap-command-parts.ts +36 -7
  146. package/src/runtime/bootstrap-core.ts +19 -4
  147. package/src/runtime/bootstrap-hook-bridge.ts +5 -0
  148. package/src/runtime/bootstrap-shell.ts +41 -17
  149. package/src/runtime/bootstrap.ts +11 -17
  150. package/src/runtime/code-index-services.ts +112 -0
  151. package/src/runtime/orchestrator-core-services.ts +49 -0
  152. package/src/runtime/process-lifecycle.ts +3 -1
  153. package/src/runtime/services.ts +91 -43
  154. package/src/runtime/ui-services.ts +8 -0
  155. package/src/runtime/workstream-services.ts +195 -0
  156. package/src/shell/blocking-input.ts +22 -1
  157. package/src/shell/ui-openers.ts +129 -17
  158. package/src/utils/format-duration.ts +8 -18
  159. package/src/utils/splash-lines.ts +1 -1
  160. package/src/version.ts +1 -1
  161. package/src/panels/agent-inspector-panel.ts +0 -786
  162. package/src/panels/approval-panel.ts +0 -252
  163. package/src/panels/automation-control-panel.ts +0 -479
  164. package/src/panels/cockpit-panel.ts +0 -481
  165. package/src/panels/cockpit-read-model.ts +0 -233
  166. package/src/panels/communication-panel.ts +0 -256
  167. package/src/panels/control-plane-panel.ts +0 -470
  168. package/src/panels/debug-panel.ts +0 -609
  169. package/src/panels/docs-panel.ts +0 -375
  170. package/src/panels/eval-panel.ts +0 -627
  171. package/src/panels/file-explorer-panel.ts +0 -664
  172. package/src/panels/file-preview-panel.ts +0 -517
  173. package/src/panels/hooks-panel.ts +0 -401
  174. package/src/panels/incident-review-panel.ts +0 -406
  175. package/src/panels/intelligence-panel.ts +0 -383
  176. package/src/panels/knowledge-graph-panel.ts +0 -506
  177. package/src/panels/marketplace-panel.ts +0 -399
  178. package/src/panels/memory-panel.ts +0 -558
  179. package/src/panels/ops-control-panel.ts +0 -329
  180. package/src/panels/ops-strategy-panel.ts +0 -321
  181. package/src/panels/orchestration-panel.ts +0 -465
  182. package/src/panels/panel-list-panel.ts +0 -557
  183. package/src/panels/plan-dashboard-panel.ts +0 -707
  184. package/src/panels/policy-panel.ts +0 -517
  185. package/src/panels/project-planning-panel.ts +0 -721
  186. package/src/panels/provider-health-panel.ts +0 -678
  187. package/src/panels/provider-health-tracker.ts +0 -306
  188. package/src/panels/provider-health-views.ts +0 -560
  189. package/src/panels/qr-panel.ts +0 -280
  190. package/src/panels/remote-panel.ts +0 -534
  191. package/src/panels/routes-panel.ts +0 -241
  192. package/src/panels/sandbox-panel.ts +0 -456
  193. package/src/panels/security-panel.ts +0 -447
  194. package/src/panels/services-panel.ts +0 -329
  195. package/src/panels/session-browser-panel.ts +0 -487
  196. package/src/panels/settings-sync-panel.ts +0 -398
  197. package/src/panels/subscription-panel.ts +0 -342
  198. package/src/panels/symbol-outline-panel.ts +0 -619
  199. package/src/panels/system-messages-panel.ts +0 -364
  200. package/src/panels/tasks-panel.ts +0 -608
  201. package/src/panels/thinking-panel.ts +0 -333
  202. package/src/panels/tool-inspector-panel.ts +0 -537
  203. package/src/panels/work-plan-panel.ts +0 -530
  204. package/src/panels/worktree-panel.ts +0 -360
  205. package/src/panels/wrfc-panel.ts +0 -790
  206. package/src/renderer/agent-detail-modal.ts +0 -465
  207. package/src/renderer/live-tail-modal.ts +0 -156
  208. package/src/renderer/process-modal.ts +0 -656
  209. package/src/renderer/qr-renderer.ts +0 -120
@@ -1,790 +0,0 @@
1
- import type { Line } from '../types/grid.ts';
2
- import type { WrfcChain, WrfcState, QualityGateResult } from '@pellux/goodvibes-sdk/platform/agents';
3
- import type { WrfcController } from '@pellux/goodvibes-sdk/platform/agents';
4
- import { BasePanel } from './base-panel.ts';
5
- import type { WorkflowEvent } from '@/runtime/index.ts';
6
- import type { UiEventFeed } from '../runtime/ui-events.ts';
7
- import {
8
- buildMeterLine,
9
- buildPanelLine,
10
- buildPanelWorkspace,
11
- resolveScrollablePanelSection,
12
- DEFAULT_PANEL_PALETTE,
13
- type PanelWorkspaceSection,
14
- buildSelectablePanelLine,
15
- buildStyledPanelLine,
16
- buildEmptyState,
17
- } from './polish.ts';
18
- import { getDisplayWidth } from '../utils/terminal-width.ts';
19
- import {
20
- type ConfirmState,
21
- handleConfirmInput,
22
- } from './confirm-state.ts';
23
- import { summarizeError } from '@pellux/goodvibes-sdk/platform/utils';
24
- import { formatDuration } from '../utils/format-duration.ts';
25
- import { AgentInspectorPanel } from './agent-inspector-panel.ts';
26
- import type { PanelIntegrationContext } from './types.ts';
27
-
28
- // ---------------------------------------------------------------------------
29
- // Constants
30
- // ---------------------------------------------------------------------------
31
-
32
- /** Chains in non-terminal state with no event for this long are shown as STALLED. */
33
- const STALL_THRESHOLD_MS = 5 * 60 * 1000;
34
-
35
- /** Terminal states — chains in these states cannot be resumed or cancelled. */
36
- const TERMINAL_STATES: readonly WrfcState[] = ['passed', 'failed'];
37
-
38
- /**
39
- * States from which resume is permitted. These mirror the states
40
- * WrfcController.resumeChain() actually recovers from: 'pending' (awaiting
41
- * retry / start engineering), 'reviewing' and 'fixing' (re-spawn the
42
- * interrupted child), and 'awaiting_gates' (re-run gates). resumeChain no-ops
43
- * safely when a child is still running, so offering resume for these states is
44
- * always safe — it is exactly the set an operator needs after a restart or a
45
- * STALLED chain.
46
- */
47
- const RESUMABLE_STATES: readonly WrfcState[] = ['pending', 'reviewing', 'fixing', 'awaiting_gates'];
48
-
49
- // ---------------------------------------------------------------------------
50
- // Colour palette + formatting helpers live in ./wrfc-panel-format.ts (extracted
51
- // to keep this module under the architecture line-count cap). Re-exported here
52
- // so ./wrfc-panel.ts remains their public import site.
53
- // ---------------------------------------------------------------------------
54
- import {
55
- C,
56
- sparkline,
57
- stateColor,
58
- stateLabel,
59
- issueColor,
60
- issuePrefix,
61
- truncate,
62
- constraintStatusMarker,
63
- } from './wrfc-panel-format.ts';
64
- export { sparkline, stateColor, stateLabel, truncate, constraintStatusMarker } from './wrfc-panel-format.ts';
65
-
66
- // ---------------------------------------------------------------------------
67
- // Panel
68
- // ---------------------------------------------------------------------------
69
- export interface WrfcPanelDeps {
70
- readonly controller: Pick<WrfcController, 'listChains' | 'resumeChain'>;
71
- /** Cancel the active agent for a chain by its ownerAgentId. Returns true if cancelled. */
72
- readonly cancelChain: (agentId: string) => boolean;
73
- }
74
-
75
- export class WrfcPanel extends BasePanel {
76
- private chains: WrfcChain[] = [];
77
- private selectedIndex = 0;
78
- private scrollOffset = 0;
79
-
80
- /**
81
- * The chain under the cursor. This panel owns its own selection state
82
- * (`selectedIndex` navigates `this.chains` directly), so every selected-row
83
- * read routes through this one accessor — indexing `this.chains` by the
84
- * cursor directly is banned by the no-raw-selectedindex-read architecture rule.
85
- */
86
- private selectedChain(): WrfcChain | undefined {
87
- return this.chains.at(this.selectedIndex);
88
- }
89
-
90
- private expandedChainIds = new Set<string>();
91
- private unsubscribers: Array<() => void> = [];
92
- /** Last event timestamp per chain id, for stall detection. */
93
- private lastEventAt = new Map<string, number>();
94
- /** Pending cancel confirmation: subject is the chain id to cancel. */
95
- private confirmCancel: ConfirmState<string> | null = null;
96
- /** Controller error seen after initialization (distinct from pre-init silence). */
97
- private controllerError: string | null = null;
98
-
99
- constructor(
100
- private readonly workflowEvents: UiEventFeed<WorkflowEvent>,
101
- private readonly deps: WrfcPanelDeps,
102
- ) {
103
- super('wrfc', 'WRFC', 'W', 'agent');
104
- this.subscribeToEvents();
105
- this.syncFromController();
106
- }
107
-
108
- // -------------------------------------------------------------------------
109
- // Lifecycle
110
- // -------------------------------------------------------------------------
111
- override onActivate(): void {
112
- super.onActivate();
113
- this.syncFromController();
114
- }
115
-
116
- override onDestroy(): void {
117
- for (const unsub of this.unsubscribers) unsub();
118
- this.unsubscribers = [];
119
- }
120
-
121
- // -------------------------------------------------------------------------
122
- // Input
123
- // -------------------------------------------------------------------------
124
- handleInput(key: string): boolean {
125
- // Confirm-cancel flow takes priority over all other keys.
126
- // Enter and y both confirm; n, escape both cancel; any other key is absorbed.
127
- if (this.confirmCancel) {
128
- const confirmResult = handleConfirmInput(this.confirmCancel, key);
129
- if (confirmResult === 'confirmed') {
130
- const chain = this.chains.find(c => c.id === this.confirmCancel!.subject);
131
- if (chain && !TERMINAL_STATES.includes(chain.state)) {
132
- this.deps.cancelChain(chain.ownerAgentId);
133
- }
134
- this.confirmCancel = null;
135
- this.markDirty();
136
- return true;
137
- }
138
- if (confirmResult === 'cancelled') {
139
- this.confirmCancel = null;
140
- this.markDirty();
141
- }
142
- // absorbed: confirm stays pending, key swallowed
143
- return true;
144
- }
145
-
146
- // Normal key dispatch.
147
- switch (key) {
148
- case 'up': this.moveSelection(-1); return true;
149
- case 'down': this.moveSelection(1); return true;
150
- case 'return':
151
- case 'enter': this.toggleExpanded(); return true;
152
- case 'c': this.beginCancelConfirm(); return true;
153
- case 'r': this.doResume(); return true;
154
- // 'a' jumps to the selected chain's owner agent in the Inspector. The
155
- // actual navigation happens in handlePanelIntegrationAction (it needs
156
- // the PanelManager); consuming the key here just requires a selected
157
- // chain to exist so the router below fires next.
158
- case 'a': return this.selectedChain() !== undefined;
159
- default: return false;
160
- }
161
- }
162
-
163
- /**
164
- * Cross-panel jump: 'a' on a selected chain opens the Inspector focused on
165
- * that chain's owner agent (inspectAgent, reverse of the forward jump
166
- * agent-detail-modal.ts makes from an agent record's wrfcId to its chain).
167
- */
168
- handlePanelIntegrationAction(key: string, ctx: PanelIntegrationContext): boolean {
169
- if (key !== 'a') return false;
170
- const chain = this.selectedChain();
171
- if (!chain) return false;
172
- const inspector = ctx.panelManager.open('inspector');
173
- if (inspector instanceof AgentInspectorPanel) {
174
- inspector.inspectAgent(chain.ownerAgentId);
175
- return true;
176
- }
177
- return false;
178
- }
179
-
180
- /**
181
- * Mouse-wheel scroll: pan the chain list viewport without moving the
182
- * selection cursor (BasePanel's default forwards wheel deltas into
183
- * handleInput('up'/'down'), which would also move the selected row).
184
- * scrollOffset is a hint consumed by resolveScrollablePanelSection's
185
- * tracked window on the next render, which clamps it into range and keeps
186
- * the selected row inside the guard band — so an over-large delta here is
187
- * always safe.
188
- */
189
- override handleScroll(deltaRows: number): boolean {
190
- if (this.confirmCancel) return false;
191
- const rows = Math.trunc(deltaRows);
192
- if (rows === 0) return false;
193
- const next = Math.max(0, this.scrollOffset + rows);
194
- if (next === this.scrollOffset) return false;
195
- this.scrollOffset = next;
196
- this.markDirty();
197
- return true;
198
- }
199
-
200
- /**
201
- * Cross-panel jump target: select the chain by id and expand it. Used by
202
- * WorkPlanPanel's 'w' jump key when an item links a wrfcId.
203
- */
204
- selectChain(chainId: string): void {
205
- const index = this.chains.findIndex((chain) => chain.id === chainId);
206
- if (index < 0) return;
207
- this.selectedIndex = index;
208
- this.expandedChainIds.add(chainId);
209
- this.markDirty();
210
- }
211
-
212
- // -------------------------------------------------------------------------
213
- // Render
214
- // -------------------------------------------------------------------------
215
- render(width: number, height: number): Line[] {
216
- return this.trackedRender(() => {
217
- const now = Date.now();
218
- const activeCount = this.chains.filter(c => !TERMINAL_STATES.includes(c.state)).length;
219
- const passedCount = this.chains.filter(c => c.state === 'passed').length;
220
- const failedCount = this.chains.filter(c => c.state === 'failed').length;
221
-
222
- if (this.chains.length === 0) {
223
- const emptySections: PanelWorkspaceSection[] = [
224
- {
225
- lines: buildEmptyState(
226
- width,
227
- ' No WRFC chains yet',
228
- 'WRFC chains appear here as review/fix cycles execute. Expanded rows show scores, gates, issues, and failure detail.',
229
- [
230
- { command: '/teamwork create-mode local-engineer <task>', summary: 'run an engineer task through the write-review-fix-commit loop (reviewMode: wrfc)' },
231
- { command: '/teamwork modes', summary: 'list every teamwork mode and see which ones use reviewMode wrfc' },
232
- ],
233
- DEFAULT_PANEL_PALETTE,
234
- ),
235
- },
236
- ];
237
- if (this.controllerError) {
238
- emptySections.push({
239
- lines: [
240
- buildPanelLine(width, [
241
- [' controller: ', DEFAULT_PANEL_PALETTE.dim],
242
- [truncate(this.controllerError, width - 16), DEFAULT_PANEL_PALETTE.warn],
243
- ]),
244
- ],
245
- });
246
- }
247
- return buildPanelWorkspace(width, height, {
248
- title: ' WRFC Chain Monitor',
249
- intro: 'Track WRFC engineering, review, fixing, gating, and final chain outcomes.',
250
- sections: emptySections,
251
- palette: DEFAULT_PANEL_PALETTE,
252
- });
253
- }
254
-
255
- const chainLines: Line[] = [];
256
- let selectedLineIndex = 0;
257
- for (let i = 0; i < this.chains.length; i++) {
258
- const chain = this.chains[i];
259
- const isSelected = i === this.selectedIndex;
260
- const isExpanded = this.expandedChainIds.has(chain.id);
261
- const rowBg = isSelected ? C.selected : '';
262
- const rowFg = isSelected ? C.selectedFg : '';
263
- const stalled = this.isStalled(chain, now);
264
-
265
- if (isSelected) selectedLineIndex = chainLines.length;
266
- chainLines.push(...this.renderChainRow(chain, width, isSelected, isExpanded, rowBg, rowFg, stalled, now));
267
-
268
- if (isExpanded) {
269
- chainLines.push(...this.renderChainDetail(chain, width, 12));
270
- }
271
- }
272
-
273
- const selectedChain = this.selectedChain();
274
- const selectedLines: Line[] = selectedChain
275
- ? [
276
- buildPanelLine(width, [
277
- [' State ', DEFAULT_PANEL_PALETTE.label],
278
- [stateLabel(selectedChain.state), stateColor(selectedChain.state)],
279
- [TERMINAL_STATES.includes(selectedChain.state) ? ' Duration ' : ' Elapsed ', DEFAULT_PANEL_PALETTE.label],
280
- [this.durationLabel(selectedChain, now), DEFAULT_PANEL_PALETTE.info],
281
- ]),
282
- buildPanelLine(width, [
283
- [' Task ', DEFAULT_PANEL_PALETTE.label],
284
- [truncate(selectedChain.task, Math.max(8, width - 8)), DEFAULT_PANEL_PALETTE.value],
285
- ]),
286
- buildPanelLine(width, [
287
- [' Reviews ', DEFAULT_PANEL_PALETTE.label],
288
- [String(selectedChain.reviewCycles), DEFAULT_PANEL_PALETTE.value],
289
- [' Fixes ', DEFAULT_PANEL_PALETTE.label],
290
- [String(selectedChain.fixAttempts), DEFAULT_PANEL_PALETTE.value],
291
- [' Scores ', DEFAULT_PANEL_PALETTE.label],
292
- [selectedChain.reviewScores.length > 0 ? selectedChain.reviewScores.map((score) => score.toFixed(0)).join(' -> ') : 'none', DEFAULT_PANEL_PALETTE.info],
293
- ]),
294
- ...(selectedChain.constraints.length > 0 ? [
295
- buildPanelLine(width, (() => {
296
- const total = selectedChain.constraints.length;
297
- const findings = selectedChain.reviewerReport?.constraintFindings;
298
- const satisfied = findings ? findings.filter(f => f.satisfied).length : 0;
299
- const satFg = !findings || findings.length === 0
300
- ? DEFAULT_PANEL_PALETTE.dim
301
- : satisfied === total ? C.good : C.bad;
302
- return [
303
- [' Constraints ', DEFAULT_PANEL_PALETTE.label],
304
- [`${satisfied} sat / ${total} total`, satFg],
305
- ] as Array<[string, string]>;
306
- })()),
307
- ] : []),
308
- ]
309
- : [];
310
-
311
- const totalChains = this.chains.length;
312
- const stalledCount = this.chains.filter((c) => this.isStalled(c, now)).length;
313
- const completedChains = passedCount + failedCount;
314
- const meterWidth = Math.max(10, Math.min(24, width - 34));
315
- const summarySection: PanelWorkspaceSection = {
316
- title: 'Summary',
317
- lines: [
318
- buildPanelLine(width, [
319
- [' Active ', DEFAULT_PANEL_PALETTE.label],
320
- [String(activeCount), activeCount > 0 ? DEFAULT_PANEL_PALETTE.warn : DEFAULT_PANEL_PALETTE.dim],
321
- [' Passed ', DEFAULT_PANEL_PALETTE.label],
322
- [String(passedCount), DEFAULT_PANEL_PALETTE.good],
323
- [' Failed ', DEFAULT_PANEL_PALETTE.label],
324
- [String(failedCount), failedCount > 0 ? DEFAULT_PANEL_PALETTE.bad : DEFAULT_PANEL_PALETTE.dim],
325
- [' Stalled ', DEFAULT_PANEL_PALETTE.label],
326
- [String(stalledCount), stalledCount > 0 ? C.warn : DEFAULT_PANEL_PALETTE.dim],
327
- ]),
328
- buildMeterLine(
329
- width,
330
- totalChains > 0 ? Math.round((completedChains / totalChains) * meterWidth) : 0,
331
- meterWidth,
332
- { filled: failedCount > 0 ? DEFAULT_PANEL_PALETTE.bad : DEFAULT_PANEL_PALETTE.good, empty: C.border, label: DEFAULT_PANEL_PALETTE.label },
333
- { prefix: ' Resolved ', suffix: ` ${completedChains}/${totalChains} ` },
334
- ),
335
- ],
336
- };
337
- const selectedSection: PanelWorkspaceSection = {
338
- title: 'Selected',
339
- lines: selectedLines,
340
- };
341
- // Confirm-cancel overlay section.
342
- const confirmSection: PanelWorkspaceSection | null = this.confirmCancel ? {
343
- title: 'Confirm Cancel',
344
- lines: [
345
- buildPanelLine(width, [
346
- [' Cancel chain "', DEFAULT_PANEL_PALETTE.warn],
347
- [truncate(this.confirmCancel.label, Math.max(8, width - 32)), DEFAULT_PANEL_PALETTE.value],
348
- ['"?', DEFAULT_PANEL_PALETTE.warn],
349
- ]),
350
- buildPanelLine(width, [
351
- [' y', DEFAULT_PANEL_PALETTE.info], [' confirm', DEFAULT_PANEL_PALETTE.dim],
352
- [' Enter', DEFAULT_PANEL_PALETTE.info], [' confirm', DEFAULT_PANEL_PALETTE.dim],
353
- [' n / Esc', DEFAULT_PANEL_PALETTE.info], [' cancel', DEFAULT_PANEL_PALETTE.dim],
354
- ]),
355
- ],
356
- } : null;
357
-
358
- // Footer: show resume-disabled reason for the selected chain.
359
- const selectedForFooter = this.selectedChain();
360
- const resumeReason = selectedForFooter ? this.resumeDisabledReason(selectedForFooter) : null;
361
- const footerLines: Line[] = [
362
- buildPanelLine(width, [
363
- [' Up/Down', DEFAULT_PANEL_PALETTE.info], [' navigate', DEFAULT_PANEL_PALETTE.dim],
364
- [' Enter', DEFAULT_PANEL_PALETTE.info], [' expand', DEFAULT_PANEL_PALETTE.dim],
365
- [' c', DEFAULT_PANEL_PALETTE.info], [' cancel', DEFAULT_PANEL_PALETTE.dim],
366
- [' r', resumeReason ? DEFAULT_PANEL_PALETTE.dim : DEFAULT_PANEL_PALETTE.info],
367
- [resumeReason ? ` resume (${resumeReason})` : ' resume', DEFAULT_PANEL_PALETTE.dim],
368
- [' a', DEFAULT_PANEL_PALETTE.info], [' jump to agent', DEFAULT_PANEL_PALETTE.dim],
369
- ]),
370
- ];
371
-
372
- const chainsSection = resolveScrollablePanelSection(width, height, {
373
- intro: 'Track WRFC engineering, review, fixing, gating, and final chain outcomes.',
374
- footerLines,
375
- palette: DEFAULT_PANEL_PALETTE,
376
- beforeSections: [summarySection],
377
- section: {
378
- title: 'Chains',
379
- scrollableLines: chainLines,
380
- selectedIndex: selectedLineIndex,
381
- scrollOffset: this.scrollOffset,
382
- minRows: 8,
383
- },
384
- afterSections: confirmSection
385
- ? [selectedSection, confirmSection]
386
- : [selectedSection],
387
- });
388
- this.scrollOffset = chainsSection.scrollOffset;
389
- const sections: PanelWorkspaceSection[] = [
390
- summarySection,
391
- chainsSection.section,
392
- selectedSection,
393
- ...(confirmSection ? [confirmSection] : []),
394
- ];
395
-
396
- // Controller error section (post-init only).
397
- if (this.controllerError) {
398
- sections.push({
399
- lines: [
400
- buildPanelLine(width, [
401
- [' controller: ', DEFAULT_PANEL_PALETTE.dim],
402
- [truncate(this.controllerError, width - 16), DEFAULT_PANEL_PALETTE.warn],
403
- ]),
404
- ],
405
- });
406
- }
407
-
408
- return buildPanelWorkspace(width, height, {
409
- title: ' WRFC Chain Monitor',
410
- intro: 'Track WRFC engineering, review, fixing, gating, and final chain outcomes.',
411
- sections,
412
- footerLines,
413
- palette: DEFAULT_PANEL_PALETTE,
414
- });
415
- });
416
- }
417
-
418
-
419
- // -------------------------------------------------------------------------
420
- // Rendering helpers
421
- // -------------------------------------------------------------------------
422
- private renderChainRow(
423
- chain: WrfcChain,
424
- width: number,
425
- isSelected: boolean,
426
- isExpanded: boolean,
427
- bg: string,
428
- fg: string,
429
- stalled = false,
430
- now: number = Date.now(),
431
- ): Line[] {
432
- const stateCol = stalled ? C.warn : stateColor(chain.state);
433
- const stateTag = stalled
434
- ? ` STALLED`
435
- : ` ${stateLabel(chain.state).padEnd(6)}`;
436
- const arrow = isExpanded ? '▾' : '▸';
437
- const chainIdShort = chain.id.slice(-6);
438
- const prefix = ` ${arrow} [${chainIdShort}] `;
439
- const fixes = chain.fixAttempts > 0 ? ` fix:${chain.fixAttempts}` : '';
440
- const cycles = chain.reviewCycles > 0 ? ` rev:${chain.reviewCycles}` : '';
441
- const latestScore = chain.reviewScores.length > 0
442
- ? ` ${chain.reviewScores[chain.reviewScores.length - 1].toFixed(1)}/10`
443
- : '';
444
- const stalledBadge = stalled ? ' [STALLED]' : '';
445
- const durationTag = ` ${this.durationLabel(chain, now)}`;
446
- // Constraint badge: c:sat/total — only when constraints exist
447
- let constraintBadge = '';
448
- if (chain.constraints.length > 0) {
449
- const total = chain.constraints.length;
450
- const findings = chain.reviewerReport?.constraintFindings;
451
- const satisfied = findings ? findings.filter(f => f.satisfied).length : 0;
452
- constraintBadge = ` c:${satisfied}/${total}`;
453
- }
454
- const rightInfo = `${stalledBadge}${durationTag}${latestScore}${fixes}${cycles}${constraintBadge} `;
455
-
456
- // Compute how much space the task text can use, then check if rightInfo fits.
457
- // If the terminal is narrow and rightInfo would overflow, omit it entirely
458
- // rather than producing corrupted layout.
459
- const usedWithoutTask = getDisplayWidth(prefix) + getDisplayWidth(stateTag) + 1; // prefix + stateTag + space
460
- const taskMax = width - usedWithoutTask - getDisplayWidth(rightInfo);
461
- const taskText = truncate(chain.task, Math.max(8, taskMax));
462
- const usedWidth = usedWithoutTask + getDisplayWidth(taskText);
463
- const remaining = width - usedWidth;
464
-
465
- const segments = [
466
- { text: prefix, fg: isSelected ? fg : C.label },
467
- { text: stateTag, fg: stateCol, bold: true },
468
- { text: ' ', fg: '' },
469
- { text: taskText, fg: isSelected ? fg : C.value },
470
- ];
471
- if (remaining >= rightInfo.length + 1) {
472
- // Right-align rightInfo in the remaining space
473
- // Colour the constraint badge separately when present
474
- if (chain.constraints.length > 0 && !isSelected) {
475
- const total = chain.constraints.length;
476
- const findings = chain.reviewerReport?.constraintFindings;
477
- const satisfied = findings ? findings.filter(f => f.satisfied).length : 0;
478
- // Determine badge colour
479
- let badgeFg: string;
480
- if (!findings || findings.length === 0) {
481
- badgeFg = C.dim;
482
- } else if (satisfied === total) {
483
- badgeFg = C.good;
484
- } else if (findings.some(f => !f.satisfied)) {
485
- badgeFg = C.bad;
486
- } else {
487
- badgeFg = C.reviewing; // some unverified but none failed
488
- }
489
- // Split: everything before the badge, then the badge
490
- const badgeText = ` c:${satisfied}/${total}`;
491
- const beforeBadge = rightInfo.slice(0, rightInfo.length - badgeText.length - 1);
492
- const padding = remaining - rightInfo.length;
493
- segments.push({ text: beforeBadge.padStart(padding + beforeBadge.length), fg: isSelected ? fg : C.label });
494
- segments.push({ text: badgeText, fg: badgeFg });
495
- segments.push({ text: ' ', fg: '' });
496
- } else {
497
- segments.push({ text: rightInfo.padStart(remaining), fg: isSelected ? fg : C.label });
498
- }
499
- }
500
- // else: no room — makeSegmentedLine will pad with spaces to fill width
501
-
502
- const row = buildSelectablePanelLine(width, segments, {
503
- selected: isSelected,
504
- selectedBg: bg,
505
- fillFg: isSelected ? fg : '',
506
- });
507
-
508
- return [row];
509
- }
510
-
511
- private renderChainDetail(chain: WrfcChain, width: number, maxLines: number): Line[] {
512
- // Build the FULL detail content first (no per-section maxLines guards
513
- // below), then apply one cap at the end so a truncation always ends with
514
- // a visible "+N more" indicator instead of silently dropping content —
515
- // the constraint sub-list keeps its own independent (+N more) cap since
516
- // that is documented, unrelated badge/marker behavior (PRESERVE).
517
- const lines: Line[] = [];
518
- const indent = ' ';
519
-
520
- // Score sparkline
521
- if (chain.reviewScores.length > 0) {
522
- const spark = sparkline(chain.reviewScores);
523
- const latestScore = chain.reviewScores[chain.reviewScores.length - 1];
524
- const sparkColor = latestScore >= 8 ? C.good : latestScore >= 5 ? C.info : C.bad;
525
- lines.push(buildStyledPanelLine(width, [
526
- { text: `${indent}Scores `, fg: C.label },
527
- { text: spark, fg: sparkColor },
528
- { text: ` (${chain.reviewScores.map(s => s.toFixed(0)).join(' -> ')})`, fg: C.dim },
529
- ]));
530
- }
531
-
532
- // Fix attempts + review cycles
533
- if (chain.fixAttempts > 0 || chain.reviewCycles > 0) {
534
- lines.push(buildStyledPanelLine(width, [
535
- { text: `${indent}Cycles `, fg: C.label },
536
- { text: `${chain.reviewCycles} review`, fg: C.value },
537
- { text: ' ', fg: '' },
538
- { text: `${chain.fixAttempts} fix`, fg: chain.fixAttempts > 0 ? C.fixing : C.value },
539
- ]));
540
- }
541
-
542
- // Constraints section (between Cycles and Gates)
543
- if (chain.constraints.length > 0) {
544
- lines.push(buildStyledPanelLine(width, [{ text: `${indent}Constraints`, fg: C.label }]));
545
- const MAX_CONSTRAINTS = 10;
546
- const findings = chain.reviewerReport?.constraintFindings;
547
- const displayed = chain.constraints.slice(0, MAX_CONSTRAINTS);
548
- for (const constraint of displayed) {
549
- const marker = constraintStatusMarker(constraint, findings);
550
- const statusTag = marker.tag;
551
- const rowPrefix = `${indent} ${statusTag} `;
552
- const textMax = Math.max(8, width - rowPrefix.length);
553
- const constraintText = truncate(constraint.text, textMax);
554
- lines.push(buildStyledPanelLine(width, [
555
- { text: `${indent} `, fg: C.dim },
556
- { text: statusTag, fg: marker.fg, dim: marker.dim, bold: !marker.dim },
557
- { text: ' ', fg: '' },
558
- { text: constraintText, fg: C.value },
559
- ]));
560
- }
561
- const remaining = chain.constraints.length - MAX_CONSTRAINTS;
562
- if (remaining > 0) {
563
- lines.push(buildStyledPanelLine(width, [
564
- { text: `${indent} (+${remaining} more)`, fg: C.dim, dim: true },
565
- ]));
566
- }
567
- }
568
-
569
- // Quality gate results
570
- if (chain.gateResults && chain.gateResults.length > 0) {
571
- lines.push(buildStyledPanelLine(width, [{ text: `${indent}Gates`, fg: C.label }]));
572
- for (const gate of chain.gateResults) {
573
- lines.push(this.renderGateResult(gate, width, indent + ' '));
574
- }
575
- }
576
-
577
- // Gate status (no results yet but state is gating)
578
- if (chain.state === 'gating' || chain.state === 'awaiting_gates') {
579
- if (!chain.gateResults || chain.gateResults.length === 0) {
580
- lines.push(buildStyledPanelLine(width, [{ text: `${indent}Gates awaiting...`, fg: C.gating, dim: true }]));
581
- }
582
- }
583
-
584
- // Synthetic issues injected by controller (continuity violations)
585
- if (chain.syntheticIssues && chain.syntheticIssues.length > 0) {
586
- lines.push(buildStyledPanelLine(width, [{ text: `${indent}Controller flags`, fg: C.issueCrit, bold: true }]));
587
- for (const synthetic of chain.syntheticIssues) {
588
- const prefix = `${indent} [CRIT] `;
589
- const descMax = Math.max(8, width - prefix.length);
590
- const desc = truncate(synthetic.description, descMax);
591
- lines.push(buildStyledPanelLine(width, [
592
- { text: prefix, fg: C.issueCrit, bold: true },
593
- { text: desc, fg: C.value },
594
- ]));
595
- }
596
- }
597
-
598
- // Issues from reviewer
599
- const issues = chain.reviewerReport?.issues ?? [];
600
- if (issues.length > 0) {
601
- lines.push(buildStyledPanelLine(width, [{ text: `${indent}Issues`, fg: C.label }]));
602
- for (const issue of issues) {
603
- const prefix = `${indent} ${issuePrefix(issue.severity)}`;
604
- const descMax = width - prefix.length;
605
- const desc = truncate(issue.description, Math.max(8, descMax));
606
- lines.push(buildStyledPanelLine(width, [
607
- { text: prefix, fg: issueColor(issue.severity), bold: issue.severity === 'critical' },
608
- { text: desc, fg: C.value },
609
- ]));
610
- }
611
- }
612
-
613
- // Error
614
- if (chain.error) {
615
- const errPrefix = `${indent}Error `;
616
- lines.push(buildStyledPanelLine(width, [
617
- { text: errPrefix, fg: C.failed, bold: true },
618
- { text: truncate(chain.error, width - errPrefix.length), fg: C.value },
619
- ]));
620
- }
621
-
622
- // Divider after expanded section
623
- lines.push(buildStyledPanelLine(width, [{ text: ' ' + '-'.repeat(Math.max(0, width - 4)) + ' ', fg: C.border, dim: true }]));
624
-
625
- if (lines.length <= maxLines) return lines;
626
- const visible = lines.slice(0, Math.max(0, maxLines - 1));
627
- const hidden = lines.length - visible.length;
628
- visible.push(buildStyledPanelLine(width, [
629
- { text: `${indent}+${hidden} more (press a to inspect the owner agent for full detail)`, fg: C.dim, dim: true },
630
- ]));
631
- return visible;
632
- }
633
-
634
- private renderGateResult(gate: QualityGateResult, width: number, indent: string): Line {
635
- const icon = gate.passed ? '✓' : '✕';
636
- const iconFg = gate.passed ? C.good : C.bad;
637
- const dur = `${gate.durationMs}ms`;
638
- const nameMax = width - indent.length - 2 - dur.length - 4;
639
- const name = truncate(gate.gate, Math.max(8, nameMax));
640
-
641
- return buildStyledPanelLine(width, [
642
- { text: indent, fg: C.dim },
643
- { text: `${icon} `, fg: iconFg, bold: true },
644
- { text: name, fg: gate.passed ? C.value : C.failed },
645
- { text: ` (${dur})`, fg: C.dim },
646
- ]);
647
- }
648
-
649
- // -------------------------------------------------------------------------
650
- // Navigation
651
- // -------------------------------------------------------------------------
652
- private moveSelection(delta: number): void {
653
- if (this.chains.length === 0) return;
654
- this.selectedIndex = Math.max(0, Math.min(this.chains.length - 1, this.selectedIndex + delta));
655
- this.markDirty();
656
- }
657
-
658
- private toggleExpanded(): void {
659
- const chain = this.selectedChain();
660
- if (!chain) return;
661
- if (this.expandedChainIds.has(chain.id)) {
662
- this.expandedChainIds.delete(chain.id);
663
- } else {
664
- this.expandedChainIds.add(chain.id);
665
- }
666
- this.markDirty();
667
- }
668
-
669
- // -------------------------------------------------------------------------
670
- // Event subscriptions
671
- // -------------------------------------------------------------------------
672
- private subscribeToEvents(): void {
673
- const refreshWithTimestamp = (chainId?: string) => {
674
- if (chainId) this.lastEventAt.set(chainId, Date.now());
675
- this.syncFromController();
676
- this.markDirty();
677
- };
678
-
679
- // Each workflow event carries the chainId in its payload — record it for
680
- // stall tracking. The handler signature is (event) => void; we extract
681
- // chainId where present using a narrow type guard so we never guess.
682
- const timestamped = (e: WorkflowEvent) => {
683
- const chainId = 'chainId' in e && typeof e.chainId === 'string' ? e.chainId : undefined;
684
- refreshWithTimestamp(chainId);
685
- };
686
-
687
- this.unsubscribers.push(
688
- this.workflowEvents.on('WORKFLOW_CHAIN_CREATED', timestamped),
689
- this.workflowEvents.on('WORKFLOW_STATE_CHANGED', timestamped),
690
- this.workflowEvents.on('WORKFLOW_REVIEW_COMPLETED', timestamped),
691
- this.workflowEvents.on('WORKFLOW_FIX_ATTEMPTED', timestamped),
692
- this.workflowEvents.on('WORKFLOW_GATE_RESULT', timestamped),
693
- this.workflowEvents.on('WORKFLOW_CHAIN_PASSED', timestamped),
694
- this.workflowEvents.on('WORKFLOW_CHAIN_FAILED', timestamped),
695
- this.workflowEvents.on('WORKFLOW_AUTO_COMMITTED', timestamped),
696
- this.workflowEvents.on('WORKFLOW_CASCADE_ABORTED', timestamped),
697
- this.workflowEvents.on('WORKFLOW_CONSTRAINTS_ENUMERATED', timestamped),
698
- );
699
- }
700
-
701
- private syncFromController(): void {
702
- // Distinguish two failure modes:
703
- // 1. Controller not yet initialized — chains is empty Map, listChains
704
- // returns [] with no error. This is the normal pre-ready path.
705
- // 2. Controller throws after initialization — an actual error we surface.
706
- // We detect (2) by checking whether we previously had chains: if chains
707
- // were present and listChains now throws, that is a post-init error.
708
- const hadChains = this.chains.length > 0;
709
- try {
710
- const all = this.deps.controller.listChains();
711
- this.controllerError = null;
712
-
713
- // Sort: active first (by createdAt desc), then completed
714
- const active = all.filter(c => !TERMINAL_STATES.includes(c.state));
715
- const done = all.filter(c => TERMINAL_STATES.includes(c.state));
716
- active.sort((a, b) => b.createdAt - a.createdAt);
717
- done.sort( (a, b) => (b.completedAt ?? 0) - (a.completedAt ?? 0));
718
- this.chains = [...active, ...done];
719
-
720
- // Clamp selection
721
- if (this.chains.length > 0) {
722
- this.selectedIndex = Math.min(this.selectedIndex, this.chains.length - 1);
723
- }
724
- } catch (err) {
725
- if (hadChains) {
726
- // Post-init error: the controller was working and now throws. Surface
727
- // a faint diagnostic rather than silently preserving stale state.
728
- const msg = summarizeError(err);
729
- this.controllerError = msg;
730
- console.debug('[WrfcPanel] controller.listChains() error post-init:', msg);
731
- }
732
- // Pre-init: controller not ready yet, leave chain list empty (no error).
733
- }
734
- }
735
-
736
- // -------------------------------------------------------------------------
737
- // Cancel / resume actions
738
- // -------------------------------------------------------------------------
739
-
740
- /** Initiate cancel-confirm flow for the selected chain (noop if terminal). */
741
- private beginCancelConfirm(): void {
742
- const chain = this.selectedChain();
743
- if (!chain || TERMINAL_STATES.includes(chain.state)) return;
744
- this.confirmCancel = {
745
- subject: chain.id,
746
- label: truncate(chain.task, 40),
747
- };
748
- this.markDirty();
749
- }
750
-
751
- /**
752
- * Resume the selected chain via the controller.
753
- * Only permitted when the chain state is in RESUMABLE_STATES.
754
- * Emits a visible noop reason when the chain is not resumable.
755
- */
756
- private doResume(): void {
757
- const chain = this.selectedChain();
758
- if (!chain) return;
759
- if (!RESUMABLE_STATES.includes(chain.state)) return;
760
- this.deps.controller.resumeChain(chain.id);
761
- // Re-sync so the rows/footer reflect the state resumeChain transitioned to.
762
- this.syncFromController();
763
- this.markDirty();
764
- }
765
-
766
- /** Returns a human-readable reason why resume is disabled for a chain, or null if it is allowed. */
767
- private resumeDisabledReason(chain: WrfcChain): string | null {
768
- if (TERMINAL_STATES.includes(chain.state)) return 'chain is complete';
769
- if (!RESUMABLE_STATES.includes(chain.state)) return `active (${stateLabel(chain.state)})`;
770
- return null;
771
- }
772
-
773
- /** Returns whether a chain is considered stalled (non-terminal, no recent event). */
774
- private isStalled(chain: WrfcChain, now: number): boolean {
775
- if (TERMINAL_STATES.includes(chain.state)) return false;
776
- const last = this.lastEventAt.get(chain.id) ?? chain.createdAt;
777
- return (now - last) >= STALL_THRESHOLD_MS;
778
- }
779
-
780
- /**
781
- * Elapsed time since createdAt for an active chain, or total duration from
782
- * createdAt to completedAt for a terminal one. createdAt/completedAt are
783
- * already consumed for chain sort order (syncFromController) — this just
784
- * surfaces the same fields as a human-readable label.
785
- */
786
- private durationLabel(chain: WrfcChain, now: number): string {
787
- const end = chain.completedAt ?? now;
788
- return formatDuration(Math.max(0, end - chain.createdAt));
789
- }
790
- }