@vellumai/cli 0.4.42 → 0.4.44

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.
@@ -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 {
@@ -49,6 +50,7 @@ import {
49
50
  import { isProcessAlive } from "../lib/process";
50
51
  import { generateRandomSuffix } from "../lib/random-name";
51
52
  import { validateAssistantName } from "../lib/retire-archive";
53
+ import { archiveLogFile, resetLogFile } from "../lib/xdg-log";
52
54
 
53
55
  export type { PollResult, WatchHatchingResult } from "../lib/gcp";
54
56
 
@@ -169,6 +171,7 @@ const DEFAULT_REMOTE: RemoteHost = "local";
169
171
  interface HatchArgs {
170
172
  species: Species;
171
173
  detached: boolean;
174
+ keepAlive: boolean;
172
175
  name: string | null;
173
176
  remote: RemoteHost;
174
177
  daemonOnly: boolean;
@@ -180,6 +183,7 @@ function parseArgs(): HatchArgs {
180
183
  const args = process.argv.slice(3);
181
184
  let species: Species = DEFAULT_SPECIES;
182
185
  let detached = false;
186
+ let keepAlive = false;
183
187
  let name: string | null = null;
184
188
  let remote: RemoteHost = DEFAULT_REMOTE;
185
189
  let daemonOnly = false;
@@ -212,6 +216,9 @@ function parseArgs(): HatchArgs {
212
216
  console.log(
213
217
  " --watch Run assistant and gateway in watch mode (hot reload on source changes)",
214
218
  );
219
+ console.log(
220
+ " --keep-alive Stay alive after hatch, exit when gateway stops",
221
+ );
215
222
  process.exit(0);
216
223
  } else if (arg === "-d") {
217
224
  detached = true;
@@ -221,6 +228,8 @@ function parseArgs(): HatchArgs {
221
228
  restart = true;
222
229
  } else if (arg === "--watch") {
223
230
  watch = true;
231
+ } else if (arg === "--keep-alive") {
232
+ keepAlive = true;
224
233
  } else if (arg === "--name") {
225
234
  const next = args[i + 1];
226
235
  if (!next || next.startsWith("-")) {
@@ -251,13 +260,13 @@ function parseArgs(): HatchArgs {
251
260
  species = arg as Species;
252
261
  } else {
253
262
  console.error(
254
- `Error: Unknown argument '${arg}'. Valid options: ${VALID_SPECIES.join(", ")}, -d, --daemon-only, --restart, --watch, --name <name>, --remote <${VALID_REMOTE_HOSTS.join("|")}>`,
263
+ `Error: Unknown argument '${arg}'. Valid options: ${VALID_SPECIES.join(", ")}, -d, --daemon-only, --restart, --watch, --keep-alive, --name <name>, --remote <${VALID_REMOTE_HOSTS.join("|")}>`,
255
264
  );
256
265
  process.exit(1);
257
266
  }
258
267
  }
259
268
 
260
- return { species, detached, name, remote, daemonOnly, restart, watch };
269
+ return { species, detached, keepAlive, name, remote, daemonOnly, restart, watch };
261
270
  }
262
271
 
263
272
  function formatElapsed(ms: number): string {
@@ -682,6 +691,7 @@ async function hatchLocal(
682
691
  daemonOnly: boolean = false,
683
692
  restart: boolean = false,
684
693
  watch: boolean = false,
694
+ keepAlive: boolean = false,
685
695
  ): Promise<void> {
686
696
  if (restart && !name && !process.env.VELLUM_ASSISTANT_NAME) {
687
697
  console.error(
@@ -717,14 +727,13 @@ async function hatchLocal(
717
727
  const existingEntry = findAssistantByName(instanceName);
718
728
  if (existingEntry?.cloud === "local" && existingEntry.resources) {
719
729
  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
730
  } else {
724
731
  resources = await allocateLocalResources(instanceName);
725
732
  }
726
733
 
727
- const baseDataDir = join(resources.instanceDir, ".vellum");
734
+ const logsDir = join(resources.instanceDir, ".vellum", "workspace", "data", "logs");
735
+ archiveLogFile("hatch.log", logsDir);
736
+ resetLogFile("hatch.log");
728
737
 
729
738
  console.log(`🥚 Hatching local assistant: ${instanceName}`);
730
739
  console.log(` Species: ${species}`);
@@ -761,7 +770,6 @@ async function hatchLocal(
761
770
  assistantId: instanceName,
762
771
  runtimeUrl,
763
772
  localUrl: `http://127.0.0.1:${resources.gatewayPort}`,
764
- baseDataDir,
765
773
  bearerToken,
766
774
  cloud: "local",
767
775
  species,
@@ -770,6 +778,7 @@ async function hatchLocal(
770
778
  };
771
779
  if (!daemonOnly && !restart) {
772
780
  saveAssistantEntry(localEntry);
781
+ setActiveAssistant(instanceName);
773
782
  syncConfigToLockfile();
774
783
 
775
784
  if (process.env.VELLUM_DESKTOP_APP) {
@@ -790,6 +799,46 @@ async function hatchLocal(
790
799
  const localGatewayUrl = `http://127.0.0.1:${resources.gatewayPort}`;
791
800
  await displayPairingQRCode(localGatewayUrl, bearerToken, runtimeUrl);
792
801
  }
802
+
803
+ if (keepAlive) {
804
+ const gatewayHealthUrl = `http://127.0.0.1:${resources.gatewayPort}/healthz`;
805
+ const POLL_INTERVAL_MS = 5000;
806
+ const MAX_FAILURES = 3;
807
+ let consecutiveFailures = 0;
808
+
809
+ const shutdown = async (): Promise<void> => {
810
+ console.log("\nShutting down local processes...");
811
+ await stopLocalProcesses(resources);
812
+ process.exit(0);
813
+ };
814
+
815
+ process.on("SIGTERM", () => void shutdown());
816
+ process.on("SIGINT", () => void shutdown());
817
+
818
+ // Poll the gateway health endpoint until it stops responding.
819
+ while (true) {
820
+ await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS));
821
+ try {
822
+ const res = await fetch(gatewayHealthUrl, {
823
+ signal: AbortSignal.timeout(3000),
824
+ });
825
+ if (res.ok) {
826
+ consecutiveFailures = 0;
827
+ } else {
828
+ consecutiveFailures++;
829
+ }
830
+ } catch {
831
+ consecutiveFailures++;
832
+ }
833
+ if (consecutiveFailures >= MAX_FAILURES) {
834
+ console.log(
835
+ "\n⚠️ Gateway stopped responding — shutting down.",
836
+ );
837
+ await stopLocalProcesses(resources);
838
+ process.exit(1);
839
+ }
840
+ }
841
+ }
793
842
  }
794
843
 
795
844
  function getCliVersion(): string {
@@ -800,7 +849,7 @@ export async function hatch(): Promise<void> {
800
849
  const cliVersion = getCliVersion();
801
850
  console.log(`@vellumai/cli v${cliVersion}`);
802
851
 
803
- const { species, detached, name, remote, daemonOnly, restart, watch } =
852
+ const { species, detached, keepAlive, name, remote, daemonOnly, restart, watch } =
804
853
  parseArgs();
805
854
 
806
855
  if (restart && remote !== "local") {
@@ -810,13 +859,15 @@ export async function hatch(): Promise<void> {
810
859
  process.exit(1);
811
860
  }
812
861
 
813
- if (watch && remote !== "local") {
814
- console.error("Error: --watch is only supported for local hatch targets.");
862
+ if (watch && remote !== "local" && remote !== "docker") {
863
+ console.error(
864
+ "Error: --watch is only supported for local and docker hatch targets.",
865
+ );
815
866
  process.exit(1);
816
867
  }
817
868
 
818
869
  if (remote === "local") {
819
- await hatchLocal(species, name, daemonOnly, restart, watch);
870
+ await hatchLocal(species, name, daemonOnly, restart, watch, keepAlive);
820
871
  return;
821
872
  }
822
873
 
@@ -831,8 +882,8 @@ export async function hatch(): Promise<void> {
831
882
  }
832
883
 
833
884
  if (remote === "docker") {
834
- console.error("Error: Docker remote host is not yet implemented.");
835
- process.exit(1);
885
+ await hatchDocker(species, detached, name, watch);
886
+ return;
836
887
  }
837
888
 
838
889
  console.error(`Error: Remote host '${remote}' is not yet supported.`);
@@ -3,7 +3,6 @@ import { homedir } from "os";
3
3
  import { join } from "path";
4
4
 
5
5
  import {
6
- defaultLocalResources,
7
6
  findAssistantByName,
8
7
  getActiveAssistant,
9
8
  loadAllAssistants,
@@ -178,45 +177,65 @@ interface DetectedProcess {
178
177
  pid: string | null;
179
178
  port: number;
180
179
  running: boolean;
180
+ watch: boolean;
181
+ }
182
+
183
+ async function isWatchMode(pid: string): Promise<boolean> {
184
+ try {
185
+ const args = await execOutput("ps", ["-p", pid, "-o", "args="]);
186
+ return args.includes("--watch");
187
+ } catch {
188
+ return false;
189
+ }
181
190
  }
182
191
 
183
192
  async function detectProcess(spec: ProcessSpec): Promise<DetectedProcess> {
184
193
  // Tier 1: pgrep by process title
185
194
  const pids = await pgrepExact(spec.pgrepName);
186
195
  if (pids.length > 0) {
187
- return { name: spec.name, pid: pids[0], port: spec.port, running: true };
196
+ const watch = await isWatchMode(pids[0]);
197
+ return { name: spec.name, pid: pids[0], port: spec.port, running: true, watch };
188
198
  }
189
199
 
190
200
  // Tier 2: TCP port probe (skip for processes without a port)
191
201
  const listening = spec.port > 0 && (await probePort(spec.port));
192
202
  if (listening) {
193
203
  const filePid = readPidFile(spec.pidFile);
204
+ const watch = filePid ? await isWatchMode(filePid) : false;
194
205
  return {
195
206
  name: spec.name,
196
207
  pid: filePid,
197
208
  port: spec.port,
198
209
  running: true,
210
+ watch,
199
211
  };
200
212
  }
201
213
 
202
214
  // Tier 3: PID file fallback
203
215
  const filePid = readPidFile(spec.pidFile);
204
216
  if (filePid && isProcessAlive(filePid)) {
205
- return { name: spec.name, pid: filePid, port: spec.port, running: true };
217
+ const watch = await isWatchMode(filePid);
218
+ return { name: spec.name, pid: filePid, port: spec.port, running: true, watch };
206
219
  }
207
220
 
208
- return { name: spec.name, pid: null, port: spec.port, running: false };
221
+ return { name: spec.name, pid: null, port: spec.port, running: false, watch: false };
209
222
  }
210
223
 
211
224
  function formatDetectionInfo(proc: DetectedProcess): string {
212
225
  const parts: string[] = [];
213
226
  if (proc.pid) parts.push(`PID ${proc.pid}`);
214
227
  if (proc.port > 0) parts.push(`port ${proc.port}`);
228
+ if (proc.watch) parts.push("watch");
215
229
  return parts.join(" | ");
216
230
  }
217
231
 
218
232
  async function getLocalProcesses(entry: AssistantEntry): Promise<TableRow[]> {
219
- const resources = entry.resources ?? defaultLocalResources();
233
+ if (!entry.resources) {
234
+ throw new Error(
235
+ `Local assistant '${entry.assistantId}' is missing resource configuration. Re-hatch to fix.`,
236
+ );
237
+ }
238
+ const resources = entry.resources;
220
239
  const vellumDir = join(resources.instanceDir, ".vellum");
221
240
 
222
241
  const specs: ProcessSpec[] = [
@@ -409,9 +428,7 @@ async function listAllAssistants(): Promise<void> {
409
428
  // process isn't running, the assistant is sleeping — skip the
410
429
  // network health check to avoid a misleading "unreachable" status.
411
430
  let health: { status: string; detail: string | null };
412
- const resources =
413
- a.resources ??
414
- (a.cloud === "local" ? defaultLocalResources() : undefined);
431
+ const resources = a.resources;
415
432
  if (a.cloud === "local" && resources) {
416
433
  const pid = readPidFile(resources.pidFile);
417
434
  const alive = pid !== null && isProcessAlive(pid);
@@ -37,7 +37,17 @@ export async function recover(): Promise<void> {
37
37
  process.exit(1);
38
38
  }
39
39
 
40
- // 2. Check ~/.vellum doesn't already exist
40
+ // 2. Read and validate metadata before any side effects
41
+ const entry: AssistantEntry = JSON.parse(readFileSync(metadataPath, "utf-8"));
42
+ if (!entry.resources) {
43
+ throw new Error(
44
+ `Retired assistant '${name}' is missing resource configuration. ` +
45
+ `Fix the archive metadata at ${metadataPath} and retry, ` +
46
+ `or run 'vellum hatch' to re-provision with proper resource allocation.`,
47
+ );
48
+ }
49
+
50
+ // 3. Check ~/.vellum doesn't already exist
41
51
  const vellumDir = join(homedir(), ".vellum");
42
52
  if (existsSync(vellumDir)) {
43
53
  console.error(
@@ -46,21 +56,20 @@ export async function recover(): Promise<void> {
46
56
  process.exit(1);
47
57
  }
48
58
 
49
- // 3. Extract archive
59
+ // 4. Extract archive
50
60
  await exec("tar", ["xzf", archivePath, "-C", homedir()]);
51
61
 
52
- // 4. Restore lockfile entry
53
- const entry: AssistantEntry = JSON.parse(readFileSync(metadataPath, "utf-8"));
62
+ // 5. Restore lockfile entry
54
63
  saveAssistantEntry(entry);
55
64
 
56
- // 5. Clean up archive
65
+ // 6. Clean up archive
57
66
  unlinkSync(archivePath);
58
67
  unlinkSync(metadataPath);
59
68
 
60
- // 6. Start daemon + gateway (same as wake)
61
- await startLocalDaemon();
69
+ // 7. Start daemon + gateway (same as wake)
70
+ await startLocalDaemon(false, entry.resources);
62
71
  if (!process.env.VELLUM_DESKTOP_APP) {
63
- await startGateway();
72
+ await startGateway(undefined, false, entry.resources);
64
73
  }
65
74
 
66
75
  console.log(`✅ Recovered assistant '${name}'.`);
@@ -4,13 +4,13 @@ import { homedir } from "os";
4
4
  import { basename, dirname, join } from "path";
5
5
 
6
6
  import {
7
- defaultLocalResources,
8
7
  findAssistantByName,
9
8
  loadAllAssistants,
10
9
  removeAssistantEntry,
11
10
  } from "../lib/assistant-config";
12
11
  import type { AssistantEntry } from "../lib/assistant-config";
13
12
  import { retireInstance as retireAwsInstance } from "../lib/aws";
13
+ import { retireDocker } from "../lib/docker";
14
14
  import { retireInstance as retireGcpInstance } from "../lib/gcp";
15
15
  import {
16
16
  stopOrphanedDaemonProcesses,
@@ -45,22 +45,20 @@ function extractHostFromUrl(url: string): string {
45
45
  async function retireLocal(name: string, entry: AssistantEntry): Promise<void> {
46
46
  console.log("\u{1F5D1}\ufe0f Stopping local assistant...\n");
47
47
 
48
- // Use entry resources when available; for legacy entries, derive paths
49
- // from baseDataDir (which may differ from homedir if BASE_DATA_DIR was set).
50
- const resources = entry.resources ?? defaultLocalResources();
51
- const legacyDir = entry.baseDataDir;
52
- const vellumDir = legacyDir ?? join(resources.instanceDir, ".vellum");
48
+ if (!entry.resources) {
49
+ throw new Error(
50
+ `Local assistant '${name}' is missing resource configuration. Re-hatch to fix.`,
51
+ );
52
+ }
53
+ const resources = entry.resources;
54
+ const vellumDir = join(resources.instanceDir, ".vellum");
53
55
 
54
56
  // Check whether another local assistant shares the same data directory.
55
- // Legacy entries without `resources` all resolve to ~/.vellum/ — if we
56
- // blindly kill processes and archive the directory, we'd destroy the
57
- // other assistant's running daemon and data.
58
57
  const otherSharesDir = loadAllAssistants().some((other) => {
59
58
  if (other.cloud !== "local") return false;
60
59
  if (other.assistantId === name) return false;
61
- const otherVellumDir =
62
- other.baseDataDir ??
63
- join((other.resources ?? defaultLocalResources()).instanceDir, ".vellum");
60
+ if (!other.resources) return false;
61
+ const otherVellumDir = join(other.resources.instanceDir, ".vellum");
64
62
  return otherVellumDir === vellumDir;
65
63
  });
66
64
 
@@ -72,17 +70,8 @@ async function retireLocal(name: string, entry: AssistantEntry): Promise<void> {
72
70
  return;
73
71
  }
74
72
 
75
- // Stop daemon via PID file — prefer resources paths, but for legacy entries
76
- // with a custom baseDataDir, derive from that directory instead.
77
- const daemonPidFile = legacyDir
78
- ? join(legacyDir, "vellum.pid")
79
- : resources.pidFile;
80
- const socketFile = legacyDir
81
- ? join(legacyDir, "vellum.sock")
82
- : resources.socketPath;
83
- const daemonStopped = await stopProcessByPidFile(daemonPidFile, "daemon", [
84
- socketFile,
85
- ]);
73
+ const daemonPidFile = resources.pidFile;
74
+ const daemonStopped = await stopProcessByPidFile(daemonPidFile, "daemon");
86
75
 
87
76
  // Stop gateway via PID file — use a longer timeout because the gateway has a
88
77
  // configurable drain window (GATEWAY_SHUTDOWN_DRAIN_MS, default 5s) before it exits.
@@ -286,6 +275,8 @@ async function retireInner(): Promise<void> {
286
275
  process.exit(1);
287
276
  }
288
277
  await retireAwsInstance(name, region, source);
278
+ } else if (cloud === "docker") {
279
+ await retireDocker(name);
289
280
  } else if (cloud === "local") {
290
281
  await retireLocal(name, entry);
291
282
  } else if (cloud === "custom") {
@@ -1,15 +1,50 @@
1
+ import { existsSync, readFileSync } from "fs";
1
2
  import { join } from "path";
2
3
 
3
- import {
4
- defaultLocalResources,
5
- resolveTargetAssistant,
6
- } from "../lib/assistant-config.js";
7
- import { stopProcessByPidFile } from "../lib/process";
4
+ import { resolveTargetAssistant } from "../lib/assistant-config.js";
5
+ import type { AssistantEntry } from "../lib/assistant-config.js";
6
+ import { isProcessAlive, stopProcessByPidFile } from "../lib/process";
7
+
8
+ const ACTIVE_CALL_LEASES_FILE = "active-call-leases.json";
9
+
10
+ type ActiveCallLease = {
11
+ callSessionId: string;
12
+ };
13
+
14
+ function getAssistantRootDir(entry: AssistantEntry): string {
15
+ if (!entry.resources) {
16
+ throw new Error(
17
+ `Local assistant '${entry.assistantId}' is missing resource configuration. Re-hatch to fix.`,
18
+ );
19
+ }
20
+ return join(entry.resources.instanceDir, ".vellum");
21
+ }
22
+
23
+ function readActiveCallLeases(vellumDir: string): ActiveCallLease[] {
24
+ const path = join(vellumDir, ACTIVE_CALL_LEASES_FILE);
25
+ if (!existsSync(path)) {
26
+ return [];
27
+ }
28
+
29
+ const raw = JSON.parse(readFileSync(path, "utf-8")) as {
30
+ version?: number;
31
+ leases?: Array<{ callSessionId?: unknown }>;
32
+ };
33
+ if (raw.version !== 1 || !Array.isArray(raw.leases)) {
34
+ throw new Error(`Invalid active call lease file at ${path}`);
35
+ }
36
+
37
+ return raw.leases.filter(
38
+ (lease): lease is ActiveCallLease =>
39
+ typeof lease?.callSessionId === "string" &&
40
+ lease.callSessionId.length > 0,
41
+ );
42
+ }
8
43
 
9
44
  export async function sleep(): Promise<void> {
10
45
  const args = process.argv.slice(3);
11
46
  if (args.includes("--help") || args.includes("-h")) {
12
- console.log("Usage: vellum sleep [<name>]");
47
+ console.log("Usage: vellum sleep [<name>] [--force]");
13
48
  console.log("");
14
49
  console.log("Stop the assistant and gateway processes.");
15
50
  console.log("");
@@ -17,9 +52,15 @@ export async function sleep(): Promise<void> {
17
52
  console.log(
18
53
  " <name> Name of the assistant to stop (default: active or only local)",
19
54
  );
55
+ console.log("");
56
+ console.log("Options:");
57
+ console.log(
58
+ " --force Stop the assistant even if a phone call keepalive lease is active",
59
+ );
20
60
  process.exit(0);
21
61
  }
22
62
 
63
+ const force = args.includes("--force");
23
64
  const nameArg = args.find((a) => !a.startsWith("-"));
24
65
  const entry = resolveTargetAssistant(nameArg);
25
66
 
@@ -30,18 +71,49 @@ export async function sleep(): Promise<void> {
30
71
  process.exit(1);
31
72
  }
32
73
 
33
- const resources = entry.resources ?? defaultLocalResources();
34
-
35
- const daemonPidFile = resources.pidFile;
36
- const socketFile = resources.socketPath;
37
- const vellumDir = join(resources.instanceDir, ".vellum");
74
+ if (!entry.resources) {
75
+ console.error(
76
+ `Error: Local assistant '${entry.assistantId}' is missing resource configuration. Re-hatch to fix.`,
77
+ );
78
+ process.exit(1);
79
+ }
80
+ const resources = entry.resources;
81
+ const assistantPidFile = resources.pidFile;
82
+ const vellumDir = getAssistantRootDir(entry);
38
83
  const gatewayPidFile = join(vellumDir, "gateway.pid");
39
84
 
40
- // Stop daemon
41
- const daemonStopped = await stopProcessByPidFile(daemonPidFile, "daemon", [
42
- socketFile,
43
- ]);
44
- if (!daemonStopped) {
85
+ if (!force) {
86
+ const assistantAlive = isProcessAlive(assistantPidFile).alive;
87
+ if (assistantAlive) {
88
+ try {
89
+ const activeCallLeases = readActiveCallLeases(vellumDir);
90
+ if (activeCallLeases.length > 0) {
91
+ const activeIds = activeCallLeases.map(
92
+ (lease) => lease.callSessionId,
93
+ );
94
+ console.error(
95
+ `Error: assistant is staying awake for active phone calls (${activeIds.join(
96
+ ", ",
97
+ )}). Use 'vellum sleep --force' to stop it anyway.`,
98
+ );
99
+ process.exit(1);
100
+ }
101
+ } catch (err) {
102
+ console.error(
103
+ `Error: ${
104
+ err instanceof Error ? err.message : String(err)
105
+ }. Use 'vellum sleep --force' to override if you want to stop the assistant anyway.`,
106
+ );
107
+ process.exit(1);
108
+ }
109
+ }
110
+ }
111
+
112
+ const assistantStopped = await stopProcessByPidFile(
113
+ assistantPidFile,
114
+ "assistant",
115
+ );
116
+ if (!assistantStopped) {
45
117
  console.log("Assistant is not running.");
46
118
  } else {
47
119
  console.log("Assistant stopped.");