@vellumai/cli 0.4.22 → 0.4.25
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 +1 -1
- package/src/commands/hatch.ts +17 -7
- package/src/lib/local.ts +116 -7
package/package.json
CHANGED
package/src/commands/hatch.ts
CHANGED
|
@@ -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
|
-
|
|
421
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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",
|
|
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();
|