@vellumai/cli 0.8.5 → 0.8.7

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 (72) hide show
  1. package/AGENTS.md +6 -0
  2. package/bun.lock +8 -0
  3. package/knip.json +6 -1
  4. package/node_modules/@vellumai/environments/bun.lock +24 -0
  5. package/node_modules/@vellumai/environments/package.json +18 -0
  6. package/node_modules/@vellumai/environments/src/__tests__/package-boundary.test.ts +95 -0
  7. package/node_modules/@vellumai/environments/src/index.ts +11 -0
  8. package/{src/lib/environments → node_modules/@vellumai/environments/src}/seeds.ts +5 -9
  9. package/node_modules/@vellumai/environments/tsconfig.json +20 -0
  10. package/node_modules/@vellumai/local-mode/bun.lock +29 -0
  11. package/node_modules/@vellumai/local-mode/package.json +21 -0
  12. package/node_modules/@vellumai/local-mode/src/__tests__/hatch.test.ts +93 -0
  13. package/node_modules/@vellumai/local-mode/src/__tests__/package-boundary.test.ts +104 -0
  14. package/node_modules/@vellumai/local-mode/src/config.ts +59 -0
  15. package/node_modules/@vellumai/local-mode/src/gateway-proxy.ts +67 -0
  16. package/node_modules/@vellumai/local-mode/src/guardian-token.ts +122 -0
  17. package/node_modules/@vellumai/local-mode/src/hatch.ts +74 -0
  18. package/node_modules/@vellumai/local-mode/src/index.ts +26 -0
  19. package/node_modules/@vellumai/local-mode/src/lockfile.ts +131 -0
  20. package/node_modules/@vellumai/local-mode/src/retire.ts +58 -0
  21. package/node_modules/@vellumai/local-mode/src/util.ts +102 -0
  22. package/node_modules/@vellumai/local-mode/tsconfig.json +16 -0
  23. package/package.json +12 -1
  24. package/src/__tests__/backup.test.ts +38 -0
  25. package/src/__tests__/env-drift.test.ts +32 -44
  26. package/src/__tests__/flags.test.ts +248 -0
  27. package/src/__tests__/multi-local.test.ts +1 -1
  28. package/src/__tests__/orphan-detection.test.ts +8 -6
  29. package/src/__tests__/recover.test.ts +307 -0
  30. package/src/__tests__/segments-to-plain-text.test.ts +37 -0
  31. package/src/__tests__/wake.test.ts +215 -0
  32. package/src/commands/backup.ts +2 -0
  33. package/src/commands/client.ts +471 -30
  34. package/src/commands/env.ts +1 -1
  35. package/src/commands/flags.ts +269 -0
  36. package/src/commands/gateway/token.ts +73 -0
  37. package/src/commands/gateway.ts +29 -0
  38. package/src/commands/logs.ts +6 -18
  39. package/src/commands/ps.ts +41 -41
  40. package/src/commands/recover.ts +47 -9
  41. package/src/commands/restore.ts +8 -1
  42. package/src/commands/retire.ts +3 -23
  43. package/src/commands/rollback.ts +2 -14
  44. package/src/commands/ssh.ts +5 -24
  45. package/src/commands/teleport.ts +34 -26
  46. package/src/commands/upgrade.ts +8 -16
  47. package/src/commands/wake.ts +68 -45
  48. package/src/components/DefaultMainScreen.tsx +16 -1
  49. package/src/index.ts +6 -0
  50. package/src/lib/__tests__/lifecycle-reporter.test.ts +59 -0
  51. package/src/lib/__tests__/step-runner.test.ts +49 -1
  52. package/src/lib/assistant-config.ts +16 -3
  53. package/src/lib/config-utils.ts +24 -3
  54. package/src/lib/docker.ts +57 -7
  55. package/src/lib/environments/__tests__/paths.test.ts +2 -1
  56. package/src/lib/environments/__tests__/seeds.test.ts +2 -1
  57. package/src/lib/environments/paths.ts +1 -1
  58. package/src/lib/environments/resolve.ts +2 -5
  59. package/src/lib/guardian-token.ts +12 -5
  60. package/src/lib/hatch-local.ts +75 -33
  61. package/src/lib/http-client.ts +1 -3
  62. package/src/lib/lifecycle-reporter.ts +31 -0
  63. package/src/lib/local.ts +173 -292
  64. package/src/lib/orphan-detection.ts +9 -5
  65. package/src/lib/pgrep.ts +5 -1
  66. package/src/lib/platform-client.ts +97 -49
  67. package/src/lib/process.ts +109 -39
  68. package/src/lib/retire-local.ts +28 -14
  69. package/src/lib/segments-to-plain-text.ts +35 -0
  70. package/src/lib/step-runner.ts +67 -7
  71. package/src/lib/sync-cloud-assistants.ts +17 -0
  72. /package/{src/lib/environments → node_modules/@vellumai/environments/src}/types.ts +0 -0
@@ -10,6 +10,8 @@ import {
10
10
  import { homedir } from "os";
11
11
  import { dirname, join } from "path";
12
12
 
13
+ import { SEEDS, type EnvironmentDefinition } from "@vellumai/environments";
14
+
13
15
  import { DAEMON_INTERNAL_ASSISTANT_ID } from "./constants.js";
14
16
  import {
15
17
  getDefaultPorts,
@@ -18,8 +20,6 @@ import {
18
20
  getMultiInstanceDir,
19
21
  } from "./environments/paths.js";
20
22
  import { getCurrentEnvironment } from "./environments/resolve.js";
21
- import { SEEDS } from "./environments/seeds.js";
22
- import type { EnvironmentDefinition } from "./environments/types.js";
23
23
  import { probePort } from "./port-probe.js";
24
24
 
25
25
  /**
@@ -559,6 +559,19 @@ export function resolveCloud(entry: AssistantEntry): string {
559
559
  return "local";
560
560
  }
561
561
 
562
+ /**
563
+ * Extract the hostname from a URL string. Falls back to stripping the scheme
564
+ * and taking the hostname portion if URL parsing fails.
565
+ */
566
+ export function extractHostFromUrl(url: string): string {
567
+ try {
568
+ const parsed = new URL(url);
569
+ return parsed.hostname;
570
+ } catch {
571
+ return url.replace(/^https?:\/\//, "").split(":")[0];
572
+ }
573
+ }
574
+
562
575
  export function saveAssistantEntry(entry: AssistantEntry): void {
563
576
  const entries = readAssistants().filter(
564
577
  (e) => e.assistantId !== entry.assistantId,
@@ -618,7 +631,7 @@ export async function allocateLocalResources(
618
631
 
619
632
  // Env-aware bases: non-prod envs sit in their own 1000-port window so
620
633
  // running prod and staging assistants side-by-side doesn't collide. See
621
- // `environments/seeds.ts:portBlock` for the layout.
634
+ // the `@vellumai/environments` `portBlock` layout.
622
635
  const basePorts = getDefaultPorts(env);
623
636
  const daemonPort = await findAvailablePort(basePorts.daemon, reservedPorts);
624
637
  const gatewayPort = await findAvailablePort(basePorts.gateway, [
@@ -35,6 +35,21 @@ export function buildNestedConfig(
35
35
  /**
36
36
  * Ensure hatch always provides enough initial LLM config for the assistant to
37
37
  * detect a fresh off-platform hatch and seed BYOK profiles.
38
+ *
39
+ * @deprecated Part of the workspace-config overlay path — a CLI→Assistant
40
+ * side channel that bypasses the Assistant's public APIs and has no
41
+ * equivalent in web/desktop. Two replacement paths are on the table:
42
+ *
43
+ * 1. Post-hatch API calls — the CLI calls public Assistant routes after
44
+ * boot (`POST /v1/secrets`, plus a small read-only endpoint that
45
+ * returns the canonical inference-profile templates so the CLI can
46
+ * PATCH them in). See the closed alternatives in PR #32061 and
47
+ * PR #32131 for the shape this would take.
48
+ * 2. Move inference-profile seeds out of workspace config and into
49
+ * Assistant code, so there is nothing for the CLI to inject in the
50
+ * first place.
51
+ *
52
+ * Either path removes the need for this helper.
38
53
  */
39
54
  export function buildHatchConfigValues(
40
55
  configValues: Record<string, string>,
@@ -52,15 +67,21 @@ export function buildHatchConfigValues(
52
67
 
53
68
  /**
54
69
  * Write arbitrary key-value pairs to a temporary JSON file and return its
55
- * path. The caller passes this path to the daemon via the
56
- * VELLUM_DEFAULT_WORKSPACE_CONFIG_PATH env var so the daemon can merge the
57
- * values into its workspace config on first boot.
70
+ * path. The caller is responsible for getting the file to the daemon for
71
+ * the local hatch flow that means setting `VELLUM_DEFAULT_WORKSPACE_CONFIG_PATH`
72
+ * on the daemon process; for the Docker hatch flow the caller stages the
73
+ * file into the workspace volume so the rename-after-consume step in
74
+ * `mergeDefaultWorkspaceConfig` is a same-filesystem rename.
58
75
  *
59
76
  * Keys use dot-notation to address nested fields. For example:
60
77
  * "llm.default.provider" → {llm: {default: {provider: ...}}}
61
78
  * "llm.default.model" → {llm: {default: {model: ...}}}
62
79
  *
63
80
  * Returns undefined when configValues is empty (nothing to write).
81
+ *
82
+ * @deprecated See {@link buildHatchConfigValues} for the replacement
83
+ * direction. This overlay path is a CLI→Assistant side channel and will be
84
+ * removed once one of the documented replacements lands.
64
85
  */
65
86
  export function writeInitialConfig(
66
87
  configValues: Record<string, string>,
package/src/lib/docker.ts CHANGED
@@ -1,5 +1,11 @@
1
1
  import { randomBytes } from "crypto";
2
- import { chmodSync, existsSync, mkdirSync, watch as fsWatch } from "fs";
2
+ import {
3
+ chmodSync,
4
+ existsSync,
5
+ mkdirSync,
6
+ readFileSync,
7
+ watch as fsWatch,
8
+ } from "fs";
3
9
  import { arch, platform } from "os";
4
10
  import { dirname, join, resolve } from "path";
5
11
 
@@ -12,6 +18,7 @@ import {
12
18
  setActiveAssistant,
13
19
  } from "./assistant-config";
14
20
  import type { AssistantEntry } from "./assistant-config";
21
+ import { buildHatchConfigValues, writeInitialConfig } from "./config-utils";
15
22
  import { buildServiceRunArgs } from "./statefulset.js";
16
23
  import type { Species } from "./constants";
17
24
  import { getDefaultPorts } from "./environments/paths.js";
@@ -35,7 +42,7 @@ import {
35
42
  resolveHatchProvider,
36
43
  } from "./provider-secrets.js";
37
44
  import { findOpenPort } from "./port-allocator.js";
38
- import { exec, execOutput } from "./step-runner";
45
+ import { exec, execOutput, execWithStdin } from "./step-runner";
39
46
  import {
40
47
  closeLogFile,
41
48
  openLogFile,
@@ -1164,11 +1171,53 @@ export async function hatchDocker(
1164
1171
  "chown 1001:1001 /workspace /run/assistant-ipc /run/gateway-ipc",
1165
1172
  ]);
1166
1173
 
1167
- // BYOK setup (API key, custom profiles, active-profile selection) is
1168
- // driven post-boot by the CLI calling the Assistant's public APIs
1169
- // (`POST /v1/secrets`, etc.) via `configureHatchProviderApiKey` below.
1170
- // The Assistant container comes up clean — no overlay file, no
1171
- // client-side workspace-config injection.
1174
+ // Stage the BYOK default-workspace-config overlay *inside* the workspace
1175
+ // volume so the daemon's startup loader can consume it. The loader
1176
+ // (`mergeDefaultWorkspaceConfig` in assistant/src/config/loader.ts) does:
1177
+ // 1. JSON.parse + deep-merge into the workspace's config.json.
1178
+ // 2. `renameSync(defaultConfigPath, <workspace>/default-config.json)`
1179
+ // to mark the overlay consumed so subsequent restarts skip it.
1180
+ //
1181
+ // The rename must be same-filesystem. Bind-mounting the host file into
1182
+ // `/tmp/...` (the pre-#32025 design) crossed filesystems and silently
1183
+ // failed with EXDEV, so every `docker start` re-applied the overlay.
1184
+ // Staging into the workspace volume keeps the rename in-place.
1185
+ //
1186
+ // Streaming the JSON via stdin (instead of bind-mounting the host file
1187
+ // into the staging container) sidesteps macOS Colima's virtiofs share,
1188
+ // which doesn't expose `/var/folders/...` (where `os.tmpdir()` resolves
1189
+ // on macOS) and would otherwise materialize an empty directory at the
1190
+ // bind-mount target.
1191
+ //
1192
+ // @deprecated stopgap. Replacement direction is one of:
1193
+ // 1. Post-hatch API calls (POST /v1/secrets + a small endpoint that
1194
+ // returns canonical inference-profile templates).
1195
+ // 2. Move inference-profile seeds out of workspace config and into
1196
+ // Assistant code, eliminating the overlay entirely.
1197
+ // See `cli/src/lib/config-utils.ts` JSDoc for context.
1198
+ const hatchConfigValues = buildHatchConfigValues(configValues, provider);
1199
+ const hostOverlayPath = writeInitialConfig(hatchConfigValues);
1200
+ const stagedOverlayInContainer = "/workspace/.default-config-overlay.json";
1201
+ const extraAssistantEnv: Record<string, string> = {};
1202
+ if (hostOverlayPath) {
1203
+ await execWithStdin(
1204
+ "docker",
1205
+ [
1206
+ "run",
1207
+ "--rm",
1208
+ "-i",
1209
+ "-v",
1210
+ `${res.workspaceVolume}:/workspace`,
1211
+ "busybox",
1212
+ "sh",
1213
+ "-c",
1214
+ `cat > ${stagedOverlayInContainer} && chown 1001:1001 ${stagedOverlayInContainer}`,
1215
+ ],
1216
+ readFileSync(hostOverlayPath, "utf-8"),
1217
+ );
1218
+ extraAssistantEnv.VELLUM_DEFAULT_WORKSPACE_CONFIG_PATH =
1219
+ stagedOverlayInContainer;
1220
+ }
1172
1221
 
1173
1222
  const cesServiceToken = randomBytes(32).toString("hex");
1174
1223
  const signingKey = randomBytes(32).toString("hex");
@@ -1190,6 +1239,7 @@ export async function hatchDocker(
1190
1239
  signingKey,
1191
1240
  bootstrapSecret,
1192
1241
  cesServiceToken,
1242
+ extraAssistantEnv,
1193
1243
  gatewayPort,
1194
1244
  imageTags,
1195
1245
  instanceName,
@@ -27,7 +27,8 @@ const {
27
27
  getLockfilePaths,
28
28
  getMultiInstanceDir,
29
29
  } = await import("../paths.js");
30
- type EnvironmentDefinition = import("../types.js").EnvironmentDefinition;
30
+ type EnvironmentDefinition =
31
+ import("@vellumai/environments").EnvironmentDefinition;
31
32
 
32
33
  const prod: EnvironmentDefinition = {
33
34
  name: "production",
@@ -1,7 +1,8 @@
1
1
  import { describe, expect, test } from "bun:test";
2
2
 
3
+ import { SEEDS } from "@vellumai/environments";
4
+
3
5
  import { getDefaultPorts } from "../paths.js";
4
- import { SEEDS } from "../seeds.js";
5
6
 
6
7
  describe("SEEDS port blocks", () => {
7
8
  test("production uses the legacy (pre-MVP) port layout", () => {
@@ -1,7 +1,7 @@
1
1
  import { homedir } from "os";
2
2
  import { join } from "path";
3
3
 
4
- import type { EnvironmentDefinition, PortMap } from "./types.js";
4
+ import type { EnvironmentDefinition, PortMap } from "@vellumai/environments";
5
5
 
6
6
  const PRODUCTION_ENVIRONMENT_NAME = "production";
7
7
 
@@ -8,8 +8,7 @@ import {
8
8
  import { homedir } from "os";
9
9
  import { dirname, join } from "path";
10
10
 
11
- import { SEEDS } from "./seeds.js";
12
- import type { EnvironmentDefinition } from "./types.js";
11
+ import { SEEDS, type EnvironmentDefinition } from "@vellumai/environments";
13
12
 
14
13
  const DEFAULT_ENVIRONMENT_NAME = "production";
15
14
 
@@ -115,7 +114,7 @@ export function getCurrentEnvironment(
115
114
  // writers don't end up in disjoint states on a typo.
116
115
  process.stderr.write(
117
116
  `warning: unknown environment "${name}"; falling back to "${DEFAULT_ENVIRONMENT_NAME}". ` +
118
- `Add it to cli/src/lib/environments/seeds.ts and rebuild if this was intentional.\n`,
117
+ `Add it to packages/environments/src/seeds.ts and rebuild if this was intentional.\n`,
119
118
  );
120
119
  }
121
120
  const fallback = SEEDS[DEFAULT_ENVIRONMENT_NAME];
@@ -174,5 +173,3 @@ export function resolveEnvironmentSource(override?: string): {
174
173
  }
175
174
  return { name: DEFAULT_ENVIRONMENT_NAME, source: "default" };
176
175
  }
177
-
178
-
@@ -11,9 +11,10 @@ import {
11
11
  import { platform } from "os";
12
12
  import { dirname, join } from "path";
13
13
 
14
+ import { SEEDS } from "@vellumai/environments";
15
+
14
16
  import { getConfigDir } from "./environments/paths.js";
15
17
  import { getCurrentEnvironment } from "./environments/resolve.js";
16
- import { SEEDS } from "./environments/seeds.js";
17
18
 
18
19
  const DEVICE_ID_SALT = "vellum-assistant-host-id";
19
20
 
@@ -176,7 +177,8 @@ export async function refreshGuardianToken(
176
177
  // Gateway persists expiresAt as epoch-ms numbers; Date.parse("1234567890000")
177
178
  // returns NaN. new Date() accepts both ISO strings and epoch-ms numbers.
178
179
  const refreshExpiry = new Date(tokenData.refreshTokenExpiresAt).getTime();
179
- if (!Number.isFinite(refreshExpiry) || refreshExpiry <= Date.now()) return null;
180
+ if (!Number.isFinite(refreshExpiry) || refreshExpiry <= Date.now())
181
+ return null;
180
182
 
181
183
  try {
182
184
  const response = await fetch(`${gatewayUrl}/v1/guardian/refresh`, {
@@ -191,11 +193,16 @@ export async function refreshGuardianToken(
191
193
 
192
194
  const json = (await response.json()) as Record<string, unknown>;
193
195
  const refreshed: GuardianTokenData = {
194
- guardianPrincipalId: (json.guardianPrincipalId as string) ?? tokenData.guardianPrincipalId,
196
+ guardianPrincipalId:
197
+ (json.guardianPrincipalId as string) ?? tokenData.guardianPrincipalId,
195
198
  accessToken: json.accessToken as string,
196
- accessTokenExpiresAt: (json.accessTokenExpiresAt as string | number) ?? tokenData.accessTokenExpiresAt,
199
+ accessTokenExpiresAt:
200
+ (json.accessTokenExpiresAt as string | number) ??
201
+ tokenData.accessTokenExpiresAt,
197
202
  refreshToken: (json.refreshToken as string) ?? tokenData.refreshToken,
198
- refreshTokenExpiresAt: (json.refreshTokenExpiresAt as string | number) ?? tokenData.refreshTokenExpiresAt,
203
+ refreshTokenExpiresAt:
204
+ (json.refreshTokenExpiresAt as string | number) ??
205
+ tokenData.refreshTokenExpiresAt,
199
206
  refreshAfter: (json.refreshAfter as string) ?? tokenData.refreshAfter,
200
207
  isNew: false,
201
208
  deviceId: tokenData.deviceId,
@@ -33,7 +33,10 @@ import {
33
33
  import { generateInstanceName } from "./random-name.js";
34
34
  import { leaseGuardianToken } from "./guardian-token.js";
35
35
  import { archiveLogFile, resetLogFile } from "./xdg-log.js";
36
- import { emitProgress } from "./desktop-progress.js";
36
+ import {
37
+ consoleLifecycleReporter,
38
+ type LifecycleReporter,
39
+ } from "./lifecycle-reporter.js";
37
40
  import {
38
41
  configureHatchProviderApiKey,
39
42
  formatProviderName,
@@ -134,6 +137,25 @@ function installCLISymlink(): void {
134
137
 
135
138
  export interface HatchLocalOptions {
136
139
  setupProviderCredentials?: boolean;
140
+ /**
141
+ * Sink for progress and log output. Defaults to the console reporter so CLI
142
+ * callers keep their existing terminal output; in-process callers can inject
143
+ * their own reporter to consume progress without writing to stdout.
144
+ */
145
+ reporter?: LifecycleReporter;
146
+ }
147
+
148
+ export interface HatchLocalResult {
149
+ assistantId: string;
150
+ runtimeUrl: string;
151
+ localUrl: string;
152
+ species: Species;
153
+ /**
154
+ * Guardian access token leased during hatch, when the lease succeeded. The
155
+ * full token pair is persisted to disk regardless; this is surfaced so an
156
+ * in-process caller can prime a connection without re-reading the file.
157
+ */
158
+ guardianAccessToken?: string;
137
159
  }
138
160
 
139
161
  export async function hatchLocal(
@@ -143,7 +165,8 @@ export async function hatchLocal(
143
165
  keepAlive: boolean = false,
144
166
  configValues: Record<string, string> = {},
145
167
  options: HatchLocalOptions = {},
146
- ): Promise<void> {
168
+ ): Promise<HatchLocalResult> {
169
+ const reporter = options.reporter ?? consoleLifecycleReporter;
147
170
  const provider =
148
171
  options.setupProviderCredentials === false
149
172
  ? undefined
@@ -153,7 +176,7 @@ export async function hatchLocal(
153
176
  name ?? process.env.VELLUM_ASSISTANT_NAME,
154
177
  );
155
178
 
156
- emitProgress(1, 6, "Allocating resources...");
179
+ reporter.progress(1, 6, "Allocating resources...");
157
180
 
158
181
  const existing = findAssistantByName(instanceName);
159
182
  if (existing && (!existing.cloud || existing.cloud === "local")) {
@@ -175,43 +198,47 @@ export async function hatchLocal(
175
198
  archiveLogFile("hatch.log", logsDir);
176
199
  resetLogFile("hatch.log");
177
200
 
178
- console.log(`🥚 Hatching local assistant: ${instanceName}`);
179
- console.log(` Species: ${species}`);
180
- console.log("");
201
+ reporter.log(`🥚 Hatching local assistant: ${instanceName}`);
202
+ reporter.log(` Species: ${species}`);
203
+ reporter.log("");
181
204
 
182
205
  const apiKeyCheck = checkProviderApiKey();
183
206
  if (!apiKeyCheck.hasKey) {
184
- console.warn(
207
+ reporter.warn(
185
208
  "Warning: No LLM provider API key is configured. The assistant will fail when you try to send a message.",
186
209
  );
187
- console.warn(" To fix, export your key before running vellum hatch:");
188
- console.warn(" export ANTHROPIC_API_KEY=<your-key>");
189
- console.warn("");
210
+ reporter.warn(" To fix, export your key before running vellum hatch:");
211
+ reporter.warn(" export ANTHROPIC_API_KEY=<your-key>");
212
+ reporter.warn("");
190
213
  }
191
214
 
192
215
  if (!process.env.APP_VERSION) {
193
216
  process.env.APP_VERSION = cliPkg.version;
194
217
  }
195
218
 
196
- emitProgress(2, 6, "Writing configuration...");
219
+ reporter.progress(2, 6, "Writing configuration...");
197
220
  const hatchConfigValues = buildHatchConfigValues(configValues, provider);
198
221
  const defaultWorkspaceConfigPath = writeInitialConfig(hatchConfigValues);
199
222
 
200
- emitProgress(3, 6, "Starting assistant...");
223
+ reporter.progress(3, 6, "Starting assistant...");
201
224
  const signingKey = generateLocalSigningKey();
225
+ const bootstrapSecret = generateLocalSigningKey();
202
226
  await startLocalDaemon(watch, resources, {
203
227
  defaultWorkspaceConfigPath,
204
228
  signingKey,
205
229
  });
206
230
 
207
- emitProgress(4, 6, "Starting gateway...");
231
+ reporter.progress(4, 6, "Starting gateway...");
208
232
  let runtimeUrl = `http://127.0.0.1:${resources.gatewayPort}`;
209
233
  try {
210
- runtimeUrl = await startGateway(watch, resources, { signingKey });
234
+ runtimeUrl = await startGateway(watch, resources, {
235
+ signingKey,
236
+ bootstrapSecret,
237
+ });
211
238
  } catch (error) {
212
239
  // Gateway failed — stop the daemon we just started so we don't leave
213
240
  // orphaned processes with no lock file entry.
214
- console.error(
241
+ reporter.error(
215
242
  `\n❌ Gateway startup failed — stopping assistant to avoid orphaned processes.`,
216
243
  );
217
244
  await stopLocalProcesses(resources);
@@ -222,24 +249,28 @@ export async function hatchLocal(
222
249
  // instead of hitting /v1/guardian/init itself. Use loopback to satisfy
223
250
  // the daemon's local-only check — the mDNS runtimeUrl resolves to a LAN
224
251
  // IP which the daemon rejects as non-loopback.
225
- emitProgress(5, 6, "Securing connection...");
252
+ reporter.progress(5, 6, "Securing connection...");
226
253
  const loopbackUrl = `http://127.0.0.1:${resources.gatewayPort}`;
227
254
  const maxLeaseAttempts = 3;
228
255
  let guardianAccessToken: string | undefined;
229
256
  for (let attempt = 1; attempt <= maxLeaseAttempts; attempt++) {
230
257
  try {
231
- const tokenData = await leaseGuardianToken(loopbackUrl, instanceName);
258
+ const tokenData = await leaseGuardianToken(
259
+ loopbackUrl,
260
+ instanceName,
261
+ bootstrapSecret,
262
+ );
232
263
  guardianAccessToken = tokenData.accessToken;
233
264
  break;
234
265
  } catch (err) {
235
266
  if (attempt < maxLeaseAttempts) {
236
267
  const delayMs = 2000 * 2 ** (attempt - 1);
237
- console.error(
268
+ reporter.error(
238
269
  `⚠️ Guardian token lease attempt ${attempt}/${maxLeaseAttempts} failed — retrying in ${delayMs / 1000}s: ${err}`,
239
270
  );
240
271
  await new Promise((r) => setTimeout(r, delayMs));
241
272
  } else {
242
- console.error(
273
+ reporter.error(
243
274
  `⚠️ Guardian token lease failed after ${maxLeaseAttempts} attempts: ${err}\n` +
244
275
  ` The assistant is running but guardian-token.json was not written.\n` +
245
276
  ` If the desktop app loses its stored credentials, re-hatch to recover.`,
@@ -257,9 +288,10 @@ export async function hatchLocal(
257
288
  species,
258
289
  hatchedAt: new Date().toISOString(),
259
290
  resources: { ...resources, signingKey },
291
+ guardianBootstrapSecret: bootstrapSecret,
260
292
  };
261
293
 
262
- emitProgress(6, 6, "Saving configuration...");
294
+ reporter.progress(6, 6, "Saving configuration...");
263
295
  saveAssistantEntry(localEntry);
264
296
  setActiveAssistant(instanceName);
265
297
 
@@ -268,13 +300,13 @@ export async function hatchLocal(
268
300
  }
269
301
 
270
302
  if (provider !== undefined && provider !== null && !guardianAccessToken) {
271
- console.error(
303
+ reporter.error(
272
304
  `⚠️ Provider credential setup skipped because the guardian token was not leased.\n` +
273
305
  ` The assistant is still hatched. Run \`vellum setup --provider ${provider}\` after fixing the connection.`,
274
306
  );
275
307
  } else if (provider !== undefined) {
276
- console.log("");
277
- console.log(
308
+ reporter.log("");
309
+ reporter.log(
278
310
  provider === null
279
311
  ? "Checking provider credentials..."
280
312
  : `Checking ${formatProviderName(provider)} credentials...`,
@@ -287,14 +319,22 @@ export async function hatchLocal(
287
319
  });
288
320
  }
289
321
 
290
- console.log("");
291
- console.log(`✅ Local assistant hatched!`);
292
- console.log("");
293
- console.log("Instance details:");
294
- console.log(` Name: ${instanceName}`);
295
- console.log(` Runtime: ${runtimeUrl}`);
296
- console.log("");
297
- logHatchNextSteps(console.log, instanceName);
322
+ reporter.log("");
323
+ reporter.log(`✅ Local assistant hatched!`);
324
+ reporter.log("");
325
+ reporter.log("Instance details:");
326
+ reporter.log(` Name: ${instanceName}`);
327
+ reporter.log(` Runtime: ${runtimeUrl}`);
328
+ reporter.log("");
329
+ logHatchNextSteps((message) => reporter.log(message), instanceName);
330
+
331
+ const result: HatchLocalResult = {
332
+ assistantId: instanceName,
333
+ runtimeUrl,
334
+ localUrl: `http://127.0.0.1:${resources.gatewayPort}`,
335
+ species,
336
+ guardianAccessToken,
337
+ };
298
338
 
299
339
  if (keepAlive) {
300
340
  const healthUrl = `http://127.0.0.1:${resources.gatewayPort}/healthz`;
@@ -304,7 +344,7 @@ export async function hatchLocal(
304
344
  let consecutiveFailures = 0;
305
345
 
306
346
  const shutdown = async (): Promise<void> => {
307
- console.log("\nShutting down local processes...");
347
+ reporter.log("\nShutting down local processes...");
308
348
  await stopLocalProcesses(resources);
309
349
  process.exit(0);
310
350
  };
@@ -328,7 +368,7 @@ export async function hatchLocal(
328
368
  consecutiveFailures++;
329
369
  }
330
370
  if (consecutiveFailures >= MAX_FAILURES) {
331
- console.log(
371
+ reporter.log(
332
372
  `\n⚠️ ${healthTarget} stopped responding — shutting down.`,
333
373
  );
334
374
  await stopLocalProcesses(resources);
@@ -336,4 +376,6 @@ export async function hatchLocal(
336
376
  }
337
377
  }
338
378
  }
379
+
380
+ return result;
339
381
  }
@@ -8,8 +8,6 @@ export function buildDaemonUrl(port: number): string {
8
8
  /**
9
9
  * Perform an HTTP health check against the daemon's `/healthz` endpoint.
10
10
  * Returns true if the daemon responds with HTTP 200, false otherwise.
11
- *
12
- * This replaces the socket-based `isSocketResponsive()` check.
13
11
  */
14
12
  export async function httpHealthCheck(
15
13
  port: number,
@@ -28,7 +26,7 @@ export async function httpHealthCheck(
28
26
 
29
27
  /**
30
28
  * Poll the daemon's `/healthz` endpoint until it responds with 200 or the
31
- * timeout is reached. This replaces `waitForSocketFile()`.
29
+ * timeout is reached.
32
30
  *
33
31
  * Returns true if the daemon became healthy within the timeout, false otherwise.
34
32
  */
@@ -0,0 +1,31 @@
1
+ import { emitProgress } from "./desktop-progress.js";
2
+
3
+ /**
4
+ * Sink for the human-facing and structured output of long-running lifecycle
5
+ * operations (hatch, retire). Injecting it lets an in-process caller (e.g. a
6
+ * desktop main process embedding these functions) observe progress without the
7
+ * operation writing to the terminal, while the CLI keeps its existing stdout.
8
+ */
9
+ export interface LifecycleReporter {
10
+ /**
11
+ * Coarse step progress. The CLI reporter mirrors this to the desktop
12
+ * `HATCH_PROGRESS:` stdout channel.
13
+ */
14
+ progress(step: number, total: number, label: string): void;
15
+ log(message: string): void;
16
+ warn(message: string): void;
17
+ error(message: string): void;
18
+ }
19
+
20
+ /**
21
+ * Reporter used by the CLI commands: human-readable lines to the console plus
22
+ * structured step events on the desktop progress channel. Reproduces the exact
23
+ * terminal output — and the `HATCH_PROGRESS:` lines under `VELLUM_DESKTOP_APP` —
24
+ * that existing subprocess consumers parse.
25
+ */
26
+ export const consoleLifecycleReporter: LifecycleReporter = {
27
+ progress: (step, total, label) => emitProgress(step, total, label),
28
+ log: (message) => console.log(message),
29
+ warn: (message) => console.warn(message),
30
+ error: (message) => console.error(message),
31
+ };