@pellux/goodvibes-tui 1.7.0 → 1.9.1

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 (140) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/README.md +10 -9
  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/config/credential-availability.ts +122 -0
  12. package/src/config/index.ts +15 -0
  13. package/src/core/composer-state.ts +11 -3
  14. package/src/core/conversation-line-cache.ts +27 -3
  15. package/src/core/conversation-rendering.ts +71 -14
  16. package/src/core/stream-event-wiring.ts +20 -1
  17. package/src/core/system-message-noise.ts +87 -0
  18. package/src/core/system-message-router.ts +68 -1
  19. package/src/core/turn-cancellation.ts +7 -2
  20. package/src/core/turn-event-wiring.ts +10 -2
  21. package/src/daemon/cli.ts +29 -2
  22. package/src/daemon/handlers/register.ts +8 -1
  23. package/src/daemon/service-commands.ts +329 -0
  24. package/src/input/autocomplete.ts +27 -1
  25. package/src/input/command-registry.ts +46 -4
  26. package/src/input/commands/codebase-runtime.ts +46 -6
  27. package/src/input/commands/config.ts +43 -3
  28. package/src/input/commands/health-runtime.ts +9 -1
  29. package/src/input/commands/memory.ts +68 -35
  30. package/src/input/commands/operator-panel-runtime.ts +31 -7
  31. package/src/input/commands/planning-runtime.ts +95 -6
  32. package/src/input/commands/qrcode-runtime.ts +25 -5
  33. package/src/input/commands/remote-runtime-setup.ts +5 -3
  34. package/src/input/commands/session-content.ts +20 -9
  35. package/src/input/commands/settings-sync-runtime.ts +15 -3
  36. package/src/input/commands/shell-core.ts +10 -1
  37. package/src/input/commands/workstream-runtime.ts +168 -18
  38. package/src/input/config-modal-types.ts +15 -1
  39. package/src/input/config-modal.ts +227 -12
  40. package/src/input/feed-context-factory.ts +3 -0
  41. package/src/input/handler-command-route.ts +10 -3
  42. package/src/input/handler-content-actions.ts +17 -2
  43. package/src/input/handler-feed-routes.ts +43 -121
  44. package/src/input/handler-feed.ts +32 -4
  45. package/src/input/handler-modal-routes.ts +78 -13
  46. package/src/input/handler-modal-stack.ts +11 -2
  47. package/src/input/handler-onboarding-daemon-adopt.ts +149 -0
  48. package/src/input/handler-onboarding.ts +71 -59
  49. package/src/input/handler-picker-routes.ts +15 -8
  50. package/src/input/handler-shortcuts.ts +59 -9
  51. package/src/input/handler.ts +6 -1
  52. package/src/input/keybindings.ts +6 -5
  53. package/src/input/model-picker.ts +19 -2
  54. package/src/input/onboarding/onboarding-runtime-status.ts +10 -1
  55. package/src/input/onboarding/onboarding-wizard-apply.ts +8 -1
  56. package/src/input/onboarding/onboarding-wizard-constants.ts +4 -1
  57. package/src/input/onboarding/onboarding-wizard-network-adopt.ts +136 -0
  58. package/src/input/onboarding/onboarding-wizard-steps.ts +9 -6
  59. package/src/input/onboarding/onboarding-wizard-types.ts +3 -1
  60. package/src/input/panel-mouse-geometry.ts +97 -0
  61. package/src/input/panel-paste-flood-guard.ts +86 -0
  62. package/src/input/selection-modal.ts +11 -0
  63. package/src/input/session-picker-modal.ts +44 -4
  64. package/src/input/settings-modal-data.ts +51 -2
  65. package/src/input/settings-modal-types.ts +4 -2
  66. package/src/main.ts +28 -27
  67. package/src/panels/builtin/shared.ts +9 -3
  68. package/src/panels/fleet-deep-link.ts +31 -0
  69. package/src/panels/fleet-panel-format.ts +62 -0
  70. package/src/panels/fleet-panel-worktree-detail.ts +48 -0
  71. package/src/panels/fleet-panel.ts +89 -131
  72. package/src/panels/fleet-read-model.ts +43 -0
  73. package/src/panels/fleet-steer.ts +35 -2
  74. package/src/panels/fleet-stop.ts +29 -1
  75. package/src/panels/modals/keybindings-modal.ts +16 -1
  76. package/src/panels/modals/modal-theme.ts +35 -29
  77. package/src/panels/modals/pairing-modal.ts +25 -6
  78. package/src/panels/modals/planning-modal.ts +43 -21
  79. package/src/panels/modals/work-plan-modal.ts +28 -0
  80. package/src/panels/panel-manager.ts +15 -4
  81. package/src/panels/polish-core.ts +38 -25
  82. package/src/panels/project-planning-answer-actions.ts +8 -13
  83. package/src/panels/types.ts +24 -0
  84. package/src/permissions/prompt.ts +160 -13
  85. package/src/renderer/autocomplete-overlay.ts +28 -2
  86. package/src/renderer/compositor.ts +2 -3
  87. package/src/renderer/config-modal.ts +19 -5
  88. package/src/renderer/footer-tips.ts +5 -1
  89. package/src/renderer/fullscreen-primitives.ts +32 -22
  90. package/src/renderer/git-status.ts +3 -1
  91. package/src/renderer/layout.ts +0 -4
  92. package/src/renderer/markdown.ts +7 -3
  93. package/src/renderer/modal-factory.ts +25 -20
  94. package/src/renderer/model-workspace.ts +18 -3
  95. package/src/renderer/overlay-box.ts +21 -17
  96. package/src/renderer/process-indicator.ts +14 -3
  97. package/src/renderer/selection-modal-overlay.ts +6 -1
  98. package/src/renderer/session-picker-modal.ts +196 -3
  99. package/src/renderer/settings-modal-helpers.ts +2 -0
  100. package/src/renderer/settings-modal.ts +7 -0
  101. package/src/renderer/shell-surface.ts +8 -1
  102. package/src/renderer/status-glyphs.ts +14 -15
  103. package/src/renderer/system-message.ts +15 -3
  104. package/src/renderer/terminal-bg-probe.ts +339 -0
  105. package/src/renderer/terminal-escapes.ts +20 -0
  106. package/src/renderer/theme-mode-config.ts +67 -0
  107. package/src/renderer/theme.ts +91 -1
  108. package/src/renderer/thinking.ts +11 -3
  109. package/src/renderer/tool-call.ts +15 -9
  110. package/src/renderer/tool-result-summary.ts +148 -0
  111. package/src/renderer/turn-injection.ts +22 -3
  112. package/src/renderer/ui-factory.ts +154 -85
  113. package/src/renderer/ui-primitives.ts +30 -129
  114. package/src/runtime/bootstrap-command-context.ts +6 -0
  115. package/src/runtime/bootstrap-command-parts.ts +8 -4
  116. package/src/runtime/bootstrap-core.ts +46 -24
  117. package/src/runtime/bootstrap-hook-bridge.ts +7 -0
  118. package/src/runtime/bootstrap-shell.ts +19 -1
  119. package/src/runtime/bootstrap.ts +118 -5
  120. package/src/runtime/code-index-services.ts +25 -2
  121. package/src/runtime/legacy-daemon-migration.ts +516 -0
  122. package/src/runtime/memory-fold.ts +55 -0
  123. package/src/runtime/onboarding/derivation.ts +7 -2
  124. package/src/runtime/onboarding/snapshot.ts +27 -1
  125. package/src/runtime/onboarding/types.ts +31 -1
  126. package/src/runtime/operator-token-cleanup.ts +82 -1
  127. package/src/runtime/orchestrator-core-services.ts +10 -0
  128. package/src/runtime/resume-notice.ts +209 -0
  129. package/src/runtime/services.ts +12 -8
  130. package/src/runtime/session-inbound-inputs.ts +252 -0
  131. package/src/runtime/session-spine-transport.ts +64 -0
  132. package/src/runtime/terminal-output-guard.ts +15 -8
  133. package/src/runtime/ui-services.ts +19 -3
  134. package/src/runtime/workstream-services.ts +160 -28
  135. package/src/runtime/wrfc-persistence.ts +124 -17
  136. package/src/shell/blocking-input.ts +46 -3
  137. package/src/shell/recovery-input-helpers.ts +170 -1
  138. package/src/shell/ui-openers.ts +42 -9
  139. package/src/utils/terminal-width.ts +52 -0
  140. package/src/version.ts +1 -1
@@ -0,0 +1,87 @@
1
+ /**
2
+ * System-message noise policy — decides whether an operational status message
3
+ * should reach the transcript unchanged, be dropped, or be folded. Every noisy
4
+ * source these rules catch is emitted (from the SDK) through
5
+ * SystemMessageRouter, so this one funnel is where first-run plumbing is kept
6
+ * out of the user's stream while the information stays reachable elsewhere
7
+ * (fleet panel, /health, /model, activity log). (UX-B item 1.)
8
+ */
9
+
10
+ export interface NoiseGateDeps {
11
+ /**
12
+ * True when a WRFC chain is terminal (passed/failed) or gone (killed/removed).
13
+ * Used to suppress stale "[Replay] … transitioned … waiting for action"
14
+ * re-notifications for chains that can no longer act. Absent → never suppress.
15
+ */
16
+ readonly isChainTerminal?: (chainId: string) => boolean;
17
+ }
18
+
19
+ export type NoiseVerdict =
20
+ /** Pass through to the transcript unchanged. */
21
+ | { readonly action: 'emit' }
22
+ /** Suppress entirely — reachable via another live surface. */
23
+ | { readonly action: 'drop' }
24
+ /** Provider "from last session" replay line — buffer and fold to one line. */
25
+ | { readonly action: 'foldProviderReplay' };
26
+
27
+ const EMIT: NoiseVerdict = { action: 'emit' };
28
+ const DROP: NoiseVerdict = { action: 'drop' };
29
+ const FOLD: NoiseVerdict = { action: 'foldProviderReplay' };
30
+
31
+ /** Matches the SDK's per-provider persisted-replay line (1b). */
32
+ const PROVIDER_REPLAY_RE = / — from last session$/;
33
+
34
+ /** Matches the SDK's 30s periodic running-agents snapshot (1d). */
35
+ const AGENTS_RUNNING_SNAPSHOT_RE = /^\[Agents\] \d+ running:/;
36
+
37
+ /** Extracts the chain id from a "[Replay]… WRFC chain <id> transitioned …" line (1c). */
38
+ const REPLAY_CHAIN_RE = /WRFC chain (\S+) transitioned .* waiting for action/;
39
+
40
+ /**
41
+ * Classify one system message. Pure aside from the injected `isChainTerminal`
42
+ * lookup, so it is trivially testable.
43
+ */
44
+ export function classifyNoise(message: string, deps: NoiseGateDeps): NoiseVerdict {
45
+ // 1b — provider-discovery replay burst ("[Local] … — from last session").
46
+ if (message.startsWith('[Local]') && PROVIDER_REPLAY_RE.test(message)) {
47
+ return FOLD;
48
+ }
49
+
50
+ // 1d — periodic "[Agents] N running:" status snapshot. The same live detail
51
+ // is shown in the fleet panel (per-agent activity) and the footer count, so
52
+ // the 30-second transcript churn is dropped. Meaningful lifecycle lines
53
+ // ("[Agents] ✓ …", "[Agents] Cohort …", "[Agents] ✗ …") do not match.
54
+ if (AGENTS_RUNNING_SNAPSHOT_RE.test(message)) {
55
+ return DROP;
56
+ }
57
+
58
+ // 1c — stale "[Replay] … waiting for action" for a terminal/killed chain.
59
+ if (message.startsWith('[Replay]') && deps.isChainTerminal) {
60
+ const match = REPLAY_CHAIN_RE.exec(message);
61
+ if (match && deps.isChainTerminal(match[1])) return DROP;
62
+ }
63
+
64
+ return EMIT;
65
+ }
66
+
67
+ /** Provider name from a "[Local] <name> at host:port (…) — from last session" line. */
68
+ export function providerNameFromReplay(message: string): string {
69
+ const match = /^\[Local\]\s+(.+?)\s+at\s/.exec(message);
70
+ if (match) return match[1];
71
+ // Fallback: first token after the tag.
72
+ const bare = /^\[Local\]\s+(\S+)/.exec(message);
73
+ return bare ? bare[1] : 'provider';
74
+ }
75
+
76
+ /**
77
+ * Build the single folded summary line for a burst of provider-replay lines.
78
+ * Names a few providers; the full list stays reachable via /health and /model.
79
+ */
80
+ export function foldProviderReplayLines(messages: readonly string[]): string {
81
+ const names = messages.map(providerNameFromReplay);
82
+ const count = names.length;
83
+ const noun = count === 1 ? 'provider' : 'providers';
84
+ const shown = names.slice(0, 3).join(', ');
85
+ const extra = count > 3 ? `, +${count - 3} more` : '';
86
+ return `[Local] Restored ${count} ${noun} from last session (${shown}${extra})`;
87
+ }
@@ -40,6 +40,7 @@
40
40
  */
41
41
 
42
42
  import type { ConversationManager } from './conversation';
43
+ import { logger } from '@pellux/goodvibes-sdk/platform/utils';
43
44
  import {
44
45
  classifySystemMessageKind,
45
46
  classifySystemMessagePriority,
@@ -48,6 +49,12 @@ import {
48
49
  type SystemMessageKind,
49
50
  type SystemMessageTarget,
50
51
  } from '@/runtime/index.ts';
52
+ import {
53
+ classifyNoise,
54
+ foldProviderReplayLines,
55
+ providerNameFromReplay,
56
+ type NoiseGateDeps,
57
+ } from './system-message-noise.ts';
51
58
 
52
59
  export type {
53
60
  SystemMessageKind,
@@ -84,9 +91,15 @@ function mustReachConversation(message: string): boolean {
84
91
  * configured target. See file doc for the W6.1 panel removal.
85
92
  */
86
93
  export class SystemMessageRouter {
94
+ /** Buffered provider "from last session" replay lines, folded on a microtask. */
95
+ private providerReplayBuffer: string[] = [];
96
+ private providerReplayScheduled = false;
97
+
87
98
  constructor(
88
99
  private readonly conversation: ConversationManager,
89
100
  private readonly getTargetForKind: (kind: SystemMessageKind) => SystemMessageTarget = defaultSystemMessageTarget,
101
+ /** Noise-gate dependencies (WRFC terminal-chain lookup). See system-message-noise.ts. */
102
+ private readonly noiseDeps: NoiseGateDeps = {},
90
103
  ) {}
91
104
 
92
105
  // ── Public API ────────────────────────────────────────────────────────────
@@ -109,6 +122,20 @@ export class SystemMessageRouter {
109
122
  _priority: SystemMessagePriority,
110
123
  kind: SystemMessageKind,
111
124
  ): void {
125
+ // Noise gate — keep first-run plumbing out of the transcript while the
126
+ // information stays reachable via other live surfaces. (UX-B item 1.)
127
+ const verdict = classifyNoise(message, this.noiseDeps);
128
+ if (verdict.action === 'drop') return;
129
+ if (verdict.action === 'foldProviderReplay') {
130
+ this.bufferProviderReplay(message);
131
+ return;
132
+ }
133
+
134
+ this.deliver(message, kind);
135
+ }
136
+
137
+ /** Post-noise-gate delivery: resolve target and append to the conversation. */
138
+ private deliver(message: string, kind: SystemMessageKind): void {
112
139
  const target = this.getTargetForKind(kind);
113
140
  // hasPanel is always false post-W6.1 — resolveSystemMessageDelivery's own
114
141
  // contract means every target ('panel' | 'conversation' | 'both')
@@ -122,6 +149,45 @@ export class SystemMessageRouter {
122
149
  }
123
150
  }
124
151
 
152
+ /**
153
+ * Buffer a provider "from last session" replay line and schedule a microtask
154
+ * flush. The SDK emits the whole persisted-provider burst synchronously, so a
155
+ * single microtask captures the full burst and folds it to one quiet line.
156
+ * (UX-B item 1b.)
157
+ */
158
+ private bufferProviderReplay(message: string): void {
159
+ this.providerReplayBuffer.push(message);
160
+ if (this.providerReplayScheduled) return;
161
+ this.providerReplayScheduled = true;
162
+ queueMicrotask(() => this.flushProviderReplay());
163
+ }
164
+
165
+ /**
166
+ * Record the folded provider-replay summary and reset the buffer.
167
+ *
168
+ * Papercut sweep item 2: this used to `deliver()` the folded line into the
169
+ * transcript — one line instead of a burst, but still boot plumbing the
170
+ * user never asked to see there ("the transcript at boot shows product
171
+ * signal only"). The persisted-provider set this summarizes is reachable
172
+ * on demand via `/health provider` (providers-modal lists every registered
173
+ * provider, discovered/local ones included) and `/model` (lists every
174
+ * selectable model, discovered ones included) — so nothing is lost by
175
+ * keeping it out of the transcript. It still goes to the activity log
176
+ * (.goodvibes/logs/activity.md) for diagnosis. Only this boot-only "— from
177
+ * last session" burst moves; unrelated provider-discovery lines emitted
178
+ * mid-session (e.g. "[Scan] Found …", "[Scan] … no longer reachable") are
179
+ * untouched — they never match PROVIDER_REPLAY_RE, so they never enter this
180
+ * buffer/fold path and keep reaching the transcript as live product signal.
181
+ */
182
+ flushProviderReplay(): void {
183
+ this.providerReplayScheduled = false;
184
+ if (this.providerReplayBuffer.length === 0) return;
185
+ const summary = foldProviderReplayLines(this.providerReplayBuffer);
186
+ const providerNames = this.providerReplayBuffer.map(providerNameFromReplay);
187
+ this.providerReplayBuffer = [];
188
+ logger.info(summary, { count: providerNames.length, providers: providerNames });
189
+ }
190
+
125
191
  routeSystemMessage(message: string, priority: SystemMessagePriority): void {
126
192
  this.routeTypedSystemMessage(message, priority, classifySystemMessageKind(message));
127
193
  }
@@ -172,6 +238,7 @@ export class SystemMessageRouter {
172
238
  export function createSystemMessageRouter(
173
239
  conversation: ConversationManager,
174
240
  getTargetForKind: (kind: SystemMessageKind) => SystemMessageTarget = defaultSystemMessageTarget,
241
+ noiseDeps: NoiseGateDeps = {},
175
242
  ): SystemMessageRouter {
176
- return new SystemMessageRouter(conversation, getTargetForKind);
243
+ return new SystemMessageRouter(conversation, getTargetForKind, noiseDeps);
177
244
  }
@@ -6,15 +6,20 @@ import type { SpokenTurnRuntime } from '../audio/spoken-turn-wiring.ts';
6
6
  * spoken output, then aborts the orchestrator's turn if one is actively
7
7
  * streaming. Extracted from main.ts so the file can stay under its 800-line
8
8
  * cap while still wiring `commandContext.isGenerating` (see W1.6 FIX 1).
9
+ *
10
+ * Returns whether SPEECH was actively stopped — a Ctrl+C that silenced live
11
+ * TTS is consumed by that job (batch replay D5), same as a press that
12
+ * cleared a non-empty prompt; the quit chord starts from a quiet state.
9
13
  */
10
14
  export function createCancelGeneration(
11
15
  orchestrator: Orchestrator,
12
16
  spokenTurns: Pick<SpokenTurnRuntime, 'stop'>,
13
- ): () => void {
17
+ ): () => boolean {
14
18
  return () => {
15
- spokenTurns.stop('Spoken output stopped.');
19
+ const stoppedSpeech = spokenTurns.stop('Spoken output stopped.');
16
20
  if (orchestrator.isThinking) {
17
21
  orchestrator.abort();
18
22
  }
23
+ return stoppedSpeech;
19
24
  };
20
25
  }
@@ -232,9 +232,17 @@ export function wireTurnEventHandlers(
232
232
  }));
233
233
  unsubs.push(events.workflows.on('WORKFLOW_CHAIN_FAILED', (payload) => {
234
234
  if (!shouldFireAlert(focusTracker, configGet, 'behavior.notifyOnChainFailure')) return;
235
- const kindLabel = payload.failureKind === 'transport' ? 'transient transport error' : payload.reason;
236
235
  try {
237
- notifyCompletion('GoodVibes WRFC chain failed', `chain ${payload.chainId.slice(0, 12)} failed: ${kindLabel}`, FORCE_NOTIFY_DURATION_MS);
236
+ // An operator cancellation is an intended stop, not a failure — narrate it
237
+ // as cancelled (the reason already carries the landed-work count from the
238
+ // chain's edit ledger), so the notification never contradicts the cancelled
239
+ // chain/owner/cohort surfaces.
240
+ if (payload.failureKind === 'cancelled') {
241
+ notifyCompletion('GoodVibes — WRFC chain cancelled', `chain ${payload.chainId.slice(0, 12)} cancelled: ${payload.reason}`, FORCE_NOTIFY_DURATION_MS);
242
+ } else {
243
+ const kindLabel = payload.failureKind === 'transport' ? 'transient transport error' : payload.reason;
244
+ notifyCompletion('GoodVibes — WRFC chain failed', `chain ${payload.chainId.slice(0, 12)} failed: ${kindLabel}`, FORCE_NOTIFY_DURATION_MS);
245
+ }
238
246
  } catch (err) {
239
247
  logger.debug('turn-event-wiring: chain-failure notify error', { error: String(err) });
240
248
  }
package/src/daemon/cli.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { homedir, networkInterfaces } from 'node:os';
2
2
  import { join } from 'node:path';
3
3
  import { readFileSync } from 'node:fs';
4
- import { ConfigManager } from '@pellux/goodvibes-sdk/platform/config';
4
+ import { ConfigManager, resolveDaemonEnabled } from '@pellux/goodvibes-sdk/platform/config';
5
5
  import { formatProviderModel, getModelIdFromProviderModel, getProviderIdFromModel } from '../config/provider-model.ts';
6
6
  import { RuntimeEventBus, GlobalNetworkTransportInstaller } from '@/runtime/index.ts';
7
7
  import { createFeatureFlagManager } from '@/runtime/index.ts';
@@ -27,6 +27,7 @@ import {
27
27
  persistProviders,
28
28
  } from '@pellux/goodvibes-sdk/platform/discovery';
29
29
  import { createSafeHostServeFactory } from './safe-serve.ts';
30
+ import { isDaemonServiceSubcommand, resolveInstalledDaemonBinary, runDaemonServiceCli } from './service-commands.ts';
30
31
 
31
32
  import {
32
33
  parseGoodVibesCli,
@@ -161,6 +162,32 @@ async function main(): Promise<void> {
161
162
  process.env['GOODVIBES_DAEMON_HOST'] = cliFlags.hostname;
162
163
  logger.info('daemon: --hostname flag applied', { hostname: cliFlags.hostname });
163
164
  }
165
+
166
+ // D7a Layer 1: `install-service` / `uninstall-service` / `service-status` manage
167
+ // the systemd USER unit for the shared daemon. They run BEFORE the daemon boots —
168
+ // no runtime/services are constructed — and exit with the honest result code.
169
+ const serviceSubcommand = cli.positionals[0];
170
+ if (isDaemonServiceSubcommand(serviceSubcommand)) {
171
+ const host = String(process.env.GOODVIBES_DAEMON_HOST ?? config.get('controlPlane.host') ?? '127.0.0.1');
172
+ const port = Number(config.get('controlPlane.port'));
173
+ const binaryPath = resolveInstalledDaemonBinary({ moduleUrl: import.meta.url });
174
+ const result = await runDaemonServiceCli({
175
+ subcommand: serviceSubcommand,
176
+ binaryPath,
177
+ homeDir: homeDirectory,
178
+ host,
179
+ port,
180
+ // migrate-service only: never auto-migrate — requires the same explicit
181
+ // consent as any other non-interactive destructive confirmation.
182
+ confirmMigration: cliFlags.yes,
183
+ });
184
+ for (const line of result.lines) {
185
+ // eslint-disable-next-line no-console
186
+ console.log(line);
187
+ }
188
+ process.exit(result.exitCode);
189
+ }
190
+
164
191
  const runtimeBus = new RuntimeEventBus();
165
192
  const runtimeStore = createRuntimeStore();
166
193
  const featureFlags = createFeatureFlagManager();
@@ -269,7 +296,7 @@ async function main(): Promise<void> {
269
296
  process.on('SIGTERM', handleSignal);
270
297
 
271
298
  logger.info('goodvibes daemon host started', {
272
- daemon: config.get('danger.daemon'),
299
+ daemon: resolveDaemonEnabled(config),
273
300
  httpListener: config.get('danger.httpListener'),
274
301
  });
275
302
 
@@ -111,7 +111,14 @@ export function registerCatalogHandler<TBody, TResult>(
111
111
  ): Unregister {
112
112
  const descriptor = catalog.get(methodId);
113
113
  if (!descriptor) {
114
- throw new HandlerError(`Unknown gateway method: ${methodId}`, 'UNKNOWN_METHOD', 404);
114
+ // 'METHOD_NOT_FOUND' (not the old locally-coined 'UNKNOWN_METHOD') so this lines
115
+ // up byte-for-byte with SDKErrorCodes.METHOD_NOT_FOUND — the code the SDK's own
116
+ // uncataloged-method 404 now carries (method-catalog.ts's GatewayMethodCatalog
117
+ // .invoke(), daemon/control-plane.ts's invokeGatewayMethodCall, and daemon-sdk's
118
+ // control-routes.ts getGatewayMethod/invokeGatewayMethod). A literal string, not
119
+ // an import of SDKErrorCodes: the pinned SDK (0.38.0) predates that constant, and
120
+ // matching the value this way needs no SDK version bump to stay aligned.
121
+ throw new HandlerError(`Unknown gateway method: ${methodId}`, 'METHOD_NOT_FOUND', 404);
115
122
  }
116
123
 
117
124
  const wrapped = async (inv: GatewayMethodInvocation): Promise<unknown> => {
@@ -0,0 +1,329 @@
1
+ /**
2
+ * D7a Layer 1 (TUI wiring) — `goodvibes-daemon install-service | uninstall-service
3
+ * | service-status`.
4
+ *
5
+ * The daemon is a SYSTEM SERVICE. These subcommands install it as a durable host
6
+ * service (systemd user unit on Linux, launchd agent on macOS, a Scheduled Task
7
+ * on Windows) so N surfaces share ONE daemon that survives reboots, instead of
8
+ * every surface spawning a session-scoped daemon.
9
+ *
10
+ * W3 drift note: the SDK's D7a-era `systemd-user-service.ts` (a Linux-only,
11
+ * bespoke systemd shim) was dead code and got deleted in SDK W3-S5. The SDK's
12
+ * REAL wired service machinery — reached in production by the daemon's own HTTP
13
+ * `/api/service/*` routes via facade-composition.ts — is
14
+ * `PlatformServiceManager` (`@pellux/goodvibes-sdk/platform/daemon`): a single
15
+ * systemd/launchd/windows-aware manager with install/uninstall/status/start/
16
+ * stop/restart and a `suggestedCommands` hint list. This module now rewires the
17
+ * three CLI subcommands onto that manager instead of the deleted shim.
18
+ *
19
+ * Two real behavioral differences from the old shim, called out honestly:
20
+ * - `install()` only WRITES the unit/plist/task; it does not enable/start it.
21
+ * So `install-service` here calls `install()` then `start()` to preserve the
22
+ * old "install implies enabled + running" behavior.
23
+ * - `uninstall()` only REMOVES the unit file; there is no manager-level
24
+ * `disable` verb (only start/stop/restart exist). So `uninstall-service`
25
+ * calls `stop()` then `uninstall()`, and honestly tells the caller that a
26
+ * stray "enabled" symlink may remain until `systemctl --user daemon-reload`
27
+ * (offered back as a suggested follow-up) or the next login cleans it up.
28
+ *
29
+ * W4-D1: a fourth subcommand, `migrate-service`, closes the Wave-3 "detect and
30
+ * disclose only" inheritance (W3 Finding 4, below) with a GUIDED, CONSENTED
31
+ * takeover of the legacy `goodvibes-daemon.service` unit. Design constraints,
32
+ * all load-bearing:
33
+ * - NEVER auto-migrate. Without explicit consent (`confirmMigration`, wired
34
+ * from the CLI's existing `-y`/`--yes` flag) this prints the exact plan
35
+ * and touches nothing.
36
+ * - NEW-UP-THEN-OLD-DOWN. The new `goodvibes.service` unit is installed,
37
+ * started, and verified healthy (a fresh `status().running` read, which
38
+ * honestly queries systemd via the injected actionRunner) BEFORE the
39
+ * legacy unit is stopped, disabled, or removed. A failed or unhealthy new
40
+ * unit rolls itself back (uninstalled) and never touches the legacy one
41
+ * — a botched takeover must never cost the user their working daemon.
42
+ * - ADOPT-OR-WARN, NEVER KILL. If the legacy unit file is simply absent but
43
+ * something is already listening on the configured host:port (Mike's real
44
+ * dev-host case: a manual `nohup`'d daemon with no unit at all), this is
45
+ * an unidentified process, not a managed unit — there is nothing to stop
46
+ * or disable, and this module will not attempt to kill it. It warns and
47
+ * leaves the decision to the operator.
48
+ * - Every action (legacy stop/disable, unit-file removal, daemon-reload)
49
+ * goes through the SAME injectable `actionRunner`/`legacyUnitFileRemove`
50
+ * seams tests already use — this module never has a code path that bypasses
51
+ * them, so the migration is exercised deterministically via fakes and never
52
+ * touches a real running service in tests.
53
+ */
54
+
55
+ import { ConfigManager } from '@pellux/goodvibes-sdk/platform/config';
56
+ import { PlatformServiceManager, type ManagedServiceStatus } from '@pellux/goodvibes-sdk/platform/daemon';
57
+ import {
58
+ buildManagedDaemonServiceManager,
59
+ detectLegacyUnit,
60
+ legacyUnitNote,
61
+ resolveManagedUnitName,
62
+ runLegacyDaemonMigration,
63
+ LEGACY_SERVICE_UNIT_NAME,
64
+ MANAGED_SERVICE_NAME as SERVICE_NAME,
65
+ MANAGED_SERVICE_DESCRIPTION as SERVICE_DESCRIPTION,
66
+ type LegacyUnitInfo,
67
+ type ManagedServiceActionRunner,
68
+ } from '../runtime/legacy-daemon-migration.ts';
69
+
70
+ // `resolveInstalledDaemonBinary` lives in the runtime module (shared with the
71
+ // onboarding guided UX) — re-exported here so this module stays the CLI's
72
+ // stable public surface (and so existing test imports keep working).
73
+ export {
74
+ resolveInstalledDaemonBinary,
75
+ type ResolveDaemonBinaryOptions,
76
+ } from '../runtime/legacy-daemon-migration.ts';
77
+ export type { ManagedServiceActionRunner } from '../runtime/legacy-daemon-migration.ts';
78
+
79
+ // ---------------------------------------------------------------------------
80
+ // W3 Finding 4: legacy-unit detection. W4-D1: guided migration.
81
+ //
82
+ // The prior D7a-era command installed the daemon's systemd unit under the
83
+ // literal name `goodvibes-daemon.service`. This module (rewired onto
84
+ // PlatformServiceManager, see the file banner above) manages a DIFFERENT
85
+ // unit name (`goodvibes`, SERVICE_NAME). A host that still has the legacy
86
+ // unit installed — this dev machine included — would otherwise see
87
+ // service-status honestly report installed:false/running:false for the
88
+ // tracked name while the legacy unit keeps running untouched underneath it,
89
+ // uninstall-service would silently leave the legacy unit orphaned with no
90
+ // mention, and install-service could start a SECOND daemon competing for the
91
+ // same port.
92
+ //
93
+ // The detection (`detectLegacyUnit`/`legacyUnitNote`) and the guided
94
+ // migration engine (`runLegacyDaemonMigration`) both live in
95
+ // `../runtime/legacy-daemon-migration.ts` rather than here, so the TUI's
96
+ // onboarding UX (`src/input/handler-onboarding-daemon-adopt.ts`) can reuse
97
+ // them directly — the architecture gate forbids `src/input/**` from
98
+ // importing `src/daemon/**` (input must stay entrypoint-agnostic), so the
99
+ // shared engine lives in the entrypoint-agnostic `runtime` layer instead and
100
+ // this CLI module is just one of its two consumers.
101
+ //
102
+ // This detection is entirely independent of PlatformServiceManager's own
103
+ // status() — it does not rely on (or get invalidated by) the parallel SDK
104
+ // fix that makes status().running itself query systemd honestly for the
105
+ // TRACKED unit name.
106
+ // ---------------------------------------------------------------------------
107
+
108
+ export type DaemonServiceSubcommand = 'install-service' | 'uninstall-service' | 'service-status' | 'migrate-service';
109
+
110
+ export function isDaemonServiceSubcommand(value: string | undefined): value is DaemonServiceSubcommand {
111
+ return value === 'install-service' || value === 'uninstall-service' || value === 'service-status' || value === 'migrate-service';
112
+ }
113
+
114
+ export interface DaemonServiceCliInput {
115
+ readonly subcommand: DaemonServiceSubcommand;
116
+ readonly binaryPath: string;
117
+ readonly homeDir: string;
118
+ readonly host: string;
119
+ readonly port: number;
120
+ /** Defaults to `homeDir` — overridable so tests can scope both to one tempdir. */
121
+ readonly workingDirectory?: string | undefined;
122
+ /** Injected in tests; a real `ConfigManager` rooted at `homeDir` otherwise. */
123
+ readonly configManager?: ConfigManager | undefined;
124
+ /** Injectable systemctl/launchctl/schtasks runner so tests never touch the host. */
125
+ readonly actionRunner?: ManagedServiceActionRunner | undefined;
126
+ /** Injectable existsSync for the legacy-unit file check (W3 Finding 4) so tests never touch the host filesystem. */
127
+ readonly legacyUnitFileExists?: ((path: string) => boolean) | undefined;
128
+ /**
129
+ * `migrate-service` only: explicit consent to actually execute the
130
+ * migration (wired from the CLI's `-y`/`--yes` flag). Without it, the
131
+ * subcommand prints the exact plan and changes nothing — never auto-migrate.
132
+ */
133
+ readonly confirmMigration?: boolean | undefined;
134
+ /**
135
+ * `migrate-service` only: injectable port-liveness check for the
136
+ * legacy-absent branch (never a raw network call in tests). Defaults to a
137
+ * real, read-only TCP connect attempt.
138
+ */
139
+ readonly portProbe?: ((host: string, port: number) => boolean | Promise<boolean>) | undefined;
140
+ /**
141
+ * `migrate-service` only: injectable removal of the legacy unit file, so
142
+ * tests never call a raw fs op against a real path. Defaults to a real
143
+ * `rmSync`.
144
+ */
145
+ readonly legacyUnitFileRemove?: ((path: string) => void) | undefined;
146
+ }
147
+
148
+ export interface DaemonServiceCliResult {
149
+ readonly ok: boolean;
150
+ readonly exitCode: number;
151
+ /** Honest, human-readable stdout lines describing exactly what happened. */
152
+ readonly lines: readonly string[];
153
+ readonly status: ManagedServiceStatus;
154
+ }
155
+
156
+ /**
157
+ * The manager's definition (`ExecStart` command/args, name, description) is
158
+ * built once, in `../runtime/legacy-daemon-migration.ts`, and shared with the
159
+ * onboarding guided UX — see that module's doc comment for why.
160
+ */
161
+ function buildManager(input: DaemonServiceCliInput): PlatformServiceManager {
162
+ return buildManagedDaemonServiceManager({
163
+ binaryPath: input.binaryPath,
164
+ homeDir: input.homeDir,
165
+ host: input.host,
166
+ port: input.port,
167
+ workingDirectory: input.workingDirectory,
168
+ configManager: input.configManager,
169
+ actionRunner: input.actionRunner,
170
+ });
171
+ }
172
+
173
+ function statusLines(status: ManagedServiceStatus): string[] {
174
+ const lines = [
175
+ `platform: ${status.platform}`,
176
+ `installed: ${status.installed}`,
177
+ `running: ${status.running}`,
178
+ `unit path: ${status.path}`,
179
+ ];
180
+ if (status.pid !== undefined) lines.push(`pid: ${status.pid}`);
181
+ if (status.platform !== 'manual' && status.installed && !status.running) {
182
+ // W3 Finding 4: this used to assert "'running' here only reflects
183
+ // processes this tool started directly" — true for the pid-file-only
184
+ // check the (currently linked) SDK still uses, but the parallel SDK
185
+ // batch is making status().running query systemd honestly via
186
+ // `is-active`, which would make that specific claim stale. Drop the
187
+ // claim about HOW running was computed and just offer the escape
188
+ // hatch — true and useful under either SDK version.
189
+ lines.push(
190
+ `note: if this looks wrong, verify directly: ${status.suggestedCommands[status.suggestedCommands.length - 1] ?? 'the platform service-status command'}`,
191
+ );
192
+ }
193
+ return lines;
194
+ }
195
+
196
+ /**
197
+ * Build the install-branch's result lines. Exported for direct unit
198
+ * coverage: the "suggested follow-ups" block is gated on the actual
199
+ * not-started case (W3 Finding 4 friction fix) rather than printed
200
+ * unconditionally, and driving that through the full
201
+ * PlatformServiceManager/systemd integration can't reliably produce
202
+ * `running: true` under the (currently linked) SDK build, whose systemd
203
+ * status check is still pid-file-only (see the honesty note above).
204
+ */
205
+ export function buildInstallResultLines(status: ManagedServiceStatus): string[] {
206
+ const lines = [`installed the ${status.platform} service at ${status.path}`];
207
+ if (status.running) {
208
+ lines.push('service is enabled and running');
209
+ } else {
210
+ lines.push('suggested follow-ups if it did not start automatically:');
211
+ for (const cmd of status.suggestedCommands) lines.push(` ${cmd}`);
212
+ }
213
+ return lines;
214
+ }
215
+
216
+ function ok(action: 'install' | 'uninstall' | 'status', status: ManagedServiceStatus, extra: string[] = []): DaemonServiceCliResult {
217
+ const lines: string[] = [];
218
+ if (action === 'install') {
219
+ lines.push(...buildInstallResultLines(status));
220
+ } else if (action === 'uninstall') {
221
+ lines.push(`removed the ${status.platform} service at ${status.path}`);
222
+ if (status.platform === 'systemd') {
223
+ lines.push(
224
+ "note: this removes the unit file but does not run `disable` — run " +
225
+ "`systemctl --user daemon-reload` to clear any stale enablement symlink.",
226
+ );
227
+ }
228
+ } else {
229
+ lines.push(...statusLines(status));
230
+ }
231
+ lines.push(...extra);
232
+ return { ok: true, exitCode: 0, lines, status };
233
+ }
234
+
235
+ function failed(action: 'install' | 'uninstall' | 'status', status: ManagedServiceStatus): DaemonServiceCliResult {
236
+ return {
237
+ ok: false,
238
+ exitCode: 1,
239
+ lines: [`service ${action} failed: ${status.actionError ?? 'unknown error'}`],
240
+ status,
241
+ };
242
+ }
243
+
244
+ /**
245
+ * `migrate-service`: the guided, consented takeover of the legacy
246
+ * `goodvibes-daemon.service` unit (W4-D1). Thin wrapper over
247
+ * `runLegacyDaemonMigration` (`../runtime/legacy-daemon-migration.ts`) — see
248
+ * that module for the design constraints (never auto-migrate,
249
+ * new-up-then-old-down, adopt-or-warn/never kill an unrecognized process,
250
+ * every action through an injectable seam) and the onboarding UX consumer.
251
+ */
252
+ function runMigrateService(
253
+ input: DaemonServiceCliInput,
254
+ manager: PlatformServiceManager,
255
+ legacy: LegacyUnitInfo,
256
+ ): Promise<DaemonServiceCliResult> {
257
+ return runLegacyDaemonMigration(
258
+ {
259
+ host: input.host,
260
+ port: input.port,
261
+ trackedServiceName: SERVICE_NAME,
262
+ confirmMigration: input.confirmMigration,
263
+ portProbe: input.portProbe,
264
+ legacyUnitFileRemove: input.legacyUnitFileRemove,
265
+ actionRunner: input.actionRunner,
266
+ },
267
+ manager,
268
+ legacy,
269
+ );
270
+ }
271
+
272
+ /** Dispatch a daemon service subcommand to the SDK's `PlatformServiceManager`. */
273
+ export async function runDaemonServiceCli(input: DaemonServiceCliInput): Promise<DaemonServiceCliResult> {
274
+ const manager = buildManager(input);
275
+ const legacy = detectLegacyUnit(input);
276
+ switch (input.subcommand) {
277
+ case 'migrate-service':
278
+ return runMigrateService(input, manager, legacy);
279
+ case 'install-service': {
280
+ // W3 Finding 4: refuse rather than risk starting a second daemon
281
+ // alongside an already-installed legacy unit. Refuses whenever the
282
+ // legacy unit is present at all (not just when currently active) —
283
+ // an installed-but-inactive legacy unit can still be enabled and
284
+ // start competing for the same port later, and "never silently
285
+ // start a second daemon" is the bar here, not "never right now."
286
+ if (legacy.present) {
287
+ const status = manager.status();
288
+ const resolvedName = resolveManagedUnitName(status);
289
+ return {
290
+ ok: false,
291
+ exitCode: 1,
292
+ lines: [
293
+ `service install refused: a service is already installed under the legacy name ${LEGACY_SERVICE_UNIT_NAME}.service.`,
294
+ legacyUnitNote(legacy, resolvedName),
295
+ `Installing this tool's ${resolvedName}.service alongside it risks two daemons competing for the same port.`,
296
+ ],
297
+ status,
298
+ };
299
+ }
300
+ const installed = manager.install();
301
+ if (installed.actionError) return failed('install', installed);
302
+ const started = manager.start();
303
+ // start()'s actionError (e.g. a platform this manager can't dispatch
304
+ // actions for) doesn't undo the write — report install as ok, but surface
305
+ // the follow-up problem honestly instead of claiming it is running.
306
+ return started.actionError
307
+ ? ok('install', { ...started, running: false }, [`could not start it automatically: ${started.actionError}`])
308
+ : ok('install', started);
309
+ }
310
+ case 'uninstall-service': {
311
+ const stopped = manager.stop();
312
+ const uninstalled = manager.uninstall();
313
+ if (uninstalled.actionError) return failed('uninstall', uninstalled);
314
+ const extra: string[] = [];
315
+ if (stopped.actionError) extra.push(`(it may not have been running: ${stopped.actionError})`);
316
+ // W3 Finding 4: this command only ever touches the TRACKED unit
317
+ // (whatever name/path actually resolved, per F2 — not necessarily the
318
+ // SERVICE_NAME constant) above — say so explicitly when a legacy unit
319
+ // also exists, so its continued presence is never a silent surprise.
320
+ if (legacy.present) extra.push(legacyUnitNote(legacy, resolveManagedUnitName(uninstalled)));
321
+ return ok('uninstall', uninstalled, extra);
322
+ }
323
+ case 'service-status': {
324
+ const status = manager.status();
325
+ if (status.actionError) return failed('status', status);
326
+ return ok('status', status, legacy.present ? [legacyUnitNote(legacy, resolveManagedUnitName(status))] : []);
327
+ }
328
+ }
329
+ }