@pellux/goodvibes-daemon 1.28.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 (113) hide show
  1. package/CHANGELOG.md +383 -0
  2. package/LICENSE +21 -0
  3. package/README.md +125 -0
  4. package/bin/goodvibes-daemon +100 -0
  5. package/bin/launcher-support.js +226 -0
  6. package/package.json +96 -0
  7. package/scripts/check-bun.sh +20 -0
  8. package/scripts/postinstall.js +244 -0
  9. package/src/cli/command-catalog.ts +828 -0
  10. package/src/cli/completion.ts +299 -0
  11. package/src/cli/help.ts +167 -0
  12. package/src/cli/index.ts +21 -0
  13. package/src/cli/parser.ts +55 -0
  14. package/src/cli/surface-catalog.ts +26 -0
  15. package/src/cli/types.ts +63 -0
  16. package/src/cluster/daemon-ws-call.ts +235 -0
  17. package/src/cluster/raw-reply-route.ts +111 -0
  18. package/src/config/checkpoint-settings.ts +113 -0
  19. package/src/config/run-daemon-config-migration.ts +47 -0
  20. package/src/config/secret-config.ts +175 -0
  21. package/src/config/secrets.ts +71 -0
  22. package/src/config/surface.ts +24 -0
  23. package/src/core/pairing-banner.ts +82 -0
  24. package/src/daemon/cli.ts +878 -0
  25. package/src/daemon/config-command.ts +281 -0
  26. package/src/daemon/handlers/context.ts +29 -0
  27. package/src/daemon/handlers/contracts.ts +43 -0
  28. package/src/daemon/handlers/credentials.ts +139 -0
  29. package/src/daemon/handlers/drafts/draft-store.ts +427 -0
  30. package/src/daemon/handlers/drafts/index.ts +17 -0
  31. package/src/daemon/handlers/drafts/register.ts +331 -0
  32. package/src/daemon/handlers/errors.ts +18 -0
  33. package/src/daemon/handlers/inbox/aggregator.ts +375 -0
  34. package/src/daemon/handlers/inbox/cursor-store.ts +512 -0
  35. package/src/daemon/handlers/inbox/index.ts +221 -0
  36. package/src/daemon/handlers/inbox/mapping.ts +192 -0
  37. package/src/daemon/handlers/inbox/poller.ts +239 -0
  38. package/src/daemon/handlers/inbox/provider-adapter.ts +171 -0
  39. package/src/daemon/handlers/inbox/providers/discord.ts +276 -0
  40. package/src/daemon/handlers/inbox/providers/email.ts +176 -0
  41. package/src/daemon/handlers/inbox/providers/imap-client.ts +300 -0
  42. package/src/daemon/handlers/inbox/providers/route-util.ts +24 -0
  43. package/src/daemon/handlers/inbox/providers/slack.ts +287 -0
  44. package/src/daemon/handlers/index.ts +117 -0
  45. package/src/daemon/handlers/register.ts +180 -0
  46. package/src/daemon/handlers/remote/backends/cloud-terminal.ts +143 -0
  47. package/src/daemon/handlers/remote/backends/docker.ts +79 -0
  48. package/src/daemon/handlers/remote/backends/index.ts +40 -0
  49. package/src/daemon/handlers/remote/backends/local-process.ts +113 -0
  50. package/src/daemon/handlers/remote/backends/process-runner.ts +127 -0
  51. package/src/daemon/handlers/remote/backends/ssh.ts +126 -0
  52. package/src/daemon/handlers/remote/backends/types.ts +97 -0
  53. package/src/daemon/handlers/remote/dispatcher.ts +181 -0
  54. package/src/daemon/handlers/remote/index.ts +120 -0
  55. package/src/daemon/handlers/remote/peer-registry.ts +357 -0
  56. package/src/daemon/handlers/remote/service.ts +191 -0
  57. package/src/daemon/handlers/routing/inbox-bridge.ts +71 -0
  58. package/src/daemon/handlers/routing/index.ts +261 -0
  59. package/src/daemon/handlers/routing/route-store.ts +319 -0
  60. package/src/daemon/handlers/routing/routing-resolver.ts +75 -0
  61. package/src/daemon/handlers/sqlite-store.ts +303 -0
  62. package/src/daemon/handlers/triage/index.ts +57 -0
  63. package/src/daemon/handlers/triage/integration.ts +213 -0
  64. package/src/daemon/handlers/triage/pipeline.ts +274 -0
  65. package/src/daemon/handlers/triage/scorer.ts +287 -0
  66. package/src/daemon/handlers/triage/tagger/discord.ts +187 -0
  67. package/src/daemon/handlers/triage/tagger/imap.ts +384 -0
  68. package/src/daemon/handlers/triage/tagger/index.ts +184 -0
  69. package/src/daemon/handlers/triage/tagger/shared.ts +70 -0
  70. package/src/daemon/handlers/triage/tagger/slack.ts +69 -0
  71. package/src/daemon/handlers/triage/types.ts +50 -0
  72. package/src/daemon/lifecycle.ts +41 -0
  73. package/src/daemon/local-daemon-state.ts +233 -0
  74. package/src/daemon/pair-command.ts +301 -0
  75. package/src/daemon/provision-wake-model.ts +81 -0
  76. package/src/daemon/send/channels.ts +200 -0
  77. package/src/daemon/send/command.ts +333 -0
  78. package/src/daemon/send/composition.ts +100 -0
  79. package/src/daemon/send/failure-text.ts +93 -0
  80. package/src/daemon/send/inert-text.ts +225 -0
  81. package/src/daemon/send/stdin.ts +24 -0
  82. package/src/daemon/service-commands.ts +530 -0
  83. package/src/daemon/sessions-command.ts +209 -0
  84. package/src/daemon/status-command.ts +481 -0
  85. package/src/daemon/webui-command.ts +339 -0
  86. package/src/runtime/boot-tasks.ts +110 -0
  87. package/src/runtime/cluster-composition.ts +124 -0
  88. package/src/runtime/cluster-group-composition.ts +284 -0
  89. package/src/runtime/conversation-rewind-port.ts +171 -0
  90. package/src/runtime/credential-composition.ts +54 -0
  91. package/src/runtime/daemon-handler-composition.ts +76 -0
  92. package/src/runtime/device-posture-composition.ts +115 -0
  93. package/src/runtime/disposal-wiring.ts +101 -0
  94. package/src/runtime/fleet-needs-input-push.ts +61 -0
  95. package/src/runtime/fleet-services.ts +41 -0
  96. package/src/runtime/hosted-session-composition.ts +128 -0
  97. package/src/runtime/index.ts +100 -0
  98. package/src/runtime/knowledge-services.ts +101 -0
  99. package/src/runtime/legacy-daemon-migration.ts +605 -0
  100. package/src/runtime/legacy-daemon-reconcile.ts +448 -0
  101. package/src/runtime/mail-composition.ts +65 -0
  102. package/src/runtime/notification-dispatch.ts +86 -0
  103. package/src/runtime/plugin-composition.ts +111 -0
  104. package/src/runtime/runtime-services-types.ts +268 -0
  105. package/src/runtime/services.ts +756 -0
  106. package/src/runtime/trigger-services.ts +62 -0
  107. package/src/runtime/trust/checkpoint-eligibility.ts +138 -0
  108. package/src/runtime/trust/trust-gated-approvals.ts +169 -0
  109. package/src/runtime/update-check.ts +61 -0
  110. package/src/runtime/workspace-checkpointing.ts +116 -0
  111. package/src/testing/daemon-fixture.ts +276 -0
  112. package/src/testing/hosted-session-failures.ts +92 -0
  113. package/src/version.ts +26 -0
@@ -0,0 +1,878 @@
1
+ import { homedir } from 'node:os';
2
+ import { join } from 'node:path';
3
+ import {
4
+ ConfigManager,
5
+ daemonConfigPathForHome,
6
+ describeDerivedBindMismatch,
7
+ readControlPlaneBinding,
8
+ resolveDaemonEnabled,
9
+ } from '@pellux/goodvibes-sdk/platform/config';
10
+ import type { ConfigKey } from '@pellux/goodvibes-sdk/platform/config';
11
+ import { GOODVIBES_DAEMON_SURFACE_ROOT } from '../config/surface.ts';
12
+ import { formatProviderModel, getModelIdFromProviderModel, getProviderIdFromModel } from '@pellux/goodvibes-sdk/platform/providers';
13
+ import { resolveGoodVibesHomeOwnership } from '@pellux/goodvibes-sdk/platform/config';
14
+ import { RuntimeEventBus, GlobalNetworkTransportInstaller, configureRuntimeEventBusDefaults, runtimeEventBusOptionsFrom } from '@/runtime/index.ts';
15
+ import { createHostedSessionOptions } from '@/runtime/hosted-session-composition.ts';
16
+ import { bindFeatureSettingsBridge, createFeatureFlagManager, deriveFeatureStates } from '@/runtime/index.ts';
17
+ import { createRuntimeStore } from '@pellux/goodvibes-sdk/platform/runtime/store';
18
+ import { createRuntimeServices } from '../runtime/services.ts';
19
+ import { startDeviceHousekeeping } from '../runtime/device-posture-composition.ts';
20
+ import { runDaemonBootTasks } from '../runtime/boot-tasks.ts';
21
+ import { DaemonServer, HttpListener } from '@pellux/goodvibes-sdk/platform/daemon';
22
+ import { createHostPowerSeam } from '@pellux/goodvibes-sdk/platform/power';
23
+ import { configureActivityLogger, flushActivityLogSync, logger } from '@pellux/goodvibes-sdk/platform/utils';
24
+ import { summarizeError } from '@pellux/goodvibes-sdk/platform/utils';
25
+ import { reportFatalBootFailure, writeExitingStdoutLine, writeFatalLine } from '@pellux/goodvibes-sdk/platform/daemon';
26
+ import {
27
+ availablePairingOffers,
28
+ ensurePublicBaseUrl,
29
+ getOrCreateCompanionToken,
30
+ pruneStaleOperatorTokens,
31
+ } from '@pellux/goodvibes-sdk/platform/pairing';
32
+ import { operations } from '@pellux/goodvibes-sdk/platform/runtime';
33
+ import {
34
+ scan,
35
+ loadPersistedProviders,
36
+ persistProviders,
37
+ } from '@pellux/goodvibes-sdk/platform/discovery';
38
+ import { createSafeHostServeFactory } from '@pellux/goodvibes-sdk/platform/daemon';
39
+ import { runProvisionWakeModelCommand } from './provision-wake-model.ts';
40
+ import { runWebuiCommand } from './webui-command.ts';
41
+ import { runSendCommand } from './send/command.ts';
42
+ import { createSendStack } from './send/composition.ts';
43
+ import { readAllStdin } from './send/stdin.ts';
44
+ import { isDaemonServiceSubcommand, resolveInstalledDaemonBinary, runDaemonServiceCli } from './service-commands.ts';
45
+ import { resolveConfiguredServiceName } from '../runtime/legacy-daemon-migration.ts';
46
+ import { runDaemonConfigMigration } from '../config/run-daemon-config-migration.ts';
47
+ import { reconcileRedundantLegacyUnit } from '../runtime/legacy-daemon-reconcile.ts';
48
+ import { resolveRuntimeEndpointBinding, runClusterCommand } from '@pellux/goodvibes-terminal-shell';
49
+ import { resolveDaemonUpdateArtifact } from './lifecycle.ts';
50
+ import { VERSION } from '../version.ts';
51
+
52
+ import {
53
+ parseDaemonCli,
54
+ isRawInterceptCommand,
55
+ renderGoodVibesDaemonHelp,
56
+ renderDaemonCommandHelp,
57
+ renderGoodVibesVersion,
58
+ renderDaemonStartupBanner,
59
+ runCompletionCommand,
60
+ } from '../cli/index.ts';
61
+ import {
62
+ applyRuntimeConfigOverrides,
63
+ applyRuntimeConfigValue,
64
+ applyRuntimeEndpointFlagOverrides,
65
+ applyRuntimeFeatureFlagOverrides,
66
+ } from '@pellux/goodvibes-terminal-shell';
67
+ import { runConfigCommand } from './config-command.ts';
68
+ import { runPairCommand } from './pair-command.ts';
69
+ import { runSessionsCommand } from './sessions-command.ts';
70
+ import { runStatusCommand, runUpdateCommand, type DaemonCommandResult } from './status-command.ts';
71
+ import { renderPairingBanner } from '../core/pairing-banner.ts';
72
+ import { readOperatorTokenFile } from '@pellux/goodvibes-sdk/platform/workspace';
73
+ type DaemonCliOwnership = {
74
+ readonly workingDirectory: string;
75
+ /** The GoodVibes tree root — settings, workspace, and discovery all hang off this. */
76
+ readonly homeDirectory: string;
77
+ /** The daemon's OWN identity home (auth users, operator tokens, daemon settings). */
78
+ readonly daemonHomeDirectory: string;
79
+ /** True when GOODVIBES_HOME or GOODVIBES_DAEMON_HOME named an override — see goodvibes-home.ts. */
80
+ readonly isOverridden: boolean;
81
+ };
82
+
83
+ // CLI flag parsing delegated to shared module — see src/cli-flags.ts
84
+
85
+ type DaemonCliTokens = {
86
+ readonly daemonToken: string | undefined;
87
+ readonly httpToken: string | undefined;
88
+ };
89
+
90
+ /**
91
+ * Two different directories that used to be one.
92
+ *
93
+ * `GOODVIBES_DAEMON_HOME` names the DAEMON's home — the identity directory
94
+ * holding auth-users.json, operator-tokens.json, and daemon-settings.json. That
95
+ * is what the name says and what the SDK's `resolveDaemonHomeDir()` has always
96
+ * meant by it. This function used to read it as the GoodVibes tree ROOT, so
97
+ * setting it relocated settings, workspace, and every discovery root as well —
98
+ * far more than the daemon's own state.
99
+ *
100
+ * `GOODVIBES_HOME` is the variable for relocating the tree root. It is what a
101
+ * test harness or a service unit should set when it wants an isolated tree; the
102
+ * daemon home then falls under it unless separately overridden.
103
+ */
104
+ function resolveDaemonCliOwnership(): DaemonCliOwnership {
105
+ // Both roots come from the SDK's platform/config goodvibes-home, which the CLIENT entry
106
+ // point also uses. They were resolved independently, and the client's copy
107
+ // simply did not read GOODVIBES_HOME — so a redirected client wrote into the
108
+ // real tree while the daemon honoured the redirect.
109
+ const { homeDirectory, daemonHomeDirectory, isOverridden } = resolveGoodVibesHomeOwnership();
110
+ return {
111
+ workingDirectory: process.env['GOODVIBES_WORKING_DIR'] ?? process.cwd(),
112
+ homeDirectory,
113
+ daemonHomeDirectory,
114
+ isOverridden,
115
+ };
116
+ }
117
+
118
+ function readDaemonCliTokens(env: NodeJS.ProcessEnv): DaemonCliTokens {
119
+ const daemonToken = env.GOODVIBES_DAEMON_TOKEN;
120
+ return {
121
+ daemonToken,
122
+ httpToken: env.GOODVIBES_HTTP_TOKEN ?? daemonToken,
123
+ };
124
+ }
125
+
126
+ async function main(): Promise<void> {
127
+ // `cluster …` is intercepted before the daemon's own flag parser runs, and
128
+ // before any runtime is composed.
129
+ //
130
+ // Before the parser, because the subcommand has its own flag vocabulary
131
+ // (--group, --key, --host, --port, --token) that the daemon parser would
132
+ // reject as unknown. Before the runtime, because this command talks to a
133
+ // daemon that is ALREADY RUNNING — composing a second runtime here would
134
+ // build a competing set of state on a machine that already has one.
135
+ //
136
+ // See the terminal shell's cluster-remote-daemon-target for the
137
+ // --host/--port/--token convention that
138
+ // every later remote-capable subcommand should follow.
139
+ const rawArgs = process.argv.slice(2);
140
+ if (rawArgs[0] === 'cluster') {
141
+ const ownership = resolveDaemonCliOwnership();
142
+ const clusterConfig = new ConfigManager({
143
+ workingDir: ownership.workingDirectory,
144
+ homeDir: ownership.homeDirectory,
145
+ surfaceRoot: GOODVIBES_DAEMON_SURFACE_ROOT,
146
+ // Named explicitly rather than left to ConfigManager's own
147
+ // `<homeDir>/.goodvibes/daemon/settings.json` derivation, so the daemon
148
+ // tier this reads/writes agrees with `ownership.daemonHomeDirectory`
149
+ // even when `GOODVIBES_DAEMON_HOME` moved it away from the plain home.
150
+ daemonTierPath: daemonConfigPathForHome(ownership.daemonHomeDirectory),
151
+ });
152
+ const result = await runClusterCommand({
153
+ argv: rawArgs.slice(1),
154
+ configManager: clusterConfig,
155
+ daemonHomeDir: ownership.daemonHomeDirectory,
156
+ });
157
+ // Every write below is immediately followed by process.exit, so all of them
158
+ // go to the descriptor synchronously rather than through a stream that can
159
+ // still be in flight when the process stops existing — see
160
+ // fatal-boot-report.ts. (The OSC 52 clipboard sequence is unterminated
161
+ // either way; the line terminator this adds after it is inert.)
162
+ if (result.rawOutput) writeExitingStdoutLine(`\u001b${result.rawOutput}`);
163
+ for (const line of result.lines) {
164
+ if (result.exitCode === 0) writeExitingStdoutLine(line);
165
+ else writeFatalLine(line);
166
+ }
167
+ process.exit(result.exitCode);
168
+ }
169
+
170
+ // `send …` is intercepted here for both of the reasons `cluster` is, plus one
171
+ // of its own.
172
+ //
173
+ // Before the parser, because the message is arbitrary operator text: a
174
+ // message beginning with a dash, or one carrying `--port` inside it, must
175
+ // reach the channel rather than be eaten as a daemon flag.
176
+ //
177
+ // Before the runtime, because this composes only the services a delivery
178
+ // needs (see send/composition.ts) and must not start a second copy of the
179
+ // pollers, cluster election and LAN scan a running daemon already owns.
180
+ //
181
+ // And it must work when NO daemon is running, which is much of the point: the
182
+ // reason to message the owner is usually that something stopped.
183
+ if (rawArgs[0] === 'send') {
184
+ const ownership = resolveDaemonCliOwnership();
185
+ // Named before the delivery runs, because the flushActivityLogSync() below
186
+ // is what preserves this send's OUTBOUND_HTTP record — and a logger with no
187
+ // destination has nothing to flush, so that record was never written.
188
+ configureActivityLogger(join(ownership.workingDirectory, '.goodvibes', 'logs'));
189
+ runDaemonConfigMigration(ownership.homeDirectory);
190
+ const stack = createSendStack({
191
+ workingDirectory: ownership.workingDirectory,
192
+ homeDirectory: ownership.homeDirectory,
193
+ daemonHomeDirectory: ownership.daemonHomeDirectory,
194
+ });
195
+ const result = await runSendCommand(rawArgs.slice(1), {
196
+ configManager: stack.configManager,
197
+ deliver: stack.deliver,
198
+ readStdin: readAllStdin,
199
+ stdinIsTty: process.stdin.isTTY === true,
200
+ });
201
+ // Descriptor writes, not stream writes: the exit two lines below can leave
202
+ // a `console.log` still in flight, and `send` is most often run precisely
203
+ // because something has already stopped. See fatal-boot-report.ts.
204
+ for (const line of result.lines) {
205
+ if (result.exitCode === 0) writeExitingStdoutLine(line);
206
+ else writeFatalLine(line);
207
+ }
208
+ // The activity log holds the OUTBOUND_HTTP record for the send that just
209
+ // happened (or did not); a process exiting this promptly would drop it.
210
+ flushActivityLogSync();
211
+ process.exit(result.exitCode);
212
+ }
213
+
214
+ // `provision-wake-model …` is intercepted here for the same two reasons, plus
215
+ // its own: the curl installer runs it on the binary it has just placed, before
216
+ // any daemon exists, and it must reach the SDK's pinned manifest rather than
217
+ // have install.sh carry a second copy of the pins. It composes no runtime — it
218
+ // resolves a home directory, derives the managed voice root the running daemon
219
+ // uses, and calls one SDK function. See provision-wake-model.ts for why it
220
+ // exits 0 on a failed download.
221
+ if (rawArgs[0] === 'provision-wake-model') {
222
+ const ownership = resolveDaemonCliOwnership();
223
+ const result = await runProvisionWakeModelCommand(rawArgs.slice(1), {
224
+ homeDirectory: ownership.homeDirectory,
225
+ });
226
+ // The curl installer runs this on a binary it has just placed and reads the
227
+ // result; an exit that discards the receipt would report nothing at all.
228
+ for (const line of result.lines) {
229
+ writeExitingStdoutLine(line);
230
+ }
231
+ process.exit(result.exitCode);
232
+ }
233
+
234
+ // `webui …` is intercepted here for the same two reasons the commands above
235
+ // are, plus its own: the curl installer runs it on the binary it has just
236
+ // placed, right after unpacking the web UI bundle, so that the key names, the
237
+ // settings-tier routing and the "which listener actually serves this" question
238
+ // all stay in one implementation instead of being copied into shell. It
239
+ // composes no runtime — it resolves a home directory, opens the same config
240
+ // manager the daemon boots with, and writes two or three keys. See
241
+ // webui-command.ts.
242
+ if (rawArgs[0] === 'webui') {
243
+ const ownership = resolveDaemonCliOwnership();
244
+ runDaemonConfigMigration(ownership.homeDirectory);
245
+ const webuiConfig = new ConfigManager({
246
+ workingDir: ownership.workingDirectory,
247
+ homeDir: ownership.homeDirectory,
248
+ surfaceRoot: GOODVIBES_DAEMON_SURFACE_ROOT,
249
+ // Same reason the cluster branch names it: the daemon tier this writes
250
+ // must be the file the booting daemon reads, even when GOODVIBES_DAEMON_HOME
251
+ // moved it away from the plain home.
252
+ daemonTierPath: daemonConfigPathForHome(ownership.daemonHomeDirectory),
253
+ });
254
+ const result = runWebuiCommand(rawArgs.slice(1), {
255
+ configManager: webuiConfig,
256
+ baseDirectory: ownership.workingDirectory,
257
+ });
258
+ // The installer runs this on a binary it has just placed and prints the
259
+ // result into its own receipt; a stream write lost to the exit below would
260
+ // leave the install saying nothing about the web UI at all.
261
+ for (const line of result.lines) {
262
+ if (result.exitCode === 0) writeExitingStdoutLine(line);
263
+ else writeFatalLine(line);
264
+ }
265
+ process.exit(result.exitCode);
266
+ }
267
+
268
+ // Parse CLI flags first so --daemon-home and --working-dir env vars are set
269
+ // before resolveDaemonCliOwnership() reads them.
270
+ //
271
+ // Everything the vocabulary does not contain is refused HERE, which is the
272
+ // change that makes the rest of this function honest. An unmatched word used
273
+ // to become a positional nothing read, and the process fell through to
274
+ // "start serving": `goodvibes-daemon doctor`, `goodvibes-daemon sessions`
275
+ // and `goodvibes-daemon install-servce` all silently started a daemon in the
276
+ // foreground. Serving now happens on a bare invocation or on `serve`, and on
277
+ // nothing else. See src/cli/command-catalog.ts.
278
+ const cli = parseDaemonCli(process.argv.slice(2), 'goodvibes-daemon');
279
+ if (cli.errors.length > 0) {
280
+ // A parse refusal is the most common reason a service unit's daemon never
281
+ // starts, so it goes on the descriptor the journal is attached to rather
282
+ // than through a stream that exits out from under it.
283
+ writeFatalLine(cli.errors.join('\n'));
284
+ writeFatalLine('');
285
+ writeFatalLine(renderGoodVibesDaemonHelp('goodvibes-daemon'));
286
+ process.exit(2);
287
+ }
288
+ if (cli.warnings.length > 0) {
289
+ for (const warning of cli.warnings) {
290
+ console.warn(`[goodvibes-daemon] warning: ${warning}`);
291
+ }
292
+ }
293
+ if (cli.flags.help || cli.command === 'help') {
294
+ // `help <command>` prints that command's own page; an unrecognized word
295
+ // gets the same refusal the parser would have given it, not a help page for
296
+ // something that does not exist.
297
+ const topic = cli.command === 'help' ? cli.commandArgs[0] : undefined;
298
+ if (topic !== undefined) {
299
+ const page = renderDaemonCommandHelp(topic, 'goodvibes-daemon');
300
+ if (page === null) {
301
+ writeFatalLine(`Unknown command: ${topic}`);
302
+ writeFatalLine('');
303
+ writeFatalLine(renderGoodVibesDaemonHelp('goodvibes-daemon'));
304
+ process.exit(2);
305
+ }
306
+ writeExitingStdoutLine(page);
307
+ process.exit(0);
308
+ }
309
+ // `--help` on a real command prints that command's page too, so
310
+ // `goodvibes-daemon sessions --help` answers about sessions.
311
+ if (cli.flags.help && cli.rawCommand !== undefined && cli.command !== 'help') {
312
+ const page = renderDaemonCommandHelp(cli.command, 'goodvibes-daemon');
313
+ if (page !== null) {
314
+ writeExitingStdoutLine(page);
315
+ process.exit(0);
316
+ }
317
+ }
318
+ writeExitingStdoutLine(renderGoodVibesDaemonHelp('goodvibes-daemon'));
319
+ process.exit(0);
320
+ }
321
+ if (cli.flags.version || cli.command === 'version') {
322
+ writeExitingStdoutLine(renderGoodVibesVersion('goodvibes-daemon'));
323
+ process.exit(0);
324
+ }
325
+ // `cluster`, `send`, `webui` and `provision-wake-model` are handled at the top
326
+ // of this function, off the raw argument list, for the reasons stated there —
327
+ // which means they only work as the FIRST word. Reaching one here means a
328
+ // global flag was written in front of it. Refusing says so; falling through
329
+ // would start a daemon in the foreground, which is the exact behaviour this
330
+ // vocabulary exists to end.
331
+ if (isRawInterceptCommand(cli.command)) {
332
+ writeFatalLine(
333
+ `\`${cli.command}\` has to be the first argument: goodvibes-daemon ${cli.command} …`,
334
+ );
335
+ writeFatalLine(
336
+ 'It is dispatched before the daemon parses anything, so it keeps its own flags '
337
+ + '(and, for `send`, message text that starts with a dash).',
338
+ );
339
+ process.exit(2);
340
+ }
341
+ if (cli.command === 'completion') {
342
+ const result = runCompletionCommand(cli.commandArgs, 'goodvibes-daemon');
343
+ for (const line of result.lines) {
344
+ if (result.exitCode === 0) writeExitingStdoutLine(line);
345
+ else writeFatalLine(line);
346
+ }
347
+ process.exit(result.exitCode);
348
+ }
349
+ const cliFlags = cli.flags;
350
+ if (cliFlags.daemonHome !== undefined) {
351
+ process.env['GOODVIBES_DAEMON_HOME'] = cliFlags.daemonHome;
352
+ logger.info('daemon: --daemon-home flag applied', { daemonHome: cliFlags.daemonHome });
353
+ }
354
+ if (cliFlags.workingDir !== undefined) {
355
+ process.env['GOODVIBES_WORKING_DIR'] = cliFlags.workingDir;
356
+ logger.info('daemon: --working-dir flag applied', { workingDir: cliFlags.workingDir });
357
+ }
358
+
359
+ const { workingDirectory: workingDir, homeDirectory, daemonHomeDirectory, isOverridden: hasOverriddenHome } = resolveDaemonCliOwnership();
360
+ // Give the shared logger a destination before anything else runs. It never
361
+ // had one in this entrypoint: `logger` only writes to a file once
362
+ // `configureActivityLogger()` has named one, so every logger.info/warn/error
363
+ // in this daemon — including the fatal handler at the bottom of this file
364
+ // and every flushActivityLogSync() call — went nowhere at all. That is why
365
+ // a crash-looping daemon left an empty activity log next to a silent
366
+ // console. The terminal app's own src/cli/entrypoint.ts configures its
367
+ // logger the same way at the same point in startup.
368
+ configureActivityLogger(join(workingDir, '.goodvibes', 'logs'));
369
+ runDaemonConfigMigration(homeDirectory);
370
+ // `daemonTierPath` named explicitly (SDK 1.21.0) rather than left to
371
+ // ConfigManager's own `<homeDir>/.goodvibes/daemon/settings.json`
372
+ // derivation: `daemonHomeDirectory` is this process's own resolution of
373
+ // `--daemon-home`/`GOODVIBES_DAEMON_HOME`, and the two must agree so the
374
+ // daemon's config reads and writes the same file its identity state
375
+ // (operator-tokens.json, auth-users.json) already lives beside.
376
+ const config = new ConfigManager({
377
+ workingDir,
378
+ homeDir: homeDirectory,
379
+ surfaceRoot: GOODVIBES_DAEMON_SURFACE_ROOT,
380
+ daemonTierPath: daemonConfigPathForHome(daemonHomeDirectory),
381
+ });
382
+ new GlobalNetworkTransportInstaller().install(config);
383
+
384
+ const overrideErrors = applyRuntimeConfigOverrides(config, cliFlags.configOverrides);
385
+ if (overrideErrors.length > 0) {
386
+ writeFatalLine(overrideErrors.join('\n'));
387
+ process.exit(2);
388
+ }
389
+ applyRuntimeFeatureFlagOverrides(config, {
390
+ enableFeatures: cliFlags.enableFeatures,
391
+ disableFeatures: cliFlags.disableFeatures,
392
+ });
393
+
394
+ // Apply remaining CLI flags before the provider registry is constructed.
395
+ // These are runtime-only overrides; they must not rewrite settings.json.
396
+ if (cliFlags.provider !== undefined || cliFlags.model !== undefined) {
397
+ const currentModel = config.get('provider.model');
398
+ const provider = cliFlags.provider ?? getProviderIdFromModel(currentModel);
399
+ const model = cliFlags.model ?? getModelIdFromProviderModel(currentModel);
400
+ const registryKey = formatProviderModel(provider, model);
401
+ applyRuntimeConfigValue(config, 'provider.model', registryKey);
402
+ logger.info('daemon: provider/model flags applied', { provider, model: registryKey });
403
+ }
404
+ // `--hostname`/`--port` name the address this process BINDS, and only `serve`
405
+ // binds anything. On `status`, `sessions`, `pair` and `update` the same
406
+ // `--port` names the daemon to CALL (see the remote-target convention), so
407
+ // writing it into this process's control-plane config would be answering a
408
+ // different question than the one asked. The catalog keeps the two apart by
409
+ // command; this keeps the write to the one command it belongs to.
410
+ if (cli.command === 'serve') {
411
+ const endpointOverrideErrors = applyRuntimeEndpointFlagOverrides(config, 'controlPlane', cliFlags);
412
+ if (endpointOverrideErrors.length > 0) {
413
+ writeFatalLine(endpointOverrideErrors.join('\n'));
414
+ process.exit(2);
415
+ }
416
+ if (cliFlags.port !== undefined) logger.info('daemon: --port flag applied', { port: cliFlags.port });
417
+ if (cliFlags.hostname !== undefined) {
418
+ process.env['GOODVIBES_DAEMON_HOST'] = cliFlags.hostname;
419
+ logger.info('daemon: --hostname flag applied', { hostname: cliFlags.hostname });
420
+ }
421
+ }
422
+
423
+ // The commands that talk to a daemon rather than being one, plus `config`,
424
+ // which talks to the settings files directly. All of them run BEFORE any
425
+ // runtime is composed: composing a second runtime graph on a machine that
426
+ // already has one is a second set of state, and every one of these commands
427
+ // exists precisely because a daemon is already there.
428
+ const remoteFlags = {
429
+ host: cliFlags.host,
430
+ port: cliFlags.port,
431
+ token: cliFlags.token,
432
+ json: cliFlags.json,
433
+ };
434
+ const remoteDeps = {
435
+ configManager: config,
436
+ daemonHomeDir: daemonHomeDirectory,
437
+ controlPlaneConfigDir: config.getControlPlaneConfigDir(),
438
+ };
439
+ const exitWith = (result: DaemonCommandResult): never => {
440
+ // Descriptor writes, not stream writes: each of these exits immediately
441
+ // afterwards, which is exactly the race a buffered console.log loses. See
442
+ // fatal-boot-report.ts.
443
+ for (const line of result.lines) {
444
+ if (result.exitCode === 0) writeExitingStdoutLine(line);
445
+ else writeFatalLine(line);
446
+ }
447
+ process.exit(result.exitCode);
448
+ };
449
+
450
+ if (cli.command === 'status') {
451
+ exitWith(await runStatusCommand({ ...remoteDeps, flags: remoteFlags }));
452
+ }
453
+ if (cli.command === 'update') {
454
+ exitWith(await runUpdateCommand({ ...remoteDeps, flags: { ...remoteFlags, check: cliFlags.check } }));
455
+ }
456
+ if (cli.command === 'sessions') {
457
+ exitWith(await runSessionsCommand({
458
+ ...remoteDeps,
459
+ flags: { ...remoteFlags, all: cliFlags.all },
460
+ args: cli.commandArgs,
461
+ }));
462
+ }
463
+ if (cli.command === 'pair') {
464
+ exitWith(await runPairCommand({
465
+ configManager: config,
466
+ daemonHomeDir: daemonHomeDirectory,
467
+ version: VERSION,
468
+ readToken: readOperatorTokenFile,
469
+ flags: { ...remoteFlags, yes: cliFlags.yes },
470
+ }));
471
+ }
472
+ if (cli.command === 'config') {
473
+ exitWith(runConfigCommand(cli.commandArgs, {
474
+ configManager: config,
475
+ json: cliFlags.json,
476
+ binary: 'goodvibes-daemon',
477
+ }));
478
+ }
479
+
480
+ // Service lifecycle: install / uninstall / start / stop / restart / status /
481
+ // migrate manage this host's service definition for the shared daemon. They
482
+ // run BEFORE the daemon boots — no runtime/services are constructed — and
483
+ // exit with the honest result code.
484
+ const serviceSubcommand = cli.command;
485
+ if (isDaemonServiceSubcommand(serviceSubcommand)) {
486
+ // The host/port baked into the unit's ExecStart (and displayed) come from
487
+ // the SAME hostMode-aware resolution the SDK bind path uses — never from
488
+ // the GOODVIBES_DAEMON_HOST env var, which nothing in the bind path reads
489
+ // (the --hostname flag already lands in config via
490
+ // applyRuntimeEndpointFlagOverrides above, so it is covered here).
491
+ const binding = resolveRuntimeEndpointBinding(config, 'controlPlane');
492
+ if (!binding.recognized) {
493
+ // The SDK bind path has no default case for an unrecognized hostMode —
494
+ // a daemon launched with this config throws before binding. Say so
495
+ // instead of presenting the fallback values as a real binding.
496
+ console.warn(
497
+ `[goodvibes-daemon] warning: controlPlane.hostMode '${binding.hostMode}' is not a recognized mode ` +
498
+ "(local|network|custom) — the daemon will fail to start until it is corrected.",
499
+ );
500
+ }
501
+ const binaryPath = resolveInstalledDaemonBinary({ moduleUrl: import.meta.url });
502
+ const result = await runDaemonServiceCli({
503
+ subcommand: serviceSubcommand,
504
+ binaryPath,
505
+ homeDir: homeDirectory,
506
+ // Unit-file paths resolve from the LOGIN home, never the
507
+ // GOODVIBES_HOME-overridable tree home above — see
508
+ // BuildManagedDaemonServiceManagerParams.unitHomeDir's doc.
509
+ unitHomeDir: homedir(),
510
+ host: binding.host,
511
+ port: binding.port,
512
+ // migrate-service only: never auto-migrate — requires the same explicit
513
+ // consent as any other non-interactive destructive confirmation.
514
+ confirmMigration: cliFlags.yes,
515
+ // service-status only.
516
+ json: cliFlags.json,
517
+ // install-service/migrate-service refuse an explicit --hostname/--port
518
+ // rather than printing/probing a binding the installed unit will never
519
+ // actually have (it re-resolves from persisted settings at boot).
520
+ hostnameFlagProvided: cliFlags.hostname !== undefined,
521
+ portFlagProvided: cliFlags.port !== undefined,
522
+ });
523
+ // These print the unit path, the follow-up commands and the honest result,
524
+ // then exit immediately — exactly the race a stream write loses. Descriptor
525
+ // writes instead.
526
+ for (const line of result.lines) {
527
+ writeExitingStdoutLine(line);
528
+ }
529
+ process.exit(result.exitCode);
530
+ }
531
+
532
+ // Honest startup identity, printed for EVERY launch shape — including a bare
533
+ // (no-arg) systemd launch, and BEFORE any runtime construction so it still
534
+ // reaches the journal when a broken config makes the daemon throw during
535
+ // composition. It states the resolved version (never a placeholder) and the
536
+ // home/host/port the daemon will actually bind: the binding comes from the
537
+ // SAME hostMode-aware resolution the SDK bind path uses (resolveHostBinding:
538
+ // 'local' forces 127.0.0.1, 'network' forces 0.0.0.0, port 0/non-numeric
539
+ // falls back to the default) — never from controlPlane.host alone, and never
540
+ // from the GOODVIBES_DAEMON_HOST env var, which the bind path does not read.
541
+ const bannerBinding = resolveRuntimeEndpointBinding(config, 'controlPlane');
542
+ // eslint-disable-next-line no-console
543
+ console.log(renderDaemonStartupBanner(VERSION, { homeDir: homeDirectory, host: bannerBinding.host, port: bannerBinding.port }));
544
+ if (!bannerBinding.recognized) {
545
+ // An unrecognized hostMode has NO binding the SDK can produce (its
546
+ // resolver has no default case and the daemon will throw below, before
547
+ // serving). Warn here so the journaled crash is explained.
548
+ console.warn(
549
+ `[goodvibes-daemon] warning: controlPlane.hostMode '${bannerBinding.hostMode}' is not a recognized mode ` +
550
+ "(local|network|custom) — the daemon cannot bind until it is corrected; the host/port above are fallback values, not a real binding.",
551
+ );
552
+ }
553
+
554
+ // Boot-time reconciliation. The banner above states the real bind; this
555
+ // compares it to the URL clients are actually handed. Those are produced by
556
+ // two different resolvers, and when they disagree the daemon is advertising an
557
+ // address it does not answer on — the state that produced two different click
558
+ // hosts from one daemon. The comparison is the SDK's own
559
+ // describeDerivedBindMismatch, which already knows the two cases that are NOT
560
+ // drift: a wildcard bind reported as 0.0.0.0 against a loopback dial target is
561
+ // a deliberate substitution, and a declared controlPlane.publicBaseUrl is
562
+ // supposed to differ from the bind. This entrypoint carried its own copy of
563
+ // that comparison while the published SDK predated the helper.
564
+ if (bannerBinding.recognized) {
565
+ const mismatch = describeDerivedBindMismatch(
566
+ { host: bannerBinding.host, port: bannerBinding.port },
567
+ readControlPlaneBinding((key) => config.get(key as ConfigKey)),
568
+ );
569
+ if (mismatch) {
570
+ console.warn(`[goodvibes-daemon] warning: ${mismatch}`);
571
+ logger.warn('daemon: control-plane base URL disagrees with the real bind', {
572
+ mismatch,
573
+ boundHost: bannerBinding.host,
574
+ boundPort: bannerBinding.port,
575
+ });
576
+ }
577
+ }
578
+
579
+ // Point the bus listener cap at runtime.eventBus.maxListeners before the
580
+ // first bus exists, so every bus this process builds later uses it.
581
+ configureRuntimeEventBusDefaults(runtimeEventBusOptionsFrom((key) => config.get(key)));
582
+ const runtimeBus = new RuntimeEventBus();
583
+ const runtimeStore = createRuntimeStore();
584
+ // Gate states derive from the domain settings keys; the bridge keeps live
585
+ // config changes flowing into the manager for the daemon's lifetime.
586
+ const featureFlags = createFeatureFlagManager();
587
+ featureFlags.loadFromConfig({ flags: deriveFeatureStates(config) });
588
+ bindFeatureSettingsBridge(config, featureFlags);
589
+ const runtimeServices = createRuntimeServices({
590
+ configManager: config,
591
+ featureFlags,
592
+ runtimeBus,
593
+ runtimeStore,
594
+ getConversationTitle: () => 'goodvibes daemon',
595
+ workingDir,
596
+ homeDirectory,
597
+ // Honour --daemon-home / GOODVIBES_DAEMON_HOME on the CREDENTIAL store, not
598
+ // just the identity directory. Without this a daemon told to run out of a
599
+ // temp tree still read the real home's daemon secrets, so "isolating" a
600
+ // test daemon left it holding the owner's live credentials.
601
+ daemonHomeDirectory,
602
+ // This daemon observes externally-launched coding-agent sessions on the
603
+ // host read-only (fleet visibility + steer; never counted, never
604
+ // stopped). Daemon-side only — the interactive process reads this snapshot
605
+ // rather than double-detecting. Mirrors the SDK daemon cli.
606
+ observeExternalAgents: true,
607
+ // Opt into the REAL host power seam (Linux logind: systemd-inhibit children
608
+ // + the dbus-monitor sleep-edge watcher) so this daemon holds live
609
+ // keep-awake/idle-inhibit. SDK 1.9.0's runtime-services factory defaults to
610
+ // the non-spawning unavailable seam; only daemon compositions opt in. Mirrors
611
+ // the SDK daemon cli's createHostPowerSeam() (sdk commit 3a5ea26d).
612
+ powerSeam: createHostPowerSeam(),
613
+ // The wake-word model ships with the installation, and this is the retry: at
614
+ // every daemon start, sweep the managed wake tree and fetch whatever the
615
+ // install could not (an offline install, a killed download, a changed pin).
616
+ // It never blocks startup and never fails it — see the SDK's
617
+ // voice/wake/install-provision.ts. The webui reads the model bytes from THIS
618
+ // process, so a provisioned daemon is what makes the browser path work too.
619
+ provisionWakeModelsAtBoot: true,
620
+ });
621
+
622
+ // Load persisted providers from disk so the provider registry is pre-populated
623
+ // at this daemon's startup (same machinery the TUI uses after /scan).
624
+ const discoveryRoots = { homeDirectory, surfaceRoot: GOODVIBES_DAEMON_SURFACE_ROOT };
625
+ const persistedProviders = loadPersistedProviders(discoveryRoots);
626
+ if (persistedProviders.length > 0) {
627
+ runtimeServices.providerRegistry.registerDiscoveredProviders(persistedProviders);
628
+ logger.info('daemon: loaded persisted providers', { count: persistedProviders.length });
629
+ }
630
+
631
+ // Run a background LAN scan (non-blocking). Discovered servers are registered
632
+ // and persisted so subsequent daemon restarts benefit from the warm cache.
633
+ void scan().then((result) => {
634
+ if (result.servers.length > 0) {
635
+ runtimeServices.providerRegistry.registerDiscoveredProviders(result.servers);
636
+ persistProviders(discoveryRoots, result.servers);
637
+ logger.info('daemon: LAN scan complete', { found: result.servers.length });
638
+ } else {
639
+ logger.info('daemon: LAN scan found no servers');
640
+ }
641
+ }).catch((err: unknown) => {
642
+ logger.warn('daemon: LAN scan failed', { error: summarizeError(err) });
643
+ });
644
+
645
+ const userAuth = runtimeServices.localUserAuthManager;
646
+ const daemon = new DaemonServer({
647
+ runtimeBus,
648
+ userAuth,
649
+ runtimeServices,
650
+ serveFactory: createSafeHostServeFactory('Standalone daemon'),
651
+ // Hand the facade this binary's real version + exec path so its self-update
652
+ // lifecycle compares the HOST's version (not the sdk package's). Resolves to
653
+ // undefined for a non-binary install, so a dev run stays host-managed and
654
+ // never swaps the interpreter. See lifecycle.ts.
655
+ updateArtifact: resolveDaemonUpdateArtifact({ version: VERSION }),
656
+ // A daemon running out of an overridden home must never adopt the machine's
657
+ // service unit. One did: started from a scratchpad with --daemon-home, it
658
+ // found the unit not running, wrote its own scratchpad ExecStart into
659
+ // ~/.config/systemd/user/goodvibes.service and exited, and systemd then
660
+ // supervised the throwaway as the machine's daemon for five hours.
661
+ hasOverriddenHome,
662
+ // The SAME coordinator this repository's inbox poller registered with (see
663
+ // runtime/cluster-composition.ts). The facade must reuse it rather than
664
+ // compose its own: two coordinators in one process are two nodes in the
665
+ // election, and whichever lost would silence consumers the other owns.
666
+ clusterCoordinator: runtimeServices.clusterCoordinator,
667
+ // The `cluster` verbs, served on /api/cluster/*. The CLI subcommands, the
668
+ // TUI's /cluster command and any web UI all call these, so a command run
669
+ // against a REMOTE daemon behaves exactly like one run on that machine.
670
+ clusterGroupVerbs: runtimeServices.clusterGroup.verbs,
671
+ // Daemon-hosted sessions. Stating this is what turns `sessions.hosted.*`
672
+ // on, and what it states is where a hosted run's asks are gated — this
673
+ // daemon's per-workspace trust decision. See
674
+ // runtime/hosted-session-composition.ts.
675
+ hostedSessions: createHostedSessionOptions(runtimeServices),
676
+ });
677
+ const listener = new HttpListener({
678
+ hookDispatcher: runtimeServices.hookDispatcher,
679
+ userAuth,
680
+ configManager: config,
681
+ serveFactory: createSafeHostServeFactory('Standalone HTTP listener'),
682
+ });
683
+ const { daemonToken, httpToken } = readDaemonCliTokens(process.env);
684
+
685
+ // If no explicit daemon token is set, use the companion token so mobile apps
686
+ // can connect.
687
+ //
688
+ // The record is named 'tui' and stays named 'tui'. It is the SHARED loopback
689
+ // token: the terminal app, the agent and the web app all read this exact
690
+ // record out of operator-tokens.json to talk to this daemon. Renaming it to
691
+ // match the daemon's new product name would mint a second token beside the one
692
+ // every installed client is already holding, and each of them would start
693
+ // getting 401s from a daemon that is running perfectly. The name is a key in a
694
+ // file, not a description of who owns it.
695
+ const daemonHomeDir = daemonHomeDirectory;
696
+ const companionTokenRecord = getOrCreateCompanionToken('tui', { daemonHomeDir });
697
+ // Cleanup: remove stale pre-0.21.28 workspace-scoped operator token files
698
+ // left behind by very old installs, so only the canonical
699
+ // <daemonHomeDir>/operator-tokens.json survives.
700
+ const prune = pruneStaleOperatorTokens({
701
+ daemonHomeDir,
702
+ candidatePaths: operations.workspaceOperatorTokenCandidates(workingDir, GOODVIBES_DAEMON_SURFACE_ROOT),
703
+ });
704
+ if (prune.prunedPaths.length > 0) {
705
+ logger.info('daemon: pruned stale operator-token files', { count: prune.prunedPaths.length, paths: prune.prunedPaths });
706
+ }
707
+ if (prune.failedPaths.length > 0) {
708
+ logger.warn('daemon: failed to prune stale operator-token files (permission/race)', { count: prune.failedPaths.length, paths: prune.failedPaths });
709
+ }
710
+ const effectiveDaemonToken = daemonToken ?? companionTokenRecord.token;
711
+ const effectiveHttpToken = httpToken ?? effectiveDaemonToken;
712
+
713
+ daemon.enable({ daemon: true }, effectiveDaemonToken);
714
+ listener.enable({ httpListener: true }, effectiveHttpToken);
715
+
716
+ // Before the daemon: the group layer owns the socket the leader election
717
+ // coordinates over, and it announces this machine's return to the group so a
718
+ // box that has been off for months re-keys itself with no operator action.
719
+ await runtimeServices.startCluster();
720
+
721
+ // Grants and captures from paired phones outlive a restart, so the recovery
722
+ // sweep runs before this daemon serves its first request and the periodic one
723
+ // keeps a long-running daemon from going days without a sweep. The stop is
724
+ // registered with the disposal scope, so shutdown clears the timer.
725
+ startDeviceHousekeeping(runtimeServices.devicePosture);
726
+
727
+ await Promise.all([
728
+ daemon.start(),
729
+ config.get('danger.httpListener') ? listener.start() : Promise.resolve(),
730
+ ]);
731
+
732
+ // The boot steps the facade does not know about, because they are this
733
+ // product's rather than the platform's: the legacy memory fold, provider
734
+ // watching, and the notifier/integration attachments. They run AFTER
735
+ // daemon.start(), because the fold has to follow the facade's memoryStore
736
+ // init(), and none of them may keep the daemon from serving — see
737
+ // runtime/boot-tasks.ts.
738
+ await runDaemonBootTasks(runtimeServices);
739
+
740
+ // The DaemonServer facade owns the hourly self-update loop now (fed this
741
+ // binary's version + exec path via updateArtifact above); it checks hourly,
742
+ // swaps only at an idle moment, keeps the outgoing binary at `<path>.previous`,
743
+ // and stops with the daemon. No separate host loop.
744
+
745
+ const abortController = new AbortController();
746
+
747
+ const shutdown = async (): Promise<void> => {
748
+ abortController.abort();
749
+ const SHUTDOWN_DEADLINE_MS = 15_000;
750
+ const timeout = new Promise<'timeout'>((resolve) =>
751
+ setTimeout(() => resolve('timeout'), SHUTDOWN_DEADLINE_MS)
752
+ );
753
+ // daemon.stop() drives the ordered inbound teardown: it stops every gated
754
+ // consumer and only then broadcasts the resignation, so another node on
755
+ // this network takes over in about a second instead of waiting out the
756
+ // crash timeout.
757
+ //
758
+ // This process built the runtime graph and handed it to DaemonServer, so by
759
+ // the SDK's ownership rule the facade leaves it alone — nothing else stops
760
+ // these pollers. Without dispose() the config watch, fleet tick, memory
761
+ // governor, watcher registry and six more kept ticking until process exit.
762
+ // The handler surfaces (inbox store + its poll timers, catalog handlers) are
763
+ // the FIRST thing dispose() unwinds — they are on the disposal owner list
764
+ // now rather than sequenced by hand here, which is what makes every other
765
+ // shutdown path stop them too instead of only this one.
766
+ const stop = Promise.allSettled([listener.stop(), daemon.stop()])
767
+ .then(() => { runtimeServices.dispose(); })
768
+ .then(() => 'done' as const);
769
+ const result = await Promise.race([stop, timeout]);
770
+ if (result === 'timeout') {
771
+ logger.warn('shutdown deadline exceeded — forcing exit');
772
+ // A forced exit is exactly the case where the log matters most and is
773
+ // least likely to have drained on its own.
774
+ flushActivityLogSync();
775
+ process.exit(1);
776
+ }
777
+ flushActivityLogSync();
778
+ process.exit(0);
779
+ };
780
+
781
+ let shutdownInFlight = false;
782
+ const handleSignal = (): void => {
783
+ if (shutdownInFlight) return;
784
+ shutdownInFlight = true;
785
+ void shutdown();
786
+ };
787
+
788
+ process.on('SIGINT', handleSignal);
789
+ process.on('SIGTERM', handleSignal);
790
+
791
+ logger.info('goodvibes daemon host started', {
792
+ daemon: resolveDaemonEnabled(config),
793
+ httpListener: config.get('danger.httpListener'),
794
+ });
795
+
796
+ // Cheap unattended reconcile: if this (canonical) daemon unit is confirmed
797
+ // serving AND a redundant installer-managed goodvibes-daemon.service (the
798
+ // retired unit name) sits enabled-but-NOT-running beside it — the exact
799
+ // production-incident state — auto-disable and remove it, printing a
800
+ // receipt. A RUNNING legacy daemon, a hand-written unit, or an unanswered
801
+ // configured endpoint all refuse with a notice instead. Best-effort: never
802
+ // let this block or crash daemon boot (per-call systemctl timeouts plus one
803
+ // cumulative pass deadline). The unit search root is the LOGIN user's home
804
+ // (where systemd user units live), never the daemon data home
805
+ // (GOODVIBES_DAEMON_HOME); the tracked name honors service.serviceName. The
806
+ // endpoint requirement uses the CLIENT view of the config — a fresh read of
807
+ // settings.json with none of this process's runtime flag overrides — because
808
+ // that is what clients resolve when they look for the daemon.
809
+ try {
810
+ runDaemonConfigMigration(homeDirectory);
811
+ const clientViewConfig = new ConfigManager({
812
+ workingDir,
813
+ homeDir: homeDirectory,
814
+ surfaceRoot: GOODVIBES_DAEMON_SURFACE_ROOT,
815
+ daemonTierPath: daemonConfigPathForHome(daemonHomeDirectory),
816
+ });
817
+ const clientEndpoint = resolveRuntimeEndpointBinding(clientViewConfig, 'controlPlane');
818
+ const reconcile = await reconcileRedundantLegacyUnit({
819
+ homeDir: homedir(),
820
+ trackedServiceName: resolveConfiguredServiceName(clientViewConfig),
821
+ configuredEndpoint: { host: clientEndpoint.host, port: clientEndpoint.port },
822
+ });
823
+ if (reconcile.action !== 'noop') {
824
+ for (const line of reconcile.lines) {
825
+ // eslint-disable-next-line no-console
826
+ console.log(line);
827
+ }
828
+ }
829
+ if (reconcile.reason !== 'no-legacy-unit') {
830
+ // Breadcrumb for EVERY outcome where a legacy unit file exists —
831
+ // including guard refusals — so a persisting two-unit state is never
832
+ // silent about why nothing was reconciled.
833
+ logger.info('daemon: legacy-unit reconcile', { action: reconcile.action, reason: reconcile.reason });
834
+ }
835
+ } catch (error) {
836
+ logger.warn('daemon: legacy-unit reconcile failed (non-fatal)', { error: summarizeError(error) });
837
+ }
838
+
839
+ // Print a device-pairing QR to stdout. The QR encodes the canonical
840
+ // `#pair=<token>` deep link the web app consumes — a camera scan opens it
841
+ // already signed in. No raw JSON connection blob is printed. This is also the
842
+ // one place web.publicBaseUrl is frozen from the stable-name resolution (never
843
+ // clobbering a user-set value), so the printed origin survives a DHCP change.
844
+ // The daemon's shared companion token rides as the pairing token; a device can
845
+ // later migrate to its own per-device token from /settings → security → devices.
846
+ const webOrigin = ensurePublicBaseUrl(config);
847
+ const offers = availablePairingOffers({
848
+ relayEnabled: config.get('relay.enabled') === true,
849
+ stepUpAvailable: true,
850
+ });
851
+ // Rendered by core/pairing-banner.ts, which `goodvibes-daemon pair` also
852
+ // calls — so the block printed here and the one printed on demand are the
853
+ // same block, from the same token, and cannot drift.
854
+ const banner = renderPairingBanner({
855
+ version: VERSION,
856
+ origin: webOrigin.origin,
857
+ token: companionTokenRecord.token,
858
+ offers,
859
+ });
860
+ // eslint-disable-next-line no-console
861
+ console.log(banner.lines.join('\n'));
862
+ // eslint-disable-next-line no-console
863
+ console.log('(print this again at any time: goodvibes-daemon pair)');
864
+ }
865
+
866
+ void main().catch((error) => {
867
+ // A daemon that dies during startup must leave the reason where an operator
868
+ // will actually find it: on the file descriptor the service journal is
869
+ // attached to, written synchronously, BEFORE the activity log is attempted.
870
+ //
871
+ // Doing it the other way round is what shipped mute. This handler used to
872
+ // call logger.error and flushActivityLogSync and nothing else, and the
873
+ // logger had no destination this early in boot — so the released 1.27.0
874
+ // binary crash-looped 77 times with exit 1, zero bytes on stdout, zero bytes
875
+ // on stderr and an empty activity log. See fatal-boot-report.ts.
876
+ reportFatalBootFailure(error);
877
+ process.exit(1);
878
+ });