@vellumai/cli 0.4.42 → 0.4.43

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.42",
3
+ "version": "0.4.43",
4
4
  "description": "CLI tools for vellum-assistant",
5
5
  "type": "module",
6
6
  "exports": {
@@ -5,7 +5,7 @@ import { join } from "node:path";
5
5
 
6
6
  // Point lockfile operations at a temp directory
7
7
  const testDir = mkdtempSync(join(tmpdir(), "cli-assistant-config-test-"));
8
- process.env.BASE_DATA_DIR = testDir;
8
+ process.env.VELLUM_LOCKFILE_DIR = testDir;
9
9
 
10
10
  import {
11
11
  loadLatestAssistant,
@@ -13,12 +13,13 @@ import {
13
13
  removeAssistantEntry,
14
14
  loadAllAssistants,
15
15
  saveAssistantEntry,
16
+ getActiveAssistant,
16
17
  type AssistantEntry,
17
18
  } from "../lib/assistant-config.js";
18
19
 
19
20
  afterAll(() => {
20
21
  rmSync(testDir, { recursive: true, force: true });
21
- delete process.env.BASE_DATA_DIR;
22
+ delete process.env.VELLUM_LOCKFILE_DIR;
22
23
  });
23
24
 
24
25
  function writeLockfile(data: unknown): void {
@@ -100,6 +101,36 @@ describe("assistant-config", () => {
100
101
  expect(all.map((e) => e.assistantId)).toEqual(["a", "c"]);
101
102
  });
102
103
 
104
+ test("removeAssistantEntry reassigns activeAssistant to remaining entry", () => {
105
+ writeLockfile({
106
+ assistants: [makeEntry("a"), makeEntry("b")],
107
+ activeAssistant: "a",
108
+ });
109
+ removeAssistantEntry("a");
110
+ expect(getActiveAssistant()).toBe("b");
111
+ expect(loadAllAssistants()).toHaveLength(1);
112
+ });
113
+
114
+ test("removeAssistantEntry clears activeAssistant when no entries remain", () => {
115
+ writeLockfile({
116
+ assistants: [makeEntry("only")],
117
+ activeAssistant: "only",
118
+ });
119
+ removeAssistantEntry("only");
120
+ expect(getActiveAssistant()).toBeNull();
121
+ expect(loadAllAssistants()).toHaveLength(0);
122
+ });
123
+
124
+ test("removeAssistantEntry preserves activeAssistant when removing a different entry", () => {
125
+ writeLockfile({
126
+ assistants: [makeEntry("a"), makeEntry("b"), makeEntry("c")],
127
+ activeAssistant: "a",
128
+ });
129
+ removeAssistantEntry("b");
130
+ expect(getActiveAssistant()).toBe("a");
131
+ expect(loadAllAssistants()).toHaveLength(2);
132
+ });
133
+
103
134
  test("loadLatestAssistant returns null when empty", () => {
104
135
  expect(loadLatestAssistant()).toBeNull();
105
136
  });
@@ -4,9 +4,9 @@ import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
5
 
6
6
  // Create a temp directory that acts as a fake home, so allocateLocalResources()
7
- // and defaultLocalResources() never touch the real ~/.vellum directory.
7
+ // never touches the real ~/.vellum directory.
8
8
  const testDir = mkdtempSync(join(tmpdir(), "cli-multi-local-test-"));
9
- process.env.BASE_DATA_DIR = testDir;
9
+ process.env.VELLUM_LOCKFILE_DIR = testDir;
10
10
 
11
11
  // Mock homedir() to return testDir — this isolates allocateLocalResources()
12
12
  // which uses homedir() directly for instance directory creation.
@@ -31,7 +31,6 @@ mock.module("../lib/port-probe.js", () => ({
31
31
 
32
32
  import {
33
33
  allocateLocalResources,
34
- defaultLocalResources,
35
34
  resolveTargetAssistant,
36
35
  setActiveAssistant,
37
36
  getActiveAssistant,
@@ -47,7 +46,7 @@ import {
47
46
 
48
47
  afterAll(() => {
49
48
  rmSync(testDir, { recursive: true, force: true });
50
- delete process.env.BASE_DATA_DIR;
49
+ delete process.env.VELLUM_LOCKFILE_DIR;
51
50
  });
52
51
 
53
52
  function writeLockfile(data: unknown): void {
@@ -95,14 +94,18 @@ describe("multi-local", () => {
95
94
  });
96
95
 
97
96
  describe("allocateLocalResources() produces non-conflicting ports", () => {
98
- test("first instance returns default legacy resources", async () => {
97
+ test("first instance gets XDG path and default ports", async () => {
99
98
  // GIVEN no local assistants exist in the lockfile
100
99
 
101
100
  // WHEN we allocate resources for the first instance
102
101
  const res = await allocateLocalResources("instance-a");
103
102
 
104
- // THEN it returns the default legacy layout (home dir, default ports)
105
- expect(res.instanceDir).toBe(testDir);
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
+ );
107
+
108
+ // AND it gets the default ports since no other instances exist
106
109
  expect(res.daemonPort).toBe(DEFAULT_DAEMON_PORT);
107
110
  expect(res.gatewayPort).toBe(DEFAULT_GATEWAY_PORT);
108
111
  expect(res.qdrantPort).toBe(DEFAULT_QDRANT_PORT);
@@ -156,18 +159,6 @@ describe("multi-local", () => {
156
159
  });
157
160
  });
158
161
 
159
- describe("defaultLocalResources() returns legacy paths", () => {
160
- test("instanceDir is homedir", () => {
161
- const res = defaultLocalResources();
162
- expect(res.instanceDir).toBe(testDir);
163
- });
164
-
165
- test("daemonPort is DEFAULT_DAEMON_PORT", () => {
166
- const res = defaultLocalResources();
167
- expect(res.daemonPort).toBe(DEFAULT_DAEMON_PORT);
168
- });
169
- });
170
-
171
162
  describe("resolveTargetAssistant() priority chain", () => {
172
163
  test("explicit name returns that entry", () => {
173
164
  writeLockfile({
@@ -243,14 +234,14 @@ describe("multi-local", () => {
243
234
  });
244
235
  });
245
236
 
246
- describe("removeAssistantEntry() clears matching activeAssistant", () => {
247
- test("set active to foo, remove foo, verify active is null", () => {
237
+ describe("removeAssistantEntry() reassigns activeAssistant on removal", () => {
238
+ test("set active to foo, remove foo, verify active is reassigned to bar", () => {
248
239
  writeLockfile({
249
240
  assistants: [makeEntry("foo"), makeEntry("bar")],
250
241
  activeAssistant: "foo",
251
242
  });
252
243
  removeAssistantEntry("foo");
253
- expect(getActiveAssistant()).toBeNull();
244
+ expect(getActiveAssistant()).toBe("bar");
254
245
  });
255
246
 
256
247
  test("set active to foo, remove bar, verify active is still foo", () => {
@@ -0,0 +1,172 @@
1
+ import {
2
+ afterAll,
3
+ beforeEach,
4
+ describe,
5
+ expect,
6
+ mock,
7
+ spyOn,
8
+ test,
9
+ } from "bun:test";
10
+ import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
11
+ import { tmpdir } from "node:os";
12
+ import { join } from "node:path";
13
+
14
+ // Create a temp directory and set VELLUM_LOCKFILE_DIR so the real
15
+ // assistant-config module reads/writes the lockfile here instead of ~/.
16
+ const testDir = mkdtempSync(join(tmpdir(), "sleep-command-test-"));
17
+ const assistantRootDir = join(testDir, ".vellum");
18
+ process.env.VELLUM_LOCKFILE_DIR = testDir;
19
+
20
+ const stopProcessByPidFileMock = mock(async () => true);
21
+ const isProcessAliveMock = mock((): { alive: boolean; pid: number | null } => ({
22
+ alive: false,
23
+ pid: null,
24
+ }));
25
+
26
+ mock.module("../lib/process.js", () => ({
27
+ isProcessAlive: isProcessAliveMock,
28
+ stopProcessByPidFile: stopProcessByPidFileMock,
29
+ }));
30
+
31
+ import { sleep } from "../commands/sleep.js";
32
+ import {
33
+ DEFAULT_DAEMON_PORT,
34
+ DEFAULT_GATEWAY_PORT,
35
+ DEFAULT_QDRANT_PORT,
36
+ } from "../lib/constants.js";
37
+
38
+ // Write a lockfile entry so the real resolveTargetAssistant() finds our test
39
+ // assistant without needing to mock the entire assistant-config module.
40
+ function writeLockfile(): void {
41
+ writeFileSync(
42
+ join(testDir, ".vellum.lock.json"),
43
+ JSON.stringify(
44
+ {
45
+ assistants: [
46
+ {
47
+ assistantId: "sleep-test",
48
+ runtimeUrl: `http://127.0.0.1:${DEFAULT_DAEMON_PORT}`,
49
+ cloud: "local",
50
+ resources: {
51
+ instanceDir: testDir,
52
+ daemonPort: DEFAULT_DAEMON_PORT,
53
+ gatewayPort: DEFAULT_GATEWAY_PORT,
54
+ qdrantPort: DEFAULT_QDRANT_PORT,
55
+ pidFile: join(assistantRootDir, "vellum.pid"),
56
+ },
57
+ },
58
+ ],
59
+ activeAssistant: "sleep-test",
60
+ },
61
+ null,
62
+ 2,
63
+ ),
64
+ );
65
+ }
66
+
67
+ function writeLeaseFile(callSessionIds: string[]): void {
68
+ mkdirSync(assistantRootDir, { recursive: true });
69
+ writeFileSync(
70
+ join(assistantRootDir, "active-call-leases.json"),
71
+ JSON.stringify(
72
+ {
73
+ version: 1,
74
+ leases: callSessionIds.map((callSessionId) => ({
75
+ callSessionId,
76
+ providerCallSid: null,
77
+ updatedAt: Date.now(),
78
+ })),
79
+ },
80
+ null,
81
+ 2,
82
+ ),
83
+ );
84
+ }
85
+
86
+ describe("sleep command", () => {
87
+ let originalArgv: string[];
88
+
89
+ beforeEach(() => {
90
+ originalArgv = [...process.argv];
91
+ isProcessAliveMock.mockReset();
92
+ isProcessAliveMock.mockReturnValue({ alive: false, pid: null });
93
+ stopProcessByPidFileMock.mockReset();
94
+ stopProcessByPidFileMock.mockResolvedValue(true);
95
+ rmSync(assistantRootDir, { recursive: true, force: true });
96
+ writeLockfile();
97
+ });
98
+
99
+ afterAll(() => {
100
+ process.argv = originalArgv;
101
+ rmSync(testDir, { recursive: true, force: true });
102
+ delete process.env.VELLUM_LOCKFILE_DIR;
103
+ });
104
+
105
+ test("refuses normal sleep while an active call lease exists", async () => {
106
+ isProcessAliveMock.mockReturnValue({ alive: true, pid: 12345 });
107
+ writeLeaseFile(["call-active-1", "call-active-2"]);
108
+ process.argv = ["bun", "vellum", "sleep", "sleep-test"];
109
+
110
+ const consoleError = spyOn(console, "error").mockImplementation(() => {});
111
+ const exitMock = mock((code?: number) => {
112
+ throw new Error(`process.exit:${code}`);
113
+ });
114
+ const originalExit = process.exit;
115
+ process.exit = exitMock as unknown as typeof process.exit;
116
+
117
+ try {
118
+ await expect(sleep()).rejects.toThrow("process.exit:1");
119
+ expect(consoleError).toHaveBeenCalledWith(
120
+ expect.stringContaining("vellum sleep --force"),
121
+ );
122
+ } finally {
123
+ process.exit = originalExit;
124
+ consoleError.mockRestore();
125
+ }
126
+
127
+ expect(stopProcessByPidFileMock).not.toHaveBeenCalled();
128
+ });
129
+
130
+ test("proceeds when assistant is not running even with stale lease file", async () => {
131
+ isProcessAliveMock.mockReturnValue({ alive: false, pid: null });
132
+ writeLeaseFile(["call-stale-1"]);
133
+ process.argv = ["bun", "vellum", "sleep", "sleep-test"];
134
+
135
+ const consoleLog = spyOn(console, "log").mockImplementation(() => {});
136
+
137
+ try {
138
+ await sleep();
139
+ } finally {
140
+ consoleLog.mockRestore();
141
+ }
142
+
143
+ expect(stopProcessByPidFileMock).toHaveBeenCalledTimes(2);
144
+ });
145
+
146
+ test("force stops the assistant even when an active call lease exists", async () => {
147
+ writeLeaseFile(["call-active-1"]);
148
+ process.argv = ["bun", "vellum", "sleep", "sleep-test", "--force"];
149
+
150
+ const consoleLog = spyOn(console, "log").mockImplementation(() => {});
151
+
152
+ try {
153
+ await sleep();
154
+ } finally {
155
+ consoleLog.mockRestore();
156
+ }
157
+
158
+ expect(stopProcessByPidFileMock).toHaveBeenCalledTimes(2);
159
+ expect(stopProcessByPidFileMock).toHaveBeenNthCalledWith(
160
+ 1,
161
+ join(assistantRootDir, "vellum.pid"),
162
+ "assistant",
163
+ );
164
+ expect(stopProcessByPidFileMock).toHaveBeenNthCalledWith(
165
+ 2,
166
+ join(assistantRootDir, "gateway.pid"),
167
+ "gateway",
168
+ undefined,
169
+ 7000,
170
+ );
171
+ });
172
+ });
@@ -1,9 +1,12 @@
1
+ import { hostname } from "os";
2
+
1
3
  import {
2
4
  findAssistantByName,
3
5
  getActiveAssistant,
4
6
  loadLatestAssistant,
5
7
  } from "../lib/assistant-config";
6
8
  import { GATEWAY_PORT, type Species } from "../lib/constants";
9
+ import { getLocalLanIPv4, getMacLocalHostname } from "../lib/local";
7
10
 
8
11
  const ANSI = {
9
12
  reset: "\x1b[0m",
@@ -46,7 +49,7 @@ function parseArgs(): ParsedArgs {
46
49
  }
47
50
  }
48
51
 
49
- let entry: ReturnType<typeof findAssistantByName>;
52
+ let entry: ReturnType<typeof findAssistantByName> = null;
50
53
  if (positionalName) {
51
54
  entry = findAssistantByName(positionalName);
52
55
  if (!entry) {
@@ -55,15 +58,30 @@ function parseArgs(): ParsedArgs {
55
58
  );
56
59
  process.exit(1);
57
60
  }
58
- } else if (process.env.RUNTIME_URL) {
59
- // Explicit env var — skip assistant resolution, will use env values below
60
- entry = loadLatestAssistant();
61
61
  } else {
62
- // Respect active assistant when set, otherwise fall back to latest
63
- // for backward compatibility with remote-only setups.
62
+ const hasExplicitUrl =
63
+ process.env.RUNTIME_URL ||
64
+ flagArgs.includes("--url") ||
65
+ flagArgs.includes("-u");
64
66
  const active = getActiveAssistant();
65
- const activeEntry = active ? findAssistantByName(active) : null;
66
- entry = activeEntry ?? loadLatestAssistant();
67
+ if (active) {
68
+ entry = findAssistantByName(active);
69
+ if (!entry && !hasExplicitUrl) {
70
+ console.error(
71
+ `Active assistant '${active}' not found in lockfile. Set an active assistant with 'vellum use <name>'.`,
72
+ );
73
+ process.exit(1);
74
+ }
75
+ }
76
+ if (!entry && hasExplicitUrl) {
77
+ // URL provided but active assistant missing or unset — use latest for remaining defaults
78
+ entry = loadLatestAssistant();
79
+ } else if (!entry) {
80
+ console.error(
81
+ "No active assistant set. Set one with 'vellum use <name>' or specify a name: 'vellum client <name>'.",
82
+ );
83
+ process.exit(1);
84
+ }
67
85
  }
68
86
 
69
87
  let runtimeUrl =
@@ -90,7 +108,7 @@ function parseArgs(): ParsedArgs {
90
108
  }
91
109
 
92
110
  return {
93
- runtimeUrl: runtimeUrl.replace(/\/+$/, ""),
111
+ runtimeUrl: maybeSwapToLocalhost(runtimeUrl.replace(/\/+$/, "")),
94
112
  assistantId,
95
113
  species,
96
114
  bearerToken,
@@ -99,6 +117,50 @@ function parseArgs(): ParsedArgs {
99
117
  };
100
118
  }
101
119
 
120
+ /**
121
+ * If the hostname in `url` matches this machine's local DNS name, LAN IP, or
122
+ * raw hostname, replace it with 127.0.0.1 so the client avoids mDNS round-trips
123
+ * when talking to an assistant running on the same machine.
124
+ */
125
+ function maybeSwapToLocalhost(url: string): string {
126
+ let parsed: URL;
127
+ try {
128
+ parsed = new URL(url);
129
+ } catch {
130
+ return url;
131
+ }
132
+
133
+ const urlHost = parsed.hostname.toLowerCase();
134
+
135
+ const localNames: string[] = [];
136
+
137
+ const host = hostname();
138
+ if (host) {
139
+ localNames.push(host.toLowerCase());
140
+ // Also consider the bare name without .local suffix
141
+ if (host.toLowerCase().endsWith(".local")) {
142
+ localNames.push(host.toLowerCase().slice(0, -".local".length));
143
+ }
144
+ }
145
+
146
+ const macHost = getMacLocalHostname();
147
+ if (macHost) {
148
+ localNames.push(macHost.toLowerCase());
149
+ }
150
+
151
+ const lanIp = getLocalLanIPv4();
152
+ if (lanIp) {
153
+ localNames.push(lanIp);
154
+ }
155
+
156
+ if (localNames.includes(urlHost)) {
157
+ parsed.hostname = "127.0.0.1";
158
+ return parsed.toString().replace(/\/+$/, "");
159
+ }
160
+
161
+ return url;
162
+ }
163
+
102
164
  function printUsage(): void {
103
165
  console.log(`${ANSI.bold}vellum client${ANSI.reset} - Connect to a hatched assistant
104
166
 
@@ -106,7 +168,7 @@ ${ANSI.bold}USAGE:${ANSI.reset}
106
168
  vellum client [name] [options]
107
169
 
108
170
  ${ANSI.bold}ARGUMENTS:${ANSI.reset}
109
- [name] Instance name (default: latest)
171
+ [name] Instance name (default: active)
110
172
 
111
173
  ${ANSI.bold}OPTIONS:${ANSI.reset}
112
174
  -u, --url <url> Runtime URL
@@ -22,10 +22,10 @@ import cliPkg from "../../package.json";
22
22
  import { buildOpenclawStartupScript } from "../adapters/openclaw";
23
23
  import {
24
24
  allocateLocalResources,
25
- defaultLocalResources,
26
25
  findAssistantByName,
27
26
  loadAllAssistants,
28
27
  saveAssistantEntry,
28
+ setActiveAssistant,
29
29
  syncConfigToLockfile,
30
30
  } from "../lib/assistant-config";
31
31
  import type {
@@ -39,6 +39,7 @@ import {
39
39
  VALID_SPECIES,
40
40
  } from "../lib/constants";
41
41
  import type { RemoteHost, Species } from "../lib/constants";
42
+ import { hatchDocker } from "../lib/docker";
42
43
  import { hatchGcp } from "../lib/gcp";
43
44
  import type { PollResult, WatchHatchingResult } from "../lib/gcp";
44
45
  import {
@@ -169,6 +170,7 @@ const DEFAULT_REMOTE: RemoteHost = "local";
169
170
  interface HatchArgs {
170
171
  species: Species;
171
172
  detached: boolean;
173
+ keepAlive: boolean;
172
174
  name: string | null;
173
175
  remote: RemoteHost;
174
176
  daemonOnly: boolean;
@@ -180,6 +182,7 @@ function parseArgs(): HatchArgs {
180
182
  const args = process.argv.slice(3);
181
183
  let species: Species = DEFAULT_SPECIES;
182
184
  let detached = false;
185
+ let keepAlive = false;
183
186
  let name: string | null = null;
184
187
  let remote: RemoteHost = DEFAULT_REMOTE;
185
188
  let daemonOnly = false;
@@ -212,6 +215,9 @@ function parseArgs(): HatchArgs {
212
215
  console.log(
213
216
  " --watch Run assistant and gateway in watch mode (hot reload on source changes)",
214
217
  );
218
+ console.log(
219
+ " --keep-alive Stay alive after hatch, exit when gateway stops",
220
+ );
215
221
  process.exit(0);
216
222
  } else if (arg === "-d") {
217
223
  detached = true;
@@ -221,6 +227,8 @@ function parseArgs(): HatchArgs {
221
227
  restart = true;
222
228
  } else if (arg === "--watch") {
223
229
  watch = true;
230
+ } else if (arg === "--keep-alive") {
231
+ keepAlive = true;
224
232
  } else if (arg === "--name") {
225
233
  const next = args[i + 1];
226
234
  if (!next || next.startsWith("-")) {
@@ -251,13 +259,13 @@ function parseArgs(): HatchArgs {
251
259
  species = arg as Species;
252
260
  } else {
253
261
  console.error(
254
- `Error: Unknown argument '${arg}'. Valid options: ${VALID_SPECIES.join(", ")}, -d, --daemon-only, --restart, --watch, --name <name>, --remote <${VALID_REMOTE_HOSTS.join("|")}>`,
262
+ `Error: Unknown argument '${arg}'. Valid options: ${VALID_SPECIES.join(", ")}, -d, --daemon-only, --restart, --watch, --keep-alive, --name <name>, --remote <${VALID_REMOTE_HOSTS.join("|")}>`,
255
263
  );
256
264
  process.exit(1);
257
265
  }
258
266
  }
259
267
 
260
- return { species, detached, name, remote, daemonOnly, restart, watch };
268
+ return { species, detached, keepAlive, name, remote, daemonOnly, restart, watch };
261
269
  }
262
270
 
263
271
  function formatElapsed(ms: number): string {
@@ -682,6 +690,7 @@ async function hatchLocal(
682
690
  daemonOnly: boolean = false,
683
691
  restart: boolean = false,
684
692
  watch: boolean = false,
693
+ keepAlive: boolean = false,
685
694
  ): Promise<void> {
686
695
  if (restart && !name && !process.env.VELLUM_ASSISTANT_NAME) {
687
696
  console.error(
@@ -717,15 +726,10 @@ async function hatchLocal(
717
726
  const existingEntry = findAssistantByName(instanceName);
718
727
  if (existingEntry?.cloud === "local" && existingEntry.resources) {
719
728
  resources = existingEntry.resources;
720
- } else if (restart && existingEntry?.cloud === "local") {
721
- // Legacy entry without resources — use default paths to match existing layout
722
- resources = defaultLocalResources();
723
729
  } else {
724
730
  resources = await allocateLocalResources(instanceName);
725
731
  }
726
732
 
727
- const baseDataDir = join(resources.instanceDir, ".vellum");
728
-
729
733
  console.log(`🥚 Hatching local assistant: ${instanceName}`);
730
734
  console.log(` Species: ${species}`);
731
735
  console.log("");
@@ -761,7 +765,6 @@ async function hatchLocal(
761
765
  assistantId: instanceName,
762
766
  runtimeUrl,
763
767
  localUrl: `http://127.0.0.1:${resources.gatewayPort}`,
764
- baseDataDir,
765
768
  bearerToken,
766
769
  cloud: "local",
767
770
  species,
@@ -770,6 +773,7 @@ async function hatchLocal(
770
773
  };
771
774
  if (!daemonOnly && !restart) {
772
775
  saveAssistantEntry(localEntry);
776
+ setActiveAssistant(instanceName);
773
777
  syncConfigToLockfile();
774
778
 
775
779
  if (process.env.VELLUM_DESKTOP_APP) {
@@ -790,6 +794,46 @@ async function hatchLocal(
790
794
  const localGatewayUrl = `http://127.0.0.1:${resources.gatewayPort}`;
791
795
  await displayPairingQRCode(localGatewayUrl, bearerToken, runtimeUrl);
792
796
  }
797
+
798
+ if (keepAlive) {
799
+ const gatewayHealthUrl = `http://127.0.0.1:${resources.gatewayPort}/healthz`;
800
+ const POLL_INTERVAL_MS = 5000;
801
+ const MAX_FAILURES = 3;
802
+ let consecutiveFailures = 0;
803
+
804
+ const shutdown = async (): Promise<void> => {
805
+ console.log("\nShutting down local processes...");
806
+ await stopLocalProcesses(resources);
807
+ process.exit(0);
808
+ };
809
+
810
+ process.on("SIGTERM", () => void shutdown());
811
+ process.on("SIGINT", () => void shutdown());
812
+
813
+ // Poll the gateway health endpoint until it stops responding.
814
+ while (true) {
815
+ await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS));
816
+ try {
817
+ const res = await fetch(gatewayHealthUrl, {
818
+ signal: AbortSignal.timeout(3000),
819
+ });
820
+ if (res.ok) {
821
+ consecutiveFailures = 0;
822
+ } else {
823
+ consecutiveFailures++;
824
+ }
825
+ } catch {
826
+ consecutiveFailures++;
827
+ }
828
+ if (consecutiveFailures >= MAX_FAILURES) {
829
+ console.log(
830
+ "\n⚠️ Gateway stopped responding — shutting down.",
831
+ );
832
+ await stopLocalProcesses(resources);
833
+ process.exit(1);
834
+ }
835
+ }
836
+ }
793
837
  }
794
838
 
795
839
  function getCliVersion(): string {
@@ -800,7 +844,7 @@ export async function hatch(): Promise<void> {
800
844
  const cliVersion = getCliVersion();
801
845
  console.log(`@vellumai/cli v${cliVersion}`);
802
846
 
803
- const { species, detached, name, remote, daemonOnly, restart, watch } =
847
+ const { species, detached, keepAlive, name, remote, daemonOnly, restart, watch } =
804
848
  parseArgs();
805
849
 
806
850
  if (restart && remote !== "local") {
@@ -810,13 +854,15 @@ export async function hatch(): Promise<void> {
810
854
  process.exit(1);
811
855
  }
812
856
 
813
- if (watch && remote !== "local") {
814
- console.error("Error: --watch is only supported for local hatch targets.");
857
+ if (watch && remote !== "local" && remote !== "docker") {
858
+ console.error(
859
+ "Error: --watch is only supported for local and docker hatch targets.",
860
+ );
815
861
  process.exit(1);
816
862
  }
817
863
 
818
864
  if (remote === "local") {
819
- await hatchLocal(species, name, daemonOnly, restart, watch);
865
+ await hatchLocal(species, name, daemonOnly, restart, watch, keepAlive);
820
866
  return;
821
867
  }
822
868
 
@@ -831,8 +877,8 @@ export async function hatch(): Promise<void> {
831
877
  }
832
878
 
833
879
  if (remote === "docker") {
834
- console.error("Error: Docker remote host is not yet implemented.");
835
- process.exit(1);
880
+ await hatchDocker(species, detached, name, watch);
881
+ return;
836
882
  }
837
883
 
838
884
  console.error(`Error: Remote host '${remote}' is not yet supported.`);