@pellux/goodvibes-tui 0.20.3 → 0.22.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 (142) hide show
  1. package/CHANGELOG.md +50 -0
  2. package/README.md +23 -2
  3. package/docs/foundation-artifacts/operator-contract.json +78 -1
  4. package/package.json +4 -2
  5. package/src/audio/spoken-turn-controller.ts +31 -1
  6. package/src/audio/spoken-turn-wiring.ts +26 -4
  7. package/src/cli/bundle-command.ts +1 -1
  8. package/src/cli/completions/generate.ts +658 -0
  9. package/src/cli/config-overrides.ts +68 -0
  10. package/src/cli/entrypoint.ts +6 -0
  11. package/src/cli/help.ts +4 -2
  12. package/src/cli/management-commands.ts +1 -1
  13. package/src/cli/management.ts +1 -8
  14. package/src/cli/parser.ts +31 -18
  15. package/src/cli/service-command.ts +1 -1
  16. package/src/cli/surface-command.ts +1 -1
  17. package/src/cli/tui-startup.ts +72 -10
  18. package/src/cli/types.ts +14 -3
  19. package/src/cli-flags.ts +1 -0
  20. package/src/config/atomic-write.ts +70 -0
  21. package/src/config/goodvibes-home-audit.ts +2 -0
  22. package/src/config/read-versioned.ts +115 -0
  23. package/src/core/context-auto-compact.ts +77 -0
  24. package/src/core/conversation-rendering.ts +49 -15
  25. package/src/core/conversation.ts +101 -16
  26. package/src/core/format-user-error.ts +192 -0
  27. package/src/core/stream-event-wiring.ts +144 -0
  28. package/src/core/stream-stall-watchdog.ts +103 -0
  29. package/src/core/system-message-router.ts +5 -1
  30. package/src/core/turn-event-wiring.ts +124 -0
  31. package/src/daemon/cli.ts +5 -0
  32. package/src/export/cost-utils.ts +71 -0
  33. package/src/export/gist-uploader.ts +136 -0
  34. package/src/input/command-registry.ts +32 -1
  35. package/src/input/commands/control-room-runtime.ts +10 -10
  36. package/src/input/commands/experience-runtime.ts +5 -4
  37. package/src/input/commands/knowledge.ts +1 -1
  38. package/src/input/commands/local-auth-runtime.ts +27 -5
  39. package/src/input/commands/local-setup.ts +4 -6
  40. package/src/input/commands/memory-product-runtime.ts +8 -6
  41. package/src/input/commands/operator-panel-runtime.ts +1 -1
  42. package/src/input/commands/operator-runtime.ts +3 -10
  43. package/src/input/commands/{integration-runtime.ts → plugin-runtime.ts} +1 -1
  44. package/src/input/commands/provider.ts +57 -3
  45. package/src/input/commands/recall-review.ts +26 -2
  46. package/src/input/commands/services-runtime.ts +2 -2
  47. package/src/input/commands/session-workflow.ts +8 -16
  48. package/src/input/commands/session.ts +70 -20
  49. package/src/input/commands/share-runtime.ts +99 -12
  50. package/src/input/commands/tts-runtime.ts +30 -4
  51. package/src/input/commands.ts +2 -4
  52. package/src/input/delete-key-policy.ts +46 -0
  53. package/src/input/feed-context-factory.ts +2 -0
  54. package/src/input/handler-feed.ts +3 -0
  55. package/src/input/handler-interactions.ts +2 -15
  56. package/src/input/handler-modal-routes.ts +128 -12
  57. package/src/input/handler-modal-token-routes.ts +22 -5
  58. package/src/input/handler-onboarding-cloudflare.ts +1 -1
  59. package/src/input/handler-onboarding.ts +73 -69
  60. package/src/input/handler-types.ts +163 -0
  61. package/src/input/handler.ts +6 -2
  62. package/src/input/input-history.ts +76 -6
  63. package/src/input/model-picker-filter.ts +265 -0
  64. package/src/input/model-picker-items.ts +208 -0
  65. package/src/input/model-picker.ts +92 -325
  66. package/src/input/onboarding/handler-onboarding-routes.ts +7 -2
  67. package/src/input/onboarding/onboarding-verification-helpers.ts +76 -0
  68. package/src/input/onboarding/onboarding-wizard-apply.ts +14 -4
  69. package/src/input/onboarding/onboarding-wizard-cloudflare-step.ts +16 -2
  70. package/src/input/onboarding/onboarding-wizard-cloudflare.ts +8 -8
  71. package/src/input/onboarding/onboarding-wizard-external-surface-extra-specs.ts +1 -1
  72. package/src/input/onboarding/onboarding-wizard-external-surfaces.ts +2 -29
  73. package/src/input/onboarding/onboarding-wizard-rules.ts +28 -28
  74. package/src/input/onboarding/onboarding-wizard-state.ts +20 -20
  75. package/src/input/onboarding/onboarding-wizard-steps.ts +24 -25
  76. package/src/input/onboarding/onboarding-wizard-types.ts +145 -3
  77. package/src/input/onboarding/onboarding-wizard-validation.ts +77 -0
  78. package/src/input/onboarding/onboarding-wizard.ts +3 -3
  79. package/src/input/settings-modal-behavior.ts +5 -0
  80. package/src/input/settings-modal-data.ts +378 -0
  81. package/src/input/settings-modal-mutations.ts +157 -0
  82. package/src/input/settings-modal-reset.ts +154 -0
  83. package/src/input/settings-modal.ts +236 -232
  84. package/src/main.ts +93 -85
  85. package/src/panels/agent-inspector-panel.ts +120 -18
  86. package/src/panels/agent-inspector-shared.ts +29 -0
  87. package/src/panels/builtin/agent.ts +4 -1
  88. package/src/panels/builtin/development.ts +5 -1
  89. package/src/panels/builtin/knowledge.ts +14 -13
  90. package/src/panels/builtin/operations.ts +22 -1
  91. package/src/panels/builtin/shared.ts +7 -0
  92. package/src/panels/cockpit-panel.ts +123 -3
  93. package/src/panels/cockpit-read-model.ts +232 -0
  94. package/src/panels/confirm-state.ts +27 -12
  95. package/src/panels/cost-tracker-panel.ts +23 -67
  96. package/src/panels/eval-panel.ts +10 -9
  97. package/src/panels/index.ts +1 -1
  98. package/src/panels/knowledge-graph-panel.ts +84 -0
  99. package/src/panels/local-auth-panel.ts +124 -4
  100. package/src/panels/memory-panel.ts +370 -40
  101. package/src/panels/project-planning-panel.ts +42 -4
  102. package/src/panels/search-focus.ts +11 -5
  103. package/src/panels/session-maintenance.ts +66 -15
  104. package/src/panels/subscription-panel.ts +33 -25
  105. package/src/panels/types.ts +28 -1
  106. package/src/panels/wrfc-panel.ts +224 -41
  107. package/src/renderer/agent-detail-modal.ts +118 -13
  108. package/src/renderer/code-block.ts +10 -2
  109. package/src/renderer/compositor.ts +18 -4
  110. package/src/renderer/context-inspector.ts +1 -5
  111. package/src/renderer/context-status-hint.ts +54 -0
  112. package/src/renderer/diff.ts +94 -21
  113. package/src/renderer/markdown.ts +29 -13
  114. package/src/renderer/settings-modal-helpers.ts +1 -1
  115. package/src/renderer/settings-modal.ts +90 -10
  116. package/src/renderer/shell-surface.ts +10 -0
  117. package/src/renderer/syntax-highlighter.ts +10 -3
  118. package/src/renderer/term-caps.ts +318 -0
  119. package/src/renderer/theme.ts +158 -0
  120. package/src/renderer/tool-call.ts +12 -2
  121. package/src/renderer/ui-factory.ts +50 -6
  122. package/src/runtime/bootstrap-command-context.ts +1 -0
  123. package/src/runtime/bootstrap-command-parts.ts +18 -0
  124. package/src/runtime/bootstrap-core.ts +145 -13
  125. package/src/runtime/bootstrap-shell.ts +11 -0
  126. package/src/runtime/bootstrap.ts +9 -0
  127. package/src/runtime/onboarding/apply.ts +4 -6
  128. package/src/runtime/onboarding/index.ts +1 -0
  129. package/src/runtime/onboarding/markers.ts +42 -49
  130. package/src/runtime/onboarding/progress.ts +148 -0
  131. package/src/runtime/onboarding/state.ts +133 -55
  132. package/src/runtime/onboarding/types.ts +20 -0
  133. package/src/runtime/services.ts +27 -1
  134. package/src/runtime/wrfc-persistence.ts +237 -0
  135. package/src/shell/blocking-input.ts +20 -5
  136. package/src/tools/wrfc-agent-guard.ts +64 -3
  137. package/src/utils/format-elapsed.ts +30 -0
  138. package/src/utils/terminal-width.ts +45 -0
  139. package/src/version.ts +1 -1
  140. package/src/work-plans/work-plan-store.ts +4 -6
  141. package/src/panels/knowledge-panel.ts +0 -345
  142. package/src/planning/project-planning-coordinator.ts +0 -543
package/src/main.ts CHANGED
@@ -33,12 +33,10 @@ import { renderPanelTabBar } from './renderer/panel-tab-bar.ts';
33
33
  import { bootstrapRuntime } from './runtime/bootstrap.ts';
34
34
  import type { BootstrapContext } from './runtime/bootstrap.ts';
35
35
  import type { HITLMode } from '@pellux/goodvibes-sdk/platform/state';
36
- import type { HookPhase, HookCategory, HookEventPath } from '@pellux/goodvibes-sdk/platform/hooks';
37
36
  import {
38
37
  checkRecoveryFile,
39
38
  deleteRecoveryFile,
40
39
  loadRecoveryConversation,
41
- persistConversation,
42
40
  writeRecoveryFile,
43
41
  } from '@/runtime/index.ts';
44
42
  import { handleBlockingShellInput, type PendingPermissionState } from './shell/blocking-input.ts';
@@ -48,12 +46,18 @@ import { buildPersistedSessionContext, formatReturnContextForDisplay, getReturnC
48
46
  import { summarizeError } from '@pellux/goodvibes-sdk/platform/utils';
49
47
  import { prepareShellCliRuntime } from './cli/entrypoint.ts';
50
48
  import { applyInitialTuiCliState } from './cli/tui-startup.ts';
49
+ import { applyRuntimeConfigDefault, applyRuntimeConfigValue } from './cli/config-overrides.ts';
50
+ import { renderToolCallBlock } from './renderer/tool-call.ts';
51
51
  import { wireSpokenTurnRuntime } from './audio/spoken-turn-wiring.ts';
52
52
  import { attachSpokenTurnModelRouting, createSpokenTurnInputOptions } from './audio/spoken-turn-model-routing.ts';
53
53
  import { allowTerminalWrite, installTuiTerminalOutputGuard } from './runtime/terminal-output-guard.ts';
54
- import { ProjectPlanningCoordinator } from './planning/project-planning-coordinator.ts';
55
54
  import { buildCommandArgsHint } from './input/command-args-hint.ts';
56
55
  import { summarizeRunningAgents } from './renderer/process-summary.ts';
56
+ import { formatUserFacingErrorLine } from './core/format-user-error.ts';
57
+ import { wireStreamEventMetrics, type StreamMetrics } from './core/stream-event-wiring.ts';
58
+ import { wireTurnEventHandlers } from './core/turn-event-wiring.ts';
59
+ import { buildContextStatusHint } from './renderer/context-status-hint.ts';
60
+ import { evaluateSessionMaintenance } from './panels/session-maintenance.ts';
57
61
 
58
62
  const ALT_SCREEN_ENTER = '\x1b[?1049h';
59
63
  const ALT_SCREEN_EXIT = '\x1b[?1049l';
@@ -101,6 +105,7 @@ async function main() {
101
105
  permissionPromptRef,
102
106
  _writeLastSessionPointer: writeLastSessionPointer,
103
107
  systemMessageRouter,
108
+ setOpenAgentDetail,
104
109
  } = ctx;
105
110
  const workingDir = ctx.services.workingDirectory;
106
111
  const homeDirectory = ctx.services.homeDirectory;
@@ -127,6 +132,15 @@ async function main() {
127
132
  }
128
133
  }
129
134
 
135
+ // TUI default: show token speed ON. The SDK schema default is false;
136
+ // applyRuntimeConfigDefault reads both the global settings file and the project
137
+ // settings file from disk before deciding whether to apply the default. If the
138
+ // user has explicitly set this key to false in EITHER their global or project
139
+ // persisted config, their value is respected and the default is NOT applied.
140
+ // Only when the key is absent from both files (e.g. a new install) does the
141
+ // TUI default of true take effect in-memory — no disk write occurs either way.
142
+ applyRuntimeConfigDefault(configManager, 'display.showTokenSpeed', true);
143
+
130
144
  const panelManager = ctx.services.panelManager;
131
145
  const buildSessionContinuityHints = () => {
132
146
  const sessionSnapshot = uiServices.readModels.session.getSnapshot();
@@ -154,12 +168,18 @@ async function main() {
154
168
  render();
155
169
  });
156
170
 
157
- let streamStartTime = 0;
158
- let streamDeltaCount = 0;
159
- let streamTokenSpeed = 0;
160
-
161
171
  let scrollTop = 0;
162
172
  let scrollLocked = true;
173
+ // Stream and tool-timer state; mutated by wireStreamEventMetrics handlers, read during render.
174
+ const streamMetrics: StreamMetrics = {
175
+ startTime: 0,
176
+ deltaCount: 0,
177
+ tokenSpeed: 0,
178
+ ttftMs: undefined,
179
+ ttftRecorded: false,
180
+ activeToolStartedAtMs: undefined,
181
+ activeToolName: undefined,
182
+ };
163
183
 
164
184
  const getPromptContentWidth = () => {
165
185
  const w = stdout.columns || 80;
@@ -213,7 +233,8 @@ async function main() {
213
233
  `[Critical] Multiple errors detected (${_unhandledRejectionCount} in 10s). If the issue persists, please restart. Latest: ${msg}`
214
234
  );
215
235
  } else {
216
- systemMessageRouter.high(`[Error] ${msg}`);
236
+ const formatted = formatUserFacingErrorLine(reason);
237
+ systemMessageRouter.high(`[Error] ${formatted}`);
217
238
  logger.error('unhandledRejection', { error: String(reason) });
218
239
  }
219
240
  render();
@@ -260,18 +281,6 @@ async function main() {
260
281
  configManager,
261
282
  notify: (message) => { systemMessageRouter.high(message); render(); },
262
283
  }));
263
- const projectPlanningCoordinator = new ProjectPlanningCoordinator({
264
- service: ctx.services.projectPlanningService,
265
- projectId: ctx.services.projectPlanningProjectId,
266
- workingDirectory: workingDir,
267
- notify: (message) => { systemMessageRouter.high(message); render(); },
268
- openPanel: () => {
269
- panelManager.open('project-planning');
270
- panelManager.show();
271
- render();
272
- },
273
- });
274
-
275
284
  const submitInput = (text: string, content?: ContentPart[], options: { readonly spokenOutput?: boolean } = {}) => {
276
285
  input.clearModalStack();
277
286
  scrollLocked = true; // Re-lock on any user input
@@ -307,32 +316,6 @@ async function main() {
307
316
  if (processedText || content) {
308
317
  void (async () => {
309
318
  let inputOptions = options.spokenOutput ? createSpokenTurnInputOptions() : undefined;
310
- if (!options.spokenOutput && processedText) {
311
- try {
312
- const planning = await projectPlanningCoordinator.prepareTurn(processedText);
313
- if (planning) {
314
- if (planning.handledLocally) {
315
- systemMessageRouter.high(planning.statusMessage);
316
- render();
317
- return;
318
- }
319
- conversation.addSystemMessage(planning.systemMessage);
320
- inputOptions = {
321
- origin: {
322
- source: 'project-planning',
323
- surface: 'tui',
324
- metadata: {
325
- projectId: ctx.services.projectPlanningProjectId,
326
- knowledgeSpaceId: planning.state.knowledgeSpaceId,
327
- readiness: planning.evaluation.readiness,
328
- },
329
- },
330
- };
331
- }
332
- } catch (err) {
333
- systemMessageRouter.high(`[Planning] ${summarizeError(err)}`);
334
- }
335
- }
336
319
  if (options.spokenOutput && processedText) {
337
320
  spokenTurns.submitNextTurn(processedText);
338
321
  }
@@ -458,6 +441,7 @@ async function main() {
458
441
  input.filePicker.setOnUpdate(() => render());
459
442
  input.agentDetailModal.setOnRefresh(() => render());
460
443
  input.processModal.setOnRefresh(() => render());
444
+ setOpenAgentDetail((id) => input.agentDetailModal.open(id));
461
445
 
462
446
  // Model picker callback is handled in bootstrap.ts — do not duplicate here.
463
447
  input.setHistory(inputHistory);
@@ -498,6 +482,17 @@ async function main() {
498
482
  hasAttachments: input.getImageAttachments().size > 0,
499
483
  turnState: sessionSnapshot.turnState,
500
484
  });
485
+ const maintenanceStatus = evaluateSessionMaintenance({
486
+ configManager,
487
+ currentTokens: orchestrator.lastInputTokens,
488
+ contextWindow: currentModel.contextWindow,
489
+ sessionMemoryCount: ctx.services.sessionMemoryStore.list().length,
490
+ });
491
+ const contextStatusHint = buildContextStatusHint({
492
+ level: maintenanceStatus.level,
493
+ autoCompactEnabled: maintenanceStatus.autoCompactEnabled,
494
+ usagePct: maintenanceStatus.usagePct,
495
+ });
501
496
  const footerLines = buildShellFooter({
502
497
  width,
503
498
  promptText: promptInfo.visibleLines.join('\n'),
@@ -515,7 +510,10 @@ async function main() {
515
510
  workingDir,
516
511
  provider: runtime.provider,
517
512
  contextWindow: currentModel.contextWindow,
518
- compactThreshold: configManager.get('behavior.autoCompactThreshold') as number,
513
+ contextStatusHint,
514
+ // behavior.autoCompactThreshold is stored as a percent integer (e.g. 80);
515
+ // the meter expects a fraction [0..1]. Clamp to [0,1] to guard nonsense values.
516
+ compactThreshold: Math.min(1, Math.max(0, (configManager.get('behavior.autoCompactThreshold') as number) / 100)),
519
517
  dangerMode: (() => {
520
518
  if (configManager.get('behavior.autoApprove')) return true;
521
519
  const permMode = configManager.get('permissions.mode');
@@ -598,16 +596,25 @@ async function main() {
598
596
  const showSpeed = configManager.get('display.showTokenSpeed') as boolean;
599
597
  const showPreview = configManager.get('display.showToolPreview') as boolean;
600
598
  const partialToolPreview = showPreview ? sessionSnapshot.streamToolPreview : undefined;
599
+ // Elapsed from turn start (stream or tool execution), used for the thinking indicator timer.
600
+ const turnElapsedMs = streamMetrics.startTime > 0 ? Date.now() - streamMetrics.startTime : undefined;
601
601
  const thinking = UIFactory.createThinkingFragment(
602
602
  conversationWidth,
603
603
  orchestrator.getSpinner(),
604
604
  orchestrator.thinkingFrame,
605
- showSpeed ? streamTokenSpeed : undefined,
605
+ showSpeed ? streamMetrics.tokenSpeed : undefined,
606
606
  showPreview ? partialToolPreview : undefined,
607
607
  orchestrator.streamingInputTokens > 0 ? orchestrator.streamingInputTokens : undefined,
608
608
  orchestrator.streamingOutputTokens > 0 ? orchestrator.streamingOutputTokens : undefined,
609
+ turnElapsedMs,
610
+ streamMetrics.ttftMs,
609
611
  );
610
612
  viewport.push(...thinking);
613
+ // Live tool timer: render the currently executing tool row with ticking elapsed.
614
+ if (streamMetrics.activeToolName !== undefined && streamMetrics.activeToolStartedAtMs !== undefined) {
615
+ const liveToolCall = { id: 'live', name: streamMetrics.activeToolName, arguments: {} };
616
+ viewport.push(...renderToolCallBlock(liveToolCall, 'executing', undefined, conversationWidth, undefined, undefined, undefined, streamMetrics.activeToolStartedAtMs));
617
+ }
611
618
  }
612
619
 
613
620
  if (pendingPermission) {
@@ -682,44 +689,36 @@ async function main() {
682
689
  render,
683
690
  });
684
691
 
685
- // --- Streaming speed + tool preview wiring ---
686
- const refreshGit = () => gitStatusProvider.refresh().then((info) => { lastGitInfoRef.value = info; render(); }).catch(() => { /* non-fatal */ });
687
- // Refresh git status after each turn completes or after tool results arrive
688
- unsubs.push(uiServices.events.turns.on('TURN_COMPLETED', () => {
689
- // Auto-save after every LLM turn so kills don't lose the session
690
- try {
691
- const snapshot = conversation.toJSON() as { messages: Array<import('./core/conversation.ts').ConversationMessageSnapshot>; timestamp?: number };
692
- const persisted = buildPersistedSessionContext(snapshot.messages, conversation.getTitleSource(), buildSessionContinuityHints());
693
- persistConversation(
694
- runtime.sessionId,
695
- { ...snapshot, ...persisted },
696
- runtime.model,
697
- runtime.provider,
698
- conversation.title || '',
699
- { workingDirectory: workingDir, homeDirectory, sessionManager: ctx.services.sessionManager },
700
- );
701
- hookDispatcher.fire({ path: 'Lifecycle:session:save' as HookEventPath, phase: 'Lifecycle' as HookPhase, category: 'session' as HookCategory, specific: 'save', sessionId: runtime.sessionId, timestamp: Date.now(), payload: { sessionId: runtime.sessionId } }).catch((err: unknown) => logger.debug('hook fire error', { error: summarizeError(err) }));
702
- } catch (e) { logger.debug('auto-save on turn:complete failed', { error: summarizeError(e) }); }
703
- refreshGit();
704
- }));
705
- unsubs.push(uiServices.events.tools.on('TOOL_SUCCEEDED', () => {
706
- refreshGit();
707
- }));
708
- unsubs.push(uiServices.events.tools.on('TOOL_FAILED', () => {
709
- refreshGit();
710
- }));
692
+ // --- Turn-completed / git-refresh event wiring ---
693
+ const { refreshGit, unsubs: turnUnsubs } = wireTurnEventHandlers({
694
+ events: uiServices.events,
695
+ conversation,
696
+ runtime,
697
+ orchestrator,
698
+ configManager,
699
+ providerRegistry,
700
+ systemMessageRouter,
701
+ hookDispatcher,
702
+ workingDir,
703
+ homeDirectory,
704
+ sessionManager: ctx.services.sessionManager,
705
+ gitStatusProvider,
706
+ lastGitInfoRef,
707
+ buildSessionContinuityHints,
708
+ render,
709
+ });
710
+ unsubs.push(...turnUnsubs);
711
711
 
712
- unsubs.push(uiServices.events.turns.on('STREAM_START', () => {
713
- streamStartTime = Date.now();
714
- streamDeltaCount = 0;
715
- streamTokenSpeed = 0;
716
- }));
717
- unsubs.push(uiServices.events.turns.on('STREAM_DELTA', () => {
718
- streamDeltaCount++;
719
- const elapsed = (Date.now() - streamStartTime) / 1000;
720
- // Note: counts stream deltas, not actual tokens. ~1 delta per token for most providers.
721
- streamTokenSpeed = elapsed > 0 ? streamDeltaCount / elapsed : 0;
722
- }));
712
+ // --- Stream metrics + tool-timer event wiring ---
713
+ const streamUnsubs = wireStreamEventMetrics({
714
+ events: uiServices.events,
715
+ orchestrator,
716
+ providerRegistry,
717
+ systemMessageRouter,
718
+ render,
719
+ metrics: streamMetrics,
720
+ });
721
+ unsubs.push(...streamUnsubs);
723
722
 
724
723
  // --- Terminal setup ---
725
724
  stdin.setRawMode(true);
@@ -747,6 +746,15 @@ async function main() {
747
746
  render,
748
747
  loadRecoveryConversation: () => loadRecoveryConversation({ homeDirectory }),
749
748
  deleteRecoveryFile: () => deleteRecoveryFile({ homeDirectory }),
749
+ reopenPanels: (snapshot) => {
750
+ const panels = snapshot.returnContext?.openPanels;
751
+ if (!panels || panels.length === 0) return;
752
+ for (const panelId of panels.slice(0, 4)) {
753
+ try { panelManager.open(panelId); } catch { /* unknown panel id — skip */ }
754
+ }
755
+ panelManager.show();
756
+ render();
757
+ },
750
758
  });
751
759
  pendingPermission = blocking.pendingPermission;
752
760
  recoveryPending = blocking.recoveryPending;
@@ -21,6 +21,10 @@ import {
21
21
  resolveScrollablePanelSection,
22
22
  DEFAULT_PANEL_PALETTE,
23
23
  } from './polish.ts';
24
+ import {
25
+ type ConfirmState,
26
+ handleConfirmInput,
27
+ } from './confirm-state.ts';
24
28
  import { truncateDisplay } from '../utils/terminal-width.ts';
25
29
  import { summarizeError } from '@pellux/goodvibes-sdk/platform/utils';
26
30
  import {
@@ -32,6 +36,8 @@ import {
32
36
  formatAgentDuration as formatMs,
33
37
  formatAgentTime as shortTime,
34
38
  jsonlToTimeline,
39
+ AGENT_TERMINAL_STATUSES,
40
+ AGENT_STALL_THRESHOLD_MS,
35
41
  } from './agent-inspector-shared.ts';
36
42
 
37
43
  // ---------------------------------------------------------------------------
@@ -77,10 +83,14 @@ const COLOR = {
77
83
  // AgentInspectorPanel
78
84
  // ---------------------------------------------------------------------------
79
85
 
86
+ // AGENT_TERMINAL_STATUSES and AGENT_STALL_THRESHOLD_MS imported from agent-inspector-shared.ts
87
+
80
88
  export interface AgentInspectorPanelDeps {
81
- readonly agentManager: Pick<AgentManager, 'list' | 'getStatus'>;
89
+ readonly agentManager: Pick<AgentManager, 'list' | 'getStatus' | 'cancel'>;
82
90
  readonly agentMessageBus: Pick<AgentMessageBus, 'getMessages'>;
83
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;
84
94
  }
85
95
 
86
96
  export class AgentInspectorPanel extends BasePanel {
@@ -102,6 +112,9 @@ export class AgentInspectorPanel extends BasePanel {
102
112
  // Row cache — cleared on markDirty(), computed once per render cycle
103
113
  private _cachedRows: DisplayRow[] | null = null;
104
114
 
115
+ /** Pending cancel confirmation — subject is the agent id to cancel. */
116
+ private confirmCancel: ConfirmState<string> | null = null;
117
+
105
118
  constructor(private readonly deps: AgentInspectorPanelDeps) {
106
119
  super('inspector', 'Inspector', 'I', 'agent');
107
120
  }
@@ -157,13 +170,36 @@ export class AgentInspectorPanel extends BasePanel {
157
170
  // -------------------------------------------------------------------------
158
171
 
159
172
  handleInput(key: string): boolean {
173
+ // Confirm-cancel flow takes priority — same contract as WRFC panel.
174
+ if (this.confirmCancel) {
175
+ const result = handleConfirmInput(this.confirmCancel, key);
176
+ if (result === 'confirmed') {
177
+ const rec = this.selectedAgentId
178
+ ? this.deps.agentManager.getStatus(this.selectedAgentId)
179
+ : null;
180
+ if (rec && !AGENT_TERMINAL_STATUSES.has(rec.status)) {
181
+ this.deps.cancelAgent(rec.id);
182
+ }
183
+ this.confirmCancel = null;
184
+ this.markDirty();
185
+ return true;
186
+ }
187
+ if (result === 'cancelled') {
188
+ this.confirmCancel = null;
189
+ this.markDirty();
190
+ }
191
+ // absorbed: confirm stays pending
192
+ return true;
193
+ }
194
+
160
195
  switch (key) {
161
- case 'up': this._moveCursor(-1); return true;
162
- case 'down': this._moveCursor(1); return true;
163
- case 'pageup': this._scroll(-10); return true;
164
- case 'pagedown': this._scroll(10); return true;
165
- case 'return': this._toggleExpand(); return true;
166
- case 'tab': this._nextAgent(); return true;
196
+ case 'up': this._moveCursor(-1); return true;
197
+ case 'down': this._moveCursor(1); return true;
198
+ case 'pageup': this._scroll(-10); return true;
199
+ case 'pagedown': this._scroll(10); return true;
200
+ case 'return': this._toggleExpand(); return true;
201
+ case 'tab': this._nextAgent(); return true;
202
+ case 'c': this._beginCancelConfirm(); return true;
167
203
  default: return false;
168
204
  }
169
205
  }
@@ -217,6 +253,11 @@ export class AgentInspectorPanel extends BasePanel {
217
253
  }
218
254
 
219
255
  summaryLines.push(this._renderAgentInfoSummary(width, rec));
256
+ const now = Date.now();
257
+ const isStalled = this._isAgentStalled(rec, now);
258
+ if (isStalled) {
259
+ summaryLines.push(buildPanelLine(width, [[' STALLED', '#f59e0b'], [' — no activity for 5+ minutes', DEFAULT_PANEL_PALETTE.dim]]));
260
+ }
220
261
  const allRows = this._getCachedRows();
221
262
  if (allRows.length === 0) {
222
263
  return buildPanelWorkspace(width, height, {
@@ -243,13 +284,42 @@ export class AgentInspectorPanel extends BasePanel {
243
284
  }
244
285
 
245
286
  this.cursorIndex = Math.max(0, Math.min(this.cursorIndex, allRows.length - 1));
287
+ const selectedRec = this.selectedAgentId
288
+ ? this.deps.agentManager.getStatus(this.selectedAgentId)
289
+ : null;
290
+ const cancellable = selectedRec && !AGENT_TERMINAL_STATUSES.has(selectedRec.status);
246
291
  const summarySection = { title: 'Summary', lines: summaryLines } as const;
247
292
  const agentsSection = { title: 'Agents', lines: [selectorLine] } as const;
293
+
294
+ // Confirm-cancel overlay section.
295
+ const confirmSection = this.confirmCancel ? {
296
+ title: 'Confirm Cancel',
297
+ lines: [
298
+ buildPanelLine(width, [
299
+ [' Cancel agent "', DEFAULT_PANEL_PALETTE.warn],
300
+ [this.confirmCancel.label, DEFAULT_PANEL_PALETTE.value],
301
+ ['"?', DEFAULT_PANEL_PALETTE.warn],
302
+ ]),
303
+ buildPanelLine(width, [
304
+ [' y', DEFAULT_PANEL_PALETTE.info], [' confirm', DEFAULT_PANEL_PALETTE.dim],
305
+ [' Enter', DEFAULT_PANEL_PALETTE.info], [' confirm', DEFAULT_PANEL_PALETTE.dim],
306
+ [' n / Esc', DEFAULT_PANEL_PALETTE.info], [' cancel', DEFAULT_PANEL_PALETTE.dim],
307
+ ]),
308
+ ],
309
+ } : null;
310
+
311
+ const cancelHintFg = cancellable ? DEFAULT_PANEL_PALETTE.info : DEFAULT_PANEL_PALETTE.dim;
312
+ const footerLine = buildPanelLine(width, [
313
+ [` L${this.cursorIndex + 1}/${allRows.length}`, DEFAULT_PANEL_PALETTE.dim],
314
+ [' Tab', DEFAULT_PANEL_PALETTE.info], [' cycle agents', DEFAULT_PANEL_PALETTE.dim],
315
+ [' Up/Down', DEFAULT_PANEL_PALETTE.info], [' navigate', DEFAULT_PANEL_PALETTE.dim],
316
+ [' Enter', DEFAULT_PANEL_PALETTE.info], [' expand', DEFAULT_PANEL_PALETTE.dim],
317
+ [' c', cancelHintFg], [cancellable ? ' cancel' : ' cancel (n/a)', DEFAULT_PANEL_PALETTE.dim],
318
+ ]);
319
+
248
320
  const timelineSection = resolveScrollablePanelSection(width, height, {
249
321
  intro: 'Inspect a selected agent timeline, tool activity, expanded details, and live/historical message flow.',
250
- footerLines: [
251
- buildPanelLine(width, [[` L${this.cursorIndex + 1}/${allRows.length}`, DEFAULT_PANEL_PALETTE.dim], [' 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]]),
252
- ],
322
+ footerLines: [footerLine],
253
323
  palette: DEFAULT_PANEL_PALETTE,
254
324
  beforeSections: [summarySection, agentsSection],
255
325
  section: {
@@ -259,20 +329,22 @@ export class AgentInspectorPanel extends BasePanel {
259
329
  scrollOffset: this.scrollOffset,
260
330
  minRows: 8,
261
331
  },
332
+ afterSections: confirmSection ? [confirmSection] : undefined,
262
333
  });
263
334
  this.scrollOffset = timelineSection.scrollOffset;
264
335
 
336
+ const sections = [
337
+ summarySection,
338
+ agentsSection,
339
+ timelineSection.section,
340
+ ...(confirmSection ? [confirmSection] : []),
341
+ ];
342
+
265
343
  return buildPanelWorkspace(width, height, {
266
344
  title: ` Inspector [${agents.length} agent${agents.length !== 1 ? 's' : ''}]`,
267
345
  intro: 'Inspect a selected agent timeline, tool activity, expanded details, and live/historical message flow.',
268
- sections: [
269
- summarySection,
270
- agentsSection,
271
- timelineSection.section,
272
- ],
273
- footerLines: [
274
- buildPanelLine(width, [[` L${this.cursorIndex + 1}/${allRows.length}`, DEFAULT_PANEL_PALETTE.dim], [' 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]]),
275
- ],
346
+ sections,
347
+ footerLines: [footerLine],
276
348
  palette: DEFAULT_PANEL_PALETTE,
277
349
  });
278
350
  }
@@ -517,4 +589,34 @@ export class AgentInspectorPanel extends BasePanel {
517
589
  this.inspectAgent(next.id);
518
590
  }
519
591
  }
592
+
593
+ // -------------------------------------------------------------------------
594
+ // Private — cancel + stall
595
+ // -------------------------------------------------------------------------
596
+
597
+ /** Initiate cancel-confirm flow for the selected agent (noop if terminal or none selected). */
598
+ private _beginCancelConfirm(): void {
599
+ if (!this.selectedAgentId) return;
600
+ const rec = this.deps.agentManager.getStatus(this.selectedAgentId);
601
+ if (!rec || AGENT_TERMINAL_STATUSES.has(rec.status)) return;
602
+ const label = rec.task.split('\n')[0]?.slice(0, 40) ?? rec.id.slice(-8);
603
+ this.confirmCancel = { subject: rec.id, label };
604
+ this.markDirty();
605
+ }
606
+
607
+ /** Returns whether an agent is considered stalled (non-terminal, running past threshold). */
608
+ private _isAgentStalled(rec: AgentRecord, now: number): boolean {
609
+ if (AGENT_TERMINAL_STATUSES.has(rec.status)) return false;
610
+ return (now - rec.startedAt) >= AGENT_STALL_THRESHOLD_MS;
611
+ }
612
+
613
+ /**
614
+ * Count of all tracked agents that are stalled (non-terminal, no activity
615
+ * for AGENT_STALL_THRESHOLD_MS). Exposed so callers can aggregate a
616
+ * stalledAgentCount for cockpit / roster read-models.
617
+ */
618
+ getStalledAgentCount(): number {
619
+ const now = Date.now();
620
+ return this.deps.agentManager.list().filter(rec => this._isAgentStalled(rec, now)).length;
621
+ }
520
622
  }
@@ -1,5 +1,34 @@
1
1
  export type AgentInspectorEntryKind = 'user' | 'assistant' | 'tool_call' | 'tool_result' | 'session' | 'error';
2
2
 
3
+ // ---------------------------------------------------------------------------
4
+ // Shared agent status / stall constants
5
+ // Used by AgentInspectorPanel, AgentDetailModal, and cockpit read-model consumers.
6
+ // ---------------------------------------------------------------------------
7
+
8
+ /** Terminal statuses — cancel not offered; stall check skipped. */
9
+ export const AGENT_TERMINAL_STATUSES = new Set(['completed', 'failed', 'cancelled']);
10
+
11
+ /** Agents in a non-terminal state for longer than this are considered STALLED. */
12
+ export const AGENT_STALL_THRESHOLD_MS = 5 * 60 * 1000;
13
+
14
+ /**
15
+ * Count stalled agents from a raw record list.
16
+ * An agent is stalled when it is non-terminal and has been running for at
17
+ * least AGENT_STALL_THRESHOLD_MS without completing.
18
+ *
19
+ * Extracted as a standalone export so read-models and panels can share the
20
+ * canonical stall-count logic (TASK-046).
21
+ */
22
+ export function countStalledAgents(
23
+ records: ReadonlyArray<{ status: string; startedAt: number }>,
24
+ now: number = Date.now(),
25
+ ): number {
26
+ return records.filter(
27
+ (r) => !AGENT_TERMINAL_STATUSES.has(r.status) && (now - r.startedAt) >= AGENT_STALL_THRESHOLD_MS,
28
+ ).length;
29
+ }
30
+
31
+
3
32
  export interface AgentTimelineEntry {
4
33
  kind: AgentInspectorEntryKind;
5
34
  timestamp: number;
@@ -76,7 +76,10 @@ export function registerAgentPanels(manager: PanelManager, deps: ResolvedBuiltin
76
76
  preload: true,
77
77
  factory: () => {
78
78
  const ui = requireUiServices(deps);
79
- return new WrfcPanel(ui.events.workflows, { controller: ui.agents.wrfcController });
79
+ return new WrfcPanel(ui.events.workflows, {
80
+ controller: ui.agents.wrfcController,
81
+ cancelChain: (agentId: string) => ui.agents.agentManager.cancel(agentId),
82
+ });
80
83
  },
81
84
  });
82
85
 
@@ -51,6 +51,7 @@ export function registerDevelopmentPanels(manager: PanelManager, deps: ResolvedB
51
51
  agentManager: ui.agents.agentManager,
52
52
  agentMessageBus: ui.agents.agentMessageBus,
53
53
  workingDirectory: ui.environment.workingDirectory,
54
+ cancelAgent: (agentId: string) => ui.agents.agentManager.cancel(agentId),
54
55
  });
55
56
  },
56
57
  });
@@ -65,7 +66,10 @@ export function registerDevelopmentPanels(manager: PanelManager, deps: ResolvedB
65
66
  description: 'Estimated costs per session, agent, and plan with budget alerts',
66
67
  factory: () => {
67
68
  const ui = requireUiServices(deps);
68
- return new CostTrackerPanel(ui.events.turns, ui.events.agents, getOrchestratorUsage, { budgetThreshold });
69
+ return new CostTrackerPanel(ui.events.turns, ui.events.agents, getOrchestratorUsage, {
70
+ budgetThreshold,
71
+ getAgentStatus: (id) => ui.agents.agentManager.getStatus(id),
72
+ });
69
73
  },
70
74
  });
71
75
  }
@@ -1,26 +1,27 @@
1
1
  import type { PanelManager } from '../panel-manager.ts';
2
2
  import { MemoryPanel } from '../memory-panel.ts';
3
- import { KnowledgePanel } from '../knowledge-panel.ts';
3
+ import { KnowledgeGraphPanel } from '../knowledge-graph-panel.ts';
4
4
  import type { ResolvedBuiltinPanelDeps } from './shared.ts';
5
5
 
6
6
  export function registerKnowledgePanels(manager: PanelManager, deps: ResolvedBuiltinPanelDeps): void {
7
- if (!deps.memoryRegistry) return;
8
-
9
- const { memoryRegistry } = deps;
7
+ // KnowledgeGraphPanel is a no-arg panel — always register it regardless of memoryRegistry.
10
8
  manager.registerType({
11
9
  id: 'knowledge',
12
10
  name: 'Knowledge',
13
11
  icon: 'K',
14
12
  category: 'agent',
15
13
  description: 'Structured project knowledge: risks, runbooks, architecture notes, incidents, and durable facts',
16
- factory: () => new KnowledgePanel(memoryRegistry),
17
- });
18
- manager.registerType({
19
- id: 'memory',
20
- name: 'Memory',
21
- icon: 'M',
22
- category: 'agent',
23
- description: 'Project memory: decisions, constraints, incidents, and patterns with provenance links',
24
- factory: () => new MemoryPanel(memoryRegistry),
14
+ factory: () => new KnowledgeGraphPanel(),
25
15
  });
16
+ if (deps.memoryRegistry) {
17
+ const { memoryRegistry } = deps;
18
+ manager.registerType({
19
+ id: 'memory',
20
+ name: 'Memory',
21
+ icon: 'M',
22
+ category: 'agent',
23
+ description: 'Project memory: decisions, constraints, incidents, and patterns with provenance links',
24
+ factory: () => new MemoryPanel(memoryRegistry),
25
+ });
26
+ }
26
27
  }
@@ -37,6 +37,7 @@ import {
37
37
  import { createRuntimeProviderApi } from '@/runtime/index.ts';
38
38
  import type { ResolvedBuiltinPanelDeps } from './shared.ts';
39
39
  import { requireAutomationManager, requireControlPlanePanelDeps, requireHookPanelDeps, requirePluginManager, requireUiServices } from './shared.ts';
40
+ import { createCockpitRosterReadModel } from '../cockpit-read-model.ts';
40
41
 
41
42
  export function registerOperationsPanels(manager: PanelManager, deps: ResolvedBuiltinPanelDeps): void {
42
43
  const ui = requireUiServices(deps);
@@ -52,13 +53,33 @@ export function registerOperationsPanels(manager: PanelManager, deps: ResolvedBu
52
53
  environment: createEnvironmentVariableQuery(process.env),
53
54
  });
54
55
 
56
+ const rosterReadModel = createCockpitRosterReadModel(ui.agents.agentManager);
57
+ // Subscribe to agent lifecycle events so the roster re-renders on state changes.
58
+ // AGENT_RUNNING covers status transitions; AGENT_CANCELLED covers the cancellation
59
+ // terminal state not emitted by AGENT_FAILED. Noisy mid-run events (STREAM_DELTA,
60
+ // AWAITING_TOOL, etc.) are intentionally excluded — they don't affect roster fields.
61
+ // Note: stall detection is time-based, so stalled/stalledAgentCount will only refresh
62
+ // on the next lifecycle event; a periodic tick would be needed for real-time stall display.
63
+ ui.events.agents.on('AGENT_SPAWNING', () => rosterReadModel.markDirty());
64
+ ui.events.agents.on('AGENT_RUNNING', () => rosterReadModel.markDirty());
65
+ ui.events.agents.on('AGENT_COMPLETED', () => rosterReadModel.markDirty());
66
+ ui.events.agents.on('AGENT_FAILED', () => rosterReadModel.markDirty());
67
+ ui.events.agents.on('AGENT_CANCELLED', () => rosterReadModel.markDirty());
68
+
55
69
  manager.registerType({
56
70
  id: 'cockpit',
57
71
  name: 'Cockpit',
58
72
  icon: 'O',
59
73
  category: 'monitoring',
60
74
  description: 'Unified operator summary for orchestration, permissions, communication, MCP, plugins, and integrations',
61
- factory: () => new CockpitPanel(ui.readModels.cockpit),
75
+ factory: () => new CockpitPanel(
76
+ ui.readModels.cockpit,
77
+ rosterReadModel,
78
+ {
79
+ openAgentDetail: (agentId: string) => deps.openAgentDetail?.(agentId),
80
+ cancelAgent: (agentId: string) => ui.agents.agentManager.cancel(agentId),
81
+ },
82
+ ),
62
83
  });
63
84
 
64
85
  manager.registerType({
@@ -114,6 +114,13 @@ export interface BuiltinPanelDeps {
114
114
  hookActivityTracker?: Pick<HookActivityTracker, 'listRecent'>;
115
115
  /** Shared MCP registry for security panels and MCP workspace commands. */
116
116
  mcpRegistry?: McpRegistry;
117
+ /**
118
+ * Open the agent detail modal for the given agent id. Wired from
119
+ * InputHandler.agentDetailModal.open() at bootstrap — passed to the
120
+ * CockpitPanel factory so the agents workspace inspect key (i) works
121
+ * without the panel depending on the modal directly.
122
+ */
123
+ openAgentDetail?: (agentId: string) => void;
117
124
  }
118
125
 
119
126
  export type ResolvedBuiltinPanelDeps = Omit<