@vellumai/cli 0.4.42 → 0.4.44

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.
@@ -16,13 +16,10 @@ import { probePort } from "./port-probe.js";
16
16
  */
17
17
  export interface LocalInstanceResources {
18
18
  /**
19
- * Instance-specific data root. The first local assistant uses `~` (workspace
20
- * at `~/.vellum`); subsequent assistants use
21
- * `~/.local/share/vellum/assistants/<name>/` (workspace at
22
- * `~/.local/share/vellum/assistants/<name>/.vellum`).
23
- * The daemon's `.vellum/` directory lives inside it. Equivalent to
24
- * `AssistantEntry.baseDataDir` minus the trailing `/.vellum` suffix —
25
- * `baseDataDir` is kept on the flat entry for legacy lockfile compat.
19
+ * Instance-specific data root. The first local assistant uses `~` (home
20
+ * directory) with default ports. Subsequent instances are placed under
21
+ * `~/.local/share/vellum/assistants/<name>/`.
22
+ * The daemon's `.vellum/` directory lives inside it.
26
23
  */
27
24
  instanceDir: string;
28
25
  /** HTTP port for the daemon runtime server */
@@ -31,10 +28,9 @@ export interface LocalInstanceResources {
31
28
  gatewayPort: number;
32
29
  /** HTTP port for the Qdrant vector store */
33
30
  qdrantPort: number;
34
- /** Absolute path to the Unix domain socket for IPC */
35
- socketPath: string;
36
31
  /** Absolute path to the daemon PID file */
37
32
  pidFile: string;
33
+ [key: string]: unknown;
38
34
  }
39
35
 
40
36
  export interface AssistantEntry {
@@ -43,8 +39,6 @@ export interface AssistantEntry {
43
39
  /** Loopback URL for same-machine health checks (e.g. `http://127.0.0.1:7831`).
44
40
  * Avoids mDNS resolution issues when the machine checks its own gateway. */
45
41
  localUrl?: string;
46
- /** @deprecated Use `resources.instanceDir` for multi-instance entries. Legacy equivalent of `join(instanceDir, ".vellum")`. */
47
- baseDataDir?: string;
48
42
  bearerToken?: string;
49
43
  cloud: string;
50
44
  instanceId?: string;
@@ -57,10 +51,11 @@ export interface AssistantEntry {
57
51
  hatchedAt?: string;
58
52
  /** Per-instance resource config. Present for local entries in multi-instance setups. */
59
53
  resources?: LocalInstanceResources;
54
+ [key: string]: unknown;
60
55
  }
61
56
 
62
57
  interface LockfileData {
63
- assistants?: AssistantEntry[];
58
+ assistants?: Record<string, unknown>[];
64
59
  activeAssistant?: string;
65
60
  platformBaseUrl?: string;
66
61
  [key: string]: unknown;
@@ -70,8 +65,13 @@ function getBaseDir(): string {
70
65
  return process.env.BASE_DATA_DIR?.trim() || homedir();
71
66
  }
72
67
 
68
+ /** The lockfile always lives under the home directory. */
69
+ function getLockfileDir(): string {
70
+ return process.env.VELLUM_LOCKFILE_DIR?.trim() || homedir();
71
+ }
72
+
73
73
  function readLockfile(): LockfileData {
74
- const base = getBaseDir();
74
+ const base = getLockfileDir();
75
75
  const candidates = [
76
76
  join(base, ".vellum.lock.json"),
77
77
  join(base, ".vellum.lockfile.json"),
@@ -92,18 +92,136 @@ function readLockfile(): LockfileData {
92
92
  }
93
93
 
94
94
  function writeLockfile(data: LockfileData): void {
95
- const lockfilePath = join(getBaseDir(), ".vellum.lock.json");
95
+ const lockfilePath = join(getLockfileDir(), ".vellum.lock.json");
96
96
  writeFileSync(lockfilePath, JSON.stringify(data, null, 2) + "\n");
97
97
  }
98
98
 
99
+ /**
100
+ * Try to extract a port number from a URL string (e.g. `http://localhost:7830`).
101
+ * Returns undefined if the URL is malformed or has no explicit port.
102
+ */
103
+ function parsePortFromUrl(url: unknown): number | undefined {
104
+ if (typeof url !== "string") return undefined;
105
+ try {
106
+ const parsed = new URL(url);
107
+ const port = parseInt(parsed.port, 10);
108
+ return isNaN(port) ? undefined : port;
109
+ } catch {
110
+ return undefined;
111
+ }
112
+ }
113
+
114
+ /**
115
+ * Detect and migrate legacy lockfile entries to the current format.
116
+ *
117
+ * Legacy entries stored `baseDataDir` as a top-level field. The current
118
+ * format nests this under `resources.instanceDir`. This function also
119
+ * synthesises a full `resources` object when one is missing by inferring
120
+ * ports from the entry's `runtimeUrl` and falling back to defaults.
121
+ *
122
+ * Returns `true` if the entry was mutated (so the caller can persist).
123
+ */
124
+ export function migrateLegacyEntry(raw: Record<string, unknown>): boolean {
125
+ if (typeof raw.cloud === "string" && raw.cloud !== "local") {
126
+ return false;
127
+ }
128
+
129
+ let mutated = false;
130
+
131
+ // Migrate top-level `baseDataDir` → `resources.instanceDir`
132
+ if (typeof raw.baseDataDir === "string" && raw.baseDataDir) {
133
+ if (!raw.resources || typeof raw.resources !== "object") {
134
+ raw.resources = {};
135
+ }
136
+ const res = raw.resources as Record<string, unknown>;
137
+ if (!res.instanceDir) {
138
+ res.instanceDir = raw.baseDataDir;
139
+ mutated = true;
140
+ }
141
+ delete raw.baseDataDir;
142
+ mutated = true;
143
+ }
144
+
145
+ // Synthesise missing `resources` for local entries
146
+ if (!raw.resources || typeof raw.resources !== "object") {
147
+ const gatewayPort =
148
+ parsePortFromUrl(raw.runtimeUrl) ?? DEFAULT_GATEWAY_PORT;
149
+ const instanceDir = join(
150
+ homedir(),
151
+ ".local",
152
+ "share",
153
+ "vellum",
154
+ "assistants",
155
+ typeof raw.assistantId === "string" ? raw.assistantId : "default",
156
+ );
157
+ raw.resources = {
158
+ instanceDir,
159
+ daemonPort: DEFAULT_DAEMON_PORT,
160
+ gatewayPort,
161
+ qdrantPort: DEFAULT_QDRANT_PORT,
162
+ pidFile: join(instanceDir, ".vellum", "vellum.pid"),
163
+ };
164
+ mutated = true;
165
+ } else {
166
+ // Backfill any missing fields on an existing partial `resources` object
167
+ const res = raw.resources as Record<string, unknown>;
168
+ if (!res.instanceDir) {
169
+ res.instanceDir = join(
170
+ homedir(),
171
+ ".local",
172
+ "share",
173
+ "vellum",
174
+ "assistants",
175
+ typeof raw.assistantId === "string" ? raw.assistantId : "default",
176
+ );
177
+ mutated = true;
178
+ }
179
+ if (typeof res.daemonPort !== "number") {
180
+ res.daemonPort = DEFAULT_DAEMON_PORT;
181
+ mutated = true;
182
+ }
183
+ if (typeof res.gatewayPort !== "number") {
184
+ res.gatewayPort =
185
+ parsePortFromUrl(raw.runtimeUrl) ?? DEFAULT_GATEWAY_PORT;
186
+ mutated = true;
187
+ }
188
+ if (typeof res.qdrantPort !== "number") {
189
+ res.qdrantPort = DEFAULT_QDRANT_PORT;
190
+ mutated = true;
191
+ }
192
+ if (typeof res.pidFile !== "string") {
193
+ res.pidFile = join(
194
+ res.instanceDir as string,
195
+ ".vellum",
196
+ "vellum.pid",
197
+ );
198
+ mutated = true;
199
+ }
200
+ }
201
+
202
+ return mutated;
203
+ }
204
+
99
205
  function readAssistants(): AssistantEntry[] {
100
206
  const data = readLockfile();
101
207
  const entries = data.assistants;
102
208
  if (!Array.isArray(entries)) {
103
209
  return [];
104
210
  }
211
+
212
+ let migrated = false;
213
+ for (const entry of entries) {
214
+ if (migrateLegacyEntry(entry)) {
215
+ migrated = true;
216
+ }
217
+ }
218
+
219
+ if (migrated) {
220
+ writeLockfile(data);
221
+ }
222
+
105
223
  return entries.filter(
106
- (e) =>
224
+ (e): e is AssistantEntry =>
107
225
  typeof e.assistantId === "string" && typeof e.runtimeUrl === "string",
108
226
  );
109
227
  }
@@ -135,12 +253,17 @@ export function findAssistantByName(name: string): AssistantEntry | null {
135
253
  export function removeAssistantEntry(assistantId: string): void {
136
254
  const data = readLockfile();
137
255
  const entries = (data.assistants ?? []).filter(
138
- (e: AssistantEntry) => e.assistantId !== assistantId,
256
+ (e) => e.assistantId !== assistantId,
139
257
  );
140
258
  data.assistants = entries;
141
- // Clear active assistant if it matches the removed entry
259
+ // Reassign active assistant if it matches the removed entry
142
260
  if (data.activeAssistant === assistantId) {
143
- delete data.activeAssistant;
261
+ const remaining = entries[0];
262
+ if (remaining) {
263
+ data.activeAssistant = String(remaining.assistantId);
264
+ } else {
265
+ delete data.activeAssistant;
266
+ }
144
267
  }
145
268
  writeLockfile(data);
146
269
  }
@@ -228,17 +351,26 @@ async function findAvailablePort(
228
351
 
229
352
  /**
230
353
  * Allocate an isolated set of resources for a named local instance.
231
- * The first local assistant gets `instanceDir = ~` with default ports (same as
232
- * legacy single-instance layout). Subsequent assistants are placed under
354
+ * The first local assistant uses the home directory with default ports.
355
+ * Subsequent assistants are placed under
233
356
  * `~/.local/share/vellum/assistants/<name>/` with scanned ports.
234
357
  */
235
358
  export async function allocateLocalResources(
236
359
  instanceName: string,
237
360
  ): Promise<LocalInstanceResources> {
238
- // First local assistant gets the home directory — identical to legacy layout.
361
+ // First local assistant gets the home directory with default ports.
239
362
  const existingLocals = loadAllAssistants().filter((e) => e.cloud === "local");
240
363
  if (existingLocals.length === 0) {
241
- return defaultLocalResources();
364
+ const home = homedir();
365
+ const vellumDir = join(home, ".vellum");
366
+ mkdirSync(vellumDir, { recursive: true });
367
+ return {
368
+ instanceDir: home,
369
+ daemonPort: DEFAULT_DAEMON_PORT,
370
+ gatewayPort: DEFAULT_GATEWAY_PORT,
371
+ qdrantPort: DEFAULT_QDRANT_PORT,
372
+ pidFile: join(vellumDir, "vellum.pid"),
373
+ };
242
374
  }
243
375
 
244
376
  const instanceDir = join(
@@ -263,13 +395,6 @@ export async function allocateLocalResources(
263
395
  entry.resources.gatewayPort,
264
396
  entry.resources.qdrantPort,
265
397
  );
266
- } else {
267
- // Legacy entries without resources use the default ports
268
- reservedPorts.push(
269
- DEFAULT_DAEMON_PORT,
270
- DEFAULT_GATEWAY_PORT,
271
- DEFAULT_QDRANT_PORT,
272
- );
273
398
  }
274
399
  }
275
400
 
@@ -294,42 +419,10 @@ export async function allocateLocalResources(
294
419
  daemonPort,
295
420
  gatewayPort,
296
421
  qdrantPort,
297
- socketPath: join(instanceDir, ".vellum", "vellum.sock"),
298
422
  pidFile: join(instanceDir, ".vellum", "vellum.pid"),
299
423
  };
300
424
  }
301
425
 
302
- /**
303
- * Return default resources representing the legacy single-instance layout.
304
- * Used to normalize existing lockfile entries so callers can treat all local
305
- * entries uniformly.
306
- */
307
- export function defaultLocalResources(): LocalInstanceResources {
308
- const vellumDir = join(homedir(), ".vellum");
309
- return {
310
- instanceDir: homedir(),
311
- daemonPort: DEFAULT_DAEMON_PORT,
312
- gatewayPort: DEFAULT_GATEWAY_PORT,
313
- qdrantPort: DEFAULT_QDRANT_PORT,
314
- socketPath: join(vellumDir, "vellum.sock"),
315
- pidFile: join(vellumDir, "vellum.pid"),
316
- };
317
- }
318
-
319
- /**
320
- * Normalize existing lockfile entries so local entries include resource fields.
321
- * Remote entries are left untouched. Returns a new array (does not mutate input).
322
- */
323
- export function normalizeExistingEntryResources(
324
- entries: AssistantEntry[],
325
- ): AssistantEntry[] {
326
- return entries.map((entry) => {
327
- if (entry.cloud !== "local") return entry;
328
- if (entry.resources) return entry;
329
- return { ...entry, resources: defaultLocalResources() };
330
- });
331
- }
332
-
333
426
  /**
334
427
  * Read the assistant config file and sync client-relevant values to the
335
428
  * lockfile. This lets external tools (e.g. vel) discover the platform URL
package/src/lib/aws.ts CHANGED
@@ -5,7 +5,7 @@ import { join } from "path";
5
5
 
6
6
  import { buildStartupScript, watchHatching } from "../commands/hatch";
7
7
  import type { PollResult } from "../commands/hatch";
8
- import { saveAssistantEntry } from "./assistant-config";
8
+ import { saveAssistantEntry, setActiveAssistant } from "./assistant-config";
9
9
  import type { AssistantEntry } from "./assistant-config";
10
10
  import { GATEWAY_PORT } from "./constants";
11
11
  import type { Species } from "./constants";
@@ -370,6 +370,28 @@ async function pollAwsInstance(
370
370
  }
371
371
  }
372
372
 
373
+ async function fetchRemoteBearerToken(
374
+ ip: string,
375
+ keyPath: string,
376
+ ): Promise<string | null> {
377
+ try {
378
+ const remoteCmd =
379
+ 'cat ~/.vellum.lock.json 2>/dev/null || cat ~/.vellum.lockfile.json 2>/dev/null || echo "{}"';
380
+ const output = await awsSshExec(ip, keyPath, remoteCmd);
381
+ const data = JSON.parse(output.trim());
382
+ const assistants = data.assistants;
383
+ if (Array.isArray(assistants) && assistants.length > 0) {
384
+ const token = assistants[0].bearerToken;
385
+ if (typeof token === "string" && token) {
386
+ return token;
387
+ }
388
+ }
389
+ return null;
390
+ } catch {
391
+ return null;
392
+ }
393
+ }
394
+
373
395
  export async function hatchAws(
374
396
  species: Species,
375
397
  detached: boolean,
@@ -500,6 +522,7 @@ export async function hatchAws(
500
522
  hatchedAt: new Date().toISOString(),
501
523
  };
502
524
  saveAssistantEntry(awsEntry);
525
+ setActiveAssistant(instanceName);
503
526
 
504
527
  if (detached) {
505
528
  console.log("\u{1F680} Startup script is running on the instance...");
@@ -535,6 +558,12 @@ export async function hatchAws(
535
558
  }
536
559
  process.exit(1);
537
560
  }
561
+
562
+ const remoteBearerToken = await fetchRemoteBearerToken(ip, keyPath);
563
+ if (remoteBearerToken) {
564
+ awsEntry.bearerToken = remoteBearerToken;
565
+ saveAssistantEntry(awsEntry);
566
+ }
538
567
  } else {
539
568
  console.log(
540
569
  "\u26a0\ufe0f No external IP available for monitoring. Instance is still running.",
@@ -0,0 +1,321 @@
1
+ import { spawn as nodeSpawn } from "child_process";
2
+ import { existsSync } from "fs";
3
+ import { createRequire } from "module";
4
+ import { dirname, join } from "path";
5
+
6
+ import { saveAssistantEntry, setActiveAssistant } from "./assistant-config";
7
+ import type { AssistantEntry } from "./assistant-config";
8
+ import { DEFAULT_GATEWAY_PORT } from "./constants";
9
+ import type { Species } from "./constants";
10
+ import { discoverPublicUrl } from "./local";
11
+ import { generateRandomSuffix } from "./random-name";
12
+ import { exec, execOutput } from "./step-runner";
13
+ import { closeLogFile, openLogFile, resetLogFile, writeToLogFile } from "./xdg-log";
14
+
15
+ const _require = createRequire(import.meta.url);
16
+
17
+ interface DockerRoot {
18
+ /** Directory to use as the Docker build context */
19
+ root: string;
20
+ /** Relative path from root to the directory containing the Dockerfiles */
21
+ dockerfileDir: string;
22
+ }
23
+
24
+ /**
25
+ * Locate the directory containing the Dockerfile. In the source tree the
26
+ * Dockerfiles live under `meta/`, but when installed as an npm package they
27
+ * are at the package root.
28
+ */
29
+ function findDockerRoot(): DockerRoot {
30
+ // Source tree: cli/src/lib/ -> repo root (Dockerfiles in meta/)
31
+ const sourceTreeRoot = join(import.meta.dir, "..", "..", "..");
32
+ if (existsSync(join(sourceTreeRoot, "meta", "Dockerfile"))) {
33
+ return { root: sourceTreeRoot, dockerfileDir: "meta" };
34
+ }
35
+
36
+ // bunx layout: @vellumai/cli/src/lib/ -> ../../../.. -> node_modules -> vellum/
37
+ const bunxRoot = join(import.meta.dir, "..", "..", "..", "..", "vellum");
38
+ if (existsSync(join(bunxRoot, "Dockerfile"))) {
39
+ return { root: bunxRoot, dockerfileDir: "." };
40
+ }
41
+
42
+ // Walk up from cwd looking for meta/Dockerfile (source checkout)
43
+ let dir = process.cwd();
44
+ while (true) {
45
+ if (existsSync(join(dir, "meta", "Dockerfile"))) {
46
+ return { root: dir, dockerfileDir: "meta" };
47
+ }
48
+ const parent = dirname(dir);
49
+ if (parent === dir) break;
50
+ dir = parent;
51
+ }
52
+
53
+ // Fall back to Node module resolution for the `vellum` package
54
+ try {
55
+ const vellumPkgPath = _require.resolve("vellum/package.json");
56
+ const vellumDir = dirname(vellumPkgPath);
57
+ if (existsSync(join(vellumDir, "Dockerfile"))) {
58
+ return { root: vellumDir, dockerfileDir: "." };
59
+ }
60
+ } catch {
61
+ // resolution failed
62
+ }
63
+
64
+ throw new Error(
65
+ "Could not find Dockerfile. Run this command from within the " +
66
+ "vellum-assistant repository, or ensure the vellum package is installed.",
67
+ );
68
+ }
69
+
70
+ /**
71
+ * Creates a line-buffered output prefixer that prepends `[docker]` to each
72
+ * line from the container's stdout/stderr. Calls `onLine` for each complete
73
+ * line so the caller can detect sentinel output (e.g. hatch completion).
74
+ */
75
+ function createLinePrefixer(
76
+ stream: NodeJS.WritableStream,
77
+ onLine?: (line: string) => void,
78
+ ): { write(data: Buffer): void; flush(): void } {
79
+ let remainder = "";
80
+ return {
81
+ write(data: Buffer) {
82
+ const text = remainder + data.toString();
83
+ const lines = text.split("\n");
84
+ remainder = lines.pop() ?? "";
85
+ for (const line of lines) {
86
+ stream.write(` [docker] ${line}\n`);
87
+ onLine?.(line);
88
+ }
89
+ },
90
+ flush() {
91
+ if (remainder) {
92
+ stream.write(` [docker] ${remainder}\n`);
93
+ onLine?.(remainder);
94
+ remainder = "";
95
+ }
96
+ },
97
+ };
98
+ }
99
+
100
+ async function fetchRemoteBearerToken(
101
+ containerName: string,
102
+ ): Promise<string | null> {
103
+ try {
104
+ const remoteCmd =
105
+ 'cat ~/.vellum.lock.json 2>/dev/null || cat ~/.vellum.lockfile.json 2>/dev/null || echo "{}"';
106
+ const output = await execOutput("docker", [
107
+ "exec",
108
+ containerName,
109
+ "sh",
110
+ "-c",
111
+ remoteCmd,
112
+ ]);
113
+ const data = JSON.parse(output.trim());
114
+ const assistants = data.assistants;
115
+ if (Array.isArray(assistants) && assistants.length > 0) {
116
+ const token = assistants[0].bearerToken;
117
+ if (typeof token === "string" && token) {
118
+ return token;
119
+ }
120
+ }
121
+ return null;
122
+ } catch {
123
+ return null;
124
+ }
125
+ }
126
+
127
+ export async function retireDocker(name: string): Promise<void> {
128
+ console.log(`\u{1F5D1}\ufe0f Stopping Docker container '${name}'...\n`);
129
+
130
+ try {
131
+ await exec("docker", ["stop", name]);
132
+ } catch (error) {
133
+ console.warn(
134
+ `\u26a0\ufe0f Failed to stop container: ${error instanceof Error ? error.message : error}`,
135
+ );
136
+ }
137
+
138
+ try {
139
+ await exec("docker", ["rm", name]);
140
+ } catch (error) {
141
+ console.warn(
142
+ `\u26a0\ufe0f Failed to remove container: ${error instanceof Error ? error.message : error}`,
143
+ );
144
+ }
145
+
146
+ console.log(`\u2705 Docker instance retired.`);
147
+ }
148
+
149
+ export async function hatchDocker(
150
+ species: Species,
151
+ detached: boolean,
152
+ name: string | null,
153
+ watch: boolean,
154
+ ): Promise<void> {
155
+ const { root: repoRoot, dockerfileDir } = findDockerRoot();
156
+ const instanceName = name ?? `${species}-${generateRandomSuffix()}`;
157
+ const dockerfileName = watch ? "Dockerfile.development" : "Dockerfile";
158
+ const dockerfile = join(dockerfileDir, dockerfileName);
159
+ const dockerfilePath = join(repoRoot, dockerfile);
160
+
161
+ if (!existsSync(dockerfilePath)) {
162
+ console.error(`Error: ${dockerfile} not found at ${dockerfilePath}`);
163
+ process.exit(1);
164
+ }
165
+
166
+ resetLogFile("hatch.log");
167
+
168
+ console.log(`🄚 Hatching Docker assistant: ${instanceName}`);
169
+ console.log(` Species: ${species}`);
170
+ console.log(` Dockerfile: ${dockerfile}`);
171
+ if (watch) {
172
+ console.log(` Mode: development (watch)`);
173
+ }
174
+ console.log("");
175
+
176
+ const imageTag = `vellum-assistant:${instanceName}`;
177
+ const logFd = openLogFile("hatch.log");
178
+ console.log("šŸ”Ø Building Docker image...");
179
+ try {
180
+ await exec("docker", ["build", "-f", dockerfile, "-t", imageTag, "."], {
181
+ cwd: repoRoot,
182
+ });
183
+ } catch (err) {
184
+ const message = err instanceof Error ? err.message : String(err);
185
+ writeToLogFile(logFd, `[docker-build] ${new Date().toISOString()} ERROR\n${message}\n`);
186
+ closeLogFile(logFd);
187
+ throw err;
188
+ }
189
+ closeLogFile(logFd);
190
+ console.log("āœ… Docker image built\n");
191
+
192
+ const gatewayPort = DEFAULT_GATEWAY_PORT;
193
+ const runArgs: string[] = [
194
+ "run",
195
+ "--init",
196
+ "--name",
197
+ instanceName,
198
+ "-p",
199
+ `${gatewayPort}:${gatewayPort}`,
200
+ ];
201
+
202
+ // Pass through environment variables the assistant needs
203
+ for (const envVar of [
204
+ "ANTHROPIC_API_KEY",
205
+ "GATEWAY_RUNTIME_PROXY_ENABLED",
206
+ "RUNTIME_PROXY_BEARER_TOKEN",
207
+ "VELLUM_ASSISTANT_PLATFORM_URL",
208
+ ]) {
209
+ if (process.env[envVar]) {
210
+ runArgs.push("-e", `${envVar}=${process.env[envVar]}`);
211
+ }
212
+ }
213
+
214
+ // Pass the instance name so the inner hatch uses the same assistant ID
215
+ // instead of generating a new random one.
216
+ runArgs.push("-e", `VELLUM_ASSISTANT_NAME=${instanceName}`);
217
+
218
+ // Mount source volumes in watch mode for hot reloading
219
+ if (watch) {
220
+ runArgs.push(
221
+ "-v",
222
+ `${join(repoRoot, "assistant", "src")}:/app/assistant/src`,
223
+ "-v",
224
+ `${join(repoRoot, "gateway", "src")}:/app/gateway/src`,
225
+ "-v",
226
+ `${join(repoRoot, "cli", "src")}:/app/cli/src`,
227
+ );
228
+ }
229
+
230
+ const publicUrl = await discoverPublicUrl(gatewayPort);
231
+ const runtimeUrl = publicUrl || `http://localhost:${gatewayPort}`;
232
+ const dockerEntry: AssistantEntry = {
233
+ assistantId: instanceName,
234
+ runtimeUrl,
235
+ cloud: "docker",
236
+ species,
237
+ hatchedAt: new Date().toISOString(),
238
+ };
239
+ saveAssistantEntry(dockerEntry);
240
+ setActiveAssistant(instanceName);
241
+
242
+ // The Dockerfiles already define a CMD that runs `vellum hatch --keep-alive`.
243
+ // Only override CMD when a non-default species is specified, since that
244
+ // requires an extra argument the Dockerfile doesn't include.
245
+ const containerCmd: string[] =
246
+ species !== "vellum"
247
+ ? ["vellum", "hatch", species, ...(watch ? ["--watch"] : []), "--keep-alive"]
248
+ : [];
249
+
250
+ // Always start the container detached so it keeps running after the CLI exits.
251
+ runArgs.push("-d");
252
+ console.log("šŸš€ Starting Docker container...");
253
+ await exec("docker", [...runArgs, imageTag, ...containerCmd], { cwd: repoRoot });
254
+
255
+ if (detached) {
256
+ console.log("\nāœ… Docker assistant hatched!\n");
257
+ console.log("Instance details:");
258
+ console.log(` Name: ${instanceName}`);
259
+ console.log(` Runtime: ${runtimeUrl}`);
260
+ console.log(` Container: ${instanceName}`);
261
+ console.log("");
262
+ console.log(`Stop with: docker stop ${instanceName}`);
263
+ } else {
264
+ console.log(` Container: ${instanceName}`);
265
+ console.log(` Runtime: ${runtimeUrl}`);
266
+ console.log("");
267
+
268
+ // Tail container logs until the inner hatch completes, then exit and
269
+ // leave the container running in the background.
270
+ await new Promise<void>((resolve, reject) => {
271
+ const child = nodeSpawn("docker", ["logs", "-f", instanceName], {
272
+ stdio: ["ignore", "pipe", "pipe"],
273
+ });
274
+
275
+ const handleLine = (line: string): void => {
276
+ if (line.includes("Local assistant hatched!")) {
277
+ process.nextTick(async () => {
278
+ const remoteBearerToken =
279
+ await fetchRemoteBearerToken(instanceName);
280
+ if (remoteBearerToken) {
281
+ dockerEntry.bearerToken = remoteBearerToken;
282
+ saveAssistantEntry(dockerEntry);
283
+ }
284
+
285
+ console.log("");
286
+ console.log(`\u2705 Docker container is up and running!`);
287
+ console.log(` Name: ${instanceName}`);
288
+ console.log(` Runtime: ${runtimeUrl}`);
289
+ console.log("");
290
+ child.kill();
291
+ resolve();
292
+ });
293
+ }
294
+ };
295
+
296
+ const stdoutPrefixer = createLinePrefixer(process.stdout, handleLine);
297
+ const stderrPrefixer = createLinePrefixer(process.stderr, handleLine);
298
+
299
+ child.stdout?.on("data", (data: Buffer) => stdoutPrefixer.write(data));
300
+ child.stderr?.on("data", (data: Buffer) => stderrPrefixer.write(data));
301
+ child.stdout?.on("end", () => stdoutPrefixer.flush());
302
+ child.stderr?.on("end", () => stderrPrefixer.flush());
303
+
304
+ child.on("close", (code) => {
305
+ // The log tail may exit if the container stops before the sentinel
306
+ // is seen, or we killed it after detecting the sentinel.
307
+ if (code === 0 || code === null || code === 130 || code === 137 || code === 143) {
308
+ resolve();
309
+ } else {
310
+ reject(new Error(`Docker container exited with code ${code}`));
311
+ }
312
+ });
313
+ child.on("error", reject);
314
+
315
+ process.on("SIGINT", () => {
316
+ child.kill();
317
+ resolve();
318
+ });
319
+ });
320
+ }
321
+ }