@pellux/goodvibes-tui 1.7.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 (137) hide show
  1. package/README.md +10 -9
  2. package/docs/foundation-artifacts/operator-contract.json +4045 -1763
  3. package/package.json +2 -2
  4. package/src/audio/spoken-turn-controller.ts +12 -2
  5. package/src/audio/spoken-turn-wiring.ts +2 -1
  6. package/src/cli/help.ts +8 -1
  7. package/src/cli/service-posture.ts +2 -1
  8. package/src/cli/status.ts +2 -1
  9. package/src/cli/surface-command.ts +2 -2
  10. package/src/core/composer-state.ts +11 -3
  11. package/src/core/conversation-line-cache.ts +27 -3
  12. package/src/core/conversation-rendering.ts +71 -14
  13. package/src/core/stream-event-wiring.ts +20 -1
  14. package/src/core/system-message-noise.ts +87 -0
  15. package/src/core/system-message-router.ts +68 -1
  16. package/src/core/turn-cancellation.ts +7 -2
  17. package/src/core/turn-event-wiring.ts +10 -2
  18. package/src/daemon/cli.ts +29 -2
  19. package/src/daemon/handlers/register.ts +8 -1
  20. package/src/daemon/service-commands.ts +329 -0
  21. package/src/input/autocomplete.ts +27 -1
  22. package/src/input/command-registry.ts +46 -4
  23. package/src/input/commands/codebase-runtime.ts +46 -6
  24. package/src/input/commands/config.ts +43 -3
  25. package/src/input/commands/health-runtime.ts +9 -1
  26. package/src/input/commands/memory.ts +68 -35
  27. package/src/input/commands/operator-panel-runtime.ts +31 -7
  28. package/src/input/commands/planning-runtime.ts +95 -6
  29. package/src/input/commands/qrcode-runtime.ts +25 -5
  30. package/src/input/commands/remote-runtime-setup.ts +5 -3
  31. package/src/input/commands/session-content.ts +20 -9
  32. package/src/input/commands/settings-sync-runtime.ts +15 -3
  33. package/src/input/commands/shell-core.ts +10 -1
  34. package/src/input/commands/workstream-runtime.ts +168 -18
  35. package/src/input/config-modal-types.ts +15 -1
  36. package/src/input/config-modal.ts +227 -12
  37. package/src/input/feed-context-factory.ts +3 -0
  38. package/src/input/handler-command-route.ts +10 -3
  39. package/src/input/handler-content-actions.ts +17 -2
  40. package/src/input/handler-feed-routes.ts +43 -121
  41. package/src/input/handler-feed.ts +32 -4
  42. package/src/input/handler-modal-routes.ts +78 -13
  43. package/src/input/handler-modal-stack.ts +11 -2
  44. package/src/input/handler-onboarding-daemon-adopt.ts +149 -0
  45. package/src/input/handler-onboarding.ts +71 -59
  46. package/src/input/handler-picker-routes.ts +15 -8
  47. package/src/input/handler-shortcuts.ts +59 -9
  48. package/src/input/handler.ts +6 -1
  49. package/src/input/keybindings.ts +6 -5
  50. package/src/input/model-picker.ts +19 -2
  51. package/src/input/onboarding/onboarding-runtime-status.ts +10 -1
  52. package/src/input/onboarding/onboarding-wizard-apply.ts +8 -1
  53. package/src/input/onboarding/onboarding-wizard-constants.ts +4 -1
  54. package/src/input/onboarding/onboarding-wizard-network-adopt.ts +136 -0
  55. package/src/input/onboarding/onboarding-wizard-steps.ts +9 -6
  56. package/src/input/onboarding/onboarding-wizard-types.ts +3 -1
  57. package/src/input/panel-mouse-geometry.ts +97 -0
  58. package/src/input/panel-paste-flood-guard.ts +86 -0
  59. package/src/input/selection-modal.ts +11 -0
  60. package/src/input/session-picker-modal.ts +44 -4
  61. package/src/input/settings-modal-data.ts +51 -2
  62. package/src/input/settings-modal-types.ts +4 -2
  63. package/src/main.ts +28 -27
  64. package/src/panels/builtin/shared.ts +9 -3
  65. package/src/panels/fleet-deep-link.ts +31 -0
  66. package/src/panels/fleet-panel-format.ts +62 -0
  67. package/src/panels/fleet-panel-worktree-detail.ts +48 -0
  68. package/src/panels/fleet-panel.ts +89 -131
  69. package/src/panels/fleet-read-model.ts +43 -0
  70. package/src/panels/fleet-steer.ts +35 -2
  71. package/src/panels/fleet-stop.ts +29 -1
  72. package/src/panels/modals/keybindings-modal.ts +16 -1
  73. package/src/panels/modals/modal-theme.ts +35 -29
  74. package/src/panels/modals/pairing-modal.ts +25 -6
  75. package/src/panels/modals/planning-modal.ts +43 -21
  76. package/src/panels/modals/work-plan-modal.ts +28 -0
  77. package/src/panels/panel-manager.ts +15 -4
  78. package/src/panels/polish-core.ts +38 -25
  79. package/src/panels/project-planning-answer-actions.ts +8 -13
  80. package/src/panels/types.ts +24 -0
  81. package/src/permissions/prompt.ts +160 -13
  82. package/src/renderer/autocomplete-overlay.ts +28 -2
  83. package/src/renderer/compositor.ts +2 -3
  84. package/src/renderer/config-modal.ts +19 -5
  85. package/src/renderer/footer-tips.ts +5 -1
  86. package/src/renderer/fullscreen-primitives.ts +32 -22
  87. package/src/renderer/git-status.ts +3 -1
  88. package/src/renderer/layout.ts +0 -4
  89. package/src/renderer/markdown.ts +7 -3
  90. package/src/renderer/modal-factory.ts +25 -20
  91. package/src/renderer/model-workspace.ts +18 -3
  92. package/src/renderer/overlay-box.ts +21 -17
  93. package/src/renderer/process-indicator.ts +14 -3
  94. package/src/renderer/selection-modal-overlay.ts +6 -1
  95. package/src/renderer/session-picker-modal.ts +196 -3
  96. package/src/renderer/settings-modal-helpers.ts +2 -0
  97. package/src/renderer/settings-modal.ts +7 -0
  98. package/src/renderer/shell-surface.ts +8 -1
  99. package/src/renderer/status-glyphs.ts +14 -15
  100. package/src/renderer/system-message.ts +15 -3
  101. package/src/renderer/terminal-bg-probe.ts +339 -0
  102. package/src/renderer/terminal-escapes.ts +20 -0
  103. package/src/renderer/theme-mode-config.ts +67 -0
  104. package/src/renderer/theme.ts +91 -1
  105. package/src/renderer/thinking.ts +11 -3
  106. package/src/renderer/tool-call.ts +15 -9
  107. package/src/renderer/tool-result-summary.ts +148 -0
  108. package/src/renderer/turn-injection.ts +22 -3
  109. package/src/renderer/ui-factory.ts +154 -85
  110. package/src/renderer/ui-primitives.ts +30 -129
  111. package/src/runtime/bootstrap-command-context.ts +6 -0
  112. package/src/runtime/bootstrap-command-parts.ts +8 -4
  113. package/src/runtime/bootstrap-core.ts +33 -11
  114. package/src/runtime/bootstrap-hook-bridge.ts +7 -0
  115. package/src/runtime/bootstrap-shell.ts +19 -1
  116. package/src/runtime/bootstrap.ts +118 -5
  117. package/src/runtime/code-index-services.ts +25 -2
  118. package/src/runtime/legacy-daemon-migration.ts +516 -0
  119. package/src/runtime/memory-fold.ts +26 -0
  120. package/src/runtime/onboarding/derivation.ts +7 -2
  121. package/src/runtime/onboarding/snapshot.ts +27 -1
  122. package/src/runtime/onboarding/types.ts +31 -1
  123. package/src/runtime/operator-token-cleanup.ts +82 -1
  124. package/src/runtime/orchestrator-core-services.ts +10 -0
  125. package/src/runtime/resume-notice.ts +209 -0
  126. package/src/runtime/services.ts +12 -8
  127. package/src/runtime/session-inbound-inputs.ts +252 -0
  128. package/src/runtime/session-spine-transport.ts +64 -0
  129. package/src/runtime/terminal-output-guard.ts +15 -8
  130. package/src/runtime/ui-services.ts +19 -3
  131. package/src/runtime/workstream-services.ts +160 -28
  132. package/src/runtime/wrfc-persistence.ts +124 -17
  133. package/src/shell/blocking-input.ts +46 -3
  134. package/src/shell/recovery-input-helpers.ts +170 -1
  135. package/src/shell/ui-openers.ts +42 -9
  136. package/src/utils/terminal-width.ts +52 -0
  137. package/src/version.ts +1 -1
@@ -38,7 +38,7 @@
38
38
  // ---------------------------------------------------------------------------
39
39
 
40
40
  import { join } from 'node:path';
41
- import { CodeIndexStore } from '@pellux/goodvibes-sdk/platform/state';
41
+ import { CodeIndexStore, CodeIndexReindexScheduler } from '@pellux/goodvibes-sdk/platform/state';
42
42
  import type { MemoryEmbeddingProviderRegistry } from '@pellux/goodvibes-sdk/platform/state';
43
43
  import type { ConfigKey, ConfigManager } from '@pellux/goodvibes-sdk/platform/config';
44
44
  import { readBooleanConfig } from '../core/alert-gating.ts';
@@ -67,8 +67,26 @@ export interface CodeIndexServicesDeps {
67
67
  readonly memoryEmbeddingRegistry: MemoryEmbeddingProviderRegistry;
68
68
  }
69
69
 
70
+ /**
71
+ * Whether Stage B code auto-injection / tool-site reindex may run: the SAME
72
+ * storage.codeIndexEnabled switch that gates auto-build. Read live so a runtime
73
+ * /config toggle takes effect without restart. (The separate, default-off
74
+ * `agent-passive-code-injection` feature flag gates injection additionally; this
75
+ * is only the storage-side setting the SDK asks embedders to respect.)
76
+ */
77
+ export function isCodeInjectionSettingEnabled(configManager: Pick<ConfigManager, 'get'>): boolean {
78
+ return isCodeIndexAutoStartEnabled(configManager);
79
+ }
80
+
70
81
  export interface CodeIndexServices {
71
82
  readonly codeIndexStore: CodeIndexStore;
83
+ /**
84
+ * Wave-5 Stage B tool-site incremental reindex scheduler, bound to codeIndexStore and
85
+ * gated live on storage.codeIndexEnabled. Threaded into both SDK orchestrators (main +
86
+ * agent) so a successful write/edit debounces an incremental reindex, and into the command
87
+ * context so /codebase status can report the last reindex activity honestly.
88
+ */
89
+ readonly codeIndexReindexScheduler: CodeIndexReindexScheduler;
72
90
  }
73
91
 
74
92
  /** Absolute path to the TUI's code-index sqlite file, sibling to memory.sqlite under .goodvibes/tui/. */
@@ -108,5 +126,10 @@ export function createCodeIndexServices(deps: CodeIndexServicesDeps): CodeIndexS
108
126
  if (isCodeIndexAutoStartEnabled(deps.configManager)) {
109
127
  codeIndexStore.scheduleBuild();
110
128
  }
111
- return { codeIndexStore };
129
+ const codeIndexReindexScheduler = new CodeIndexReindexScheduler({
130
+ target: codeIndexStore,
131
+ workingDirectory: deps.workingDirectory,
132
+ isEnabled: () => isCodeInjectionSettingEnabled(deps.configManager),
133
+ });
134
+ return { codeIndexStore, codeIndexReindexScheduler };
112
135
  }
@@ -0,0 +1,516 @@
1
+ /**
2
+ * W4-D1: the legacy `goodvibes-daemon.service` detect/migrate engine, shared
3
+ * by the daemon CLI (`src/daemon/service-commands.ts`, the `migrate-service`
4
+ * subcommand) and the interactive TUI's onboarding guided UX
5
+ * (`src/input/handler-onboarding-daemon-adopt.ts`).
6
+ *
7
+ * This lives under `src/runtime/` — not `src/daemon/` — specifically so the
8
+ * `input` layer can consume it directly: the architecture gate's
9
+ * `input-no-entrypoints` rule forbids `src/input/**` from importing
10
+ * `src/daemon/**` (input must stay a pure event-handling layer, never
11
+ * depending on CLI/daemon entrypoint concerns), and `src/runtime/**` is the
12
+ * shared, entrypoint-agnostic layer both sides are already allowed to import.
13
+ *
14
+ * Wave 3 (W3 Finding 4) shipped DETECT + DISCLOSE only: a read-only check for
15
+ * the prior D7a-era systemd unit name plus a manual-removal hint, never
16
+ * touching it. This module adds the guided, CONSENTED migration itself.
17
+ * Design constraints, all load-bearing (see also the per-function docs):
18
+ * - NEVER auto-migrate. Without explicit consent nothing runs except a
19
+ * dry-run plan.
20
+ * - NEW-UP-THEN-OLD-DOWN. The new unit is installed, started, and verified
21
+ * healthy (a fresh, honest systemd `is-active` read through the injected
22
+ * actionRunner) BEFORE the legacy unit is stopped, disabled, or removed.
23
+ * A new unit that fails or doesn't come up healthy rolls itself back
24
+ * (uninstalled) and never touches the legacy one.
25
+ * - ADOPT-OR-WARN, NEVER KILL. If the legacy unit file is simply absent but
26
+ * something is already listening on the configured host:port (this dev
27
+ * host's real case: a manually `nohup`'d daemon with no unit at all),
28
+ * that is an unidentified process, not a managed unit — nothing to stop
29
+ * or disable, and this module never attempts to kill it.
30
+ * - Every action (legacy stop/disable, unit-file removal, daemon-reload)
31
+ * goes through the injectable `actionRunner`/`legacyUnitFileRemove` seams
32
+ * tests use — no code path here bypasses them, so the migration is
33
+ * exercised deterministically via fakes and never touches a real running
34
+ * service in tests.
35
+ */
36
+ import { existsSync, rmSync } from 'node:fs';
37
+ import { spawnSync } from 'node:child_process';
38
+ import net from 'node:net';
39
+ import { basename, dirname, join } from 'node:path';
40
+ import { fileURLToPath } from 'node:url';
41
+ import { ConfigManager } from '@pellux/goodvibes-sdk/platform/config';
42
+ import { PlatformServiceManager, type ManagedServiceStatus } from '@pellux/goodvibes-sdk/platform/daemon';
43
+ import { summarizeError } from '@pellux/goodvibes-sdk/platform/utils';
44
+
45
+ /** Structurally derived from `PlatformServiceManager`'s own constructor — the
46
+ * SDK's public `platform/daemon` entry point only re-exports the class and
47
+ * `ManagedServiceStatus`, not the options/definition/action-runner interfaces
48
+ * by name, so we pull their shapes off the class itself rather than reaching
49
+ * past the package's declared export map. */
50
+ type ManagedServiceManagerOptions = ConstructorParameters<typeof PlatformServiceManager>[1];
51
+ type ManagedServiceDefinition = NonNullable<ManagedServiceManagerOptions['definitionOverride']>;
52
+ export type ManagedServiceActionRunner = NonNullable<ManagedServiceManagerOptions['actionRunner']>;
53
+ type ManagedServiceActionResult = ReturnType<ManagedServiceActionRunner>;
54
+
55
+ // The one unit name/description this tool manages — shared by the daemon CLI
56
+ // (`goodvibes-daemon install-service|uninstall-service|service-status|migrate-service`)
57
+ // and the TUI onboarding UX, so both build the EXACT same service definition.
58
+ // `service.serviceName`/nothing-set config default is 'goodvibes'
59
+ // (schema-domain-runtime.ts), which is what PlatformServiceManager actually
60
+ // resolves to in the common case via `resolveServiceName()`'s `config.get(...)
61
+ // ?? defaultServiceName`.
62
+ export const MANAGED_SERVICE_NAME = 'goodvibes';
63
+ export const MANAGED_SERVICE_DESCRIPTION = 'GoodVibes daemon (shared session broker + companion host)';
64
+
65
+ /**
66
+ * F2 follow-up: resolve the unit name the SDK's `PlatformServiceManager`
67
+ * would actually manage, from config alone — for callers that need the
68
+ * honest display name BEFORE any manager/status exists (the onboarding
69
+ * wizard's detection banner resolves this at snapshot-collection time and
70
+ * carries it on `OnboardingLegacyDaemonSnapshot.trackedServiceName`).
71
+ * Mirrors the SDK's own internal `resolveServiceName()` precedence exactly:
72
+ * the `service.serviceName` config key first, trimmed, falling back to the
73
+ * default (`MANAGED_SERVICE_NAME`, which `buildManagedDaemonServiceManager`
74
+ * passes as `defaultServiceName`) when the key is unset or blank. Takes a
75
+ * minimal `{ get }` shape rather than the full ConfigManager class so
76
+ * snapshot code and tests can pass whatever config accessor they already
77
+ * hold.
78
+ */
79
+ export function resolveConfiguredServiceName(config: { get(key: string): unknown }): string {
80
+ const raw = config.get('service.serviceName');
81
+ const configured = raw === undefined || raw === null ? '' : String(raw).trim();
82
+ return configured || MANAGED_SERVICE_NAME;
83
+ }
84
+
85
+ export interface BuildManagedDaemonServiceManagerParams {
86
+ readonly binaryPath: string;
87
+ readonly homeDir: string;
88
+ readonly host: string;
89
+ readonly port: number;
90
+ /** Defaults to `homeDir` — overridable so tests can scope both to one tempdir. */
91
+ readonly workingDirectory?: string | undefined;
92
+ /** Injected in tests; a real `ConfigManager` rooted at `homeDir` otherwise. */
93
+ readonly configManager?: ConfigManager | undefined;
94
+ /** Injectable systemctl/launchctl/schtasks runner so tests never touch the host. */
95
+ readonly actionRunner?: ManagedServiceActionRunner | undefined;
96
+ }
97
+
98
+ /**
99
+ * Build the ONE `PlatformServiceManager` this tool manages — the single
100
+ * source of truth for the unit's definition (`ExecStart` command/args,
101
+ * name, description). Both `src/daemon/service-commands.ts` (the CLI) and
102
+ * `src/input/handler-onboarding-daemon-adopt.ts` (the onboarding guided UX)
103
+ * call this so a migration triggered from either surface installs the
104
+ * identical unit — no risk of the two consumers drifting apart.
105
+ */
106
+ export function buildManagedDaemonServiceManager(params: BuildManagedDaemonServiceManagerParams): PlatformServiceManager {
107
+ const workingDirectory = params.workingDirectory ?? params.homeDir;
108
+ const configManager = params.configManager ?? new ConfigManager({
109
+ workingDir: workingDirectory,
110
+ homeDir: params.homeDir,
111
+ surfaceRoot: 'tui',
112
+ });
113
+ const definition: ManagedServiceDefinition = {
114
+ name: MANAGED_SERVICE_NAME,
115
+ description: MANAGED_SERVICE_DESCRIPTION,
116
+ workingDirectory,
117
+ command: params.binaryPath,
118
+ args: ['--daemon-home', params.homeDir, '--hostname', params.host, '--port', String(params.port)],
119
+ env: {},
120
+ restartOnFailure: true,
121
+ };
122
+ return new PlatformServiceManager(configManager, {
123
+ workingDirectory,
124
+ homeDirectory: params.homeDir,
125
+ definitionOverride: definition,
126
+ defaultServiceName: MANAGED_SERVICE_NAME,
127
+ defaultServiceDescription: MANAGED_SERVICE_DESCRIPTION,
128
+ actionRunner: params.actionRunner,
129
+ // No `featureFlags` passed: `isFeatureGateEnabled` treats a missing reader
130
+ // as always-open. Both consumers here are the user's explicit request to
131
+ // manage the service, unlike the daemon's own HTTP /api/service/* routes
132
+ // (which gate on the real, config-backed 'service-management' flag).
133
+ });
134
+ }
135
+
136
+ export interface ResolveDaemonBinaryOptions {
137
+ readonly env?: NodeJS.ProcessEnv | undefined;
138
+ /** `import.meta.url` of the caller so the packaged `bin/goodvibes-daemon` can be located. */
139
+ readonly moduleUrl?: string | undefined;
140
+ readonly execPath?: string | undefined;
141
+ readonly fileExists?: ((path: string) => boolean) | undefined;
142
+ }
143
+
144
+ /**
145
+ * Resolve the absolute path to the installed daemon binary used for the unit's
146
+ * `ExecStart`. Preference order:
147
+ * 1. `GOODVIBES_DAEMON_BINARY` env override.
148
+ * 2. The packaged `bin/goodvibes-daemon` launcher next to this checkout.
149
+ * 3. `process.execPath` when this IS the compiled daemon binary.
150
+ * 4. Bare `goodvibes-daemon` (resolved on PATH by systemd's service environment).
151
+ *
152
+ * Lives here (not in `src/daemon/service-commands.ts`) so both consumers can
153
+ * call it: it only needs the CALLER's own `import.meta.url` to locate the
154
+ * packaged `bin/` directory two levels up from ANY `src/<layer>/*.ts` file
155
+ * (`src/daemon/service-commands.ts` and `src/input/handler-onboarding-daemon-adopt.ts`
156
+ * are equally one level deep under `src/`), so the resolution has no actual
157
+ * CLI/daemon-specific dependency.
158
+ */
159
+ export function resolveInstalledDaemonBinary(options: ResolveDaemonBinaryOptions = {}): string {
160
+ const env = options.env ?? process.env;
161
+ const override = env.GOODVIBES_DAEMON_BINARY?.trim();
162
+ if (override) return override;
163
+
164
+ const fileExists = options.fileExists ?? existsSync;
165
+ if (options.moduleUrl) {
166
+ try {
167
+ // e.g. src/daemon/service-commands.ts -> package root is two directories up.
168
+ const here = dirname(fileURLToPath(options.moduleUrl));
169
+ const launcher = join(here, '..', '..', 'bin', 'goodvibes-daemon');
170
+ if (fileExists(launcher)) return launcher;
171
+ } catch {
172
+ // fall through to execPath / PATH resolution
173
+ }
174
+ }
175
+
176
+ const execPath = options.execPath ?? process.execPath;
177
+ if (execPath && /goodvibes-daemon/.test(execPath)) return execPath;
178
+
179
+ return 'goodvibes-daemon';
180
+ }
181
+
182
+ export const LEGACY_SERVICE_UNIT_NAME = 'goodvibes-daemon';
183
+
184
+ export interface LegacyUnitInfo {
185
+ readonly present: boolean;
186
+ readonly active: boolean;
187
+ readonly path: string;
188
+ }
189
+
190
+ export function legacyUnitPath(homeDir: string): string {
191
+ return join(homeDir, '.config', 'systemd', 'user', `${LEGACY_SERVICE_UNIT_NAME}.service`);
192
+ }
193
+
194
+ export interface DetectLegacyUnitInput {
195
+ readonly homeDir: string;
196
+ /** Injectable existsSync so tests never touch the host filesystem. */
197
+ readonly legacyUnitFileExists?: ((path: string) => boolean) | undefined;
198
+ /** Injectable systemctl/launchctl/schtasks runner so tests never touch the host. */
199
+ readonly actionRunner?: ManagedServiceActionRunner | undefined;
200
+ }
201
+
202
+ /**
203
+ * Read-only detection: does a legacy `goodvibes-daemon.service` unit file
204
+ * exist, and if so, is it currently active? Never stops, disables, or
205
+ * modifies anything — a file-existence check plus a read-only
206
+ * `systemctl --user is-active` query through the injected actionRunner.
207
+ */
208
+ export function detectLegacyUnit(input: DetectLegacyUnitInput): LegacyUnitInfo {
209
+ const path = legacyUnitPath(input.homeDir);
210
+ const fileExists = input.legacyUnitFileExists ?? existsSync;
211
+ if (!fileExists(path)) return { present: false, active: false, path };
212
+ const run: ManagedServiceActionRunner = input.actionRunner
213
+ ?? ((command, args) => spawnSync(command, args, { stdio: 'pipe', encoding: 'utf-8' }) as ManagedServiceActionResult);
214
+ const result = run('systemctl', ['--user', 'is-active', `${LEGACY_SERVICE_UNIT_NAME}.service`]);
215
+ const state = (result.stdout ?? '').trim();
216
+ const active = (result.status ?? 1) === 0 && state === 'active';
217
+ return { present: true, active, path };
218
+ }
219
+
220
+ /**
221
+ * F1: the unit name `PlatformServiceManager` is ACTUALLY about to mutate can
222
+ * differ from `MANAGED_SERVICE_NAME` / `definitionOverride.name`. The SDK's
223
+ * internal `resolveServiceName()` — used by `install()`, `uninstall()`, and
224
+ * `status()` alike to compute the unit file PATH — resolves from the
225
+ * `service.serviceName` CONFIG key first, falling back to the
226
+ * `defaultServiceName` this module passes only when that key is unset. It
227
+ * never consults `definitionOverride.name` for the path. So if a host's
228
+ * config sets `service.serviceName` to the legacy unit's own name
229
+ * (`goodvibes-daemon`), `install()` writes over the legacy unit file,
230
+ * `uninstall()` (used for this engine's failed-health rollback) removes it,
231
+ * and a "successful" migration would immediately retire the very unit it
232
+ * just installed.
233
+ *
234
+ * This resolves the name actually in play so callers can detect that
235
+ * collision before mutating anything: it prefers `status.serviceName` when
236
+ * the linked SDK build carries it (a parallel SDK change adds this field to
237
+ * `ManagedServiceStatus` precisely so callers never have to guess), and
238
+ * falls back to the basename of `status.path` with its unit-file extension
239
+ * stripped against an SDK build that predates that field. The fallback only
240
+ * has to handle the systemd `<name>.service` (and launchd `<name>.plist`)
241
+ * shapes: every call site in this module reaches this after already
242
+ * confirming the platform is 'systemd' (a non-systemd host is refused
243
+ * earlier, before any mutation), so the basename fallback is never
244
+ * exercised against the windows/manual path shapes that don't embed the
245
+ * name in their basename.
246
+ */
247
+ export function resolveManagedUnitName(status: ManagedServiceStatus): string {
248
+ const carried = (status as { readonly serviceName?: unknown }).serviceName;
249
+ if (typeof carried === 'string' && carried.trim()) return carried.trim();
250
+ return basename(status.path).replace(/\.(service|plist)$/, '');
251
+ }
252
+
253
+ /** Honest one-line disclosure of the legacy unit's presence/state plus a manual migration hint — never auto-acted-on. */
254
+ export function legacyUnitNote(legacy: LegacyUnitInfo, trackedServiceName: string): string {
255
+ const stateWord = legacy.active ? 'installed and RUNNING' : 'installed (not currently active)';
256
+ return (
257
+ `note: a service under the legacy name ${LEGACY_SERVICE_UNIT_NAME}.service is ${stateWord} at ${legacy.path} — ` +
258
+ `this tool manages a different unit name (${trackedServiceName}.service) and will not touch the legacy one automatically. ` +
259
+ `Remove it yourself when ready: systemctl --user disable --now ${LEGACY_SERVICE_UNIT_NAME}.service && rm ${legacy.path} && systemctl --user daemon-reload`
260
+ );
261
+ }
262
+
263
+ /**
264
+ * Read-only, best-effort TCP connect probe used ONLY by the legacy-absent
265
+ * branch to tell "nothing is listening on this port" apart from "an
266
+ * unmanaged process (e.g. a manual `nohup`) already owns it." Never used to
267
+ * identify or act on that process — a positive result only produces a
268
+ * warning, never a kill. Tests always inject a fake `portProbe`; this default
269
+ * is never exercised against a real host in this repo's test suite.
270
+ */
271
+ export function defaultPortProbe(host: string, port: number, timeoutMs = 750): Promise<boolean> {
272
+ return new Promise<boolean>((resolve) => {
273
+ const connectHost = host === '0.0.0.0' || host === '::' ? '127.0.0.1' : (host || '127.0.0.1');
274
+ const socket = net.createConnection({ host: connectHost, port });
275
+ const finish = (value: boolean): void => {
276
+ socket.removeAllListeners();
277
+ socket.destroy();
278
+ resolve(value);
279
+ };
280
+ socket.setTimeout(timeoutMs);
281
+ socket.once('connect', () => finish(true));
282
+ socket.once('timeout', () => finish(false));
283
+ socket.once('error', () => finish(false));
284
+ });
285
+ }
286
+
287
+ export interface RunLegacyDaemonMigrationParams {
288
+ readonly host: string;
289
+ readonly port: number;
290
+ /** The unit name this tool manages (e.g. 'goodvibes') — distinct from LEGACY_SERVICE_UNIT_NAME. */
291
+ readonly trackedServiceName: string;
292
+ /**
293
+ * Explicit consent to actually execute the migration. Without it, the
294
+ * result is a printed plan only — never auto-migrate.
295
+ */
296
+ readonly confirmMigration?: boolean | undefined;
297
+ /** Injectable port-liveness check for the legacy-absent branch. Defaults to `defaultPortProbe`. */
298
+ readonly portProbe?: ((host: string, port: number) => boolean | Promise<boolean>) | undefined;
299
+ /** Injectable removal of the legacy unit file. Defaults to a real `rmSync`. */
300
+ readonly legacyUnitFileRemove?: ((path: string) => void) | undefined;
301
+ /** Injectable systemctl runner for the legacy stop/disable/daemon-reload steps. */
302
+ readonly actionRunner?: ManagedServiceActionRunner | undefined;
303
+ }
304
+
305
+ /**
306
+ * F1 belt-and-braces guard: throws if the resolved unit `status` is the
307
+ * legacy unit. Called immediately before the two mutation calls
308
+ * (`manager.install()`, and `manager.uninstall()` on the failed-health
309
+ * rollback path) that would otherwise write to or remove that path. This is
310
+ * an internal invariant check, not a normal user-facing error path — the
311
+ * pre-flight collision check in `runLegacyDaemonMigration` already returns
312
+ * before either call site is reached whenever this would trip, so tripping
313
+ * here means that earlier check regressed, not that the user did anything
314
+ * wrong.
315
+ */
316
+ function assertUnitIsNotLegacy(status: ManagedServiceStatus, legacy: LegacyUnitInfo, action: string): void {
317
+ if (status.path === legacy.path || resolveManagedUnitName(status) === LEGACY_SERVICE_UNIT_NAME) {
318
+ throw new Error(
319
+ `refusing to ${action}: the resolved managed unit (${resolveManagedUnitName(status)} at ${status.path}) is the ` +
320
+ `legacy ${LEGACY_SERVICE_UNIT_NAME}.service unit — this should already have been caught by the pre-flight ` +
321
+ 'collision check in runLegacyDaemonMigration',
322
+ );
323
+ }
324
+ }
325
+
326
+ export interface LegacyDaemonMigrationResult {
327
+ readonly ok: boolean;
328
+ readonly exitCode: number;
329
+ readonly lines: readonly string[];
330
+ readonly status: ManagedServiceStatus;
331
+ }
332
+
333
+ /**
334
+ * The guided, consented takeover itself. See the file banner for the design
335
+ * constraints (never auto-migrate, new-up-then-old-down, adopt-or-warn/never
336
+ * kill an unrecognized process, every action through an injectable seam).
337
+ */
338
+ export async function runLegacyDaemonMigration(
339
+ params: RunLegacyDaemonMigrationParams,
340
+ manager: PlatformServiceManager,
341
+ legacy: LegacyUnitInfo,
342
+ ): Promise<LegacyDaemonMigrationResult> {
343
+ const { trackedServiceName } = params;
344
+ // Computed once, up front, and reused for every branch below (this is the
345
+ // exact same single call each branch made individually before — see the
346
+ // F1 fix note on `resolveManagedUnitName` for why the name/path it reports
347
+ // can differ from `trackedServiceName`).
348
+ const currentStatus = manager.status();
349
+ const resolvedUnitName = resolveManagedUnitName(currentStatus);
350
+
351
+ if (!legacy.present) {
352
+ const probe = params.portProbe ?? defaultPortProbe;
353
+ const occupied = await probe(params.host, params.port);
354
+ if (occupied) {
355
+ return {
356
+ ok: false,
357
+ exitCode: 1,
358
+ lines: [
359
+ `migrate-service: no legacy ${LEGACY_SERVICE_UNIT_NAME}.service unit was found, but something is already ` +
360
+ `listening on ${params.host}:${params.port}.`,
361
+ "That looks like a process this tool doesn't manage (for example, a manually-started `nohup` daemon) rather " +
362
+ 'than a systemd unit — there is nothing here to stop or disable, and this tool will not attempt to kill an ' +
363
+ 'unrecognized process.',
364
+ 'Stop that process yourself (or point this TUI at it instead — see the onboarding "connect to an existing ' +
365
+ 'daemon" option), then re-run migrate-service or install-service once the port is free.',
366
+ ],
367
+ status: currentStatus,
368
+ };
369
+ }
370
+ return {
371
+ ok: true,
372
+ exitCode: 0,
373
+ lines: [
374
+ `migrate-service: no legacy ${LEGACY_SERVICE_UNIT_NAME}.service unit was found and ${params.host}:${params.port} ` +
375
+ 'is free — there is nothing to migrate.',
376
+ `Run install-service to set up the managed ${resolvedUnitName}.service directly.`,
377
+ ],
378
+ status: currentStatus,
379
+ };
380
+ }
381
+
382
+ if (currentStatus.platform !== 'systemd') {
383
+ return {
384
+ ok: false,
385
+ exitCode: 1,
386
+ lines: [
387
+ `migrate-service: this host's detected service platform is '${currentStatus.platform}', not systemd, but a ` +
388
+ `legacy unit file exists at ${legacy.path}.`,
389
+ 'The legacy unit is systemd-specific and this tool only knows how to migrate a systemd legacy unit today — ' +
390
+ 'nothing was changed.',
391
+ ],
392
+ status: currentStatus,
393
+ };
394
+ }
395
+
396
+ // F1: before any mutation, confirm the unit PlatformServiceManager is
397
+ // actually about to install/uninstall isn't the legacy unit itself. This
398
+ // happens when the host's `service.serviceName` config key is set to the
399
+ // legacy unit's own name — see `resolveManagedUnitName`'s doc comment for
400
+ // why the SDK resolves mutation paths from that config key rather than
401
+ // from the definition this engine passes. Without this check, `install()`
402
+ // below would overwrite the legacy unit file, a failed-health rollback
403
+ // (`uninstall()`) would DELETE it while still claiming it was "never
404
+ // touched," and a successful migration would immediately retire the unit
405
+ // it just installed.
406
+ if (resolvedUnitName === LEGACY_SERVICE_UNIT_NAME || currentStatus.path === legacy.path) {
407
+ return {
408
+ ok: false,
409
+ exitCode: 1,
410
+ lines: [
411
+ `migrate-service aborted: this host's 'service.serviceName' config key resolves to '${resolvedUnitName}', which ` +
412
+ `is the exact legacy unit name (${LEGACY_SERVICE_UNIT_NAME}.service at ${legacy.path}) this migration is ` +
413
+ 'supposed to retire.',
414
+ 'Installing or rolling back a unit under that name would overwrite or delete the legacy unit instead of ' +
415
+ 'managing a separate one, so nothing has been changed.',
416
+ `Fix: set the 'service.serviceName' config key to something other than '${LEGACY_SERVICE_UNIT_NAME}' (for ` +
417
+ `example, the default '${trackedServiceName}') and re-run migrate-service.`,
418
+ ],
419
+ status: currentStatus,
420
+ };
421
+ }
422
+
423
+ if (!params.confirmMigration) {
424
+ return {
425
+ ok: true,
426
+ exitCode: 0,
427
+ lines: [
428
+ legacyUnitNote(legacy, resolvedUnitName),
429
+ 'migrate-service (dry run — re-run with confirmation to execute): this would',
430
+ ` 1. install and start the new ${resolvedUnitName}.service unit`,
431
+ ' 2. verify it comes up healthy (a fresh, honest systemd is-active check)',
432
+ ` 3. only if that succeeds, stop, disable, and remove the legacy ${LEGACY_SERVICE_UNIT_NAME}.service unit ` +
433
+ 'and run `systemctl --user daemon-reload`',
434
+ 'Nothing has been changed. Nothing is migrated automatically — re-run with explicit confirmation ' +
435
+ "(the CLI's -y/--yes flag) to execute this plan.",
436
+ ],
437
+ status: currentStatus,
438
+ };
439
+ }
440
+
441
+ // Consented: new-up-then-old-down. The legacy unit is not touched until the
442
+ // new unit is verified healthy.
443
+ // Belt-and-braces (F1): the collision check above already returns before
444
+ // reaching here whenever the resolved unit is the legacy one — this
445
+ // re-asserts the same invariant right at the mutation site so a future
446
+ // change to the check above can never silently reopen the hole.
447
+ assertUnitIsNotLegacy(currentStatus, legacy, 'install the new unit');
448
+ const installed = manager.install();
449
+ if (installed.actionError) {
450
+ return {
451
+ ok: false,
452
+ exitCode: 1,
453
+ lines: [
454
+ `migrate-service aborted: could not write the new ${resolvedUnitName}.service unit (${installed.actionError}).`,
455
+ `The legacy ${LEGACY_SERVICE_UNIT_NAME}.service unit was never touched.`,
456
+ ],
457
+ status: installed,
458
+ };
459
+ }
460
+ const started = manager.start();
461
+ const healthCheck = manager.status();
462
+ const healthy = !started.actionError && healthCheck.running;
463
+ if (!healthy) {
464
+ assertUnitIsNotLegacy(installed, legacy, 'roll back (uninstall) the new unit');
465
+ const rollback = manager.uninstall();
466
+ const rollbackNote = rollback.actionError
467
+ ? `rolling back the new unit ALSO hit an error (${rollback.actionError}) — remove ${installed.path} by hand.`
468
+ : 'the newly-written unit has been rolled back (removed).';
469
+ return {
470
+ ok: false,
471
+ exitCode: 1,
472
+ lines: [
473
+ `migrate-service aborted: the new ${resolvedUnitName}.service unit did not come up healthy` +
474
+ (started.actionError ? ` (${started.actionError}).` : '.'),
475
+ rollbackNote,
476
+ `The legacy ${LEGACY_SERVICE_UNIT_NAME}.service unit was never touched and should still be running as before.`,
477
+ ],
478
+ status: healthCheck,
479
+ };
480
+ }
481
+
482
+ // New unit verified healthy — now, and only now, retire the legacy unit.
483
+ const run: ManagedServiceActionRunner = params.actionRunner
484
+ ?? ((command, args) => spawnSync(command, args, { stdio: 'pipe', encoding: 'utf-8' }) as ManagedServiceActionResult);
485
+ const stopResult = run('systemctl', ['--user', 'stop', `${LEGACY_SERVICE_UNIT_NAME}.service`]);
486
+ const disableResult = run('systemctl', ['--user', 'disable', `${LEGACY_SERVICE_UNIT_NAME}.service`]);
487
+ const removeFile = params.legacyUnitFileRemove ?? ((path: string) => rmSync(path, { force: true }));
488
+ let removeError: string | undefined;
489
+ try {
490
+ removeFile(legacy.path);
491
+ } catch (error) {
492
+ removeError = summarizeError(error);
493
+ }
494
+ run('systemctl', ['--user', 'daemon-reload']);
495
+
496
+ const lines = [`migrated: the new ${resolvedUnitName}.service unit is installed, enabled, and running.`];
497
+ if ((stopResult.status ?? 1) !== 0) {
498
+ lines.push(
499
+ `note: stopping the legacy unit reported a non-zero exit (${stopResult.stderr ?? stopResult.stdout ?? 'no output'}); ` +
500
+ 'it may already have been stopped.',
501
+ );
502
+ }
503
+ if ((disableResult.status ?? 1) !== 0) {
504
+ lines.push(
505
+ `note: disabling the legacy unit reported a non-zero exit (${disableResult.stderr ?? disableResult.stdout ?? 'no output'}); ` +
506
+ 'it may already have been disabled.',
507
+ );
508
+ }
509
+ if (removeError) {
510
+ lines.push(`note: could not remove the legacy unit file at ${legacy.path}: ${removeError} — remove it by hand.`);
511
+ } else {
512
+ lines.push(`the legacy ${LEGACY_SERVICE_UNIT_NAME}.service unit has been stopped, disabled, and removed.`);
513
+ }
514
+ lines.push('ran `systemctl --user daemon-reload`.');
515
+ return { ok: true, exitCode: 0, lines, status: healthCheck };
516
+ }
@@ -0,0 +1,26 @@
1
+ import { join } from 'node:path';
2
+ import {
3
+ foldMemoryStores,
4
+ type LegacyMemorySource,
5
+ type MemoryEmbeddingProviderRegistry,
6
+ type MemoryFoldReport,
7
+ type MemoryStore,
8
+ } from '@pellux/goodvibes-sdk/platform/state';
9
+
10
+ /**
11
+ * W6-C2 (E6): fold the TUI's legacy per-project memory store into the canonical
12
+ * cross-surface store. Called once at boot AFTER `memoryStore.init()` so records written
13
+ * before unification survive. Id-keyed and idempotent — a re-run imports nothing new and
14
+ * never deletes the legacy file. Returns the report so boot can log what moved.
15
+ */
16
+ export async function foldTuiLegacyMemory(
17
+ memoryStore: MemoryStore,
18
+ memoryEmbeddingRegistry: MemoryEmbeddingProviderRegistry,
19
+ workingDirectory: string,
20
+ ): Promise<MemoryFoldReport> {
21
+ const legacyTuiProject = join(workingDirectory, '.goodvibes', 'tui', 'memory.sqlite');
22
+ const sources: LegacyMemorySource[] = [
23
+ { label: `tui:${workingDirectory} (pre-E6)`, dbPath: legacyTuiProject },
24
+ ];
25
+ return foldMemoryStores(memoryStore, sources, { embeddingRegistry: memoryEmbeddingRegistry });
26
+ }
@@ -263,11 +263,16 @@ function hasCloudflareBatch(snapshot: OnboardingSnapshotState): boolean {
263
263
  }
264
264
 
265
265
  function describeLocalTuiOnly(snapshot: OnboardingSnapshotState): string {
266
+ // Honest wording (One-Platform daemon-by-default): this option turns off
267
+ // browser/LAN/webhook/external-app exposure, but the loopback-only GoodVibes
268
+ // daemon still runs by default so sessions here stay visible to other
269
+ // GoodVibes surfaces on this machine. Turn it off in Settings > daemon if you
270
+ // want none at all.
266
271
  if (!hasAnyServerEnabled(snapshot)) {
267
- return 'Use GoodVibes only in this terminal. No browser access, background service, HTTP listener, external app surface, or network setup.';
272
+ return 'Use GoodVibes only in this terminal. No browser access, HTTP listener, external app surface, or network setup. A loopback-only background daemon still runs by default so sessions here stay visible to other GoodVibes surfaces on this machine — turn it off in Settings > daemon if you want none at all.';
268
273
  }
269
274
 
270
- return 'Turn off browser access, background services, HTTP listeners, external app surfaces, and network setup.';
275
+ return 'Turn off browser access, HTTP listeners, external app surfaces, and network setup. The loopback-only background daemon keeps running by default for cross-surface visibility unless you turn it off in Settings > daemon.';
271
276
  }
272
277
 
273
278
  function describeBrowserAccess(snapshot: OnboardingSnapshotState): string {