@vellumai/cli 0.4.43 → 0.4.45

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/src/lib/docker.ts CHANGED
@@ -10,7 +10,12 @@ 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 {
14
+ closeLogFile,
15
+ openLogFile,
16
+ resetLogFile,
17
+ writeToLogFile,
18
+ } from "./xdg-log";
14
19
 
15
20
  const _require = createRequire(import.meta.url);
16
21
 
@@ -50,6 +55,12 @@ function findDockerRoot(): DockerRoot {
50
55
  dir = parent;
51
56
  }
52
57
 
58
+ // macOS app bundle: Contents/MacOS/vellum-cli -> Contents/Resources/Dockerfile
59
+ const appResourcesDir = join(dirname(process.execPath), "..", "Resources");
60
+ if (existsSync(join(appResourcesDir, "Dockerfile"))) {
61
+ return { root: appResourcesDir, dockerfileDir: "." };
62
+ }
63
+
53
64
  // Fall back to Node module resolution for the `vellum` package
54
65
  try {
55
66
  const vellumPkgPath = _require.resolve("vellum/package.json");
@@ -152,14 +163,38 @@ export async function hatchDocker(
152
163
  name: string | null,
153
164
  watch: boolean,
154
165
  ): Promise<void> {
155
- const { root: repoRoot, dockerfileDir } = findDockerRoot();
166
+ resetLogFile("hatch.log");
167
+
168
+ let repoRoot: string;
169
+ let dockerfileDir: string;
170
+ try {
171
+ ({ root: repoRoot, dockerfileDir } = findDockerRoot());
172
+ } catch (err) {
173
+ const message = err instanceof Error ? err.message : String(err);
174
+ const logFd = openLogFile("hatch.log");
175
+ writeToLogFile(
176
+ logFd,
177
+ `[docker-hatch] ${new Date().toISOString()} ERROR\n${message}\n`,
178
+ );
179
+ closeLogFile(logFd);
180
+ console.error(message);
181
+ throw err;
182
+ }
183
+
156
184
  const instanceName = name ?? `${species}-${generateRandomSuffix()}`;
157
185
  const dockerfileName = watch ? "Dockerfile.development" : "Dockerfile";
158
186
  const dockerfile = join(dockerfileDir, dockerfileName);
159
187
  const dockerfilePath = join(repoRoot, dockerfile);
160
188
 
161
189
  if (!existsSync(dockerfilePath)) {
162
- console.error(`Error: ${dockerfile} not found at ${dockerfilePath}`);
190
+ const message = `Error: ${dockerfile} not found at ${dockerfilePath}`;
191
+ const logFd = openLogFile("hatch.log");
192
+ writeToLogFile(
193
+ logFd,
194
+ `[docker-hatch] ${new Date().toISOString()} ERROR\n${message}\n`,
195
+ );
196
+ closeLogFile(logFd);
197
+ console.error(message);
163
198
  process.exit(1);
164
199
  }
165
200
 
@@ -180,7 +215,10 @@ export async function hatchDocker(
180
215
  });
181
216
  } catch (err) {
182
217
  const message = err instanceof Error ? err.message : String(err);
183
- writeToLogFile(logFd, `[docker-build] ${new Date().toISOString()} ERROR\n${message}\n`);
218
+ writeToLogFile(
219
+ logFd,
220
+ `[docker-build] ${new Date().toISOString()} ERROR\n${message}\n`,
221
+ );
184
222
  closeLogFile(logFd);
185
223
  throw err;
186
224
  }
@@ -242,13 +280,21 @@ export async function hatchDocker(
242
280
  // requires an extra argument the Dockerfile doesn't include.
243
281
  const containerCmd: string[] =
244
282
  species !== "vellum"
245
- ? ["vellum", "hatch", species, ...(watch ? ["--watch"] : []), "--keep-alive"]
283
+ ? [
284
+ "vellum",
285
+ "hatch",
286
+ species,
287
+ ...(watch ? ["--watch"] : []),
288
+ "--keep-alive",
289
+ ]
246
290
  : [];
247
291
 
248
292
  // Always start the container detached so it keeps running after the CLI exits.
249
293
  runArgs.push("-d");
250
294
  console.log("šŸš€ Starting Docker container...");
251
- await exec("docker", [...runArgs, imageTag, ...containerCmd], { cwd: repoRoot });
295
+ await exec("docker", [...runArgs, imageTag, ...containerCmd], {
296
+ cwd: repoRoot,
297
+ });
252
298
 
253
299
  if (detached) {
254
300
  console.log("\nāœ… Docker assistant hatched!\n");
@@ -302,7 +348,13 @@ export async function hatchDocker(
302
348
  child.on("close", (code) => {
303
349
  // The log tail may exit if the container stops before the sentinel
304
350
  // is seen, or we killed it after detecting the sentinel.
305
- if (code === 0 || code === null || code === 130 || code === 137 || code === 143) {
351
+ if (
352
+ code === 0 ||
353
+ code === null ||
354
+ code === 130 ||
355
+ code === 137 ||
356
+ code === 143
357
+ ) {
306
358
  resolve();
307
359
  } else {
308
360
  reject(new Error(`Docker container exited with code ${code}`));
package/src/lib/gcp.ts CHANGED
@@ -441,45 +441,6 @@ async function recoverFromCurlFailure(
441
441
  } catch {}
442
442
  }
443
443
 
444
- async function fetchRemoteBearerToken(
445
- instanceName: string,
446
- project: string,
447
- zone: string,
448
- sshUser: string,
449
- account?: string,
450
- ): Promise<string | null> {
451
- try {
452
- const remoteCmd =
453
- 'cat ~/.vellum.lock.json 2>/dev/null || cat ~/.vellum.lockfile.json 2>/dev/null || echo "{}"';
454
- const args = [
455
- "compute",
456
- "ssh",
457
- `${sshUser}@${instanceName}`,
458
- `--project=${project}`,
459
- `--zone=${zone}`,
460
- "--quiet",
461
- "--ssh-flag=-o StrictHostKeyChecking=no",
462
- "--ssh-flag=-o UserKnownHostsFile=/dev/null",
463
- "--ssh-flag=-o ConnectTimeout=10",
464
- "--ssh-flag=-o LogLevel=ERROR",
465
- `--command=${remoteCmd}`,
466
- ];
467
- if (account) args.push(`--account=${account}`);
468
- const output = await execOutput("gcloud", args);
469
- const data = JSON.parse(output.trim());
470
- const assistants = data.assistants;
471
- if (Array.isArray(assistants) && assistants.length > 0) {
472
- const token = assistants[0].bearerToken;
473
- if (typeof token === "string" && token) {
474
- return token;
475
- }
476
- }
477
- return null;
478
- } catch {
479
- return null;
480
- }
481
- }
482
-
483
444
  export async function hatchGcp(
484
445
  species: Species,
485
446
  detached: boolean,
@@ -629,7 +590,6 @@ export async function hatchGcp(
629
590
  const gcpEntry: AssistantEntry = {
630
591
  assistantId: instanceName,
631
592
  runtimeUrl,
632
- bearerToken,
633
593
  cloud: "gcp",
634
594
  project,
635
595
  zone,
@@ -694,18 +654,6 @@ export async function hatchGcp(
694
654
  }
695
655
  }
696
656
 
697
- const remoteBearerToken = await fetchRemoteBearerToken(
698
- instanceName,
699
- project,
700
- zone,
701
- sshUser,
702
- account,
703
- );
704
- if (remoteBearerToken) {
705
- gcpEntry.bearerToken = remoteBearerToken;
706
- saveAssistantEntry(gcpEntry);
707
- }
708
-
709
657
  console.log("Instance details:");
710
658
  console.log(` Name: ${instanceName}`);
711
659
  console.log(` Project: ${project}`);
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
  }
@@ -1044,4 +1046,20 @@ export async function stopLocalProcesses(
1044
1046
 
1045
1047
  const gatewayPidFile = join(vellumDir, "gateway.pid");
1046
1048
  await stopProcessByPidFile(gatewayPidFile, "gateway", undefined, 7000);
1049
+
1050
+ // Kill ngrok directly by PID rather than using stopProcessByPidFile, because
1051
+ // isVellumProcess() checks for /vellum|@vellumai|--vellum-gateway/ which
1052
+ // won't match the ngrok binary — resulting in a no-op that leaves ngrok running.
1053
+ const ngrokPidFile = join(vellumDir, "ngrok.pid");
1054
+ if (existsSync(ngrokPidFile)) {
1055
+ try {
1056
+ const pid = parseInt(readFileSync(ngrokPidFile, "utf-8").trim(), 10);
1057
+ if (!isNaN(pid)) {
1058
+ try {
1059
+ process.kill(pid, "SIGTERM");
1060
+ } catch {}
1061
+ }
1062
+ unlinkSync(ngrokPidFile);
1063
+ } catch {}
1064
+ }
1047
1065
  }
package/src/lib/ngrok.ts CHANGED
@@ -115,6 +115,7 @@ export async function findExistingTunnel(
115
115
  */
116
116
  export function startNgrokProcess(targetPort: number): ChildProcess {
117
117
  const child = spawn("ngrok", ["http", String(targetPort), "--log=stdout"], {
118
+ detached: true,
118
119
  stdio: ["ignore", "pipe", "pipe"],
119
120
  });
120
121
  return child;
@@ -168,6 +169,97 @@ function clearIngressUrl(): void {
168
169
  saveRawConfig(config);
169
170
  }
170
171
 
172
+ /**
173
+ * Check whether any webhook-based integrations (e.g. Telegram) are configured
174
+ * that require a public ingress URL.
175
+ */
176
+ function hasWebhookIntegrationsConfigured(): boolean {
177
+ try {
178
+ const config = loadRawConfig();
179
+ const telegram = config.telegram as Record<string, unknown> | undefined;
180
+ if (telegram?.botUsername) return true;
181
+ return false;
182
+ } catch {
183
+ return false;
184
+ }
185
+ }
186
+
187
+ /**
188
+ * Check whether a non-ngrok ingress URL is already configured (e.g. custom
189
+ * domain or cloud deployment), meaning ngrok is not needed.
190
+ */
191
+ function hasNonNgrokIngressUrl(): boolean {
192
+ try {
193
+ const config = loadRawConfig();
194
+ const ingress = config.ingress as Record<string, unknown> | undefined;
195
+ const publicBaseUrl = ingress?.publicBaseUrl;
196
+ if (!publicBaseUrl || typeof publicBaseUrl !== "string") return false;
197
+ return !publicBaseUrl.includes("ngrok");
198
+ } catch {
199
+ return false;
200
+ }
201
+ }
202
+
203
+ /**
204
+ * Auto-start an ngrok tunnel if webhook integrations are configured and no
205
+ * non-ngrok ingress URL is present. Designed to be called during daemon/gateway
206
+ * startup. Non-fatal: if ngrok is unavailable or fails, startup continues.
207
+ *
208
+ * Returns the spawned ngrok child process (for PID tracking) or null.
209
+ */
210
+ export async function maybeStartNgrokTunnel(
211
+ targetPort: number,
212
+ ): Promise<ChildProcess | null> {
213
+ if (!hasWebhookIntegrationsConfigured()) return null;
214
+ if (hasNonNgrokIngressUrl()) return null;
215
+
216
+ const version = getNgrokVersion();
217
+ if (!version) return null;
218
+
219
+ // Reuse an existing tunnel if one is already running
220
+ const existingUrl = await findExistingTunnel(targetPort);
221
+ if (existingUrl) {
222
+ console.log(` Found existing ngrok tunnel: ${existingUrl}`);
223
+ saveIngressUrl(existingUrl);
224
+ return null;
225
+ }
226
+
227
+ console.log(` Starting ngrok tunnel for webhook integrations...`);
228
+ const ngrokProcess = startNgrokProcess(targetPort);
229
+
230
+ // Pipe output for debugging but don't block on it
231
+ ngrokProcess.stdout?.on("data", (data: Buffer) => {
232
+ const line = data.toString().trim();
233
+ if (line) console.log(`[ngrok] ${line}`);
234
+ });
235
+ ngrokProcess.stderr?.on("data", (data: Buffer) => {
236
+ const line = data.toString().trim();
237
+ if (line) console.error(`[ngrok] ${line}`);
238
+ });
239
+
240
+ try {
241
+ const publicUrl = await waitForNgrokUrl();
242
+ saveIngressUrl(publicUrl);
243
+ console.log(` Tunnel established: ${publicUrl}`);
244
+
245
+ // Detach the ngrok process so the CLI (hatch/wake) can exit without
246
+ // keeping it alive. Remove stdout/stderr listeners and unref all handles.
247
+ ngrokProcess.stdout?.removeAllListeners("data");
248
+ ngrokProcess.stderr?.removeAllListeners("data");
249
+ ngrokProcess.stdout?.destroy();
250
+ ngrokProcess.stderr?.destroy();
251
+ ngrokProcess.unref();
252
+
253
+ return ngrokProcess;
254
+ } catch {
255
+ console.warn(
256
+ ` ⚠ Could not start ngrok tunnel. Webhook integrations may not work until you run \`vellum tunnel\`.`,
257
+ );
258
+ if (!ngrokProcess.killed) ngrokProcess.kill("SIGTERM");
259
+ return null;
260
+ }
261
+ }
262
+
171
263
  /**
172
264
  * Run the ngrok tunnel workflow: check installation, find or start a tunnel,
173
265
  * save the public URL to config, and block until exit or signal.
@@ -0,0 +1,103 @@
1
+ import { existsSync, readFileSync } from "fs";
2
+ import { homedir } from "os";
3
+ import { join } from "path";
4
+
5
+ import { execOutput } from "./step-runner";
6
+
7
+ export interface RemoteProcess {
8
+ pid: string;
9
+ ppid: string;
10
+ command: string;
11
+ }
12
+
13
+ export function classifyProcess(command: string): string {
14
+ if (/qdrant/.test(command)) return "qdrant";
15
+ if (/vellum-gateway/.test(command)) return "gateway";
16
+ if (/openclaw/.test(command)) return "openclaw-adapter";
17
+ if (/vellum-daemon/.test(command)) return "assistant";
18
+ if (/daemon\s+(start|restart)/.test(command)) return "assistant";
19
+ // Exclude macOS desktop app processes — their path contains .app/Contents/MacOS/
20
+ // but they are not background service processes.
21
+ if (/\.app\/Contents\/MacOS\//.test(command)) return "unknown";
22
+ if (/vellum/.test(command)) return "vellum";
23
+ return "unknown";
24
+ }
25
+
26
+ export function parseRemotePs(output: string): RemoteProcess[] {
27
+ return output
28
+ .trim()
29
+ .split("\n")
30
+ .filter((line) => line.trim().length > 0)
31
+ .map((line) => {
32
+ const trimmed = line.trim();
33
+ const parts = trimmed.split(/\s+/);
34
+ const pid = parts[0];
35
+ const ppid = parts[1];
36
+ const command = parts.slice(2).join(" ");
37
+ return { pid, ppid, command };
38
+ });
39
+ }
40
+
41
+ export function readPidFile(pidFile: string): string | null {
42
+ if (!existsSync(pidFile)) return null;
43
+ const pid = readFileSync(pidFile, "utf-8").trim();
44
+ return pid || null;
45
+ }
46
+
47
+ export function isProcessAlive(pid: string): boolean {
48
+ try {
49
+ process.kill(parseInt(pid, 10), 0);
50
+ return true;
51
+ } catch {
52
+ return false;
53
+ }
54
+ }
55
+
56
+ export interface OrphanedProcess {
57
+ name: string;
58
+ pid: string;
59
+ source: string;
60
+ }
61
+
62
+ export async function detectOrphanedProcesses(): Promise<OrphanedProcess[]> {
63
+ const results: OrphanedProcess[] = [];
64
+ const seenPids = new Set<string>();
65
+ const vellumDir = join(homedir(), ".vellum");
66
+
67
+ // Strategy 1: PID file scan
68
+ const pidFiles: Array<{ file: string; name: string }> = [
69
+ { file: join(vellumDir, "vellum.pid"), name: "assistant" },
70
+ { file: join(vellumDir, "gateway.pid"), name: "gateway" },
71
+ { file: join(vellumDir, "qdrant.pid"), name: "qdrant" },
72
+ ];
73
+
74
+ for (const { file, name } of pidFiles) {
75
+ const pid = readPidFile(file);
76
+ if (pid && isProcessAlive(pid)) {
77
+ results.push({ name, pid, source: "pid file" });
78
+ seenPids.add(pid);
79
+ }
80
+ }
81
+
82
+ // Strategy 2: Process table scan
83
+ try {
84
+ const output = await execOutput("sh", [
85
+ "-c",
86
+ "ps ax -o pid=,ppid=,args= | grep -E 'vellum|vellum-gateway|qdrant|openclaw' | grep -v grep",
87
+ ]);
88
+ const procs = parseRemotePs(output);
89
+ const ownPid = String(process.pid);
90
+
91
+ for (const p of procs) {
92
+ if (p.pid === ownPid || seenPids.has(p.pid)) continue;
93
+ const type = classifyProcess(p.command);
94
+ if (type === "unknown") continue;
95
+ results.push({ name: type, pid: p.pid, source: "process table" });
96
+ seenPids.add(p.pid);
97
+ }
98
+ } catch {
99
+ // grep exits 1 when no matches found — ignore
100
+ }
101
+
102
+ return results;
103
+ }
@@ -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
  }