@vellumai/cli 0.4.22 → 0.4.23

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.22",
3
+ "version": "0.4.23",
4
4
  "description": "CLI tools for vellum-assistant",
5
5
  "type": "module",
6
6
  "exports": {
@@ -147,6 +147,7 @@ interface HatchArgs {
147
147
  remote: RemoteHost;
148
148
  daemonOnly: boolean;
149
149
  restart: boolean;
150
+ watch: boolean;
150
151
  }
151
152
 
152
153
  function parseArgs(): HatchArgs {
@@ -157,6 +158,7 @@ function parseArgs(): HatchArgs {
157
158
  let remote: RemoteHost = DEFAULT_REMOTE;
158
159
  let daemonOnly = false;
159
160
  let restart = false;
161
+ let watch = false;
160
162
 
161
163
  for (let i = 0; i < args.length; i++) {
162
164
  const arg = args[i];
@@ -175,6 +177,7 @@ function parseArgs(): HatchArgs {
175
177
  console.log(" --remote <host> Remote host (local, gcp, aws, custom)");
176
178
  console.log(" --daemon-only Start daemon only, skip gateway");
177
179
  console.log(" --restart Restart processes without onboarding side effects");
180
+ console.log(" --watch Run daemon and gateway in watch mode (hot reload on source changes)");
178
181
  process.exit(0);
179
182
  } else if (arg === "-d") {
180
183
  detached = true;
@@ -182,6 +185,8 @@ function parseArgs(): HatchArgs {
182
185
  daemonOnly = true;
183
186
  } else if (arg === "--restart") {
184
187
  restart = true;
188
+ } else if (arg === "--watch") {
189
+ watch = true;
185
190
  } else if (arg === "--name") {
186
191
  const next = args[i + 1];
187
192
  if (!next || next.startsWith("-")) {
@@ -210,13 +215,13 @@ function parseArgs(): HatchArgs {
210
215
  species = arg as Species;
211
216
  } else {
212
217
  console.error(
213
- `Error: Unknown argument '${arg}'. Valid options: ${VALID_SPECIES.join(", ")}, -d, --daemon-only, --restart, --name <name>, --remote <${VALID_REMOTE_HOSTS.join("|")}>`,
218
+ `Error: Unknown argument '${arg}'. Valid options: ${VALID_SPECIES.join(", ")}, -d, --daemon-only, --restart, --watch, --name <name>, --remote <${VALID_REMOTE_HOSTS.join("|")}>`,
214
219
  );
215
220
  process.exit(1);
216
221
  }
217
222
  }
218
223
 
219
- return { species, detached, name, remote, daemonOnly, restart };
224
+ return { species, detached, name, remote, daemonOnly, restart, watch };
220
225
  }
221
226
 
222
227
  function formatElapsed(ms: number): string {
@@ -594,7 +599,7 @@ async function displayPairingQRCode(runtimeUrl: string, bearerToken: string | un
594
599
  }
595
600
  }
596
601
 
597
- async function hatchLocal(species: Species, name: string | null, daemonOnly: boolean = false, restart: boolean = false): Promise<void> {
602
+ async function hatchLocal(species: Species, name: string | null, daemonOnly: boolean = false, restart: boolean = false, watch: boolean = false): Promise<void> {
598
603
  if (restart && !name && !process.env.VELLUM_ASSISTANT_NAME) {
599
604
  console.error("Error: Cannot restart without a known assistant ID. Provide --name or ensure VELLUM_ASSISTANT_NAME is set.");
600
605
  process.exit(1);
@@ -649,11 +654,11 @@ async function hatchLocal(species: Species, name: string | null, daemonOnly: boo
649
654
  console.log(` Species: ${species}`);
650
655
  console.log("");
651
656
 
652
- await startLocalDaemon();
657
+ await startLocalDaemon(watch);
653
658
 
654
659
  let runtimeUrl: string;
655
660
  try {
656
- runtimeUrl = await startGateway(instanceName);
661
+ runtimeUrl = await startGateway(instanceName, watch);
657
662
  } catch (error) {
658
663
  // Gateway failed — stop the daemon we just started so we don't leave
659
664
  // orphaned processes with no lock file entry.
@@ -710,15 +715,20 @@ export async function hatch(): Promise<void> {
710
715
  const cliVersion = getCliVersion();
711
716
  console.log(`@vellumai/cli v${cliVersion}`);
712
717
 
713
- const { species, detached, name, remote, daemonOnly, restart } = parseArgs();
718
+ const { species, detached, name, remote, daemonOnly, restart, watch } = parseArgs();
714
719
 
715
720
  if (restart && remote !== "local") {
716
721
  console.error("Error: --restart is only supported for local hatch targets.");
717
722
  process.exit(1);
718
723
  }
719
724
 
725
+ if (watch && remote !== "local") {
726
+ console.error("Error: --watch is only supported for local hatch targets.");
727
+ process.exit(1);
728
+ }
729
+
720
730
  if (remote === "local") {
721
- await hatchLocal(species, name, daemonOnly, restart);
731
+ await hatchLocal(species, name, daemonOnly, restart, watch);
722
732
  return;
723
733
  }
724
734
 
package/src/lib/local.ts CHANGED
@@ -148,6 +148,10 @@ async function waitForSocketFile(
148
148
  return existsSync(socketPath);
149
149
  }
150
150
 
151
+ function resolveDaemonMainPath(assistantIndex: string): string {
152
+ return join(dirname(assistantIndex), "daemon", "main.ts");
153
+ }
154
+
151
155
  async function startDaemonFromSource(assistantIndex: string): Promise<void> {
152
156
  const env: Record<string, string | undefined> = {
153
157
  ...process.env,
@@ -176,6 +180,80 @@ async function startDaemonFromSource(assistantIndex: string): Promise<void> {
176
180
  });
177
181
  }
178
182
 
183
+ // NOTE: startDaemonWatchFromSource() is the CLI-side watch-mode daemon
184
+ // launcher. Its lifecycle guards should eventually converge with
185
+ // assistant/src/daemon/daemon-control.ts::startDaemon which is the
186
+ // assistant-side equivalent.
187
+ async function startDaemonWatchFromSource(assistantIndex: string): Promise<void> {
188
+ const mainPath = resolveDaemonMainPath(assistantIndex);
189
+ if (!existsSync(mainPath)) {
190
+ throw new Error(`Daemon main.ts not found at ${mainPath}`);
191
+ }
192
+
193
+ const vellumDir = join(homedir(), ".vellum");
194
+ mkdirSync(vellumDir, { recursive: true });
195
+
196
+ const pidFile = join(vellumDir, "vellum.pid");
197
+ const socketFile = join(vellumDir, "vellum.sock");
198
+
199
+ // --- Lifecycle guard: prevent split-brain daemon state ---
200
+ // If a daemon is already running, skip spawning a new one.
201
+ if (existsSync(pidFile)) {
202
+ try {
203
+ const pid = parseInt(readFileSync(pidFile, "utf-8").trim(), 10);
204
+ if (!isNaN(pid)) {
205
+ try {
206
+ process.kill(pid, 0); // Check if alive
207
+ console.log(` Daemon already running (pid ${pid})\n`);
208
+ return;
209
+ } catch {
210
+ // Process doesn't exist, clean up stale PID file
211
+ try { unlinkSync(pidFile); } catch {}
212
+ }
213
+ }
214
+ } catch {}
215
+ }
216
+
217
+ // PID file was stale or missing, but a daemon with a different PID may
218
+ // still be listening on the socket. Check before starting a new one.
219
+ if (await isSocketResponsive(socketFile)) {
220
+ const ownerPid = findSocketOwnerPid(socketFile);
221
+ if (ownerPid) {
222
+ writeFileSync(pidFile, String(ownerPid), "utf-8");
223
+ console.log(
224
+ ` Daemon socket is responsive (pid ${ownerPid}) — skipping restart\n`,
225
+ );
226
+ } else {
227
+ console.log(" Daemon socket is responsive — skipping restart\n");
228
+ }
229
+ return;
230
+ }
231
+
232
+ // Socket is unresponsive or missing — safe to clean up and start fresh.
233
+ try { unlinkSync(socketFile); } catch {}
234
+
235
+ const env: Record<string, string | undefined> = {
236
+ ...process.env,
237
+ RUNTIME_HTTP_PORT: process.env.RUNTIME_HTTP_PORT || "7821",
238
+ };
239
+
240
+ const daemonLogFd = openLogFile("hatch.log");
241
+ const child = spawn("bun", ["--watch", "run", mainPath], {
242
+ detached: true,
243
+ stdio: ["ignore", "pipe", "pipe"],
244
+ env,
245
+ });
246
+ pipeToLogFile(child, daemonLogFd, "daemon");
247
+ child.unref();
248
+ const daemonPid = child.pid;
249
+
250
+ if (daemonPid) {
251
+ writeFileSync(pidFile, String(daemonPid), "utf-8");
252
+ }
253
+
254
+ console.log(" Daemon started in watch mode (bun --watch)");
255
+ }
256
+
179
257
  function resolveGatewayDir(): string {
180
258
  const override = process.env.VELLUM_GATEWAY_DIR?.trim();
181
259
  if (override) {
@@ -417,10 +495,16 @@ function getLocalLanIPv4(): string | undefined {
417
495
  return undefined;
418
496
  }
419
497
 
420
- export async function startLocalDaemon(): Promise<void> {
421
- if (process.env.VELLUM_DESKTOP_APP) {
498
+ // NOTE: startLocalDaemon() is the CLI-side daemon lifecycle manager.
499
+ // It should eventually converge with
500
+ // assistant/src/daemon/daemon-control.ts::startDaemon which is the
501
+ // assistant-side equivalent.
502
+ export async function startLocalDaemon(watch: boolean = false): Promise<void> {
503
+ if (process.env.VELLUM_DESKTOP_APP && !watch) {
422
504
  // When running inside the desktop app, the CLI owns the daemon lifecycle.
423
505
  // Find the vellum-daemon binary adjacent to the CLI binary.
506
+ // In watch mode, skip the bundled binary and use source (bun --watch
507
+ // only works with source files, not compiled binaries).
424
508
  const daemonBinary = join(dirname(process.execPath), "vellum-daemon");
425
509
  if (!existsSync(daemonBinary)) {
426
510
  throw new Error(
@@ -545,7 +629,11 @@ export async function startLocalDaemon(): Promise<void> {
545
629
  );
546
630
  // Kill the bundled daemon to avoid two processes competing for the same socket/port
547
631
  await stopProcessByPidFile(pidFile, "bundled daemon", [socketFile]);
548
- await startDaemonFromSource(assistantIndex);
632
+ if (watch) {
633
+ await startDaemonWatchFromSource(assistantIndex);
634
+ } else {
635
+ await startDaemonFromSource(assistantIndex);
636
+ }
549
637
  socketReady = await waitForSocketFile(socketFile, 60000);
550
638
  }
551
639
  }
@@ -567,11 +655,24 @@ export async function startLocalDaemon(): Promise<void> {
567
655
  " Ensure the daemon binary is bundled alongside the CLI, or run from the source tree.",
568
656
  );
569
657
  }
570
- await startDaemonFromSource(assistantIndex);
658
+ if (watch) {
659
+ await startDaemonWatchFromSource(assistantIndex);
660
+
661
+ const vellumDir = join(homedir(), ".vellum");
662
+ const socketFile = join(vellumDir, "vellum.sock");
663
+ const socketReady = await waitForSocketFile(socketFile, 60000);
664
+ if (socketReady) {
665
+ console.log(" Daemon socket ready\n");
666
+ } else {
667
+ console.log(" ⚠️ Daemon socket did not appear within 60s — continuing anyway\n");
668
+ }
669
+ } else {
670
+ await startDaemonFromSource(assistantIndex);
671
+ }
571
672
  }
572
673
  }
573
674
 
574
- export async function startGateway(assistantId?: string): Promise<string> {
675
+ export async function startGateway(assistantId?: string, watch: boolean = false): Promise<string> {
575
676
  const publicUrl = await discoverPublicUrl();
576
677
  if (publicUrl) {
577
678
  console.log(` Public URL: ${publicUrl}`);
@@ -677,8 +778,10 @@ export async function startGateway(assistantId?: string): Promise<string> {
677
778
 
678
779
  let gateway;
679
780
 
680
- if (process.env.VELLUM_DESKTOP_APP) {
781
+ if (process.env.VELLUM_DESKTOP_APP && !watch) {
681
782
  // Desktop app: spawn the compiled gateway binary directly (mirrors daemon pattern).
783
+ // In watch mode, skip the bundled binary and use source (bun --watch
784
+ // only works with source files, not compiled binaries).
682
785
  const gatewayBinary = join(dirname(process.execPath), "vellum-gateway");
683
786
  if (!existsSync(gatewayBinary)) {
684
787
  throw new Error(
@@ -697,14 +800,20 @@ export async function startGateway(assistantId?: string): Promise<string> {
697
800
  } else {
698
801
  // Source tree / bunx: resolve the gateway source directory and run via bun.
699
802
  const gatewayDir = resolveGatewayDir();
803
+ const bunArgs = watch
804
+ ? ["--watch", "run", "src/index.ts", "--vellum-gateway"]
805
+ : ["run", "src/index.ts", "--vellum-gateway"];
700
806
  const gwLogFd = openLogFile("hatch.log");
701
- gateway = spawn("bun", ["run", "src/index.ts", "--vellum-gateway"], {
807
+ gateway = spawn("bun", bunArgs, {
702
808
  cwd: gatewayDir,
703
809
  detached: true,
704
810
  stdio: ["ignore", "pipe", "pipe"],
705
811
  env: gatewayEnv,
706
812
  });
707
813
  pipeToLogFile(gateway, gwLogFd, "gateway");
814
+ if (watch) {
815
+ console.log(" Gateway started in watch mode (bun --watch)");
816
+ }
708
817
  }
709
818
 
710
819
  gateway.unref();