@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.
- package/CHANGELOG.md +383 -0
- package/LICENSE +21 -0
- package/README.md +125 -0
- package/bin/goodvibes-daemon +100 -0
- package/bin/launcher-support.js +226 -0
- package/package.json +96 -0
- package/scripts/check-bun.sh +20 -0
- package/scripts/postinstall.js +244 -0
- package/src/cli/command-catalog.ts +828 -0
- package/src/cli/completion.ts +299 -0
- package/src/cli/help.ts +167 -0
- package/src/cli/index.ts +21 -0
- package/src/cli/parser.ts +55 -0
- package/src/cli/surface-catalog.ts +26 -0
- package/src/cli/types.ts +63 -0
- package/src/cluster/daemon-ws-call.ts +235 -0
- package/src/cluster/raw-reply-route.ts +111 -0
- package/src/config/checkpoint-settings.ts +113 -0
- package/src/config/run-daemon-config-migration.ts +47 -0
- package/src/config/secret-config.ts +175 -0
- package/src/config/secrets.ts +71 -0
- package/src/config/surface.ts +24 -0
- package/src/core/pairing-banner.ts +82 -0
- package/src/daemon/cli.ts +878 -0
- package/src/daemon/config-command.ts +281 -0
- package/src/daemon/handlers/context.ts +29 -0
- package/src/daemon/handlers/contracts.ts +43 -0
- package/src/daemon/handlers/credentials.ts +139 -0
- package/src/daemon/handlers/drafts/draft-store.ts +427 -0
- package/src/daemon/handlers/drafts/index.ts +17 -0
- package/src/daemon/handlers/drafts/register.ts +331 -0
- package/src/daemon/handlers/errors.ts +18 -0
- package/src/daemon/handlers/inbox/aggregator.ts +375 -0
- package/src/daemon/handlers/inbox/cursor-store.ts +512 -0
- package/src/daemon/handlers/inbox/index.ts +221 -0
- package/src/daemon/handlers/inbox/mapping.ts +192 -0
- package/src/daemon/handlers/inbox/poller.ts +239 -0
- package/src/daemon/handlers/inbox/provider-adapter.ts +171 -0
- package/src/daemon/handlers/inbox/providers/discord.ts +276 -0
- package/src/daemon/handlers/inbox/providers/email.ts +176 -0
- package/src/daemon/handlers/inbox/providers/imap-client.ts +300 -0
- package/src/daemon/handlers/inbox/providers/route-util.ts +24 -0
- package/src/daemon/handlers/inbox/providers/slack.ts +287 -0
- package/src/daemon/handlers/index.ts +117 -0
- package/src/daemon/handlers/register.ts +180 -0
- package/src/daemon/handlers/remote/backends/cloud-terminal.ts +143 -0
- package/src/daemon/handlers/remote/backends/docker.ts +79 -0
- package/src/daemon/handlers/remote/backends/index.ts +40 -0
- package/src/daemon/handlers/remote/backends/local-process.ts +113 -0
- package/src/daemon/handlers/remote/backends/process-runner.ts +127 -0
- package/src/daemon/handlers/remote/backends/ssh.ts +126 -0
- package/src/daemon/handlers/remote/backends/types.ts +97 -0
- package/src/daemon/handlers/remote/dispatcher.ts +181 -0
- package/src/daemon/handlers/remote/index.ts +120 -0
- package/src/daemon/handlers/remote/peer-registry.ts +357 -0
- package/src/daemon/handlers/remote/service.ts +191 -0
- package/src/daemon/handlers/routing/inbox-bridge.ts +71 -0
- package/src/daemon/handlers/routing/index.ts +261 -0
- package/src/daemon/handlers/routing/route-store.ts +319 -0
- package/src/daemon/handlers/routing/routing-resolver.ts +75 -0
- package/src/daemon/handlers/sqlite-store.ts +303 -0
- package/src/daemon/handlers/triage/index.ts +57 -0
- package/src/daemon/handlers/triage/integration.ts +213 -0
- package/src/daemon/handlers/triage/pipeline.ts +274 -0
- package/src/daemon/handlers/triage/scorer.ts +287 -0
- package/src/daemon/handlers/triage/tagger/discord.ts +187 -0
- package/src/daemon/handlers/triage/tagger/imap.ts +384 -0
- package/src/daemon/handlers/triage/tagger/index.ts +184 -0
- package/src/daemon/handlers/triage/tagger/shared.ts +70 -0
- package/src/daemon/handlers/triage/tagger/slack.ts +69 -0
- package/src/daemon/handlers/triage/types.ts +50 -0
- package/src/daemon/lifecycle.ts +41 -0
- package/src/daemon/local-daemon-state.ts +233 -0
- package/src/daemon/pair-command.ts +301 -0
- package/src/daemon/provision-wake-model.ts +81 -0
- package/src/daemon/send/channels.ts +200 -0
- package/src/daemon/send/command.ts +333 -0
- package/src/daemon/send/composition.ts +100 -0
- package/src/daemon/send/failure-text.ts +93 -0
- package/src/daemon/send/inert-text.ts +225 -0
- package/src/daemon/send/stdin.ts +24 -0
- package/src/daemon/service-commands.ts +530 -0
- package/src/daemon/sessions-command.ts +209 -0
- package/src/daemon/status-command.ts +481 -0
- package/src/daemon/webui-command.ts +339 -0
- package/src/runtime/boot-tasks.ts +110 -0
- package/src/runtime/cluster-composition.ts +124 -0
- package/src/runtime/cluster-group-composition.ts +284 -0
- package/src/runtime/conversation-rewind-port.ts +171 -0
- package/src/runtime/credential-composition.ts +54 -0
- package/src/runtime/daemon-handler-composition.ts +76 -0
- package/src/runtime/device-posture-composition.ts +115 -0
- package/src/runtime/disposal-wiring.ts +101 -0
- package/src/runtime/fleet-needs-input-push.ts +61 -0
- package/src/runtime/fleet-services.ts +41 -0
- package/src/runtime/hosted-session-composition.ts +128 -0
- package/src/runtime/index.ts +100 -0
- package/src/runtime/knowledge-services.ts +101 -0
- package/src/runtime/legacy-daemon-migration.ts +605 -0
- package/src/runtime/legacy-daemon-reconcile.ts +448 -0
- package/src/runtime/mail-composition.ts +65 -0
- package/src/runtime/notification-dispatch.ts +86 -0
- package/src/runtime/plugin-composition.ts +111 -0
- package/src/runtime/runtime-services-types.ts +268 -0
- package/src/runtime/services.ts +756 -0
- package/src/runtime/trigger-services.ts +62 -0
- package/src/runtime/trust/checkpoint-eligibility.ts +138 -0
- package/src/runtime/trust/trust-gated-approvals.ts +169 -0
- package/src/runtime/update-check.ts +61 -0
- package/src/runtime/workspace-checkpointing.ts +116 -0
- package/src/testing/daemon-fixture.ts +276 -0
- package/src/testing/hosted-session-failures.ts +92 -0
- 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
|
+
});
|