@vellumai/cli 0.5.15 โ†’ 0.6.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.
@@ -291,6 +291,11 @@ async function upgradeDocker(
291
291
  ` Captured ${Object.keys(capturedEnv).length} env var(s) from ${res.assistantContainer}\n`,
292
292
  );
293
293
 
294
+ // Capture GUARDIAN_BOOTSTRAP_SECRET from the gateway container (it is only
295
+ // set on gateway, not assistant) so it persists across container restarts.
296
+ const gatewayEnv = await captureContainerEnv(res.gatewayContainer);
297
+ const bootstrapSecret = gatewayEnv["GUARDIAN_BOOTSTRAP_SECRET"];
298
+
294
299
  // Notify connected clients that an upgrade is about to begin.
295
300
  // This must fire BEFORE any progress broadcasts so the UI sets
296
301
  // isUpdateInProgress = true and starts displaying status messages.
@@ -419,6 +424,7 @@ async function upgradeDocker(
419
424
  await startContainers(
420
425
  {
421
426
  signingKey,
427
+ bootstrapSecret,
422
428
  cesServiceToken,
423
429
  extraAssistantEnv,
424
430
  gatewayPort,
@@ -517,6 +523,7 @@ async function upgradeDocker(
517
523
  await startContainers(
518
524
  {
519
525
  signingKey,
526
+ bootstrapSecret,
520
527
  cesServiceToken,
521
528
  extraAssistantEnv,
522
529
  gatewayPort,
package/src/lib/aws.ts CHANGED
@@ -6,7 +6,8 @@ import { buildStartupScript, watchHatching } from "../commands/hatch";
6
6
  import type { PollResult } from "../commands/hatch";
7
7
  import { saveAssistantEntry, setActiveAssistant } from "./assistant-config";
8
8
  import type { AssistantEntry } from "./assistant-config";
9
- import { GATEWAY_PORT, PROVIDER_ENV_VAR_NAMES } from "./constants";
9
+ import { GATEWAY_PORT } from "./constants";
10
+ import { PROVIDER_ENV_VAR_NAMES } from "../shared/provider-env-vars.js";
10
11
  import type { Species } from "./constants";
11
12
  import { leaseGuardianToken } from "./guardian-token";
12
13
  import { generateInstanceName } from "./random-name";
@@ -1,5 +1,3 @@
1
- import providerEnvVarsRegistry from "../../../meta/provider-env-vars.json";
2
-
3
1
  /**
4
2
  * Canonical internal assistant ID used as the default/fallback across the CLI
5
3
  * and daemon. Mirrors `DAEMON_INTERNAL_ASSISTANT_ID` from
@@ -28,15 +26,6 @@ export const LOCKFILE_NAMES = [
28
26
  ".vellum.lockfile.json",
29
27
  ] as const;
30
28
 
31
- /**
32
- * Environment variable names for provider API keys, keyed by provider ID.
33
- * Loaded from the shared registry at `meta/provider-env-vars.json` โ€” the
34
- * single source of truth also consumed by the assistant runtime and the
35
- * macOS client.
36
- */
37
- export const PROVIDER_ENV_VAR_NAMES: Record<string, string> =
38
- providerEnvVarsRegistry.providers;
39
-
40
29
  export const VALID_REMOTE_HOSTS = [
41
30
  "local",
42
31
  "gcp",
package/src/lib/docker.ts CHANGED
@@ -13,7 +13,8 @@ import {
13
13
  } from "./assistant-config";
14
14
  import type { AssistantEntry } from "./assistant-config";
15
15
  import { writeInitialConfig } from "./config-utils";
16
- import { DEFAULT_GATEWAY_PORT, PROVIDER_ENV_VAR_NAMES } from "./constants";
16
+ import { DEFAULT_GATEWAY_PORT } from "./constants";
17
+ import { PROVIDER_ENV_VAR_NAMES } from "../shared/provider-env-vars.js";
17
18
  import type { Species } from "./constants";
18
19
  import { leaseGuardianToken } from "./guardian-token";
19
20
  import { isVellumProcess, stopProcess } from "./process";
@@ -479,8 +480,9 @@ async function buildAllImages(
479
480
 
480
481
  /**
481
482
  * Returns a function that builds the `docker run` arguments for a given
482
- * service. Each container joins a shared Docker bridge network so they
483
- * can be restarted independently.
483
+ * service. All three containers share a network namespace via
484
+ * `--network=container:` so inter-service traffic is over localhost,
485
+ * matching the platform's Kubernetes pod topology.
484
486
  */
485
487
  export function serviceDockerRunArgs(opts: {
486
488
  signingKey?: string;
@@ -511,12 +513,14 @@ export function serviceDockerRunArgs(opts: {
511
513
  "--name",
512
514
  res.assistantContainer,
513
515
  `--network=${res.network}`,
516
+ "-p",
517
+ `${gatewayPort}:${GATEWAY_INTERNAL_PORT}`,
514
518
  "-v",
515
519
  `${res.workspaceVolume}:/workspace`,
516
520
  "-v",
517
521
  `${res.socketVolume}:/run/ces-bootstrap`,
518
522
  "-e",
519
- "IS_CONTAINERIZED=false",
523
+ "IS_CONTAINERIZED=true",
520
524
  "-e",
521
525
  `VELLUM_ASSISTANT_NAME=${instanceName}`,
522
526
  "-e",
@@ -526,9 +530,9 @@ export function serviceDockerRunArgs(opts: {
526
530
  "-e",
527
531
  "VELLUM_WORKSPACE_DIR=/workspace",
528
532
  "-e",
529
- `CES_CREDENTIAL_URL=http://${res.cesContainer}:8090`,
533
+ "CES_CREDENTIAL_URL=http://localhost:8090",
530
534
  "-e",
531
- `GATEWAY_INTERNAL_URL=http://${res.gatewayContainer}:${GATEWAY_INTERNAL_PORT}`,
535
+ `GATEWAY_INTERNAL_URL=http://localhost:${GATEWAY_INTERNAL_PORT}`,
532
536
  ];
533
537
  if (defaultWorkspaceConfigPath) {
534
538
  const containerPath = `/tmp/vellum-default-workspace-config-${Date.now()}.json`;
@@ -567,9 +571,7 @@ export function serviceDockerRunArgs(opts: {
567
571
  "-d",
568
572
  "--name",
569
573
  res.gatewayContainer,
570
- `--network=${res.network}`,
571
- "-p",
572
- `${gatewayPort}:${GATEWAY_INTERNAL_PORT}`,
574
+ `--network=container:${res.assistantContainer}`,
573
575
  "-v",
574
576
  `${res.workspaceVolume}:/workspace`,
575
577
  "-v",
@@ -581,13 +583,13 @@ export function serviceDockerRunArgs(opts: {
581
583
  "-e",
582
584
  `GATEWAY_PORT=${GATEWAY_INTERNAL_PORT}`,
583
585
  "-e",
584
- `ASSISTANT_HOST=${res.assistantContainer}`,
586
+ "ASSISTANT_HOST=localhost",
585
587
  "-e",
586
588
  `RUNTIME_HTTP_PORT=${ASSISTANT_INTERNAL_PORT}`,
587
589
  "-e",
588
590
  "RUNTIME_PROXY_ENABLED=true",
589
591
  "-e",
590
- `CES_CREDENTIAL_URL=http://${res.cesContainer}:8090`,
592
+ "CES_CREDENTIAL_URL=http://localhost:8090",
591
593
  ...(cesServiceToken
592
594
  ? ["-e", `CES_SERVICE_TOKEN=${cesServiceToken}`]
593
595
  : []),
@@ -605,7 +607,7 @@ export function serviceDockerRunArgs(opts: {
605
607
  "-d",
606
608
  "--name",
607
609
  res.cesContainer,
608
- `--network=${res.network}`,
610
+ `--network=container:${res.assistantContainer}`,
609
611
  "-v",
610
612
  `${res.socketVolume}:/run/ces-bootstrap`,
611
613
  "-v",
@@ -842,6 +844,15 @@ function startFileWatcher(opts: {
842
844
  const services = pendingServices;
843
845
  pendingServices = new Set();
844
846
 
847
+ // Gateway and CES share the assistant's network namespace. If the
848
+ // assistant container is removed and recreated, the shared namespace
849
+ // is destroyed and the other two lose connectivity. Cascade the
850
+ // restart to all three services in that case.
851
+ if (services.has("assistant")) {
852
+ services.add("gateway");
853
+ services.add("credential-executor");
854
+ }
855
+
845
856
  const serviceNames = [...services].join(", ");
846
857
  console.log(`\n๐Ÿ”„ Changes detected โ€” rebuilding: ${serviceNames}`);
847
858
 
@@ -854,7 +865,10 @@ function startFileWatcher(opts: {
854
865
  }),
855
866
  );
856
867
 
857
- for (const service of services) {
868
+ // Restart in dependency order (assistant first) so the network
869
+ // namespace owner is up before dependents try to attach.
870
+ for (const service of SERVICE_START_ORDER) {
871
+ if (!services.has(service)) continue;
858
872
  const container = containerForService[service];
859
873
  console.log(`๐Ÿ”„ Restarting ${container}...`);
860
874
  await removeContainer(container);
package/src/lib/gcp.ts CHANGED
@@ -4,11 +4,8 @@ import { join } from "path";
4
4
 
5
5
  import { saveAssistantEntry, setActiveAssistant } from "./assistant-config";
6
6
  import type { AssistantEntry } from "./assistant-config";
7
- import {
8
- FIREWALL_TAG,
9
- GATEWAY_PORT,
10
- PROVIDER_ENV_VAR_NAMES,
11
- } from "./constants";
7
+ import { FIREWALL_TAG, GATEWAY_PORT } from "./constants";
8
+ import { PROVIDER_ENV_VAR_NAMES } from "../shared/provider-env-vars.js";
12
9
  import type { Species } from "./constants";
13
10
  import { leaseGuardianToken } from "./guardian-token";
14
11
  import { getPlatformUrl } from "./platform-client";
@@ -0,0 +1,403 @@
1
+ import {
2
+ existsSync,
3
+ lstatSync,
4
+ mkdirSync,
5
+ readlinkSync,
6
+ rmSync,
7
+ symlinkSync,
8
+ unlinkSync,
9
+ writeFileSync,
10
+ appendFileSync,
11
+ readFileSync,
12
+ } from "fs";
13
+ import { homedir } from "os";
14
+ import { join } from "path";
15
+
16
+ // Direct import โ€” bun embeds this at compile time so it works in compiled binaries.
17
+ import cliPkg from "../../package.json";
18
+
19
+ import {
20
+ allocateLocalResources,
21
+ findAssistantByName,
22
+ loadAllAssistants,
23
+ saveAssistantEntry,
24
+ setActiveAssistant,
25
+ syncConfigToLockfile,
26
+ } from "./assistant-config.js";
27
+ import type {
28
+ AssistantEntry,
29
+ LocalInstanceResources,
30
+ } from "./assistant-config.js";
31
+ import type { Species } from "./constants.js";
32
+ import { writeInitialConfig } from "./config-utils.js";
33
+ import {
34
+ generateLocalSigningKey,
35
+ startLocalDaemon,
36
+ startGateway,
37
+ stopLocalProcesses,
38
+ } from "./local.js";
39
+ import { maybeStartNgrokTunnel } from "./ngrok.js";
40
+ import { httpHealthCheck } from "./http-client.js";
41
+ import { detectOrphanedProcesses } from "./orphan-detection.js";
42
+ import { isProcessAlive, stopProcess } from "./process.js";
43
+ import { generateInstanceName } from "./random-name.js";
44
+ import { leaseGuardianToken } from "./guardian-token.js";
45
+ import { archiveLogFile, resetLogFile } from "./xdg-log.js";
46
+ import { emitProgress } from "./desktop-progress.js";
47
+
48
+ const IS_DESKTOP = !!process.env.VELLUM_DESKTOP_APP;
49
+
50
+ function desktopLog(msg: string): void {
51
+ process.stdout.write(msg + "\n");
52
+ }
53
+
54
+ /**
55
+ * Attempts to place a symlink at the given path pointing to cliBinary.
56
+ * Returns true if the symlink was created (or already correct), false on failure.
57
+ */
58
+ function trySymlink(cliBinary: string, symlinkPath: string): boolean {
59
+ try {
60
+ // Use lstatSync (not existsSync) to detect dangling symlinks โ€”
61
+ // existsSync follows symlinks and returns false for broken links.
62
+ try {
63
+ const stats = lstatSync(symlinkPath);
64
+ if (!stats.isSymbolicLink()) {
65
+ // Real file โ€” don't overwrite (developer's local install)
66
+ return false;
67
+ }
68
+ // Already a symlink โ€” skip if it already points to our binary
69
+ const dest = readlinkSync(symlinkPath);
70
+ if (dest === cliBinary) return true;
71
+ // Stale or dangling symlink โ€” remove before creating new one
72
+ unlinkSync(symlinkPath);
73
+ } catch (e) {
74
+ if ((e as NodeJS.ErrnoException)?.code !== "ENOENT") return false;
75
+ // Path doesn't exist โ€” proceed to create symlink
76
+ }
77
+
78
+ const dir = join(symlinkPath, "..");
79
+ if (!existsSync(dir)) {
80
+ mkdirSync(dir, { recursive: true });
81
+ }
82
+ symlinkSync(cliBinary, symlinkPath);
83
+ return true;
84
+ } catch {
85
+ return false;
86
+ }
87
+ }
88
+
89
+ /**
90
+ * Ensures ~/.local/bin is present in the user's shell profile so that
91
+ * symlinks placed there are on PATH in new terminal sessions.
92
+ */
93
+ function ensureLocalBinInShellProfile(localBinDir: string): void {
94
+ const shell = process.env.SHELL ?? "";
95
+ const home = homedir();
96
+ // Determine the appropriate shell profile to modify
97
+ const profilePath = shell.endsWith("/zsh")
98
+ ? join(home, ".zshrc")
99
+ : shell.endsWith("/bash")
100
+ ? join(home, ".bash_profile")
101
+ : null;
102
+ if (!profilePath) return;
103
+
104
+ try {
105
+ const contents = existsSync(profilePath)
106
+ ? readFileSync(profilePath, "utf-8")
107
+ : "";
108
+ // Check if ~/.local/bin is already referenced in PATH exports
109
+ if (contents.includes(localBinDir)) return;
110
+ const line = `\nexport PATH="${localBinDir}:\$PATH"\n`;
111
+ appendFileSync(profilePath, line);
112
+ console.log(` Added ${localBinDir} to ${profilePath}`);
113
+ } catch {
114
+ // Not critical โ€” user can add it manually
115
+ }
116
+ }
117
+
118
+ function installCLISymlink(): void {
119
+ const cliBinary = process.execPath;
120
+ if (!cliBinary || !existsSync(cliBinary)) return;
121
+
122
+ // Preferred location โ€” works on most Macs where /usr/local/bin exists
123
+ const preferredPath = "/usr/local/bin/vellum";
124
+ if (trySymlink(cliBinary, preferredPath)) {
125
+ console.log(` Symlinked ${preferredPath} โ†’ ${cliBinary}`);
126
+ return;
127
+ }
128
+
129
+ // Fallback โ€” use ~/.local/bin which is user-writable and doesn't need root.
130
+ // On some Macs /usr/local doesn't exist and creating it requires admin privileges.
131
+ const localBinDir = join(homedir(), ".local", "bin");
132
+ const fallbackPath = join(localBinDir, "vellum");
133
+ if (trySymlink(cliBinary, fallbackPath)) {
134
+ console.log(` Symlinked ${fallbackPath} โ†’ ${cliBinary}`);
135
+ ensureLocalBinInShellProfile(localBinDir);
136
+ return;
137
+ }
138
+
139
+ console.log(
140
+ ` โš  Could not create symlink for vellum CLI (tried ${preferredPath} and ${fallbackPath})`,
141
+ );
142
+ }
143
+
144
+ export async function hatchLocal(
145
+ species: Species,
146
+ name: string | null,
147
+ restart: boolean = false,
148
+ watch: boolean = false,
149
+ keepAlive: boolean = false,
150
+ configValues: Record<string, string> = {},
151
+ ): Promise<void> {
152
+ if (restart && !name && !process.env.VELLUM_ASSISTANT_NAME) {
153
+ console.error(
154
+ "Error: Cannot restart without a known assistant ID. Provide --name or ensure VELLUM_ASSISTANT_NAME is set.",
155
+ );
156
+ process.exit(1);
157
+ }
158
+
159
+ const instanceName = generateInstanceName(
160
+ species,
161
+ name ?? process.env.VELLUM_ASSISTANT_NAME,
162
+ );
163
+
164
+ emitProgress(1, 7, "Preparing workspace...");
165
+
166
+ // Clean up stale local state: if daemon/gateway processes are running but
167
+ // the lock file has no entries AND the daemon is not healthy, stop them
168
+ // before starting fresh. A healthy daemon should be reused, not killed โ€”
169
+ // it may have been started intentionally via `vellum wake`.
170
+ const vellumDir = join(homedir(), ".vellum");
171
+ const existingAssistants = loadAllAssistants();
172
+ const localAssistants = existingAssistants.filter((a) => a.cloud === "local");
173
+ if (localAssistants.length === 0) {
174
+ const daemonPid = isProcessAlive(join(vellumDir, "vellum.pid"));
175
+ const gatewayPid = isProcessAlive(join(vellumDir, "gateway.pid"));
176
+ if (daemonPid.alive || gatewayPid.alive) {
177
+ // Check if the daemon is actually healthy before killing it.
178
+ // Default port 7821 is used when there's no lockfile entry.
179
+ const defaultPort = parseInt(process.env.RUNTIME_HTTP_PORT || "7821", 10);
180
+ const healthy = await httpHealthCheck(defaultPort);
181
+ if (!healthy) {
182
+ console.log(
183
+ "๐Ÿงน Cleaning up stale local processes (no lock file entry)...\n",
184
+ );
185
+ await stopLocalProcesses();
186
+ }
187
+ }
188
+ }
189
+
190
+ // On desktop, scan the process table for orphaned vellum processes that
191
+ // are not tracked by any PID file or lock file entry and kill them before
192
+ // starting new ones. This prevents resource leaks when the desktop app
193
+ // crashes or is force-quit without a clean shutdown.
194
+ //
195
+ // Skip orphan cleanup if the daemon is already healthy on the expected port
196
+ // โ€” those processes are intentional (e.g. started via `vellum wake`) and
197
+ // startLocalDaemon() will reuse them.
198
+ if (IS_DESKTOP) {
199
+ const existingResources = findAssistantByName(instanceName);
200
+ const expectedPort =
201
+ existingResources?.cloud === "local" && existingResources.resources
202
+ ? existingResources.resources.daemonPort
203
+ : undefined;
204
+ const daemonAlreadyHealthy = expectedPort
205
+ ? await httpHealthCheck(expectedPort)
206
+ : false;
207
+
208
+ if (!daemonAlreadyHealthy) {
209
+ const orphans = await detectOrphanedProcesses();
210
+ if (orphans.length > 0) {
211
+ desktopLog(
212
+ `๐Ÿงน Found ${orphans.length} orphaned process${orphans.length === 1 ? "" : "es"} โ€” cleaning up...`,
213
+ );
214
+ for (const orphan of orphans) {
215
+ await stopProcess(
216
+ parseInt(orphan.pid, 10),
217
+ `${orphan.name} (PID ${orphan.pid})`,
218
+ );
219
+ }
220
+ }
221
+ }
222
+ }
223
+
224
+ emitProgress(2, 7, "Allocating resources...");
225
+
226
+ // Reuse existing resources if re-hatching with --name that matches a known
227
+ // local assistant, otherwise allocate fresh per-instance ports and directories.
228
+ let resources: LocalInstanceResources;
229
+ const existingEntry = findAssistantByName(instanceName);
230
+ if (existingEntry?.cloud === "local" && existingEntry.resources) {
231
+ resources = existingEntry.resources;
232
+ } else {
233
+ resources = await allocateLocalResources(instanceName);
234
+ }
235
+
236
+ // Clean up stale workspace data: if the workspace directory already exists for
237
+ // this instance but no local lockfile entry owns it, a previous retire failed
238
+ // to archive it (or a managed-only retire left local data behind). Remove the
239
+ // workspace subtree so the new assistant starts fresh โ€” but preserve the rest
240
+ // of .vellum (e.g. protected/, credentials) which may be shared.
241
+ if (
242
+ !existingEntry ||
243
+ (existingEntry.cloud != null && existingEntry.cloud !== "local")
244
+ ) {
245
+ const instanceWorkspaceDir = join(
246
+ resources.instanceDir,
247
+ ".vellum",
248
+ "workspace",
249
+ );
250
+ if (existsSync(instanceWorkspaceDir)) {
251
+ const ownedByOther = loadAllAssistants().some((a) => {
252
+ if ((a.cloud != null && a.cloud !== "local") || !a.resources)
253
+ return false;
254
+ return (
255
+ join(a.resources.instanceDir, ".vellum", "workspace") ===
256
+ instanceWorkspaceDir
257
+ );
258
+ });
259
+ if (!ownedByOther) {
260
+ console.log(
261
+ `๐Ÿงน Removing stale workspace at ${instanceWorkspaceDir} (not owned by any assistant)...\n`,
262
+ );
263
+ rmSync(instanceWorkspaceDir, { recursive: true, force: true });
264
+ }
265
+ }
266
+ }
267
+
268
+ const logsDir = join(
269
+ resources.instanceDir,
270
+ ".vellum",
271
+ "workspace",
272
+ "data",
273
+ "logs",
274
+ );
275
+ archiveLogFile("hatch.log", logsDir);
276
+ resetLogFile("hatch.log");
277
+
278
+ console.log(`๐Ÿฅš Hatching local assistant: ${instanceName}`);
279
+ console.log(` Species: ${species}`);
280
+ console.log("");
281
+
282
+ if (!process.env.APP_VERSION) {
283
+ process.env.APP_VERSION = cliPkg.version;
284
+ }
285
+
286
+ emitProgress(3, 7, "Writing configuration...");
287
+ const defaultWorkspaceConfigPath = writeInitialConfig(configValues);
288
+
289
+ emitProgress(4, 7, "Starting assistant...");
290
+ const signingKey = generateLocalSigningKey();
291
+ await startLocalDaemon(watch, resources, {
292
+ defaultWorkspaceConfigPath,
293
+ signingKey,
294
+ });
295
+
296
+ emitProgress(5, 7, "Starting gateway...");
297
+ let runtimeUrl = `http://127.0.0.1:${resources.gatewayPort}`;
298
+ try {
299
+ runtimeUrl = await startGateway(watch, resources, { signingKey });
300
+ } catch (error) {
301
+ // Gateway failed โ€” stop the daemon we just started so we don't leave
302
+ // orphaned processes with no lock file entry.
303
+ console.error(
304
+ `\nโŒ Gateway startup failed โ€” stopping assistant to avoid orphaned processes.`,
305
+ );
306
+ await stopLocalProcesses(resources);
307
+ throw error;
308
+ }
309
+
310
+ // Lease a guardian token so the desktop app can import it on first launch
311
+ // instead of hitting /v1/guardian/init itself.
312
+ emitProgress(6, 7, "Securing connection...");
313
+ try {
314
+ await leaseGuardianToken(runtimeUrl, instanceName);
315
+ } catch (err) {
316
+ console.error(`โš ๏ธ Guardian token lease failed: ${err}`);
317
+ }
318
+
319
+ // Auto-start ngrok if webhook integrations (e.g. Telegram, Twilio) are configured.
320
+ // Set BASE_DATA_DIR so ngrok reads the correct instance config.
321
+ const prevBaseDataDir = process.env.BASE_DATA_DIR;
322
+ process.env.BASE_DATA_DIR = resources.instanceDir;
323
+ const ngrokChild = await maybeStartNgrokTunnel(resources.gatewayPort);
324
+ if (ngrokChild?.pid) {
325
+ const ngrokPidFile = join(resources.instanceDir, ".vellum", "ngrok.pid");
326
+ writeFileSync(ngrokPidFile, String(ngrokChild.pid));
327
+ }
328
+ if (prevBaseDataDir !== undefined) {
329
+ process.env.BASE_DATA_DIR = prevBaseDataDir;
330
+ } else {
331
+ delete process.env.BASE_DATA_DIR;
332
+ }
333
+
334
+ const localEntry: AssistantEntry = {
335
+ assistantId: instanceName,
336
+ runtimeUrl,
337
+ localUrl: `http://127.0.0.1:${resources.gatewayPort}`,
338
+ cloud: "local",
339
+ species,
340
+ hatchedAt: new Date().toISOString(),
341
+ serviceGroupVersion: cliPkg.version ? `v${cliPkg.version}` : undefined,
342
+ resources: { ...resources, signingKey },
343
+ };
344
+ emitProgress(7, 7, "Saving configuration...");
345
+ if (!restart) {
346
+ saveAssistantEntry(localEntry);
347
+ setActiveAssistant(instanceName);
348
+ syncConfigToLockfile();
349
+
350
+ if (process.env.VELLUM_DESKTOP_APP) {
351
+ installCLISymlink();
352
+ }
353
+
354
+ console.log("");
355
+ console.log(`โœ… Local assistant hatched!`);
356
+ console.log("");
357
+ console.log("Instance details:");
358
+ console.log(` Name: ${instanceName}`);
359
+ console.log(` Runtime: ${runtimeUrl}`);
360
+ console.log("");
361
+ }
362
+
363
+ if (keepAlive) {
364
+ const healthUrl = `http://127.0.0.1:${resources.gatewayPort}/healthz`;
365
+ const healthTarget = "Gateway";
366
+ const POLL_INTERVAL_MS = 5000;
367
+ const MAX_FAILURES = 3;
368
+ let consecutiveFailures = 0;
369
+
370
+ const shutdown = async (): Promise<void> => {
371
+ console.log("\nShutting down local processes...");
372
+ await stopLocalProcesses(resources);
373
+ process.exit(0);
374
+ };
375
+
376
+ process.on("SIGTERM", () => void shutdown());
377
+ process.on("SIGINT", () => void shutdown());
378
+
379
+ // Poll the health endpoint until it stops responding.
380
+ while (true) {
381
+ await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS));
382
+ try {
383
+ const res = await fetch(healthUrl, {
384
+ signal: AbortSignal.timeout(3000),
385
+ });
386
+ if (res.ok) {
387
+ consecutiveFailures = 0;
388
+ } else {
389
+ consecutiveFailures++;
390
+ }
391
+ } catch {
392
+ consecutiveFailures++;
393
+ }
394
+ if (consecutiveFailures >= MAX_FAILURES) {
395
+ console.log(
396
+ `\nโš ๏ธ ${healthTarget} stopped responding โ€” shutting down.`,
397
+ );
398
+ await stopLocalProcesses(resources);
399
+ process.exit(1);
400
+ }
401
+ }
402
+ }
403
+ }