@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,786 +0,0 @@
1
- // ---------------------------------------------------------------------------
2
- // AgentInspectorPanel — detailed view of a specific agent's messages and tool
3
- // calls. WO-110: merged agent-logs-panel.ts in (pause/filter/follow/auto-
4
- // select/stall-fix) on top of this panel's correct JSONL parser + cancel.
5
- // ---------------------------------------------------------------------------
6
-
7
- import { join } from 'node:path';
8
- import { readFile } from 'node:fs/promises';
9
- import type { Line } from '../types/grid.ts';
10
- import { createEmptyLine } from '../types/grid.ts';
11
- import { BasePanel } from './base-panel.ts';
12
- import type { AgentManager, AgentRecord } from '@pellux/goodvibes-sdk/platform/tools';
13
- import type { AgentMessageBus } from '@pellux/goodvibes-sdk/platform/agents';
14
- import type { AgentEvent } from '@/runtime/index.ts';
15
- import type { UiEventFeed } from '../runtime/ui-events.ts';
16
- import { logger } from '@pellux/goodvibes-sdk/platform/utils';
17
- import {
18
- buildEmptyState,
19
- buildPanelLine,
20
- buildSelectablePanelLine,
21
- type StyledPanelSegment,
22
- buildStyledPanelLine,
23
- buildPanelWorkspace,
24
- resolveScrollablePanelSection,
25
- DEFAULT_PANEL_PALETTE,
26
- extendPalette,
27
- } from './polish.ts';
28
- import {
29
- type ConfirmState,
30
- handleConfirmInput,
31
- } from './confirm-state.ts';
32
- import { truncateDisplay } from '../utils/terminal-width.ts';
33
- import { abbreviateCount } from '../utils/format-number.ts';
34
- import { summarizeError } from '@pellux/goodvibes-sdk/platform/utils';
35
- import {
36
- type AgentDisplayRow as DisplayRow,
37
- type AgentInspectorEntryKind as EntryKind,
38
- type AgentTimelineEntry as TimelineEntry,
39
- type AgentInspectorFilterType as FilterType,
40
- agentKindStyle,
41
- formatAgentDuration as formatMs,
42
- formatAgentTime as shortTime,
43
- jsonlToTimeline,
44
- matchesInspectorFilter,
45
- buildWrfcCostSegments,
46
- subscribeAgentActivity,
47
- AGENT_INSPECTOR_FILTER_CYCLE,
48
- AGENT_INSPECTOR_FILTER_LABELS,
49
- AGENT_TERMINAL_STATUSES,
50
- AGENT_STALL_THRESHOLD_MS,
51
- } from './agent-inspector-shared.ts';
52
-
53
- // ---------------------------------------------------------------------------
54
- // Constants & colour palette
55
- // ---------------------------------------------------------------------------
56
-
57
- const REFRESH_MS = 500;
58
- const MAX_JSONL_ENTRIES = 200;
59
-
60
- // Domain accents only — base chrome (header/headerBg/label/value/dim/selectBg/
61
- // warn) comes from DEFAULT_PANEL_PALETTE via extendPalette; the title band is
62
- // the one canonical UI_TONES.bg.title, not a per-panel override.
63
- const COLOR = extendPalette(DEFAULT_PANEL_PALETTE, {
64
- // Agent selector chrome
65
- statusBar: '#222233',
66
- separator: '#333355',
67
-
68
- // Timeline roles
69
- user: '#cc88ff', // purple
70
- assistant: '#ffffff', // white
71
- tool: '#00ccff', // cyan
72
- toolResult: '#66ddff', // light cyan
73
- error: '#ff6666', // red
74
- system: '#888888', // dim grey
75
- timestamp: '#555577',
76
-
77
- // Selector / labels
78
- selected: '#ffee88',
79
- expandHint: '#445566',
80
- } as const);
81
-
82
- // ---------------------------------------------------------------------------
83
- // AgentInspectorPanel
84
- // ---------------------------------------------------------------------------
85
-
86
- // AGENT_TERMINAL_STATUSES and AGENT_STALL_THRESHOLD_MS imported from agent-inspector-shared.ts
87
-
88
- export interface AgentInspectorPanelDeps {
89
- readonly agentManager: Pick<AgentManager, 'list' | 'getStatus' | 'cancel'>;
90
- readonly agentMessageBus: Pick<AgentMessageBus, 'getMessages'>;
91
- readonly workingDirectory: string;
92
- /** Cancel the agent by id. Uses the same orphan-free path as WRFC. Returns true if cancelled. */
93
- readonly cancelAgent: (agentId: string) => boolean;
94
- /** Agent lifecycle feed; drives auto-select-on-spawn + event-driven stall detection (WO-110). */
95
- readonly agentEvents: UiEventFeed<AgentEvent>;
96
- /**
97
- * Request a compositor repaint. The 500ms refresh timer and other async
98
- * update paths call this (via markDirty) so live agent output is painted
99
- * while the main thread is idle — render() is otherwise only invoked on
100
- * input/turn/resize, which makes a running agent's timeline look frozen.
101
- * Optional: when omitted the panel still marks dirty and repaints on the
102
- * next event.
103
- */
104
- readonly requestRender?: () => void;
105
- }
106
-
107
- export class AgentInspectorPanel extends BasePanel {
108
- // The agent currently being inspected
109
- private selectedAgentId: string | null = null;
110
-
111
- // Flattened timeline for the selected agent
112
- private timeline: TimelineEntry[] = [];
113
-
114
- // Scroll state
115
- private scrollOffset = 0;
116
-
117
- // Cursor index for expand/collapse
118
- private cursorIndex = 0;
119
-
120
- // Refresh timer (active only while panel is active)
121
- private refreshTimerId: ReturnType<typeof setInterval> | null = null;
122
-
123
- // Row cache — cleared on markDirty(), computed once per render cycle
124
- private _cachedRows: DisplayRow[] | null = null;
125
-
126
- /** Pending cancel confirmation — subject is the agent id to cancel. */
127
- private confirmCancel: ConfirmState<string> | null = null;
128
-
129
- /** True while this panel is the active view — gates async repaint requests. */
130
- private _active = false;
131
-
132
- // ---- ported from agent-logs (WO-110 merge) -------------------------------
133
- /** Freezes the 500ms JSONL refresh tick while true; manual navigation still works. */
134
- private paused = false;
135
- /** When true, cursor snaps to the newest row on every render (tail -f style). */
136
- private autoFollow = true;
137
- private filter: FilterType = 'all';
138
- /** Per-agent last-activity timestamp derived from AGENT_* events — stall detection uses this, not startedAt. */
139
- private readonly lastActivityByAgent = new Map<string, number>();
140
- private unsubs: Array<() => void> = [];
141
-
142
- constructor(private readonly deps: AgentInspectorPanelDeps) {
143
- super('inspector', 'Inspector', 'I', 'agent');
144
- this._subscribeAgentEvents();
145
- }
146
-
147
- // -------------------------------------------------------------------------
148
- // Row cache
149
- // -------------------------------------------------------------------------
150
-
151
- private _getCachedRows(): DisplayRow[] {
152
- if (!this._cachedRows) this._cachedRows = this._buildVisibleRows();
153
- return this._cachedRows;
154
- }
155
-
156
- override markDirty(): void {
157
- this._cachedRows = null;
158
- super.markDirty();
159
- // T17: a bare markDirty() does not repaint — render() only runs on
160
- // input/turn/resize. While active, ask the compositor for a frame so
161
- // timer/async refreshes (live streaming output) are not stuck off-screen.
162
- if (this._active) this.deps.requestRender?.();
163
- }
164
-
165
- // -------------------------------------------------------------------------
166
- // Public API
167
- // -------------------------------------------------------------------------
168
-
169
- /** Focus this panel on a specific agent by ID. */
170
- inspectAgent(agentId: string): void {
171
- this.selectedAgentId = agentId;
172
- this.scrollOffset = 0;
173
- this.cursorIndex = 0;
174
- this.timeline = [];
175
- this.autoFollow = true;
176
- this.markDirty();
177
- this._refreshTimeline().catch((err) => { logger.debug('agent inspector timeline refresh failed', { err }); });
178
- }
179
-
180
- // -------------------------------------------------------------------------
181
- // Lifecycle
182
- // -------------------------------------------------------------------------
183
-
184
- override onActivate(): void {
185
- this._active = true;
186
- this.needsRender = true;
187
- this._startRefresh();
188
- }
189
-
190
- override onDeactivate(): void {
191
- this._active = false;
192
- this._stopRefresh();
193
- }
194
-
195
- override onDestroy(): void {
196
- this._stopRefresh();
197
- for (const unsub of this.unsubs) unsub();
198
- this.unsubs = [];
199
- super.onDestroy();
200
- }
201
-
202
- // -------------------------------------------------------------------------
203
- // Input
204
- // -------------------------------------------------------------------------
205
-
206
- handleInput(key: string): boolean {
207
- // Confirm-cancel flow takes priority — same contract as WRFC panel.
208
- if (this.confirmCancel) {
209
- const result = handleConfirmInput(this.confirmCancel, key);
210
- if (result === 'confirmed') {
211
- const rec = this.selectedAgentId
212
- ? this.deps.agentManager.getStatus(this.selectedAgentId)
213
- : null;
214
- if (rec && !AGENT_TERMINAL_STATUSES.has(rec.status)) {
215
- this.deps.cancelAgent(rec.id);
216
- }
217
- this.confirmCancel = null;
218
- this.markDirty();
219
- return true;
220
- }
221
- if (result === 'cancelled') {
222
- this.confirmCancel = null;
223
- this.markDirty();
224
- }
225
- // absorbed: confirm stays pending
226
- return true;
227
- }
228
-
229
- switch (key) {
230
- case 'up': this._moveCursor(-1); return true;
231
- case 'down': this._moveCursor(1); return true;
232
- case 'pageup': this._scroll(-10); return true;
233
- case 'pagedown': this._scroll(10); return true;
234
- case 'return': this._toggleExpand(); return true;
235
- case 'tab': this._nextAgent(); return true;
236
- case 'c': this._beginCancelConfirm(); return true;
237
- case ' ': this._togglePause(); return true;
238
- case 'f': this._cycleFilter(); return true;
239
- case 'g': this._jumpTop(); return true;
240
- case 'G': this._jumpBottom(); return true;
241
- default: return false;
242
- }
243
- }
244
-
245
- // -------------------------------------------------------------------------
246
- // Render
247
- // -------------------------------------------------------------------------
248
-
249
- render(width: number, height: number): Line[] {
250
- if (height <= 0 || width <= 0) return [];
251
-
252
- const manager = this.deps.agentManager;
253
- const agents = manager.list();
254
- const rec = this.selectedAgentId
255
- ? manager.getStatus(this.selectedAgentId)
256
- : null;
257
- const selectorLine = this._renderSelector(width, agents);
258
- const summaryLines = [
259
- buildPanelLine(width, [
260
- [' Agents ', DEFAULT_PANEL_PALETTE.label],
261
- [String(agents.length), DEFAULT_PANEL_PALETTE.value],
262
- [' Selected ', DEFAULT_PANEL_PALETTE.label],
263
- [this.selectedAgentId ? this.selectedAgentId.slice(-8) : 'none', this.selectedAgentId ? DEFAULT_PANEL_PALETTE.info : DEFAULT_PANEL_PALETTE.dim],
264
- [' Filter ', DEFAULT_PANEL_PALETTE.label],
265
- [AGENT_INSPECTOR_FILTER_LABELS[this.filter], DEFAULT_PANEL_PALETTE.info],
266
- [' Mode ', DEFAULT_PANEL_PALETTE.label],
267
- [this.paused ? 'paused' : this.autoFollow ? 'auto-follow' : 'manual', this.paused ? DEFAULT_PANEL_PALETTE.warn : this.autoFollow ? DEFAULT_PANEL_PALETTE.good : DEFAULT_PANEL_PALETTE.dim],
268
- ]),
269
- ];
270
-
271
- if (!rec) {
272
- return buildPanelWorkspace(width, height, {
273
- title: ` Inspector [${agents.length} agent${agents.length !== 1 ? 's' : ''}]`,
274
- intro: 'Inspect a selected agent timeline, tool activity, expanded details, and live/historical message flow.',
275
- sections: [
276
- { title: 'Summary', lines: summaryLines },
277
- { title: 'Agents', lines: [selectorLine] },
278
- {
279
- lines: buildEmptyState(
280
- width,
281
- agents.length === 0 ? ' No agents running' : ' No agent selected',
282
- agents.length === 0
283
- ? 'Spawn an agent to inspect its live and historical timeline, tool calls, and output.'
284
- : 'Press Tab to cycle through available agents and open one in the inspector.',
285
- agents.length === 0
286
- ? [{ command: '/spawn <task>', summary: 'launch an agent, then Tab here to inspect its timeline' }]
287
- : [{ command: 'Tab', summary: 'select the first available agent' }],
288
- DEFAULT_PANEL_PALETTE,
289
- ),
290
- },
291
- ],
292
- footerLines: [
293
- buildPanelLine(width, [[' Tab', DEFAULT_PANEL_PALETTE.info], [' cycle agents', DEFAULT_PANEL_PALETTE.dim], [' Up/Down', DEFAULT_PANEL_PALETTE.info], [' navigate', DEFAULT_PANEL_PALETTE.dim], [' Enter', DEFAULT_PANEL_PALETTE.info], [' expand', DEFAULT_PANEL_PALETTE.dim]]),
294
- ],
295
- palette: DEFAULT_PANEL_PALETTE,
296
- });
297
- }
298
-
299
- summaryLines.push(this._renderAgentInfoSummary(width, rec));
300
- const wrfcCostLine = this._renderWrfcCostLine(width, rec);
301
- if (wrfcCostLine) summaryLines.push(wrfcCostLine);
302
- const now = Date.now();
303
- const isStalled = this._isAgentStalled(rec, now);
304
- if (isStalled) {
305
- summaryLines.push(buildPanelLine(width, [[' STALLED', COLOR.warn], [' — no activity for 5+ minutes', DEFAULT_PANEL_PALETTE.dim]]));
306
- }
307
- const allRows = this._getCachedRows();
308
- if (allRows.length === 0) {
309
- return buildPanelWorkspace(width, height, {
310
- title: ` Inspector [${agents.length} agent${agents.length !== 1 ? 's' : ''}]`,
311
- intro: 'Inspect a selected agent timeline, tool activity, expanded details, and live/historical message flow.',
312
- sections: [
313
- { title: 'Summary', lines: summaryLines },
314
- { title: 'Agents', lines: [selectorLine] },
315
- {
316
- lines: buildEmptyState(
317
- width,
318
- ' No messages yet',
319
- rec.status === 'running'
320
- ? 'The selected agent is running but has not emitted visible timeline entries yet — live output streams in as it works.'
321
- : 'The selected agent has not emitted any visible timeline entries.',
322
- rec.status === 'running'
323
- ? [{ command: 'c', summary: 'cancel this running agent if it appears stalled' }]
324
- : [{ command: 'Tab', summary: 'cycle to another agent with timeline activity' }],
325
- DEFAULT_PANEL_PALETTE,
326
- ),
327
- },
328
- ],
329
- footerLines: [
330
- buildPanelLine(width, [
331
- [' Tab', DEFAULT_PANEL_PALETTE.info], [' cycle agents', DEFAULT_PANEL_PALETTE.dim],
332
- [' Up/Down', DEFAULT_PANEL_PALETTE.info], [' navigate', DEFAULT_PANEL_PALETTE.dim],
333
- [' Enter', DEFAULT_PANEL_PALETTE.info], [' expand', DEFAULT_PANEL_PALETTE.dim],
334
- [' Space', DEFAULT_PANEL_PALETTE.info], [this.paused ? ' resume' : ' pause', DEFAULT_PANEL_PALETTE.dim],
335
- [' f', DEFAULT_PANEL_PALETTE.info], [' filter', DEFAULT_PANEL_PALETTE.dim],
336
- ]),
337
- ],
338
- palette: DEFAULT_PANEL_PALETTE,
339
- });
340
- }
341
-
342
- if (this.autoFollow) {
343
- this.cursorIndex = allRows.length - 1;
344
- } else {
345
- this.cursorIndex = Math.max(0, Math.min(this.cursorIndex, allRows.length - 1));
346
- }
347
- const selectedRec = this.selectedAgentId
348
- ? this.deps.agentManager.getStatus(this.selectedAgentId)
349
- : null;
350
- const cancellable = selectedRec && !AGENT_TERMINAL_STATUSES.has(selectedRec.status);
351
- const summarySection = { title: 'Summary', lines: summaryLines } as const;
352
- const agentsSection = { title: 'Agents', lines: [selectorLine] } as const;
353
-
354
- // Confirm-cancel overlay section.
355
- const confirmSection = this.confirmCancel ? {
356
- title: 'Confirm Cancel',
357
- lines: [
358
- buildPanelLine(width, [
359
- [' Cancel agent "', DEFAULT_PANEL_PALETTE.warn],
360
- [this.confirmCancel.label, DEFAULT_PANEL_PALETTE.value],
361
- ['"?', DEFAULT_PANEL_PALETTE.warn],
362
- ]),
363
- buildPanelLine(width, [
364
- [' y', DEFAULT_PANEL_PALETTE.info], [' confirm', DEFAULT_PANEL_PALETTE.dim],
365
- [' Enter', DEFAULT_PANEL_PALETTE.info], [' confirm', DEFAULT_PANEL_PALETTE.dim],
366
- [' n / Esc', DEFAULT_PANEL_PALETTE.info], [' cancel', DEFAULT_PANEL_PALETTE.dim],
367
- ]),
368
- ],
369
- } : null;
370
-
371
- const cancelHintFg = cancellable ? DEFAULT_PANEL_PALETTE.info : DEFAULT_PANEL_PALETTE.dim;
372
- const footerLine = buildPanelLine(width, [
373
- [` L${this.cursorIndex + 1}/${allRows.length}`, DEFAULT_PANEL_PALETTE.dim],
374
- [' Tab', DEFAULT_PANEL_PALETTE.info], [' cycle agents', DEFAULT_PANEL_PALETTE.dim],
375
- [' Up/Down', DEFAULT_PANEL_PALETTE.info], [' navigate', DEFAULT_PANEL_PALETTE.dim],
376
- [' Enter', DEFAULT_PANEL_PALETTE.info], [' expand', DEFAULT_PANEL_PALETTE.dim],
377
- [' Space', DEFAULT_PANEL_PALETTE.info], [this.paused ? ' resume' : ' pause', DEFAULT_PANEL_PALETTE.dim],
378
- [' f', DEFAULT_PANEL_PALETTE.info], [' filter', DEFAULT_PANEL_PALETTE.dim],
379
- [' g/G', DEFAULT_PANEL_PALETTE.info], [this.autoFollow ? ' top/follow' : ' top/bottom', DEFAULT_PANEL_PALETTE.dim],
380
- [' c', cancelHintFg], [cancellable ? ' cancel' : ' cancel (n/a)', DEFAULT_PANEL_PALETTE.dim],
381
- ]);
382
-
383
- const timelineSection = resolveScrollablePanelSection(width, height, {
384
- intro: 'Inspect a selected agent timeline, tool activity, expanded details, and live/historical message flow.',
385
- footerLines: [footerLine],
386
- palette: DEFAULT_PANEL_PALETTE,
387
- beforeSections: [summarySection, agentsSection],
388
- section: {
389
- title: 'Timeline',
390
- scrollableLines: allRows.map((row, index) => this._renderTimelineRow(width, row, index === this.cursorIndex)),
391
- selectedIndex: this.cursorIndex,
392
- scrollOffset: this.scrollOffset,
393
- minRows: 8,
394
- },
395
- afterSections: confirmSection ? [confirmSection] : undefined,
396
- });
397
- this.scrollOffset = timelineSection.scrollOffset;
398
-
399
- const sections = [
400
- summarySection,
401
- agentsSection,
402
- timelineSection.section,
403
- ...(confirmSection ? [confirmSection] : []),
404
- ];
405
-
406
- return buildPanelWorkspace(width, height, {
407
- title: ` Inspector [${agents.length} agent${agents.length !== 1 ? 's' : ''}]`,
408
- intro: 'Inspect a selected agent timeline, tool activity, expanded details, and live/historical message flow.',
409
- sections,
410
- footerLines: [footerLine],
411
- palette: DEFAULT_PANEL_PALETTE,
412
- });
413
- }
414
-
415
- private _renderSelector(
416
- width: number,
417
- agents: AgentRecord[],
418
- ): Line {
419
- if (agents.length === 0) {
420
- return buildStyledPanelLine(width, [
421
- { text: ' (no agents)', fg: COLOR.dim, bg: COLOR.statusBar, dim: true },
422
- ]);
423
- }
424
- const segments: StyledPanelSegment[] = [{ text: ' ', fg: COLOR.dim, bg: COLOR.statusBar }];
425
- for (const agent of agents) {
426
- const isSelected = agent.id === this.selectedAgentId;
427
- const shortId = agent.id.slice(-8);
428
- const badge = ` ${shortId} `;
429
- segments.push({
430
- text: badge,
431
- fg: isSelected ? COLOR.selected : COLOR.dim,
432
- bg: COLOR.statusBar,
433
- bold: isSelected,
434
- });
435
- segments.push({ text: '│', fg: COLOR.separator, bg: COLOR.statusBar });
436
- }
437
- return buildStyledPanelLine(width, segments);
438
- }
439
-
440
- private _renderAgentInfoSummary(width: number, rec: AgentRecord): Line {
441
- const now = Date.now();
442
- const elapsed = (rec.completedAt ?? now) - rec.startedAt;
443
- const taskPreview = rec.task.split('\n')[0] ?? '';
444
- const maxTask = Math.max(0, width - 40);
445
- const taskDisplay = truncateDisplay(taskPreview, maxTask);
446
- return buildPanelLine(width, [
447
- [' Status ', DEFAULT_PANEL_PALETTE.label],
448
- [rec.status.toUpperCase(), rec.status === 'running' ? DEFAULT_PANEL_PALETTE.good : rec.status === 'failed' ? DEFAULT_PANEL_PALETTE.bad : DEFAULT_PANEL_PALETTE.dim],
449
- [' Duration ', DEFAULT_PANEL_PALETTE.label],
450
- [formatMs(elapsed), DEFAULT_PANEL_PALETTE.value],
451
- [' Tools ', DEFAULT_PANEL_PALETTE.label],
452
- [String(rec.toolCallCount), DEFAULT_PANEL_PALETTE.info],
453
- // SDK 0.23.0: show addendum indicator when WRFC injected a constraint addendum
454
- ...(rec.systemPromptAddendum
455
- ? [[' Addendum ', DEFAULT_PANEL_PALETTE.label] as [string, string], ['yes', DEFAULT_PANEL_PALETTE.info] as [string, string]]
456
- : []),
457
- [' Task ', DEFAULT_PANEL_PALETTE.label],
458
- [taskDisplay, DEFAULT_PANEL_PALETTE.value],
459
- ]);
460
- }
461
-
462
- /** WRFC badge + per-agent tokens/cost (WO-110); null when neither applies. */
463
- private _renderWrfcCostLine(width: number, rec: AgentRecord): Line | null {
464
- const segments = buildWrfcCostSegments(rec, DEFAULT_PANEL_PALETTE, abbreviateCount);
465
- return segments ? buildPanelLine(width, segments) : null;
466
- }
467
-
468
- // -------------------------------------------------------------------------
469
- // Timeline row rendering
470
- // -------------------------------------------------------------------------
471
-
472
- private _renderTimelineRow(
473
- width: number,
474
- row: DisplayRow,
475
- isCursor: boolean,
476
- ): Line {
477
- const bg = isCursor ? COLOR.selectBg : '';
478
- const ts = shortTime(row.timestamp);
479
- const { fg, prefix } = agentKindStyle(row.kind, COLOR);
480
- const hint = row.hasDetail ? (row.expanded ? ' ▾' : ' ▸') : '';
481
- const prefixText = `${isCursor ? '▸' : ' '} ${ts} ${prefix} `;
482
- const reserved = prefixText.length + hint.length;
483
- const contentBudget = Math.max(0, width - reserved);
484
- const text = truncateDisplay(row.content, contentBudget);
485
-
486
- return buildSelectablePanelLine(width, [
487
- { text: isCursor ? '▸' : ' ', fg: COLOR.selected, bg, bold: isCursor },
488
- { text: ' ', fg: COLOR.value, bg },
489
- { text: ts, fg: COLOR.timestamp, bg, dim: true },
490
- { text: ' ', fg: COLOR.value, bg },
491
- { text: prefix, fg, bg, bold: true },
492
- { text: ' ', fg: COLOR.value, bg },
493
- { text: text, fg: COLOR.value, bg },
494
- { text: hint.length > 0 ? hint.padStart(Math.max(hint.length, width - (prefixText.length + text.length))) : '', fg: COLOR.expandHint, bg, dim: true },
495
- ], { selected: isCursor, selectedBg: bg, fillFg: isCursor ? COLOR.selected : '' });
496
- }
497
-
498
- // -------------------------------------------------------------------------
499
- // Private — data
500
- // -------------------------------------------------------------------------
501
-
502
- /** Flat DisplayRow list from timeline + expanded detail sub-rows, filtered by `this.filter`. */
503
- private _buildVisibleRows(): DisplayRow[] {
504
- const rows: DisplayRow[] = [];
505
-
506
- // Merge bus messages (live) + JSONL (historical), sorted by timestamp
507
- const busEntries = this._busToTimeline();
508
- const merged = [...this.timeline, ...busEntries];
509
-
510
- // T13: the JSONL timeline only records coarse per-turn summaries
511
- // ([assistant] N chars, M tool calls) and never the model's actual text,
512
- // so a mid-turn agent looks idle here. Surface the selected agent's live
513
- // streaming output while it runs, and its final output once terminal.
514
- const liveRec = this.selectedAgentId
515
- ? this.deps.agentManager.getStatus(this.selectedAgentId)
516
- : null;
517
- if (liveRec) {
518
- const liveEntry = this._buildLiveOutputEntry(liveRec);
519
- if (liveEntry) merged.push(liveEntry);
520
- }
521
-
522
- merged.sort((a, b) => a.timestamp - b.timestamp);
523
-
524
- // Entries are filtered against `this.filter` here (WO-110).
525
- for (const entry of merged) {
526
- if (!matchesInspectorFilter(entry.kind, this.filter)) continue;
527
-
528
- rows.push({
529
- kind: entry.kind,
530
- timestamp: entry.timestamp,
531
- content: entry.content,
532
- hasDetail: !!entry.detail,
533
- expanded: entry.expanded,
534
- entryRef: entry,
535
- });
536
-
537
- // If expanded and has detail — insert sub-rows
538
- if (entry.expanded && entry.detail) {
539
- const detailLines = entry.detail.split('\n');
540
- for (const dl of detailLines) {
541
- rows.push({
542
- kind: 'tool_result',
543
- timestamp: entry.timestamp,
544
- content: dl,
545
- hasDetail: false,
546
- expanded: false,
547
- entryRef: null,
548
- });
549
- }
550
- }
551
- }
552
-
553
- return rows;
554
- }
555
-
556
- private _busToTimeline(): TimelineEntry[] {
557
- if (!this.selectedAgentId) return [];
558
- const messages = this.deps.agentMessageBus.getMessages(this.selectedAgentId);
559
- const DEDUP_WINDOW_MS = 2000;
560
- const seen = new Map<string, number>(); // hash -> last timestamp
561
- const result: TimelineEntry[] = [];
562
- for (const msg of messages) {
563
- const isFromUser = msg.from === 'orchestrator' || msg.from === 'system';
564
- const kind: EntryKind = isFromUser ? 'user' : 'assistant';
565
- const contentSnippet = msg.content.slice(0, 50);
566
- const dedupKey = `${kind}:${contentSnippet}`;
567
- const lastSeen = seen.get(dedupKey);
568
- if (lastSeen !== undefined && msg.timestamp - lastSeen < DEDUP_WINDOW_MS) {
569
- continue;
570
- }
571
- seen.set(dedupKey, msg.timestamp);
572
- result.push({
573
- kind,
574
- timestamp: msg.timestamp,
575
- label: msg.from,
576
- content: truncateDisplay(msg.content, 200),
577
- expanded: false,
578
- } satisfies TimelineEntry);
579
- }
580
- return result;
581
- }
582
-
583
- /**
584
- * Build a synthetic timeline entry exposing the selected agent's live model
585
- * output. While the agent runs this is the streaming token buffer
586
- * (tail-truncated like AgentDetailModal); once it terminates it is the final
587
- * assistant text (expandable). Exactly one is produced — the SDK clears
588
- * streamingContent on completion — so live and final rows never coexist.
589
- * Returns null when there is nothing to show.
590
- */
591
- private _buildLiveOutputEntry(rec: AgentRecord): TimelineEntry | null {
592
- const STREAM_MAX_CHARS = 500;
593
- if (rec.status === 'running' && rec.streamingContent) {
594
- const sc = rec.streamingContent;
595
- const truncated = sc.length > STREAM_MAX_CHARS;
596
- const tail = (truncated ? sc.slice(-STREAM_MAX_CHARS) : sc)
597
- .replace(/\s+/g, ' ')
598
- .trim();
599
- return {
600
- kind: 'assistant',
601
- timestamp: Date.now(),
602
- label: 'streaming',
603
- content: `(live) ${truncated ? '…' : ''}${tail}`,
604
- expanded: false,
605
- } satisfies TimelineEntry;
606
- }
607
- if (AGENT_TERMINAL_STATUSES.has(rec.status) && rec.fullOutput) {
608
- const fo = rec.fullOutput;
609
- const firstLine = (fo.split('\n').find((l) => l.trim().length > 0) ?? '').trim();
610
- return {
611
- kind: 'assistant',
612
- timestamp: rec.completedAt ?? Date.now(),
613
- label: 'output',
614
- content: `(final) ${firstLine}`,
615
- detail: fo,
616
- expanded: false,
617
- } satisfies TimelineEntry;
618
- }
619
- return null;
620
- }
621
-
622
- // -------------------------------------------------------------------------
623
- // Private — refresh
624
- // -------------------------------------------------------------------------
625
-
626
- private async _refreshTimeline(): Promise<void> {
627
- if (!this.selectedAgentId) return;
628
- try {
629
- const sessionFile = join(
630
- this.deps.workingDirectory,
631
- '.goodvibes', 'tui', 'sessions',
632
- `${this.selectedAgentId}.jsonl`,
633
- );
634
- const raw = await readFile(sessionFile, 'utf-8');
635
- const logLines = raw.trim().split('\n').filter(Boolean);
636
- const rows = logLines
637
- .slice(-MAX_JSONL_ENTRIES)
638
- .map((line) => {
639
- try { return JSON.parse(line) as Record<string, unknown>; }
640
- catch { return null; }
641
- })
642
- .filter((r): r is Record<string, unknown> => r !== null);
643
- this.timeline = jsonlToTimeline(rows);
644
- } catch (err) {
645
- const code = (err as NodeJS.ErrnoException).code;
646
- if (code !== 'ENOENT') {
647
- logger.debug('AgentInspectorPanel: failed to load session log', { error: summarizeError(err) });
648
- }
649
- this.timeline = [];
650
- }
651
- this.markDirty();
652
- }
653
-
654
- private _startRefresh(): void {
655
- if (this.refreshTimerId) return;
656
- this.refreshTimerId = this.registerTimer(setInterval(() => {
657
- // WO-110: Space pause/resume freezes the JSONL tail (ported from
658
- // agent-logs); manual navigation and agent switching still work.
659
- if (this.paused) return;
660
- this._refreshTimeline().catch((err) => { logger.debug('agent inspector timeline refresh tick failed', { err }); });
661
- }, REFRESH_MS));
662
- }
663
-
664
- private _stopRefresh(): void {
665
- if (this.refreshTimerId) {
666
- this.clearTimer(this.refreshTimerId);
667
- this.refreshTimerId = null;
668
- }
669
- }
670
-
671
- // -------------------------------------------------------------------------
672
- // Private — agent lifecycle event subscriptions (WO-110 merge)
673
- // -------------------------------------------------------------------------
674
-
675
- /** Auto-select-on-spawn + real event-driven last-activity (stall fix), ported from agent-logs. */
676
- private _subscribeAgentEvents(): void {
677
- this.unsubs.push(...subscribeAgentActivity(this.deps.agentEvents, {
678
- onActivity: (agentId) => {
679
- this.lastActivityByAgent.set(agentId, Date.now());
680
- if (!this.paused && agentId === this.selectedAgentId) this.markDirty();
681
- },
682
- onSpawn: (agentId) => {
683
- const current = this.selectedAgentId ? this.deps.agentManager.getStatus(this.selectedAgentId) : null;
684
- if (!current || AGENT_TERMINAL_STATUSES.has(current.status)) this.inspectAgent(agentId);
685
- },
686
- onTerminal: (agentId) => {
687
- this.lastActivityByAgent.delete(agentId);
688
- this.markDirty();
689
- },
690
- }));
691
- }
692
-
693
- private _togglePause(): void {
694
- this.paused = !this.paused;
695
- this.markDirty();
696
- }
697
-
698
- private _cycleFilter(): void {
699
- const idx = AGENT_INSPECTOR_FILTER_CYCLE.indexOf(this.filter);
700
- this.filter = AGENT_INSPECTOR_FILTER_CYCLE[(idx + 1) % AGENT_INSPECTOR_FILTER_CYCLE.length]!;
701
- this.cursorIndex = 0;
702
- this.markDirty();
703
- }
704
-
705
- private _jumpTop(): void {
706
- this.autoFollow = false;
707
- this.cursorIndex = 0;
708
- this.markDirty();
709
- }
710
-
711
- private _jumpBottom(): void {
712
- this.autoFollow = true;
713
- this.markDirty();
714
- }
715
-
716
- // -------------------------------------------------------------------------
717
- // Private — navigation
718
- // -------------------------------------------------------------------------
719
-
720
- private _moveCursor(delta: number): void {
721
- const rows = this._getCachedRows();
722
- if (rows.length === 0) return;
723
- this.cursorIndex = Math.max(0, Math.min(rows.length - 1, this.cursorIndex + delta));
724
- this.markDirty();
725
- }
726
-
727
- private _scroll(delta: number): void {
728
- this._moveCursor(delta);
729
- }
730
-
731
- private _toggleExpand(): void {
732
- const rows = this._getCachedRows();
733
- const row = rows[this.cursorIndex];
734
- if (!row?.entryRef || !row.hasDetail) return;
735
- row.entryRef.expanded = !row.entryRef.expanded;
736
- this.markDirty();
737
- }
738
-
739
- private _nextAgent(): void {
740
- const agents = this.deps.agentManager.list();
741
- if (agents.length === 0) return;
742
-
743
- if (!this.selectedAgentId) {
744
- this.inspectAgent(agents[0]!.id);
745
- return;
746
- }
747
-
748
- const idx = agents.findIndex(a => a.id === this.selectedAgentId);
749
- const next = agents[(idx + 1) % agents.length];
750
- if (next) {
751
- this.inspectAgent(next.id);
752
- }
753
- }
754
-
755
- // -------------------------------------------------------------------------
756
- // Private — cancel + stall
757
- // -------------------------------------------------------------------------
758
-
759
- /** Initiate cancel-confirm flow for the selected agent (noop if terminal or none selected). */
760
- private _beginCancelConfirm(): void {
761
- if (!this.selectedAgentId) return;
762
- const rec = this.deps.agentManager.getStatus(this.selectedAgentId);
763
- if (!rec || AGENT_TERMINAL_STATUSES.has(rec.status)) return;
764
- const firstLine = rec.task.split('\n')[0];
765
- const label = firstLine ? truncateDisplay(firstLine, 40) : rec.id.slice(-8);
766
- this.confirmCancel = { subject: rec.id, label };
767
- this.markDirty();
768
- }
769
-
770
- /** Stalled = non-terminal + no AGENT_* activity for AGENT_STALL_THRESHOLD_MS (not just elapsed-since-start). */
771
- private _isAgentStalled(rec: AgentRecord, now: number): boolean {
772
- if (AGENT_TERMINAL_STATUSES.has(rec.status)) return false;
773
- const lastActivity = this.lastActivityByAgent.get(rec.id) ?? rec.startedAt;
774
- return (now - lastActivity) >= AGENT_STALL_THRESHOLD_MS;
775
- }
776
-
777
- /**
778
- * Count of all tracked agents that are stalled (non-terminal, no activity
779
- * for AGENT_STALL_THRESHOLD_MS). Exposed so callers can aggregate a
780
- * stalledAgentCount for cockpit / roster read-models.
781
- */
782
- getStalledAgentCount(): number {
783
- const now = Date.now();
784
- return this.deps.agentManager.list().filter(rec => this._isAgentStalled(rec, now)).length;
785
- }
786
- }