@vellumai/cli 0.4.43 → 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/cli",
3
- "version": "0.4.43",
3
+ "version": "0.4.44",
4
4
  "description": "CLI tools for vellum-assistant",
5
5
  "type": "module",
6
6
  "exports": {
@@ -1,5 +1,5 @@
1
1
  import { describe, test, expect, beforeEach, afterAll } from "bun:test";
2
- import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
2
+ import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
5
 
@@ -14,6 +14,7 @@ import {
14
14
  loadAllAssistants,
15
15
  saveAssistantEntry,
16
16
  getActiveAssistant,
17
+ migrateLegacyEntry,
17
18
  type AssistantEntry,
18
19
  } from "../lib/assistant-config.js";
19
20
 
@@ -180,3 +181,250 @@ describe("assistant-config", () => {
180
181
  expect(all[0].assistantId).toBe("valid");
181
182
  });
182
183
  });
184
+
185
+ describe("migrateLegacyEntry", () => {
186
+ test("rewrites baseDataDir as resources.instanceDir", () => {
187
+ /**
188
+ * Tests that a legacy entry with top-level baseDataDir gets migrated
189
+ * to the current resources.instanceDir format.
190
+ */
191
+
192
+ // GIVEN a legacy entry with baseDataDir set at the top level
193
+ const entry: Record<string, unknown> = {
194
+ assistantId: "my-assistant",
195
+ runtimeUrl: "http://localhost:7830",
196
+ cloud: "local",
197
+ baseDataDir: "/home/user/.local/share/vellum/assistants/my-assistant",
198
+ };
199
+
200
+ // WHEN we migrate the entry
201
+ const changed = migrateLegacyEntry(entry);
202
+
203
+ // THEN the entry should be mutated
204
+ expect(changed).toBe(true);
205
+
206
+ // AND baseDataDir should be removed
207
+ expect(entry.baseDataDir).toBeUndefined();
208
+
209
+ // AND resources.instanceDir should contain the old baseDataDir value
210
+ const resources = entry.resources as Record<string, unknown>;
211
+ expect(resources.instanceDir).toBe(
212
+ "/home/user/.local/share/vellum/assistants/my-assistant",
213
+ );
214
+ });
215
+
216
+ test("synthesises full resources when none exist", () => {
217
+ /**
218
+ * Tests that a legacy local entry with no resources object gets a
219
+ * complete resources object synthesised with default ports and pidFile.
220
+ */
221
+
222
+ // GIVEN a local entry with no resources
223
+ const entry: Record<string, unknown> = {
224
+ assistantId: "old-assistant",
225
+ runtimeUrl: "http://localhost:7830",
226
+ cloud: "local",
227
+ };
228
+
229
+ // WHEN we migrate the entry
230
+ const changed = migrateLegacyEntry(entry);
231
+
232
+ // THEN the entry should be mutated
233
+ expect(changed).toBe(true);
234
+
235
+ // AND resources should be fully populated
236
+ const resources = entry.resources as Record<string, unknown>;
237
+ expect(resources.instanceDir).toContain("old-assistant");
238
+ expect(resources.daemonPort).toBe(7821);
239
+ expect(resources.gatewayPort).toBe(7830);
240
+ expect(resources.qdrantPort).toBe(6333);
241
+ expect(resources.pidFile).toContain("vellum.pid");
242
+ });
243
+
244
+ test("infers gateway port from runtimeUrl", () => {
245
+ /**
246
+ * Tests that the gateway port is extracted from the runtimeUrl when
247
+ * synthesising resources for a legacy entry.
248
+ */
249
+
250
+ // GIVEN a local entry with a non-default gateway port in the runtimeUrl
251
+ const entry: Record<string, unknown> = {
252
+ assistantId: "custom-port",
253
+ runtimeUrl: "http://localhost:9999",
254
+ cloud: "local",
255
+ };
256
+
257
+ // WHEN we migrate the entry
258
+ migrateLegacyEntry(entry);
259
+
260
+ // THEN the gateway port should match the runtimeUrl port
261
+ const resources = entry.resources as Record<string, unknown>;
262
+ expect(resources.gatewayPort).toBe(9999);
263
+ });
264
+
265
+ test("skips non-local entries", () => {
266
+ /**
267
+ * Tests that remote (non-local) entries are left untouched.
268
+ */
269
+
270
+ // GIVEN a GCP entry without resources
271
+ const entry: Record<string, unknown> = {
272
+ assistantId: "gcp-assistant",
273
+ runtimeUrl: "https://example.com",
274
+ cloud: "gcp",
275
+ };
276
+
277
+ // WHEN we attempt to migrate it
278
+ const changed = migrateLegacyEntry(entry);
279
+
280
+ // THEN nothing should change
281
+ expect(changed).toBe(false);
282
+ expect(entry.resources).toBeUndefined();
283
+ });
284
+
285
+ test("backfills missing fields on partial resources", () => {
286
+ /**
287
+ * Tests that an entry with a partial resources object (e.g. only
288
+ * instanceDir) gets the remaining fields backfilled.
289
+ */
290
+
291
+ // GIVEN an entry with partial resources (only instanceDir)
292
+ const entry: Record<string, unknown> = {
293
+ assistantId: "partial",
294
+ runtimeUrl: "http://localhost:7830",
295
+ cloud: "local",
296
+ resources: {
297
+ instanceDir: "/custom/path",
298
+ },
299
+ };
300
+
301
+ // WHEN we migrate the entry
302
+ const changed = migrateLegacyEntry(entry);
303
+
304
+ // THEN the entry should be mutated
305
+ expect(changed).toBe(true);
306
+
307
+ // AND all missing resources fields should be filled in
308
+ const resources = entry.resources as Record<string, unknown>;
309
+ expect(resources.instanceDir).toBe("/custom/path");
310
+ expect(resources.daemonPort).toBe(7821);
311
+ expect(resources.gatewayPort).toBe(7830);
312
+ expect(resources.qdrantPort).toBe(6333);
313
+ expect(resources.pidFile).toBe("/custom/path/.vellum/vellum.pid");
314
+ });
315
+
316
+ test("does not overwrite existing resources fields", () => {
317
+ /**
318
+ * Tests that an entry with a complete resources object is left untouched.
319
+ */
320
+
321
+ // GIVEN an entry with a complete resources object
322
+ const entry: Record<string, unknown> = {
323
+ assistantId: "complete",
324
+ runtimeUrl: "http://localhost:7830",
325
+ cloud: "local",
326
+ resources: {
327
+ instanceDir: "/my/path",
328
+ daemonPort: 8000,
329
+ gatewayPort: 8001,
330
+ qdrantPort: 8002,
331
+ pidFile: "/my/path/.vellum/vellum.pid",
332
+ },
333
+ };
334
+
335
+ // WHEN we migrate the entry
336
+ const changed = migrateLegacyEntry(entry);
337
+
338
+ // THEN nothing should change
339
+ expect(changed).toBe(false);
340
+
341
+ // AND existing values should be preserved
342
+ const resources = entry.resources as Record<string, unknown>;
343
+ expect(resources.daemonPort).toBe(8000);
344
+ expect(resources.gatewayPort).toBe(8001);
345
+ expect(resources.qdrantPort).toBe(8002);
346
+ });
347
+
348
+ test("baseDataDir does not overwrite existing resources.instanceDir", () => {
349
+ /**
350
+ * Tests that when both baseDataDir and resources.instanceDir exist,
351
+ * the existing instanceDir is preserved and baseDataDir is removed.
352
+ */
353
+
354
+ // GIVEN an entry with both baseDataDir and resources.instanceDir
355
+ const entry: Record<string, unknown> = {
356
+ assistantId: "conflict",
357
+ runtimeUrl: "http://localhost:7830",
358
+ cloud: "local",
359
+ baseDataDir: "/old/path",
360
+ resources: {
361
+ instanceDir: "/new/path",
362
+ daemonPort: 7821,
363
+ gatewayPort: 7830,
364
+ qdrantPort: 6333,
365
+ pidFile: "/new/path/.vellum/vellum.pid",
366
+ },
367
+ };
368
+
369
+ // WHEN we migrate the entry
370
+ const changed = migrateLegacyEntry(entry);
371
+
372
+ // THEN baseDataDir should be removed
373
+ expect(changed).toBe(true);
374
+ expect(entry.baseDataDir).toBeUndefined();
375
+
376
+ // AND the existing instanceDir should be preserved
377
+ const resources = entry.resources as Record<string, unknown>;
378
+ expect(resources.instanceDir).toBe("/new/path");
379
+ });
380
+ });
381
+
382
+ describe("legacy migration via loadAllAssistants", () => {
383
+ beforeEach(() => {
384
+ try {
385
+ rmSync(join(testDir, ".vellum.lock.json"));
386
+ } catch {
387
+ // file may not exist
388
+ }
389
+ });
390
+
391
+ test("migrates legacy entries and persists to disk on read", () => {
392
+ /**
393
+ * Tests that reading assistants from a lockfile with legacy entries
394
+ * triggers migration and persists the updated format to disk.
395
+ */
396
+
397
+ // GIVEN a lockfile with a legacy entry containing baseDataDir
398
+ writeLockfile({
399
+ assistants: [
400
+ {
401
+ assistantId: "legacy-bot",
402
+ runtimeUrl: "http://localhost:7830",
403
+ cloud: "local",
404
+ baseDataDir: "/home/user/.local/share/vellum/assistants/legacy-bot",
405
+ },
406
+ ],
407
+ });
408
+
409
+ // WHEN we load assistants
410
+ const all = loadAllAssistants();
411
+
412
+ // THEN the entry should have resources populated
413
+ expect(all).toHaveLength(1);
414
+ expect(all[0].resources).toBeDefined();
415
+ expect(all[0].resources!.instanceDir).toBe(
416
+ "/home/user/.local/share/vellum/assistants/legacy-bot",
417
+ );
418
+ expect(all[0].resources!.gatewayPort).toBe(7830);
419
+
420
+ // AND the lockfile on disk should reflect the migration
421
+ const rawDisk = JSON.parse(
422
+ readFileSync(join(testDir, ".vellum.lock.json"), "utf-8"),
423
+ );
424
+ const diskEntry = rawDisk.assistants[0];
425
+ expect(diskEntry.baseDataDir).toBeUndefined();
426
+ expect(diskEntry.resources.instanceDir).toBe(
427
+ "/home/user/.local/share/vellum/assistants/legacy-bot",
428
+ );
429
+ });
430
+ });
@@ -94,21 +94,22 @@ describe("multi-local", () => {
94
94
  });
95
95
 
96
96
  describe("allocateLocalResources() produces non-conflicting ports", () => {
97
- test("first instance gets XDG path and default ports", async () => {
97
+ test("first instance gets home directory and default ports", async () => {
98
98
  // GIVEN no local assistants exist in the lockfile
99
99
 
100
100
  // WHEN we allocate resources for the first instance
101
101
  const res = await allocateLocalResources("instance-a");
102
102
 
103
- // THEN it gets an XDG instance directory under the home dir
104
- expect(res.instanceDir).toBe(
105
- join(testDir, ".local", "share", "vellum", "assistants", "instance-a"),
106
- );
103
+ // THEN it gets the home directory as its instance root
104
+ expect(res.instanceDir).toBe(testDir);
107
105
 
108
106
  // AND it gets the default ports since no other instances exist
109
107
  expect(res.daemonPort).toBe(DEFAULT_DAEMON_PORT);
110
108
  expect(res.gatewayPort).toBe(DEFAULT_GATEWAY_PORT);
111
109
  expect(res.qdrantPort).toBe(DEFAULT_QDRANT_PORT);
110
+
111
+ // AND the PID file is under ~/.vellum/
112
+ expect(res.pidFile).toBe(join(testDir, ".vellum", "vellum.pid"));
112
113
  });
113
114
 
114
115
  test("second instance gets distinct ports and dir when first instance is saved", async () => {
@@ -50,6 +50,7 @@ import {
50
50
  import { isProcessAlive } from "../lib/process";
51
51
  import { generateRandomSuffix } from "../lib/random-name";
52
52
  import { validateAssistantName } from "../lib/retire-archive";
53
+ import { archiveLogFile, resetLogFile } from "../lib/xdg-log";
53
54
 
54
55
  export type { PollResult, WatchHatchingResult } from "../lib/gcp";
55
56
 
@@ -730,6 +731,10 @@ async function hatchLocal(
730
731
  resources = await allocateLocalResources(instanceName);
731
732
  }
732
733
 
734
+ const logsDir = join(resources.instanceDir, ".vellum", "workspace", "data", "logs");
735
+ archiveLogFile("hatch.log", logsDir);
736
+ resetLogFile("hatch.log");
737
+
733
738
  console.log(`🥚 Hatching local assistant: ${instanceName}`);
734
739
  console.log(` Species: ${species}`);
735
740
  console.log("");
@@ -152,6 +152,19 @@ interface HealthResponse {
152
152
  message?: string;
153
153
  }
154
154
 
155
+ /** Extract human-readable message from a daemon JSON error response. */
156
+ function friendlyErrorMessage(status: number, body: string): string {
157
+ try {
158
+ const parsed = JSON.parse(body) as { error?: { message?: string } };
159
+ if (parsed?.error?.message) {
160
+ return parsed.error.message;
161
+ }
162
+ } catch {
163
+ // Not JSON — fall through
164
+ }
165
+ return `HTTP ${status}: ${body || "Unknown error"}`;
166
+ }
167
+
155
168
  async function runtimeRequest<T>(
156
169
  baseUrl: string,
157
170
  assistantId: string,
@@ -171,7 +184,7 @@ async function runtimeRequest<T>(
171
184
 
172
185
  if (!response.ok) {
173
186
  const body = await response.text().catch(() => "");
174
- throw new Error(`HTTP ${response.status}: ${body || response.statusText}`);
187
+ throw new Error(friendlyErrorMessage(response.status, body));
175
188
  }
176
189
 
177
190
  if (response.status === 204) {
@@ -1758,7 +1771,8 @@ function ChatApp({
1758
1771
  clearTimeout(timeoutId);
1759
1772
  h.setBusy(false);
1760
1773
  h.hideSpinner();
1761
- const errorMsg = `Failed to send: ${sendErr instanceof Error ? sendErr.message : sendErr}`;
1774
+ const errorMsg =
1775
+ sendErr instanceof Error ? sendErr.message : String(sendErr);
1762
1776
  h.showError(errorMsg);
1763
1777
  chatLogRef.current.push({ role: "error", content: errorMsg });
1764
1778
  return;
@@ -16,7 +16,9 @@ import { probePort } from "./port-probe.js";
16
16
  */
17
17
  export interface LocalInstanceResources {
18
18
  /**
19
- * Instance-specific data root at `~/.local/share/vellum/assistants/<name>/`.
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>/`.
20
22
  * The daemon's `.vellum/` directory lives inside it.
21
23
  */
22
24
  instanceDir: string;
@@ -28,6 +30,7 @@ export interface LocalInstanceResources {
28
30
  qdrantPort: number;
29
31
  /** Absolute path to the daemon PID file */
30
32
  pidFile: string;
33
+ [key: string]: unknown;
31
34
  }
32
35
 
33
36
  export interface AssistantEntry {
@@ -48,10 +51,11 @@ export interface AssistantEntry {
48
51
  hatchedAt?: string;
49
52
  /** Per-instance resource config. Present for local entries in multi-instance setups. */
50
53
  resources?: LocalInstanceResources;
54
+ [key: string]: unknown;
51
55
  }
52
56
 
53
57
  interface LockfileData {
54
- assistants?: AssistantEntry[];
58
+ assistants?: Record<string, unknown>[];
55
59
  activeAssistant?: string;
56
60
  platformBaseUrl?: string;
57
61
  [key: string]: unknown;
@@ -92,14 +96,132 @@ function writeLockfile(data: LockfileData): void {
92
96
  writeFileSync(lockfilePath, JSON.stringify(data, null, 2) + "\n");
93
97
  }
94
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
+
95
205
  function readAssistants(): AssistantEntry[] {
96
206
  const data = readLockfile();
97
207
  const entries = data.assistants;
98
208
  if (!Array.isArray(entries)) {
99
209
  return [];
100
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
+
101
223
  return entries.filter(
102
- (e) =>
224
+ (e): e is AssistantEntry =>
103
225
  typeof e.assistantId === "string" && typeof e.runtimeUrl === "string",
104
226
  );
105
227
  }
@@ -131,14 +253,14 @@ export function findAssistantByName(name: string): AssistantEntry | null {
131
253
  export function removeAssistantEntry(assistantId: string): void {
132
254
  const data = readLockfile();
133
255
  const entries = (data.assistants ?? []).filter(
134
- (e: AssistantEntry) => e.assistantId !== assistantId,
256
+ (e) => e.assistantId !== assistantId,
135
257
  );
136
258
  data.assistants = entries;
137
259
  // Reassign active assistant if it matches the removed entry
138
260
  if (data.activeAssistant === assistantId) {
139
261
  const remaining = entries[0];
140
262
  if (remaining) {
141
- data.activeAssistant = remaining.assistantId;
263
+ data.activeAssistant = String(remaining.assistantId);
142
264
  } else {
143
265
  delete data.activeAssistant;
144
266
  }
@@ -229,12 +351,28 @@ async function findAvailablePort(
229
351
 
230
352
  /**
231
353
  * Allocate an isolated set of resources for a named local instance.
232
- * Each assistant is 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> {
361
+ // First local assistant gets the home directory with default ports.
362
+ const existingLocals = loadAllAssistants().filter((e) => e.cloud === "local");
363
+ if (existingLocals.length === 0) {
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
+ };
374
+ }
375
+
238
376
  const instanceDir = join(
239
377
  homedir(),
240
378
  ".local",
package/src/lib/docker.ts CHANGED
@@ -10,7 +10,7 @@ import type { Species } from "./constants";
10
10
  import { discoverPublicUrl } from "./local";
11
11
  import { generateRandomSuffix } from "./random-name";
12
12
  import { exec, execOutput } from "./step-runner";
13
- import { closeLogFile, openLogFile, writeToLogFile } from "./xdg-log";
13
+ import { closeLogFile, openLogFile, resetLogFile, writeToLogFile } from "./xdg-log";
14
14
 
15
15
  const _require = createRequire(import.meta.url);
16
16
 
@@ -163,6 +163,8 @@ export async function hatchDocker(
163
163
  process.exit(1);
164
164
  }
165
165
 
166
+ resetLogFile("hatch.log");
167
+
166
168
  console.log(`🥚 Hatching Docker assistant: ${instanceName}`);
167
169
  console.log(` Species: ${species}`);
168
170
  console.log(` Dockerfile: ${dockerfile}`);
package/src/lib/local.ts CHANGED
@@ -1,6 +1,5 @@
1
1
  import { execFileSync, execSync, spawn } from "child_process";
2
2
  import {
3
- closeSync,
4
3
  existsSync,
5
4
  mkdirSync,
6
5
  readFileSync,
@@ -258,16 +257,13 @@ async function startDaemonFromSource(
258
257
  delete env.QDRANT_URL;
259
258
  }
260
259
 
261
- // Use fd inheritance instead of pipes so the daemon's stdout/stderr survive
262
- // after the parent (hatch) exits. Bun does not ignore SIGPIPE, so piped
263
- // stdio would kill the daemon on its first write after the parent closes.
264
- const logFd = openLogFile("hatch.log");
260
+ const daemonLogFd = openLogFile("hatch.log");
265
261
  const child = spawn("bun", ["run", daemonMainPath], {
266
262
  detached: true,
267
- stdio: ["ignore", logFd, logFd],
263
+ stdio: ["ignore", "pipe", "pipe"],
268
264
  env,
269
265
  });
270
- if (typeof logFd === "number") closeSync(logFd);
266
+ pipeToLogFile(child, daemonLogFd, "daemon");
271
267
  child.unref();
272
268
 
273
269
  if (child.pid) {
@@ -472,7 +468,9 @@ function recoverPidFile(
472
468
  return pid;
473
469
  }
474
470
 
475
- export async function discoverPublicUrl(port?: number): Promise<string | undefined> {
471
+ export async function discoverPublicUrl(
472
+ port?: number,
473
+ ): Promise<string | undefined> {
476
474
  const effectivePort = port ?? GATEWAY_PORT;
477
475
  const cloud = process.env.VELLUM_CLOUD;
478
476
 
@@ -714,18 +712,14 @@ export async function startLocalDaemon(
714
712
  delete daemonEnv.QDRANT_URL;
715
713
  }
716
714
 
717
- // Use fd inheritance instead of pipes so the daemon's stdout/stderr
718
- // survive after the parent (hatch) exits. Bun does not ignore SIGPIPE,
719
- // so piped stdio would kill the daemon on its first write after the
720
- // parent closes.
721
715
  const daemonLogFd = openLogFile("hatch.log");
722
716
  const child = spawn(daemonBinary, [], {
723
717
  cwd: dirname(daemonBinary),
724
718
  detached: true,
725
- stdio: ["ignore", daemonLogFd, daemonLogFd],
719
+ stdio: ["ignore", "pipe", "pipe"],
726
720
  env: daemonEnv,
727
721
  });
728
- if (typeof daemonLogFd === "number") closeSync(daemonLogFd);
722
+ pipeToLogFile(child, daemonLogFd, "daemon");
729
723
  child.unref();
730
724
  const daemonPid = child.pid;
731
725
 
@@ -816,6 +810,16 @@ export async function startGateway(
816
810
  ): Promise<string> {
817
811
  const effectiveGatewayPort = resources?.gatewayPort ?? GATEWAY_PORT;
818
812
 
813
+ // Kill any existing gateway process before spawning a new one.
814
+ // Without this, crashed/stale gateways accumulate as zombies — the old
815
+ // process holds the port (or lingers after losing it), and every restart
816
+ // attempt spawns yet another process that fails with EADDRINUSE.
817
+ const gwPidDir = resources
818
+ ? join(resources.instanceDir, ".vellum")
819
+ : join(homedir(), ".vellum");
820
+ const gwPidFile = join(gwPidDir, "gateway.pid");
821
+ await stopProcessByPidFile(gwPidFile, "gateway");
822
+
819
823
  const publicUrl = await discoverPublicUrl(effectiveGatewayPort);
820
824
  if (publicUrl) {
821
825
  console.log(` Public URL: ${publicUrl}`);
@@ -952,15 +956,13 @@ export async function startGateway(
952
956
  );
953
957
  }
954
958
 
955
- // Use fd inheritance (not pipes) so the gateway survives after the
956
- // hatch CLI exits — Bun does not ignore SIGPIPE.
957
959
  const gatewayLogFd = openLogFile("hatch.log");
958
960
  gateway = spawn(gatewayBinary, [], {
959
961
  detached: true,
960
- stdio: ["ignore", gatewayLogFd, gatewayLogFd],
962
+ stdio: ["ignore", "pipe", "pipe"],
961
963
  env: gatewayEnv,
962
964
  });
963
- if (typeof gatewayLogFd === "number") closeSync(gatewayLogFd);
965
+ pipeToLogFile(gateway, gatewayLogFd, "gateway");
964
966
  } else {
965
967
  // Source tree / bunx: resolve the gateway source directory and run via bun.
966
968
  const gatewayDir = resolveGatewayDir();
@@ -971,10 +973,10 @@ export async function startGateway(
971
973
  gateway = spawn("bun", bunArgs, {
972
974
  cwd: gatewayDir,
973
975
  detached: true,
974
- stdio: ["ignore", gwLogFd, gwLogFd],
976
+ stdio: ["ignore", "pipe", "pipe"],
975
977
  env: gatewayEnv,
976
978
  });
977
- if (typeof gwLogFd === "number") closeSync(gwLogFd);
979
+ pipeToLogFile(gateway, gwLogFd, "gateway");
978
980
  if (watch) {
979
981
  console.log(" Gateway started in watch mode (bun --watch)");
980
982
  }
@@ -1,8 +1,20 @@
1
- import { closeSync, mkdirSync, openSync, writeSync } from "fs";
2
1
  import type { ChildProcess } from "child_process";
2
+ import {
3
+ closeSync,
4
+ copyFileSync,
5
+ existsSync,
6
+ mkdirSync,
7
+ openSync,
8
+ statSync,
9
+ writeFileSync,
10
+ writeSync,
11
+ } from "fs";
3
12
  import { homedir } from "os";
4
13
  import { join } from "path";
5
14
 
15
+ /** Regex matching pino-pretty's short time prefix, e.g. `[12:07:37.467] `. */
16
+ const PINO_TIME_RE = /^\[\d{2}:\d{2}:\d{2}\.\d{3}\]\s*/;
17
+
6
18
  /** Returns the XDG-compatible log directory for Vellum CLI logs. */
7
19
  export function getLogDir(): string {
8
20
  const configHome = process.env.XDG_CONFIG_HOME || join(homedir(), ".config");
@@ -23,6 +35,36 @@ export function openLogFile(name: string): number | "ignore" {
23
35
  }
24
36
  }
25
37
 
38
+ /** Truncate (or create) a log file so each session starts fresh. */
39
+ export function resetLogFile(name: string): void {
40
+ try {
41
+ const dir = getLogDir();
42
+ mkdirSync(dir, { recursive: true });
43
+ writeFileSync(join(dir, name), "");
44
+ } catch {
45
+ /* best-effort */
46
+ }
47
+ }
48
+
49
+ /**
50
+ * Copy the current log file into `destDir` with a timestamped name so that
51
+ * previous session logs are preserved for debugging. No-op when the source
52
+ * file is missing or empty.
53
+ */
54
+ export function archiveLogFile(name: string, destDir: string): void {
55
+ try {
56
+ const srcPath = join(getLogDir(), name);
57
+ if (!existsSync(srcPath) || statSync(srcPath).size === 0) return;
58
+
59
+ mkdirSync(destDir, { recursive: true });
60
+ const ts = new Date().toISOString().replace(/[:.]/g, "-");
61
+ const base = name.replace(/\.log$/, "");
62
+ copyFileSync(srcPath, join(destDir, `${base}-${ts}.log`));
63
+ } catch {
64
+ /* best-effort */
65
+ }
66
+ }
67
+
26
68
  /** Close a file descriptor returned by openLogFile (no-op for "ignore"). */
27
69
  export function closeLogFile(fd: number | "ignore"): void {
28
70
  if (typeof fd === "number") {
@@ -46,7 +88,8 @@ export function writeToLogFile(fd: number | "ignore", msg: string): void {
46
88
  }
47
89
 
48
90
  /** Pipe a child process's stdout/stderr to a shared log file descriptor,
49
- * prefixing each line with a tag (e.g. "[daemon]" or "[gateway]").
91
+ * prefixing each line with an ISO timestamp and tag (e.g. "[daemon]").
92
+ * Strips pino-pretty's redundant short time prefix when present.
50
93
  * Streams are unref'd so they don't prevent the parent from exiting.
51
94
  * The fd is closed automatically when both streams end. */
52
95
  export function pipeToLogFile(
@@ -80,9 +123,10 @@ export function pipeToLogFile(
80
123
  for (let i = 0; i < lines.length; i++) {
81
124
  if (i === lines.length - 1 && lines[i] === "") break;
82
125
  const nl = i < lines.length - 1 ? "\n" : "";
126
+ const stripped = lines[i].replace(PINO_TIME_RE, "");
83
127
  const prefix = `${new Date().toISOString()} ${tagLabel} `;
84
128
  try {
85
- writeSync(numFd, prefix + lines[i] + nl);
129
+ writeSync(numFd, prefix + stripped + nl);
86
130
  } catch {
87
131
  /* best-effort */
88
132
  }