@pellux/goodvibes-tui 1.1.0 → 1.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (267) hide show
  1. package/CHANGELOG.md +85 -21
  2. package/README.md +28 -18
  3. package/docs/foundation-artifacts/operator-contract.json +4045 -1763
  4. package/package.json +2 -2
  5. package/src/audio/spoken-turn-controller.ts +12 -2
  6. package/src/audio/spoken-turn-wiring.ts +2 -1
  7. package/src/cli/help.ts +8 -1
  8. package/src/cli/service-posture.ts +2 -1
  9. package/src/cli/status.ts +2 -1
  10. package/src/cli/surface-command.ts +2 -2
  11. package/src/core/alert-gating.ts +67 -0
  12. package/src/core/approval-alert.ts +72 -0
  13. package/src/core/budget-breach-notifier.ts +106 -0
  14. package/src/core/composer-state.ts +11 -3
  15. package/src/core/conversation-line-cache.ts +27 -3
  16. package/src/core/conversation-rendering.ts +90 -14
  17. package/src/core/conversation.ts +18 -0
  18. package/src/core/focus-tracker.ts +41 -0
  19. package/src/core/long-task-notifier.ts +33 -6
  20. package/src/core/stream-event-wiring.ts +20 -1
  21. package/src/core/system-message-noise.ts +87 -0
  22. package/src/core/system-message-router.ts +104 -51
  23. package/src/core/turn-cancellation.ts +25 -0
  24. package/src/core/turn-event-wiring.ts +72 -3
  25. package/src/daemon/cli.ts +29 -2
  26. package/src/daemon/handlers/register.ts +8 -1
  27. package/src/daemon/service-commands.ts +329 -0
  28. package/src/export/cost-utils.ts +36 -0
  29. package/src/input/autocomplete.ts +27 -1
  30. package/src/input/command-registry.ts +84 -5
  31. package/src/input/commands/checkpoint-runtime.ts +280 -0
  32. package/src/input/commands/codebase-runtime.ts +232 -0
  33. package/src/input/commands/config.ts +43 -3
  34. package/src/input/commands/eval.ts +1 -1
  35. package/src/input/commands/health-runtime.ts +10 -2
  36. package/src/input/commands/image-runtime.ts +112 -0
  37. package/src/input/commands/intelligence-runtime.ts +11 -1
  38. package/src/input/commands/local-auth-runtime.ts +4 -1
  39. package/src/input/commands/local-runtime.ts +9 -3
  40. package/src/input/commands/marketplace-runtime.ts +2 -2
  41. package/src/input/commands/memory.ts +73 -35
  42. package/src/input/commands/operator-panel-runtime.ts +71 -31
  43. package/src/input/commands/planning-runtime.ts +116 -4
  44. package/src/input/commands/platform-sandbox-runtime.ts +1 -6
  45. package/src/input/commands/plugin-runtime.ts +2 -2
  46. package/src/input/commands/policy-dispatch.ts +21 -0
  47. package/src/input/commands/provider-accounts-runtime.ts +1 -1
  48. package/src/input/commands/qrcode-runtime.ts +26 -6
  49. package/src/input/commands/recall-review.ts +35 -0
  50. package/src/input/commands/remote-runtime-setup.ts +5 -3
  51. package/src/input/commands/remote-runtime.ts +3 -3
  52. package/src/input/commands/runtime-services.ts +54 -13
  53. package/src/input/commands/services-runtime.ts +1 -1
  54. package/src/input/commands/session-content.ts +20 -9
  55. package/src/input/commands/session-workflow.ts +16 -1
  56. package/src/input/commands/settings-sync-runtime.ts +16 -4
  57. package/src/input/commands/shell-core.ts +25 -8
  58. package/src/input/commands/skills-runtime.ts +2 -8
  59. package/src/input/commands/subscription-runtime.ts +2 -2
  60. package/src/input/commands/test-runtime.ts +277 -0
  61. package/src/input/commands/websearch-runtime.ts +101 -0
  62. package/src/input/commands/work-plan-runtime.ts +36 -10
  63. package/src/input/commands/workstream-runtime.ts +488 -0
  64. package/src/input/commands.ts +12 -0
  65. package/src/input/config-modal-types.ts +175 -0
  66. package/src/input/config-modal.ts +592 -0
  67. package/src/input/feed-context-factory.ts +10 -8
  68. package/src/input/handler-command-route.ts +37 -20
  69. package/src/input/handler-content-actions.ts +17 -2
  70. package/src/input/handler-feed-routes.ts +74 -114
  71. package/src/input/handler-feed.ts +79 -27
  72. package/src/input/handler-interactions.ts +1 -3
  73. package/src/input/handler-modal-routes.ts +136 -6
  74. package/src/input/handler-modal-stack.ts +14 -7
  75. package/src/input/handler-modal-token-routes.ts +16 -49
  76. package/src/input/handler-onboarding-daemon-adopt.ts +149 -0
  77. package/src/input/handler-onboarding.ts +71 -59
  78. package/src/input/handler-picker-routes.ts +26 -102
  79. package/src/input/handler-shortcuts.ts +74 -14
  80. package/src/input/handler-types.ts +2 -6
  81. package/src/input/handler-ui-state.ts +10 -22
  82. package/src/input/handler.ts +12 -29
  83. package/src/input/keybindings.ts +23 -8
  84. package/src/input/model-picker-types.ts +24 -6
  85. package/src/input/model-picker.ts +77 -2
  86. package/src/input/onboarding/onboarding-runtime-status.ts +10 -1
  87. package/src/input/onboarding/onboarding-wizard-apply.ts +8 -1
  88. package/src/input/onboarding/onboarding-wizard-constants.ts +4 -1
  89. package/src/input/onboarding/onboarding-wizard-network-adopt.ts +136 -0
  90. package/src/input/onboarding/onboarding-wizard-steps.ts +9 -6
  91. package/src/input/onboarding/onboarding-wizard-types.ts +3 -1
  92. package/src/input/panel-integration-actions.ts +9 -170
  93. package/src/input/panel-mouse-geometry.ts +97 -0
  94. package/src/input/panel-paste-flood-guard.ts +86 -0
  95. package/src/input/selection-modal.ts +11 -0
  96. package/src/input/session-picker-modal.ts +44 -4
  97. package/src/input/settings-modal-data.ts +146 -2
  98. package/src/input/settings-modal-mutations.ts +6 -4
  99. package/src/input/settings-modal-types.ts +4 -2
  100. package/src/main.ts +49 -51
  101. package/src/panels/agent-inspector-shared.ts +2 -1
  102. package/src/panels/base-panel.ts +22 -1
  103. package/src/panels/builtin/agent.ts +21 -107
  104. package/src/panels/builtin/development.ts +15 -61
  105. package/src/panels/builtin/knowledge.ts +8 -32
  106. package/src/panels/builtin/operations.ts +84 -428
  107. package/src/panels/builtin/session.ts +21 -112
  108. package/src/panels/builtin/shared.ts +18 -46
  109. package/src/panels/builtin-modals.ts +218 -0
  110. package/src/panels/builtin-panels.ts +5 -0
  111. package/src/panels/cost-tracker-panel.ts +36 -10
  112. package/src/panels/diff-panel.ts +12 -43
  113. package/src/panels/eval-registry.ts +60 -0
  114. package/src/panels/fleet-deep-link.ts +31 -0
  115. package/src/panels/fleet-panel-format.ts +62 -0
  116. package/src/panels/fleet-panel-worktree-detail.ts +48 -0
  117. package/src/panels/fleet-panel.ts +758 -0
  118. package/src/panels/fleet-read-model.ts +520 -0
  119. package/src/panels/fleet-steer.ts +125 -0
  120. package/src/panels/fleet-stop.ts +153 -0
  121. package/src/panels/fleet-tabs.ts +230 -0
  122. package/src/panels/fleet-transcript.ts +356 -0
  123. package/src/panels/index.ts +7 -31
  124. package/src/panels/modals/hooks-modal.ts +187 -0
  125. package/src/panels/modals/keybindings-modal.ts +166 -0
  126. package/src/panels/modals/knowledge-modal.ts +158 -0
  127. package/src/panels/modals/local-auth-modal.ts +132 -0
  128. package/src/panels/modals/marketplace-modal.ts +218 -0
  129. package/src/panels/modals/memory-modal.ts +180 -0
  130. package/src/panels/modals/modal-surface-helpers.ts +54 -0
  131. package/src/panels/modals/modal-theme.ts +42 -0
  132. package/src/panels/modals/pairing-modal.ts +163 -0
  133. package/src/panels/modals/planning-modal.ts +304 -0
  134. package/src/panels/modals/plugins-modal.ts +174 -0
  135. package/src/panels/modals/policy-modal.ts +256 -0
  136. package/src/panels/modals/provider-health-modal.ts +136 -0
  137. package/src/panels/modals/remote-modal.ts +115 -0
  138. package/src/panels/modals/sandbox-modal.ts +150 -0
  139. package/src/panels/modals/security-modal.ts +217 -0
  140. package/src/panels/modals/services-modal.ts +179 -0
  141. package/src/panels/modals/settings-sync-modal.ts +142 -0
  142. package/src/panels/modals/skills-modal.ts +189 -0
  143. package/src/panels/modals/subscription-modal.ts +172 -0
  144. package/src/panels/modals/work-plan-modal.ts +177 -0
  145. package/src/panels/panel-confirm-overlay.ts +72 -0
  146. package/src/panels/panel-manager.ts +123 -9
  147. package/src/panels/polish-core.ts +38 -25
  148. package/src/panels/project-planning-answer-actions.ts +8 -11
  149. package/src/panels/skills-panel.ts +7 -51
  150. package/src/panels/types.ts +37 -0
  151. package/src/permissions/hunk-selection.ts +179 -0
  152. package/src/permissions/prompt.ts +216 -13
  153. package/src/renderer/autocomplete-overlay.ts +28 -2
  154. package/src/renderer/compaction-history-modal.ts +19 -3
  155. package/src/renderer/compaction-preview.ts +13 -2
  156. package/src/renderer/compaction-quality.ts +100 -0
  157. package/src/renderer/compositor.ts +2 -3
  158. package/src/renderer/config-modal.ts +95 -0
  159. package/src/renderer/conversation-overlays.ts +10 -17
  160. package/src/renderer/fleet-tab-strip.ts +56 -0
  161. package/src/renderer/footer-tips.ts +15 -3
  162. package/src/renderer/fullscreen-primitives.ts +32 -22
  163. package/src/renderer/git-status.ts +43 -1
  164. package/src/renderer/help-overlay.ts +2 -2
  165. package/src/renderer/layout.ts +0 -4
  166. package/src/renderer/markdown.ts +7 -3
  167. package/src/renderer/modal-factory.ts +25 -20
  168. package/src/renderer/model-workspace.ts +62 -4
  169. package/src/renderer/overlay-box.ts +21 -17
  170. package/src/renderer/panel-workspace-bar.ts +8 -2
  171. package/src/renderer/process-indicator.ts +14 -3
  172. package/src/renderer/selection-modal-overlay.ts +6 -1
  173. package/src/renderer/session-picker-modal.ts +196 -3
  174. package/src/renderer/settings-modal-helpers.ts +2 -0
  175. package/src/renderer/settings-modal.ts +7 -0
  176. package/src/renderer/shell-surface.ts +8 -1
  177. package/src/renderer/status-glyphs.ts +14 -15
  178. package/src/renderer/system-message.ts +15 -3
  179. package/src/renderer/terminal-bg-probe.ts +339 -0
  180. package/src/renderer/terminal-escapes.ts +20 -0
  181. package/src/renderer/theme-mode-config.ts +67 -0
  182. package/src/renderer/theme.ts +91 -1
  183. package/src/renderer/thinking.ts +11 -3
  184. package/src/renderer/tool-call.ts +15 -9
  185. package/src/renderer/tool-result-summary.ts +148 -0
  186. package/src/renderer/turn-injection.ts +133 -0
  187. package/src/renderer/ui-factory.ts +154 -85
  188. package/src/renderer/ui-primitives.ts +30 -129
  189. package/src/runtime/bootstrap-command-context.ts +27 -1
  190. package/src/runtime/bootstrap-command-parts.ts +41 -10
  191. package/src/runtime/bootstrap-core.ts +47 -15
  192. package/src/runtime/bootstrap-hook-bridge.ts +7 -0
  193. package/src/runtime/bootstrap-shell.ts +48 -17
  194. package/src/runtime/bootstrap.ts +129 -22
  195. package/src/runtime/code-index-services.ts +135 -0
  196. package/src/runtime/legacy-daemon-migration.ts +516 -0
  197. package/src/runtime/memory-fold.ts +26 -0
  198. package/src/runtime/onboarding/derivation.ts +7 -2
  199. package/src/runtime/onboarding/snapshot.ts +27 -1
  200. package/src/runtime/onboarding/types.ts +31 -1
  201. package/src/runtime/operator-token-cleanup.ts +82 -1
  202. package/src/runtime/orchestrator-core-services.ts +59 -0
  203. package/src/runtime/process-lifecycle.ts +3 -1
  204. package/src/runtime/resume-notice.ts +209 -0
  205. package/src/runtime/services.ts +98 -46
  206. package/src/runtime/session-inbound-inputs.ts +252 -0
  207. package/src/runtime/session-spine-transport.ts +64 -0
  208. package/src/runtime/terminal-output-guard.ts +15 -8
  209. package/src/runtime/ui-services.ts +27 -3
  210. package/src/runtime/workstream-services.ts +327 -0
  211. package/src/runtime/wrfc-persistence.ts +124 -17
  212. package/src/shell/blocking-input.ts +68 -4
  213. package/src/shell/recovery-input-helpers.ts +170 -1
  214. package/src/shell/ui-openers.ts +171 -26
  215. package/src/utils/format-duration.ts +8 -18
  216. package/src/utils/splash-lines.ts +1 -1
  217. package/src/utils/terminal-width.ts +52 -0
  218. package/src/version.ts +1 -1
  219. package/src/panels/agent-inspector-panel.ts +0 -786
  220. package/src/panels/approval-panel.ts +0 -252
  221. package/src/panels/automation-control-panel.ts +0 -479
  222. package/src/panels/cockpit-panel.ts +0 -481
  223. package/src/panels/cockpit-read-model.ts +0 -238
  224. package/src/panels/communication-panel.ts +0 -256
  225. package/src/panels/control-plane-panel.ts +0 -470
  226. package/src/panels/debug-panel.ts +0 -615
  227. package/src/panels/docs-panel.ts +0 -384
  228. package/src/panels/eval-panel.ts +0 -627
  229. package/src/panels/file-explorer-panel.ts +0 -673
  230. package/src/panels/file-preview-panel.ts +0 -517
  231. package/src/panels/hooks-panel.ts +0 -401
  232. package/src/panels/incident-review-panel.ts +0 -406
  233. package/src/panels/intelligence-panel.ts +0 -383
  234. package/src/panels/knowledge-graph-panel.ts +0 -515
  235. package/src/panels/marketplace-panel.ts +0 -399
  236. package/src/panels/memory-panel.ts +0 -558
  237. package/src/panels/ops-control-panel.ts +0 -329
  238. package/src/panels/ops-strategy-panel.ts +0 -321
  239. package/src/panels/orchestration-panel.ts +0 -465
  240. package/src/panels/panel-list-panel.ts +0 -566
  241. package/src/panels/plan-dashboard-panel.ts +0 -707
  242. package/src/panels/policy-panel.ts +0 -517
  243. package/src/panels/project-planning-panel.ts +0 -731
  244. package/src/panels/provider-health-panel.ts +0 -678
  245. package/src/panels/provider-health-tracker.ts +0 -310
  246. package/src/panels/provider-health-views.ts +0 -567
  247. package/src/panels/qr-panel.ts +0 -280
  248. package/src/panels/remote-panel.ts +0 -534
  249. package/src/panels/routes-panel.ts +0 -241
  250. package/src/panels/sandbox-panel.ts +0 -456
  251. package/src/panels/security-panel.ts +0 -447
  252. package/src/panels/services-panel.ts +0 -329
  253. package/src/panels/session-browser-panel.ts +0 -496
  254. package/src/panels/settings-sync-panel.ts +0 -398
  255. package/src/panels/subscription-panel.ts +0 -342
  256. package/src/panels/symbol-outline-panel.ts +0 -619
  257. package/src/panels/system-messages-panel.ts +0 -364
  258. package/src/panels/tasks-panel.ts +0 -608
  259. package/src/panels/thinking-panel.ts +0 -333
  260. package/src/panels/tool-inspector-panel.ts +0 -537
  261. package/src/panels/work-plan-panel.ts +0 -540
  262. package/src/panels/worktree-panel.ts +0 -360
  263. package/src/panels/wrfc-panel.ts +0 -790
  264. package/src/renderer/agent-detail-modal.ts +0 -466
  265. package/src/renderer/live-tail-modal.ts +0 -156
  266. package/src/renderer/process-modal.ts +0 -671
  267. package/src/renderer/qr-renderer.ts +0 -120
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pellux/goodvibes-tui",
3
- "version": "1.1.0",
3
+ "version": "1.9.0",
4
4
  "description": "Terminal-native GoodVibes product for coding, operations, automation, knowledge, channels, and daemon-backed control-plane workflows.",
5
5
  "type": "module",
6
6
  "main": "src/main.ts",
@@ -99,7 +99,7 @@
99
99
  "@anthropic-ai/vertex-sdk": "^0.16.0",
100
100
  "@ast-grep/napi": "^0.42.0",
101
101
  "@aws/bedrock-token-generator": "^1.1.0",
102
- "@pellux/goodvibes-sdk": "0.36.0",
102
+ "@pellux/goodvibes-sdk": "1.0.0",
103
103
  "bash-language-server": "^5.6.0",
104
104
  "fuse.js": "^7.1.0",
105
105
  "graphql": "^16.13.2",
@@ -61,7 +61,16 @@ export class SpokenTurnController {
61
61
  return true;
62
62
  }
63
63
 
64
- stop(message?: string): void {
64
+ /**
65
+ * Returns whether speech was actually ACTIVE when stopped. The notice only
66
+ * prints in that case — stop() on an idle controller used to notify anyway,
67
+ * spamming "[TTS] Spoken output stopped." on every Ctrl+C (batch replay
68
+ * D5/N-noise); callers use the return to decide whether the press "did a
69
+ * job" (see handleCtrlC's consume-on-speech-stop).
70
+ */
71
+ stop(message?: string): boolean {
72
+ const wasActive = this.pendingPrompt !== null || this.activeTurnId !== null
73
+ || this.chunker !== null || this.abortControllers.size > 0;
65
74
  this.pendingPrompt = null;
66
75
  this.activeTurnId = null;
67
76
  this.chunker?.reset();
@@ -72,7 +81,8 @@ export class SpokenTurnController {
72
81
  this.player.stop();
73
82
  this.playbackChain = Promise.resolve();
74
83
  this.errorReportedForTurn = false;
75
- if (message) this.notify?.(`[TTS] ${message}`);
84
+ if (message && wasActive) this.notify?.(`[TTS] ${message}`);
85
+ return wasActive;
76
86
  }
77
87
 
78
88
  handleTurnEvent(event: TurnEvent): void {
@@ -8,7 +8,8 @@ import { SpokenTurnController } from './spoken-turn-controller.ts';
8
8
  export interface SpokenTurnRuntime {
9
9
  readonly unsubs: readonly (() => void)[];
10
10
  submitNextTurn(prompt: string): boolean;
11
- stop(message?: string): void;
11
+ /** Returns whether speech was actually active (see controller.stop). */
12
+ stop(message?: string): boolean;
12
13
  }
13
14
 
14
15
  export interface WireSpokenTurnRuntimeOptions {
package/src/cli/help.ts CHANGED
@@ -283,10 +283,17 @@ export function renderGoodVibesCommandHelp(topic: string, binary = 'goodvibes'):
283
283
 
284
284
  export function renderGoodVibesDaemonHelp(binary = 'goodvibes-daemon'): string {
285
285
  return [
286
- `Usage: ${binary} [OPTIONS]`,
286
+ `Usage: ${binary} [COMMAND] [OPTIONS]`,
287
287
  '',
288
288
  'Starts the headless GoodVibes daemon/API host.',
289
289
  '',
290
+ 'Commands:',
291
+ ' install-service Install + enable the daemon as a systemd user service (survives reboots)',
292
+ ' uninstall-service Disable + remove the daemon systemd user service',
293
+ ' service-status Show whether the daemon service is installed / enabled / active',
294
+ ' migrate-service Guided takeover of a legacy goodvibes-daemon.service unit; prints a plan',
295
+ ' unless run with -y/--yes (never auto-migrates)',
296
+ '',
290
297
  'Options:',
291
298
  ' --daemon-home <dir> Override daemon home',
292
299
  ' --working-dir <dir> Override working directory',
@@ -5,6 +5,7 @@ import { basename, delimiter, dirname, isAbsolute, join, resolve } from 'node:pa
5
5
  import { fileURLToPath } from 'node:url';
6
6
  import { PlatformServiceManager } from '@pellux/goodvibes-sdk/platform/daemon';
7
7
  import type { ManagedServiceStatus } from '@pellux/goodvibes-sdk/platform/daemon';
8
+ import { resolveDaemonEnabled } from '@pellux/goodvibes-sdk/platform/config';
8
9
  import type { ConfigManager } from '../config/index.ts';
9
10
  import { resolveRuntimeEndpointBinding } from './endpoints.ts';
10
11
  import type { RuntimeEndpointBinding, RuntimeEndpointId } from './endpoints.ts';
@@ -392,7 +393,7 @@ export async function buildCliServicePosture(
392
393
  enabled: runtime.configManager.get('service.enabled') === true,
393
394
  autostart: runtime.configManager.get('service.autostart') === true,
394
395
  restartOnFailure: runtime.configManager.get('service.restartOnFailure') === true,
395
- daemonEnabled: runtime.configManager.get('danger.daemon') === true,
396
+ daemonEnabled: resolveDaemonEnabled(runtime.configManager),
396
397
  };
397
398
  const serverBackedEnabled = config.daemonEnabled || endpoints.some((endpoint) => endpoint.enabled);
398
399
  const issues: string[] = [];
package/src/cli/status.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type { ConfigManager } from '@pellux/goodvibes-sdk/platform/config';
2
+ import { resolveDaemonEnabled } from '@pellux/goodvibes-sdk/platform/config';
2
3
  import type { OnboardingCheckMarkersState } from '../runtime/onboarding/index.ts';
3
4
  import { resolveRuntimeEndpointBinding } from './endpoints.ts';
4
5
  import { isNetworkFacing } from './network-posture.ts';
@@ -98,7 +99,7 @@ export function buildCliDoctorFindings(options: CliStatusOptions): readonly CliD
98
99
  const serviceEnabled = config.get('service.enabled') === true;
99
100
  const serviceAutostart = config.get('service.autostart') === true;
100
101
  const restartOnFailure = config.get('service.restartOnFailure') === true;
101
- const daemonEnabled = config.get('danger.daemon') === true;
102
+ const daemonEnabled = resolveDaemonEnabled(config);
102
103
  const listenerEnabled = config.get('danger.httpListener') === true;
103
104
  const webEnabled = config.get('web.enabled') === true;
104
105
  const controlPlaneEnabled = config.get('controlPlane.enabled') === true;
@@ -47,7 +47,7 @@ export async function handleSurfacesCommand(runtime: CliCommandRuntime): Promise
47
47
  runtime.configManager.setDynamic('web.enabled', enabled);
48
48
  if (enabled) {
49
49
  enableFeatureFlags(runtime.configManager, getServerSurfaceFeatureFlags({ serverBacked: true, web: true }));
50
- runtime.configManager.setDynamic('danger.daemon', true);
50
+ runtime.configManager.setDynamic('daemon.enabled', true);
51
51
  runtime.configManager.setDynamic('controlPlane.enabled', true);
52
52
  const webError = applyTargetEndpointFlagsOrDefault(runtime, 'web');
53
53
  if (webError) return { output: webError, exitCode: 2 };
@@ -70,7 +70,7 @@ export async function handleSurfacesCommand(runtime: CliCommandRuntime): Promise
70
70
  }
71
71
  else if (target === 'control-plane' || target === 'controlPlane') {
72
72
  runtime.configManager.setDynamic('controlPlane.enabled', enabled);
73
- runtime.configManager.setDynamic('danger.daemon', enabled);
73
+ runtime.configManager.setDynamic('daemon.enabled', enabled);
74
74
  if (enabled) {
75
75
  const controlPlaneError = applyTargetEndpointFlagsOrDefault(runtime, 'controlPlane');
76
76
  if (controlPlaneError) return { output: controlPlaneError, exitCode: 2 };
@@ -0,0 +1,67 @@
1
+ /**
2
+ * alert-gating — shared config + focus gating logic for the unfocused-user
3
+ * alert classes (budget-breach, long-task, agent/chain failure,
4
+ * awaiting-approval). Each alert class has its own on/off config key
5
+ * (default true); a single master key, `behavior.notifyOnlyWhenUnfocused`
6
+ * (default true), decides whether any of them additionally require the
7
+ * terminal to be unfocused-or-unknown before firing.
8
+ *
9
+ * Extracted so every alert-class module (long-task-notifier.ts,
10
+ * budget-breach-notifier.ts, approval-alert.ts, and the agent/chain-failure
11
+ * wiring in turn-event-wiring.ts) shares one implementation of "should this
12
+ * fire right now" instead of re-deriving the same two config reads and the
13
+ * same focus check.
14
+ */
15
+ import type { FocusTracker } from './focus-tracker.ts';
16
+
17
+ /** Minimal config-read surface every gating check needs. */
18
+ export type ConfigGet = (key: string) => unknown;
19
+
20
+ /** Read a TUI-local synthetic boolean setting, defaulting when absent/invalid. */
21
+ export function readBooleanConfig(configGet: ConfigGet, key: string, defaultValue: boolean): boolean {
22
+ const raw = configGet(key);
23
+ if (typeof raw === 'boolean') return raw;
24
+ if (raw === undefined || raw === null) return defaultValue;
25
+ // Tolerate string/number forms the same way the rest of the config layer does
26
+ // (settings-modal writes real booleans, but env/file edits may not).
27
+ if (raw === 'true' || raw === 1) return true;
28
+ if (raw === 'false' || raw === 0) return false;
29
+ return defaultValue;
30
+ }
31
+
32
+ /** `behavior.notifyOnlyWhenUnfocused` — master gate, default on. */
33
+ export function readNotifyOnlyWhenUnfocused(configGet: ConfigGet): boolean {
34
+ return readBooleanConfig(configGet, 'behavior.notifyOnlyWhenUnfocused', true);
35
+ }
36
+
37
+ /**
38
+ * Decide whether a given alert class should fire right now.
39
+ *
40
+ * `classConfigKey` is the per-class on/off switch (e.g.
41
+ * 'behavior.notifyOnBudgetBreach'); default true when absent. When that key
42
+ * is off, the class never fires regardless of focus. When it's on, the
43
+ * result additionally depends on the master `notifyOnlyWhenUnfocused` gate:
44
+ * if that master gate is off, the class fires unconditionally (today's
45
+ * pre-focus-tracking behavior); if on (the default), it only fires when the
46
+ * terminal is unfocused or focus state was never observed (see
47
+ * FocusTracker.shouldAlertWhenUnfocused).
48
+ */
49
+ export function shouldFireAlert(
50
+ focusTracker: Pick<FocusTracker, 'shouldAlertWhenUnfocused'>,
51
+ configGet: ConfigGet,
52
+ classConfigKey: string,
53
+ ): boolean {
54
+ if (!readBooleanConfig(configGet, classConfigKey, true)) return false;
55
+ if (!readNotifyOnlyWhenUnfocused(configGet)) return true;
56
+ return focusTracker.shouldAlertWhenUnfocused();
57
+ }
58
+
59
+ /**
60
+ * notifyCompletion (SDK platform/utils) only rings the bell above 5s of
61
+ * "duration" and only pops a desktop notification above 30s — a heuristic
62
+ * tuned for long-running-turn notifications. The alert classes in this
63
+ * directory are point-in-time events with no natural duration, so they pass
64
+ * this constant as the `durationMs` argument to force both the bell and the
65
+ * desktop popup unconditionally.
66
+ */
67
+ export const FORCE_NOTIFY_DURATION_MS = 30_001;
@@ -0,0 +1,72 @@
1
+ /**
2
+ * approval-alert — fires an unfocused-user alert the moment a tool call
3
+ * becomes a real, user-blocking permission prompt.
4
+ *
5
+ * Anchor: `PERMISSION_REQUESTED` (SDK events/permissions.ts) is the wrong
6
+ * signal — it fires for every policy-evaluation step, including ones
7
+ * auto-resolved by allow-lists/yolo-mode in milliseconds. The only place a
8
+ * request becomes an actual user-facing prompt is where main.ts assigns
9
+ * `permissionPromptRef.requestPermission`. There is no `permissions` feed on
10
+ * UiRuntimeEvents to subscribe to instead, so this wraps the handler at that
11
+ * assignment site.
12
+ *
13
+ * wrapRequestPermissionWithAlert fires the alert synchronously (before the
14
+ * wrapped promise settles) so it reflects "a prompt just appeared", not
15
+ * "a prompt was just resolved".
16
+ *
17
+ * PRIVACY: message content is tool name + permission category only — no
18
+ * args, no file contents, no command strings (mirrors long-task-notifier's
19
+ * privacy guarantee).
20
+ */
21
+ import { notifyCompletion } from '@pellux/goodvibes-sdk/platform/utils';
22
+ import { logger } from '@pellux/goodvibes-sdk/platform/utils';
23
+ import type { WebhookNotifier } from '@pellux/goodvibes-sdk/platform/integrations';
24
+ import type { PermissionRequestHandler, PermissionPromptRequest } from '@pellux/goodvibes-sdk/platform/permissions';
25
+ import type { FocusTracker } from './focus-tracker.ts';
26
+ import { shouldFireAlert, FORCE_NOTIFY_DURATION_MS, type ConfigGet } from './alert-gating.ts';
27
+
28
+ export interface ApprovalAlertDeps {
29
+ readonly focusTracker: Pick<FocusTracker, 'shouldAlertWhenUnfocused'>;
30
+ readonly configGet: ConfigGet;
31
+ readonly webhookNotifier?: WebhookNotifier | null;
32
+ }
33
+
34
+ /**
35
+ * Wrap a PermissionRequestHandler so every call also fires an unfocused-user
36
+ * alert (subject to the behavior.notifyOnApprovalPending / notifyOnlyWhenUnfocused
37
+ * gates). The wrapped handler's behavior (what it resolves to) is completely
38
+ * unchanged — this only adds a side effect at call time.
39
+ */
40
+ export function wrapRequestPermissionWithAlert(
41
+ original: PermissionRequestHandler,
42
+ deps: ApprovalAlertDeps,
43
+ ): PermissionRequestHandler {
44
+ return (request: PermissionPromptRequest) => {
45
+ fireApprovalAlert(request, deps);
46
+ return original(request);
47
+ };
48
+ }
49
+
50
+ function fireApprovalAlert(request: PermissionPromptRequest, deps: ApprovalAlertDeps): void {
51
+ if (!shouldFireAlert(deps.focusTracker, deps.configGet, 'behavior.notifyOnApprovalPending')) return;
52
+
53
+ const title = 'GoodVibes — approval needed';
54
+ // PRIVACY: tool name + category only. Never request.args.
55
+ const message = `${request.tool} (${request.category}) is waiting for approval`;
56
+
57
+ try {
58
+ notifyCompletion(title, message, FORCE_NOTIFY_DURATION_MS);
59
+ } catch (err) {
60
+ logger.debug('approval-alert: desktop notify error', { error: String(err) });
61
+ }
62
+
63
+ const webhookNotifier = deps.webhookNotifier;
64
+ if (webhookNotifier) {
65
+ const urls = webhookNotifier.getUrls();
66
+ if (urls.length > 0) {
67
+ webhookNotifier.send(message).catch((err: unknown) => {
68
+ logger.debug('approval-alert: webhook send error', { error: String(err) });
69
+ });
70
+ }
71
+ }
72
+ }
@@ -0,0 +1,106 @@
1
+ /**
2
+ * budget-breach-notifier — fires an unfocused-user alert the moment session
3
+ * cost crosses the configured budget threshold (behavior.budgetAlertUsd).
4
+ *
5
+ * One-shot semantics: fires exactly once on the false→true edge (crossing
6
+ * into breach), never again while still over budget on every subsequent
7
+ * turn. The "already notified" latch resets when the session drops back
8
+ * under budget (so a later re-breach can fire again) or when the threshold
9
+ * itself changes (raising, lowering, or clearing the budget re-arms it
10
+ * against the new value).
11
+ *
12
+ * Delivery mirrors long-task-notifier.ts: desktop notification
13
+ * (notifyCompletion) + outbound webhook (WebhookNotifier), both gated by
14
+ * the shared focus/config gating in alert-gating.ts. Skips evaluation
15
+ * entirely when the session model is unpriced — a $0 placeholder cost must
16
+ * never be reported as a real breach (mirrors CostTrackerPanel's "unpriced"
17
+ * display convention).
18
+ *
19
+ * PRIVACY: message text is built from cost/threshold numbers and the
20
+ * session id prefix only — no conversation content.
21
+ */
22
+ import { notifyCompletion } from '@pellux/goodvibes-sdk/platform/utils';
23
+ import { logger } from '@pellux/goodvibes-sdk/platform/utils';
24
+ import type { WebhookNotifier } from '@pellux/goodvibes-sdk/platform/integrations';
25
+ import { calcSessionCost, computeBudgetBreach, isModelPriced } from '../export/cost-utils.ts';
26
+ import type { FocusTracker } from './focus-tracker.ts';
27
+ import { shouldFireAlert, FORCE_NOTIFY_DURATION_MS, type ConfigGet } from './alert-gating.ts';
28
+
29
+ export interface BudgetBreachUsageSnapshot {
30
+ readonly input: number;
31
+ readonly output: number;
32
+ readonly cacheRead: number;
33
+ readonly cacheWrite: number;
34
+ }
35
+
36
+ export interface BudgetBreachNotifierDeps {
37
+ readonly focusTracker: Pick<FocusTracker, 'shouldAlertWhenUnfocused'>;
38
+ readonly configGet: ConfigGet;
39
+ readonly webhookNotifier?: WebhookNotifier | null;
40
+ readonly sessionId: string;
41
+ }
42
+
43
+ /** Stateful edge-trigger checker — construct once per session, call on every TURN_COMPLETED. */
44
+ export interface BudgetBreachNotifier {
45
+ /**
46
+ * Evaluate current usage against the configured threshold and fire an
47
+ * alert on the false→true breach edge. Returns true when an alert was
48
+ * fired this call (for tests / observability), false otherwise.
49
+ */
50
+ check(usage: BudgetBreachUsageSnapshot, sessionModel: string, budgetThresholdUsd: number): boolean;
51
+ }
52
+
53
+ export function createBudgetBreachNotifier(deps: BudgetBreachNotifierDeps): BudgetBreachNotifier {
54
+ let lastThreshold: number | null = null;
55
+ let notified = false;
56
+
57
+ return {
58
+ check(usage, sessionModel, budgetThresholdUsd) {
59
+ if (budgetThresholdUsd !== lastThreshold) {
60
+ // Threshold raised, lowered, or cleared since the last check — re-arm
61
+ // the latch so a breach against the new threshold can fire again.
62
+ lastThreshold = budgetThresholdUsd;
63
+ notified = false;
64
+ }
65
+
66
+ if (budgetThresholdUsd <= 0) return false; // disabled
67
+ if (!isModelPriced(sessionModel)) return false; // cost would be a placeholder, not real
68
+
69
+ const sessionCost = calcSessionCost(usage.input, usage.output, usage.cacheRead, usage.cacheWrite, sessionModel);
70
+ const breached = computeBudgetBreach(sessionCost, budgetThresholdUsd);
71
+
72
+ if (!breached) {
73
+ notified = false; // dropped back under budget — can re-fire on the next crossing
74
+ return false;
75
+ }
76
+ if (notified) return false; // already alerted for this crossing
77
+
78
+ notified = true;
79
+ fireBudgetBreachAlert(deps, sessionCost, budgetThresholdUsd);
80
+ return true;
81
+ },
82
+ };
83
+ }
84
+
85
+ function fireBudgetBreachAlert(deps: BudgetBreachNotifierDeps, sessionCost: number, budgetThresholdUsd: number): void {
86
+ if (!shouldFireAlert(deps.focusTracker, deps.configGet, 'behavior.notifyOnBudgetBreach')) return;
87
+
88
+ const title = 'GoodVibes — budget breach';
89
+ const message = `session cost $${sessionCost.toFixed(2)} exceeded budget $${budgetThresholdUsd.toFixed(2)} · session ${deps.sessionId.slice(0, 8)}`;
90
+
91
+ try {
92
+ notifyCompletion(title, message, FORCE_NOTIFY_DURATION_MS);
93
+ } catch (err) {
94
+ logger.debug('budget-breach-notifier: desktop notify error', { error: String(err) });
95
+ }
96
+
97
+ const webhookNotifier = deps.webhookNotifier;
98
+ if (webhookNotifier) {
99
+ const urls = webhookNotifier.getUrls();
100
+ if (urls.length > 0) {
101
+ webhookNotifier.send(message).catch((err: unknown) => {
102
+ logger.debug('budget-breach-notifier: webhook send error', { error: String(err) });
103
+ });
104
+ }
105
+ }
106
+ }
@@ -20,8 +20,13 @@ export function deriveComposerState(input: ComposerStateInput): ComposerState {
20
20
  const flags: string[] = [];
21
21
  let pendingRisk: ComposerState['pendingRisk'] = 'none';
22
22
 
23
- if (input.pendingApproval) {
24
- flags.push('approval');
23
+ // An approval wait is the dominant composer state and owns the single honest
24
+ // status tag: `risk:approval-wait`. It is therefore NOT also duplicated as a
25
+ // `approval` flag, and (below) it suppresses the competing `state:` turn tag —
26
+ // the turn is blocked on the user, not really streaming. Precedence:
27
+ // approval-wait > live turn state. (UX-B item 4.)
28
+ const approvalWait = input.pendingApproval === true;
29
+ if (approvalWait) {
25
30
  pendingRisk = 'approval-wait';
26
31
  }
27
32
  if (intent.kind === 'shell') {
@@ -35,7 +40,7 @@ export function deriveComposerState(input: ComposerStateInput): ComposerState {
35
40
  }
36
41
  if (input.hasAttachments) flags.push('attachments');
37
42
 
38
- const statusLabel = (() => {
43
+ const turnStatus = (() => {
39
44
  switch (input.turnState) {
40
45
  case 'preflight': return 'preflight';
41
46
  case 'streaming': return 'streaming';
@@ -47,6 +52,9 @@ export function deriveComposerState(input: ComposerStateInput): ComposerState {
47
52
  default: return 'idle';
48
53
  }
49
54
  })();
55
+ // 'idle' is suppressed by the footer renderer, so an approval wait shows only
56
+ // `risk:approval-wait` — one honest tag, not three spellings of the wait.
57
+ const statusLabel = approvalWait ? 'idle' : turnStatus;
50
58
 
51
59
  return {
52
60
  intent,
@@ -49,6 +49,7 @@ import {
49
49
  renderConversationSystemMessage,
50
50
  renderConversationToolMessage,
51
51
  renderConversationUserMessage,
52
+ collectCompletedToolCallIds,
52
53
  } from './conversation-rendering.ts';
53
54
  import type { ConversationMessageSnapshot } from '@pellux/goodvibes-sdk/platform/core';
54
55
  // SystemMessageKind imported from runtime directly to avoid a cycle, mirroring
@@ -94,6 +95,18 @@ interface KeyMeta {
94
95
  readonly showReasoningSummary: boolean;
95
96
  readonly blockBase: number;
96
97
  readonly kind: SystemMessageKind | undefined;
98
+ /**
99
+ * True when this (assistant) message has a tool call with no result yet — it
100
+ * renders as pending rather than done. Keyed so the entry invalidates and
101
+ * re-renders ✓ once the tool result arrives. (UX-B item 2c.)
102
+ */
103
+ readonly hasPendingTool: boolean;
104
+ }
105
+
106
+ /** Whether an assistant message has any tool call still awaiting a result. */
107
+ function hasPendingToolCall(m: Message, completed: ReadonlySet<string>): boolean {
108
+ if (m.role !== 'assistant' || !m.toolCalls) return false;
109
+ return m.toolCalls.some((tc) => tc.id === undefined || !completed.has(tc.id));
97
110
  }
98
111
 
99
112
  interface CacheEntry {
@@ -272,6 +285,11 @@ export class MessageLineCache {
272
285
  showReasoningSummary: context.configManager?.get('display.showReasoningSummary') ?? false,
273
286
  };
274
287
 
288
+ // Tool calls with no matching tool-result message are still pending; the
289
+ // render context and the cache key both depend on this. (UX-B item 2c.)
290
+ const completedToolCallIds = collectCompletedToolCallIds(messages);
291
+ const renderContext: ConversationRenderContext = { ...context, completedToolCallIds };
292
+
275
293
  const touched = new Set<number>();
276
294
 
277
295
  for (let i = 0; i < messages.length; i++) {
@@ -283,17 +301,18 @@ export class MessageLineCache {
283
301
 
284
302
  const uncacheable = absoluteIdx === streamingPlaceholderAbsIdx;
285
303
  const kind = context.messageKindRegistry.get(absoluteIdx);
304
+ const hasPendingTool = hasPendingToolCall(message, completedToolCallIds);
286
305
 
287
306
  if (!uncacheable) {
288
307
  const existing = this.entries.get(absoluteIdx);
289
- if (existing && this.isValid(existing, message, width, cfg, blockBase, kind, context.collapseState)) {
308
+ if (existing && this.isValid(existing, message, width, cfg, blockBase, kind, hasPendingTool, context.collapseState)) {
290
309
  this.applyEntry(context, existing, base);
291
310
  touched.add(absoluteIdx);
292
311
  continue;
293
312
  }
294
313
  }
295
314
 
296
- const entry = this.renderScratch(context, message, width, absoluteIdx, cfg, blockBase, kind);
315
+ const entry = this.renderScratch(renderContext, message, width, absoluteIdx, cfg, blockBase, kind, hasPendingTool);
297
316
  this.applyEntry(context, entry, base);
298
317
  if (!uncacheable) {
299
318
  this.entries.set(absoluteIdx, entry);
@@ -319,6 +338,7 @@ export class MessageLineCache {
319
338
  cfg: RenderConfig,
320
339
  blockBase: number,
321
340
  kind: SystemMessageKind | undefined,
341
+ hasPendingTool: boolean,
322
342
  collapseState: Map<string, boolean>,
323
343
  ): boolean {
324
344
  const k = entry.keyMeta;
@@ -330,7 +350,8 @@ export class MessageLineCache {
330
350
  k.showThinking !== cfg.showThinking ||
331
351
  k.showReasoningSummary !== cfg.showReasoningSummary ||
332
352
  k.blockBase !== blockBase ||
333
- k.kind !== kind
353
+ k.kind !== kind ||
354
+ k.hasPendingTool !== hasPendingTool
334
355
  ) {
335
356
  return false;
336
357
  }
@@ -355,6 +376,7 @@ export class MessageLineCache {
355
376
  cfg: RenderConfig,
356
377
  blockBase: number,
357
378
  kind: SystemMessageKind | undefined,
379
+ hasPendingTool: boolean,
358
380
  ): CacheEntry {
359
381
  const scratchLines: Line[] = [];
360
382
  const scratchHistory = {
@@ -378,6 +400,7 @@ export class MessageLineCache {
378
400
  messageKindRegistry: context.messageKindRegistry,
379
401
  configManager: context.configManager,
380
402
  splashOptions: context.splashOptions,
403
+ completedToolCallIds: context.completedToolCallIds,
381
404
  };
382
405
 
383
406
  renderOne(scratchCtx, message, width, absoluteIdx, cfg);
@@ -397,6 +420,7 @@ export class MessageLineCache {
397
420
  showReasoningSummary: cfg.showReasoningSummary,
398
421
  blockBase,
399
422
  kind,
423
+ hasPendingTool,
400
424
  },
401
425
  contentSig: contentSigOf(message),
402
426
  lines: scratchLines,