@vellumai/cli 0.4.5 → 0.4.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/cli",
3
- "version": "0.4.5",
3
+ "version": "0.4.7",
4
4
  "description": "CLI tools for vellum-assistant",
5
5
  "type": "module",
6
6
  "exports": {
@@ -24,11 +24,58 @@ ensure_git() {
24
24
 
25
25
  info "Installing git..."
26
26
  if [ "$(uname -s)" = "Darwin" ]; then
27
- if command -v brew >/dev/null 2>&1; then
28
- brew install git
29
- else
30
- error "git is required. Install Homebrew (https://brew.sh) then run: brew install git"
31
- exit 1
27
+ # On macOS, the standard way to get git is via Xcode Command Line Tools.
28
+ # Try installing CLT first before falling back to Homebrew.
29
+ if ! xcode-select -p >/dev/null 2>&1; then
30
+ info "Installing Xcode Command Line Tools (includes git)..."
31
+
32
+ # Use softwareupdate to install CLT non-interactively instead of
33
+ # xcode-select --install which opens a GUI dialog requiring manual
34
+ # confirmation.
35
+ touch /tmp/.com.apple.dt.CommandLineTools.installondemand.in-progress
36
+ local clt_package
37
+ clt_package=$(softwareupdate -l 2>/dev/null \
38
+ | grep -o '.*Command Line Tools.*' \
39
+ | grep -v "^\*" \
40
+ | sed 's/^ *//' \
41
+ | sort -V \
42
+ | tail -1)
43
+
44
+ if [ -n "$clt_package" ]; then
45
+ info "Found package: $clt_package"
46
+ softwareupdate -i "$clt_package" --verbose 2>&1 | while IFS= read -r line; do
47
+ printf " %s\n" "$line"
48
+ done
49
+ else
50
+ # Fallback: if softwareupdate can't find the package, try
51
+ # xcode-select --install and wait for user interaction.
52
+ info "Could not find CLT package via softwareupdate, falling back to xcode-select..."
53
+ xcode-select --install 2>/dev/null || true
54
+ info "Please follow the on-screen dialog to install. Waiting..."
55
+ local waited=0
56
+ while ! xcode-select -p >/dev/null 2>&1; do
57
+ sleep 5
58
+ waited=$((waited + 5))
59
+ if [ "$waited" -ge 600 ]; then
60
+ error "Timed out waiting for Xcode Command Line Tools. Please install manually and re-run."
61
+ exit 1
62
+ fi
63
+ done
64
+ fi
65
+
66
+ rm -f /tmp/.com.apple.dt.CommandLineTools.installondemand.in-progress
67
+ hash -r 2>/dev/null || true
68
+ fi
69
+
70
+ # If git still doesn't work after CLT, try Homebrew as a fallback.
71
+ if ! git --version >/dev/null 2>&1; then
72
+ hash -r 2>/dev/null || true
73
+ if command -v brew >/dev/null 2>&1; then
74
+ brew install git
75
+ else
76
+ error "git is still not available. Please install manually: xcode-select --install"
77
+ exit 1
78
+ fi
32
79
  fi
33
80
  elif command -v apt-get >/dev/null 2>&1; then
34
81
  sudo apt-get update -qq && sudo apt-get install -y -qq git
@@ -1,8 +1,9 @@
1
1
  import { createHash, randomBytes, randomUUID } from "crypto";
2
- import { appendFileSync, existsSync, lstatSync, mkdirSync, readFileSync, readlinkSync, symlinkSync, unlinkSync } from "fs";
2
+ import { appendFileSync, existsSync, lstatSync, mkdirSync, readFileSync, readlinkSync, symlinkSync, unlinkSync, writeFileSync } from "fs";
3
3
  import { homedir, hostname, userInfo } from "os";
4
4
  import { join } from "path";
5
5
 
6
+ import QRCode from "qrcode";
6
7
  import qrcode from "qrcode-terminal";
7
8
 
8
9
  // Direct import — bun embeds this at compile time so it works in compiled binaries.
@@ -508,7 +509,7 @@ async function displayPairingQRCode(runtimeUrl: string, bearerToken: string | un
508
509
  const pairingRequestId = randomUUID();
509
510
  const pairingSecret = randomBytes(32).toString("hex");
510
511
 
511
- const registerRes = await fetch(`${runtimeUrl}/v1/pairing/register`, {
512
+ const registerRes = await fetch(`${runtimeUrl}/pairing/register`, {
512
513
  method: "POST",
513
514
  headers: {
514
515
  "Content-Type": "application/json",
@@ -539,6 +540,20 @@ async function displayPairingQRCode(runtimeUrl: string, bearerToken: string | un
539
540
  });
540
541
  });
541
542
 
543
+ // Save QR code as PNG to a well-known location so it can be retrieved
544
+ // (e.g. via SCP) for pairing through the Desktop app.
545
+ const qrDir = join(homedir(), ".vellum", "pairing-qr");
546
+ mkdirSync(qrDir, { recursive: true });
547
+ const qrPngPath = join(qrDir, "initial.png");
548
+ try {
549
+ const pngBuffer = await QRCode.toBuffer(payload, { type: "png", width: 512 });
550
+ writeFileSync(qrPngPath, pngBuffer);
551
+ console.log(`QR code PNG saved to ${qrPngPath}\n`);
552
+ } catch (pngErr) {
553
+ const pngReason = pngErr instanceof Error ? pngErr.message : String(pngErr);
554
+ console.warn(`\u26A0 Could not save QR code PNG: ${pngReason}\n`);
555
+ }
556
+
542
557
  console.log("Scan this QR code with the Vellum iOS app to pair:\n");
543
558
  console.log(qrString);
544
559
  console.log("This pairing request expires in 5 minutes.");
@@ -1,6 +1,6 @@
1
1
  import { spawn } from "child_process";
2
2
  import { createHash, randomBytes, randomUUID } from "crypto";
3
- import { hostname, userInfo } from "os";
3
+ import { hostname, platform, userInfo } from "os";
4
4
  import { basename } from "path";
5
5
  import qrcode from "qrcode-terminal";
6
6
  import { useCallback, useEffect, useMemo, useRef, useState, type ReactElement } from "react";
@@ -135,6 +135,7 @@ async function runtimeRequest<T>(
135
135
  path: string,
136
136
  init?: RequestInit,
137
137
  bearerToken?: string,
138
+ actorToken?: string,
138
139
  ): Promise<T> {
139
140
  const url = `${baseUrl}/v1/assistants/${assistantId}${path}`;
140
141
  const response = await fetch(url, {
@@ -142,6 +143,7 @@ async function runtimeRequest<T>(
142
143
  headers: {
143
144
  "Content-Type": "application/json",
144
145
  ...(bearerToken ? { Authorization: `Bearer ${bearerToken}` } : {}),
146
+ ...(actorToken ? { "X-Actor-Token": actorToken } : {}),
145
147
  ...(init?.headers as Record<string, string> | undefined),
146
148
  },
147
149
  });
@@ -177,10 +179,47 @@ async function checkHealthRuntime(baseUrl: string): Promise<HealthResponse> {
177
179
  return response.json() as Promise<HealthResponse>;
178
180
  }
179
181
 
182
+ async function bootstrapActorToken(baseUrl: string, bearerToken?: string): Promise<string> {
183
+ if (!bearerToken) {
184
+ throw new Error("Missing bearer token; cannot bootstrap actor identity");
185
+ }
186
+
187
+ const deviceId = `vellum-cli:${platform()}:${hostname()}:${userInfo().username}`;
188
+ const url = `${baseUrl}/v1/integrations/guardian/vellum/bootstrap`;
189
+
190
+ const response = await fetch(url, {
191
+ method: "POST",
192
+ headers: {
193
+ "Content-Type": "application/json",
194
+ Authorization: `Bearer ${bearerToken}`,
195
+ },
196
+ body: JSON.stringify({ platform: "cli", deviceId }),
197
+ });
198
+
199
+ if (!response.ok) {
200
+ const body = await response.text().catch(() => "");
201
+ throw new Error(`HTTP ${response.status}: ${body || response.statusText}`);
202
+ }
203
+
204
+ const json: unknown = await response.json();
205
+
206
+ if (typeof json !== "object" || json === null) {
207
+ throw new Error("Invalid bootstrap response from gateway/runtime");
208
+ }
209
+
210
+ const actorToken = (json as Record<string, unknown>).actorToken;
211
+ if (typeof actorToken !== "string" || actorToken.length === 0) {
212
+ throw new Error("Invalid bootstrap response from gateway/runtime");
213
+ }
214
+
215
+ return actorToken;
216
+ }
217
+
180
218
  async function pollMessages(
181
219
  baseUrl: string,
182
220
  assistantId: string,
183
221
  bearerToken?: string,
222
+ actorToken?: string,
184
223
  ): Promise<ListMessagesResponse> {
185
224
  const params = new URLSearchParams({ conversationKey: assistantId });
186
225
  return runtimeRequest<ListMessagesResponse>(
@@ -189,6 +228,7 @@ async function pollMessages(
189
228
  `/messages?${params.toString()}`,
190
229
  undefined,
191
230
  bearerToken,
231
+ actorToken,
192
232
  );
193
233
  }
194
234
 
@@ -198,6 +238,7 @@ async function sendMessage(
198
238
  content: string,
199
239
  signal?: AbortSignal,
200
240
  bearerToken?: string,
241
+ actorToken?: string,
201
242
  ): Promise<SendMessageResponse> {
202
243
  return runtimeRequest<SendMessageResponse>(
203
244
  baseUrl,
@@ -209,6 +250,7 @@ async function sendMessage(
209
250
  signal,
210
251
  },
211
252
  bearerToken,
253
+ actorToken,
212
254
  );
213
255
  }
214
256
 
@@ -218,6 +260,7 @@ async function submitDecision(
218
260
  requestId: string,
219
261
  decision: "allow" | "deny",
220
262
  bearerToken?: string,
263
+ actorToken?: string,
221
264
  ): Promise<SubmitDecisionResponse> {
222
265
  return runtimeRequest<SubmitDecisionResponse>(
223
266
  baseUrl,
@@ -228,6 +271,7 @@ async function submitDecision(
228
271
  body: JSON.stringify({ requestId, decision }),
229
272
  },
230
273
  bearerToken,
274
+ actorToken,
231
275
  );
232
276
  }
233
277
 
@@ -239,6 +283,7 @@ async function addTrustRule(
239
283
  scope: string,
240
284
  decision: "allow" | "deny",
241
285
  bearerToken?: string,
286
+ actorToken?: string,
242
287
  ): Promise<AddTrustRuleResponse> {
243
288
  return runtimeRequest<AddTrustRuleResponse>(
244
289
  baseUrl,
@@ -249,6 +294,7 @@ async function addTrustRule(
249
294
  body: JSON.stringify({ requestId, pattern, scope, decision }),
250
295
  },
251
296
  bearerToken,
297
+ actorToken,
252
298
  );
253
299
  }
254
300
 
@@ -256,6 +302,7 @@ async function pollPendingInteractions(
256
302
  baseUrl: string,
257
303
  assistantId: string,
258
304
  bearerToken?: string,
305
+ actorToken?: string,
259
306
  ): Promise<PendingInteractionsResponse> {
260
307
  const params = new URLSearchParams({ conversationKey: assistantId });
261
308
  return runtimeRequest<PendingInteractionsResponse>(
@@ -264,6 +311,7 @@ async function pollPendingInteractions(
264
311
  `/pending-interactions?${params.toString()}`,
265
312
  undefined,
266
313
  bearerToken,
314
+ actorToken,
267
315
  );
268
316
  }
269
317
 
@@ -301,6 +349,7 @@ async function handleConfirmationPrompt(
301
349
  confirmation: PendingConfirmation,
302
350
  chatApp: ChatAppHandle,
303
351
  bearerToken?: string,
352
+ actorToken?: string,
304
353
  ): Promise<void> {
305
354
  const preview = formatConfirmationPreview(confirmation.toolName, confirmation.input);
306
355
  const allowlistOptions = confirmation.allowlistOptions ?? [];
@@ -320,7 +369,7 @@ async function handleConfirmationPrompt(
320
369
  const index = await chatApp.showSelection("Tool Approval", options);
321
370
 
322
371
  if (index === 0) {
323
- await submitDecision(baseUrl, assistantId, requestId, "allow", bearerToken);
372
+ await submitDecision(baseUrl, assistantId, requestId, "allow", bearerToken, actorToken);
324
373
  chatApp.addStatus("\u2714 Allowed", "green");
325
374
  return;
326
375
  }
@@ -333,6 +382,7 @@ async function handleConfirmationPrompt(
333
382
  chatApp,
334
383
  "always_allow",
335
384
  bearerToken,
385
+ actorToken,
336
386
  );
337
387
  return;
338
388
  }
@@ -345,11 +395,12 @@ async function handleConfirmationPrompt(
345
395
  chatApp,
346
396
  "always_deny",
347
397
  bearerToken,
398
+ actorToken,
348
399
  );
349
400
  return;
350
401
  }
351
402
 
352
- await submitDecision(baseUrl, assistantId, requestId, "deny", bearerToken);
403
+ await submitDecision(baseUrl, assistantId, requestId, "deny", bearerToken, actorToken);
353
404
  chatApp.addStatus("\u2718 Denied", "yellow");
354
405
  }
355
406
 
@@ -361,6 +412,7 @@ async function handlePatternSelection(
361
412
  chatApp: ChatAppHandle,
362
413
  trustDecision: TrustDecision,
363
414
  bearerToken?: string,
415
+ actorToken?: string,
364
416
  ): Promise<void> {
365
417
  const allowlistOptions = confirmation.allowlistOptions ?? [];
366
418
  const label = trustDecision === "always_deny" ? "Denylist" : "Allowlist";
@@ -379,11 +431,12 @@ async function handlePatternSelection(
379
431
  selectedPattern,
380
432
  trustDecision,
381
433
  bearerToken,
434
+ actorToken,
382
435
  );
383
436
  return;
384
437
  }
385
438
 
386
- await submitDecision(baseUrl, assistantId, requestId, "deny", bearerToken);
439
+ await submitDecision(baseUrl, assistantId, requestId, "deny", bearerToken, actorToken);
387
440
  chatApp.addStatus("\u2718 Denied", "yellow");
388
441
  }
389
442
 
@@ -396,6 +449,7 @@ async function handleScopeSelection(
396
449
  selectedPattern: string,
397
450
  trustDecision: TrustDecision,
398
451
  bearerToken?: string,
452
+ actorToken?: string,
399
453
  ): Promise<void> {
400
454
  const scopeOptions = confirmation.scopeOptions ?? [];
401
455
  const label = trustDecision === "always_deny" ? "Denylist" : "Allowlist";
@@ -413,6 +467,7 @@ async function handleScopeSelection(
413
467
  scopeOptions[index].scope,
414
468
  ruleDecision,
415
469
  bearerToken,
470
+ actorToken,
416
471
  );
417
472
  await submitDecision(
418
473
  baseUrl,
@@ -420,6 +475,7 @@ async function handleScopeSelection(
420
475
  requestId,
421
476
  ruleDecision === "deny" ? "deny" : "allow",
422
477
  bearerToken,
478
+ actorToken,
423
479
  );
424
480
  const ruleLabel = trustDecision === "always_deny" ? "Denylisted" : "Allowlisted";
425
481
  const ruleColor = trustDecision === "always_deny" ? "yellow" : "green";
@@ -430,7 +486,7 @@ async function handleScopeSelection(
430
486
  return;
431
487
  }
432
488
 
433
- await submitDecision(baseUrl, assistantId, requestId, "deny", bearerToken);
489
+ await submitDecision(baseUrl, assistantId, requestId, "deny", bearerToken, actorToken);
434
490
  chatApp.addStatus("\u2718 Denied", "yellow");
435
491
  }
436
492
 
@@ -1025,6 +1081,7 @@ function ChatApp({
1025
1081
  const pollTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
1026
1082
  const doctorSessionIdRef = useRef(randomUUID());
1027
1083
  const handleRef_ = useRef<ChatAppHandle | null>(null);
1084
+ const actorTokenRef = useRef<string | undefined>(undefined);
1028
1085
 
1029
1086
  const { stdout } = useStdout();
1030
1087
  const terminalRows = stdout.rows || DEFAULT_TERMINAL_ROWS;
@@ -1250,6 +1307,9 @@ function ChatApp({
1250
1307
 
1251
1308
  try {
1252
1309
  const health = await checkHealthRuntime(runtimeUrl);
1310
+ if (!actorTokenRef.current) {
1311
+ actorTokenRef.current = await bootstrapActorToken(runtimeUrl, bearerToken);
1312
+ }
1253
1313
  h.hideSpinner();
1254
1314
  h.updateHealthStatus(health.status);
1255
1315
  if (health.status === "healthy" || health.status === "ok") {
@@ -1265,7 +1325,12 @@ function ChatApp({
1265
1325
  h.showSpinner("Loading conversation history...");
1266
1326
 
1267
1327
  try {
1268
- const historyResponse = await pollMessages(runtimeUrl, assistantId, bearerToken);
1328
+ const historyResponse = await pollMessages(
1329
+ runtimeUrl,
1330
+ assistantId,
1331
+ bearerToken,
1332
+ actorTokenRef.current,
1333
+ );
1269
1334
  h.hideSpinner();
1270
1335
  if (historyResponse.messages.length > 0) {
1271
1336
  for (const msg of historyResponse.messages) {
@@ -1279,7 +1344,12 @@ function ChatApp({
1279
1344
 
1280
1345
  pollTimerRef.current = setInterval(async () => {
1281
1346
  try {
1282
- const response = await pollMessages(runtimeUrl, assistantId, bearerToken);
1347
+ const response = await pollMessages(
1348
+ runtimeUrl,
1349
+ assistantId,
1350
+ bearerToken,
1351
+ actorTokenRef.current,
1352
+ );
1283
1353
  for (const msg of response.messages) {
1284
1354
  if (!seenMessageIdsRef.current.has(msg.id)) {
1285
1355
  seenMessageIdsRef.current.add(msg.id);
@@ -1296,11 +1366,12 @@ function ChatApp({
1296
1366
  connectedRef.current = true;
1297
1367
  connectingRef.current = false;
1298
1368
  return true;
1299
- } catch {
1369
+ } catch (err) {
1300
1370
  h.hideSpinner();
1301
1371
  connectingRef.current = false;
1302
1372
  h.updateHealthStatus("unreachable");
1303
- h.addStatus(`${statusEmoji("unreachable")} Failed to connect: Timeout`, "red");
1373
+ const msg = err instanceof Error ? err.message : String(err);
1374
+ h.addStatus(`${statusEmoji("unreachable")} Failed to connect: ${msg}`, "red");
1304
1375
  return false;
1305
1376
  }
1306
1377
  }, [runtimeUrl, assistantId, bearerToken]);
@@ -1347,8 +1418,8 @@ function ChatApp({
1347
1418
  const pairingSecret = randomBytes(32).toString("hex");
1348
1419
  const gatewayUrl = runtimeUrl;
1349
1420
 
1350
- // Call /v1/pairing/register directly (not under /v1/assistants/:id/)
1351
- const registerUrl = `${runtimeUrl}/v1/pairing/register`;
1421
+ // Call /pairing/register on the gateway (dedicated pairing proxy route)
1422
+ const registerUrl = `${runtimeUrl}/pairing/register`;
1352
1423
  const registerRes = await fetch(registerUrl, {
1353
1424
  method: "POST",
1354
1425
  headers: {
@@ -1561,6 +1632,7 @@ function ChatApp({
1561
1632
  trimmed,
1562
1633
  controller.signal,
1563
1634
  bearerToken,
1635
+ actorTokenRef.current,
1564
1636
  );
1565
1637
  clearTimeout(timeoutId);
1566
1638
  if (!sendResult.accepted) {
@@ -1586,7 +1658,12 @@ function ChatApp({
1586
1658
 
1587
1659
  // Check for pending confirmations/secrets
1588
1660
  try {
1589
- const pending = await pollPendingInteractions(runtimeUrl, assistantId, bearerToken);
1661
+ const pending = await pollPendingInteractions(
1662
+ runtimeUrl,
1663
+ assistantId,
1664
+ bearerToken,
1665
+ actorTokenRef.current,
1666
+ );
1590
1667
 
1591
1668
  if (pending.pendingConfirmation) {
1592
1669
  h.hideSpinner();
@@ -1597,6 +1674,7 @@ function ChatApp({
1597
1674
  pending.pendingConfirmation,
1598
1675
  h,
1599
1676
  bearerToken,
1677
+ actorTokenRef.current,
1600
1678
  );
1601
1679
  h.showSpinner("Working...");
1602
1680
  continue;
@@ -1615,6 +1693,7 @@ function ChatApp({
1615
1693
  body: JSON.stringify({ requestId: secretRequestId, value, delivery }),
1616
1694
  },
1617
1695
  bearerToken,
1696
+ actorTokenRef.current,
1618
1697
  );
1619
1698
  });
1620
1699
  h.showSpinner("Working...");
@@ -1626,7 +1705,12 @@ function ChatApp({
1626
1705
 
1627
1706
  // Poll for new messages to detect completion
1628
1707
  try {
1629
- const pollResult = await pollMessages(runtimeUrl, assistantId, bearerToken);
1708
+ const pollResult = await pollMessages(
1709
+ runtimeUrl,
1710
+ assistantId,
1711
+ bearerToken,
1712
+ actorTokenRef.current,
1713
+ );
1630
1714
  for (const msg of pollResult.messages) {
1631
1715
  if (!seenMessageIdsRef.current.has(msg.id)) {
1632
1716
  seenMessageIdsRef.current.add(msg.id);
package/src/lib/local.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { execFileSync, spawn } from "child_process";
2
- import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "fs";
2
+ import { closeSync, existsSync, mkdirSync, openSync, readFileSync, unlinkSync, writeFileSync } from "fs";
3
3
  import { createRequire } from "module";
4
4
  import { createConnection } from "net";
5
5
  import { homedir } from "os";
@@ -11,6 +11,33 @@ import { stopProcessByPidFile } from "./process.js";
11
11
 
12
12
  const _require = createRequire(import.meta.url);
13
13
 
14
+ /** Returns the XDG-compatible log directory for Vellum hatch logs. */
15
+ function getHatchLogDir(): string {
16
+ const configHome = process.env.XDG_CONFIG_HOME || join(homedir(), ".config");
17
+ return join(configHome, "vellum", "logs");
18
+ }
19
+
20
+ /** Open (or create) a log file in append mode, returning the file descriptor.
21
+ * Creates the parent directory if it doesn't exist. Returns "ignore" if the
22
+ * directory or file cannot be created (permissions, read-only filesystem, etc.)
23
+ * so that spawn falls back to discarding output instead of aborting startup. */
24
+ function openHatchLogFile(name: string): number | "ignore" {
25
+ try {
26
+ const dir = getHatchLogDir();
27
+ mkdirSync(dir, { recursive: true });
28
+ return openSync(join(dir, name), "a");
29
+ } catch {
30
+ return "ignore";
31
+ }
32
+ }
33
+
34
+ /** Close a file descriptor returned by openHatchLogFile (no-op for "ignore"). */
35
+ function closeHatchLogFile(fd: number | "ignore"): void {
36
+ if (typeof fd === "number") {
37
+ try { closeSync(fd); } catch { /* best-effort */ }
38
+ }
39
+ }
40
+
14
41
  function isAssistantSourceDir(dir: string): boolean {
15
42
  const pkgPath = join(dir, "package.json");
16
43
  if (!existsSync(pkgPath) || !existsSync(join(dir, "src", "index.ts"))) return false;
@@ -376,18 +403,25 @@ export async function startLocalDaemon(): Promise<void> {
376
403
  }
377
404
  }
378
405
 
379
- const child = spawn(daemonBinary, [], {
380
- cwd: dirname(daemonBinary),
381
- detached: true,
382
- stdio: "ignore",
383
- env: daemonEnv,
384
- });
385
- child.unref();
406
+ const daemonLogFd = openHatchLogFile("daemon.log");
407
+ let daemonPid: number | undefined;
408
+ try {
409
+ const child = spawn(daemonBinary, [], {
410
+ cwd: dirname(daemonBinary),
411
+ detached: true,
412
+ stdio: ["ignore", daemonLogFd, daemonLogFd],
413
+ env: daemonEnv,
414
+ });
415
+ child.unref();
416
+ daemonPid = child.pid;
417
+ } finally {
418
+ closeHatchLogFile(daemonLogFd);
419
+ }
386
420
 
387
421
  // Write PID file immediately so the health monitor can find the process
388
422
  // and concurrent hatch() calls see it as alive.
389
- if (child.pid) {
390
- writeFileSync(pidFile, String(child.pid), "utf-8");
423
+ if (daemonPid) {
424
+ writeFileSync(pidFile, String(daemonPid), "utf-8");
391
425
  }
392
426
  }
393
427
 
@@ -534,20 +568,30 @@ export async function startGateway(assistantId?: string): Promise<string> {
534
568
  );
535
569
  }
536
570
 
537
- gateway = spawn(gatewayBinary, [], {
538
- detached: true,
539
- stdio: "ignore",
540
- env: gatewayEnv,
541
- });
571
+ const gatewayLogFd = openHatchLogFile("gateway.log");
572
+ try {
573
+ gateway = spawn(gatewayBinary, [], {
574
+ detached: true,
575
+ stdio: ["ignore", gatewayLogFd, gatewayLogFd],
576
+ env: gatewayEnv,
577
+ });
578
+ } finally {
579
+ closeHatchLogFile(gatewayLogFd);
580
+ }
542
581
  } else {
543
582
  // Source tree / bunx: resolve the gateway source directory and run via bun.
544
583
  const gatewayDir = resolveGatewayDir();
545
- gateway = spawn("bun", ["run", "src/index.ts"], {
546
- cwd: gatewayDir,
547
- detached: true,
548
- stdio: "ignore",
549
- env: gatewayEnv,
550
- });
584
+ const gwLogFd = openHatchLogFile("gateway.log");
585
+ try {
586
+ gateway = spawn("bun", ["run", "src/index.ts"], {
587
+ cwd: gatewayDir,
588
+ detached: true,
589
+ stdio: ["ignore", gwLogFd, gwLogFd],
590
+ env: gatewayEnv,
591
+ });
592
+ } finally {
593
+ closeHatchLogFile(gwLogFd);
594
+ }
551
595
  }
552
596
 
553
597
  gateway.unref();