@vellumai/cli 0.6.3 → 0.6.5

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.
Files changed (56) hide show
  1. package/AGENTS.md +12 -2
  2. package/README.md +3 -3
  3. package/bun.lock +17 -17
  4. package/bunfig.toml +6 -0
  5. package/package.json +18 -18
  6. package/src/__tests__/assistant-config.test.ts +124 -0
  7. package/src/__tests__/env-drift.test.ts +87 -0
  8. package/src/__tests__/guardian-token.test.ts +225 -0
  9. package/src/__tests__/llm-provider-env-var-parity.test.ts +64 -0
  10. package/src/__tests__/multi-local.test.ts +90 -13
  11. package/src/__tests__/orphan-detection.test.ts +214 -0
  12. package/src/__tests__/platform-client.test.ts +204 -0
  13. package/src/__tests__/preload.ts +27 -0
  14. package/src/__tests__/ssh-user-guard.test.ts +28 -0
  15. package/src/__tests__/teleport.test.ts +1073 -56
  16. package/src/commands/backup.ts +8 -0
  17. package/src/commands/exec.ts +186 -0
  18. package/src/commands/hatch.ts +1 -1
  19. package/src/commands/login.ts +209 -9
  20. package/src/commands/logs.ts +652 -0
  21. package/src/commands/pair.ts +9 -1
  22. package/src/commands/ps.ts +37 -7
  23. package/src/commands/recover.ts +8 -4
  24. package/src/commands/restore.ts +8 -0
  25. package/src/commands/retire.ts +16 -9
  26. package/src/commands/rollback.ts +32 -33
  27. package/src/commands/ssh.ts +7 -0
  28. package/src/commands/teleport.ts +253 -1
  29. package/src/commands/upgrade.ts +43 -52
  30. package/src/commands/wake.ts +25 -10
  31. package/src/components/DefaultMainScreen.tsx +7 -1
  32. package/src/index.ts +6 -0
  33. package/src/lib/__tests__/docker.test.ts +168 -0
  34. package/src/lib/assistant-config.ts +82 -108
  35. package/src/lib/aws.ts +12 -1
  36. package/src/lib/config-utils.ts +4 -4
  37. package/src/lib/constants.ts +0 -10
  38. package/src/lib/docker.ts +158 -8
  39. package/src/lib/environments/__tests__/paths.test.ts +228 -0
  40. package/src/lib/environments/__tests__/resolve.test.ts +226 -0
  41. package/src/lib/environments/__tests__/seeds.test.ts +72 -0
  42. package/src/lib/environments/paths.ts +109 -0
  43. package/src/lib/environments/resolve.ts +96 -0
  44. package/src/lib/environments/seeds.ts +74 -0
  45. package/src/lib/environments/types.ts +60 -0
  46. package/src/lib/exec-apple-container.ts +122 -0
  47. package/src/lib/gcp.ts +12 -1
  48. package/src/lib/guardian-token.ts +71 -10
  49. package/src/lib/hatch-local.ts +44 -23
  50. package/src/lib/local.ts +47 -5
  51. package/src/lib/orphan-detection.ts +28 -12
  52. package/src/lib/platform-client.ts +354 -24
  53. package/src/lib/retire-apple-container.ts +102 -0
  54. package/src/lib/ssh-apple-container.ts +166 -0
  55. package/src/lib/upgrade-lifecycle.ts +101 -28
  56. package/src/shared/provider-env-vars.ts +30 -6
@@ -0,0 +1,122 @@
1
+ import { createConnection } from "net";
2
+ import { existsSync } from "fs";
3
+
4
+ import type { AssistantEntry } from "./assistant-config";
5
+
6
+ /**
7
+ * Execute a command inside an Apple Container assistant via the management
8
+ * socket. Non-interactive: sends the command, streams stdout/stderr to the
9
+ * terminal, and exits with the appropriate code.
10
+ */
11
+ export async function execAppleContainer(
12
+ entry: AssistantEntry,
13
+ command: string[],
14
+ service: string,
15
+ ): Promise<void> {
16
+ const mgmtSocket = entry.mgmtSocket as string | undefined;
17
+ if (!mgmtSocket) {
18
+ console.error(
19
+ `No management socket found for '${entry.assistantId}'.\n` +
20
+ "The assistant may not be running.",
21
+ );
22
+ process.exit(1);
23
+ }
24
+
25
+ if (!existsSync(mgmtSocket)) {
26
+ console.error(
27
+ `Management socket not found at ${mgmtSocket}.\n` +
28
+ "The assistant may have been stopped.",
29
+ );
30
+ process.exit(1);
31
+ }
32
+
33
+ const handshake =
34
+ JSON.stringify({
35
+ command,
36
+ service,
37
+ cols: process.stdout.columns || 120,
38
+ rows: process.stdout.rows || 40,
39
+ }) + "\n";
40
+
41
+ return new Promise<void>((resolve, reject) => {
42
+ const socket = createConnection({ path: mgmtSocket }, () => {
43
+ socket.write(handshake);
44
+ });
45
+
46
+ const HANDSHAKE_TIMEOUT_MS = 10_000;
47
+ let handshakeComplete = false;
48
+ const handshakeChunks: Buffer[] = [];
49
+ let handshakeLen = 0;
50
+
51
+ socket.setTimeout(HANDSHAKE_TIMEOUT_MS);
52
+ socket.on("timeout", () => {
53
+ if (!handshakeComplete) {
54
+ console.error("Timed out waiting for response from management socket.");
55
+ socket.destroy();
56
+ process.exit(1);
57
+ }
58
+ });
59
+
60
+ socket.on("data", (data: Buffer) => {
61
+ if (!handshakeComplete) {
62
+ handshakeChunks.push(data);
63
+ handshakeLen += data.length;
64
+ const accumulated = Buffer.concat(handshakeChunks, handshakeLen);
65
+ const nlIndex = accumulated.indexOf(0x0a);
66
+ if (nlIndex === -1) return;
67
+
68
+ const responseLine = accumulated.slice(0, nlIndex).toString("utf-8");
69
+ const remainder = accumulated.slice(nlIndex + 1);
70
+ handshakeComplete = true;
71
+ socket.setTimeout(0);
72
+
73
+ let response: { status: string; message?: string };
74
+ try {
75
+ response = JSON.parse(responseLine) as {
76
+ status: string;
77
+ message?: string;
78
+ };
79
+ } catch {
80
+ console.error("Invalid response from management socket.");
81
+ socket.destroy();
82
+ process.exit(1);
83
+ return;
84
+ }
85
+
86
+ if (response.status !== "ok") {
87
+ console.error(`Exec failed: ${response.message || "unknown error"}`);
88
+ socket.destroy();
89
+ process.exit(1);
90
+ return;
91
+ }
92
+
93
+ // Write any bytes that arrived after the handshake newline.
94
+ if (remainder.length > 0) {
95
+ process.stdout.write(remainder);
96
+ }
97
+ return;
98
+ }
99
+
100
+ // Stream command output to stdout.
101
+ process.stdout.write(data);
102
+ });
103
+
104
+ socket.on("end", () => {
105
+ if (handshakeComplete) {
106
+ resolve();
107
+ } else {
108
+ reject(new Error("Connection closed before handshake completed."));
109
+ }
110
+ });
111
+
112
+ socket.on("error", (err) => {
113
+ reject(new Error(`Management socket error: ${err.message}`));
114
+ });
115
+
116
+ socket.on("close", () => {
117
+ if (handshakeComplete) {
118
+ resolve();
119
+ }
120
+ });
121
+ });
122
+ }
package/src/lib/gcp.ts CHANGED
@@ -503,7 +503,18 @@ export async function hatchGcp(
503
503
  }
504
504
  }
505
505
 
506
- const sshUser = userInfo().username;
506
+ let sshUser: string;
507
+ try {
508
+ sshUser = userInfo().username;
509
+ } catch {
510
+ sshUser = process.env.USER ?? "";
511
+ }
512
+ if (!sshUser) {
513
+ console.error(
514
+ "Error: Could not determine SSH username. Set the USER environment variable and try again.",
515
+ );
516
+ process.exit(1);
517
+ }
507
518
  const hatchedBy = process.env.VELLUM_HATCHED_BY;
508
519
  const providerApiKeys: Record<string, string> = {};
509
520
  for (const [, envVar] of Object.entries(PROVIDER_ENV_VAR_NAMES)) {
@@ -5,11 +5,16 @@ import {
5
5
  existsSync,
6
6
  mkdirSync,
7
7
  readFileSync,
8
+ statSync,
8
9
  writeFileSync,
9
10
  } from "fs";
10
- import { homedir, platform } from "os";
11
+ import { platform } from "os";
11
12
  import { dirname, join } from "path";
12
13
 
14
+ import { getConfigDir } from "./environments/paths.js";
15
+ import { getCurrentEnvironment } from "./environments/resolve.js";
16
+ import { SEEDS } from "./environments/seeds.js";
17
+
13
18
  const DEVICE_ID_SALT = "vellum-assistant-host-id";
14
19
 
15
20
  export interface GuardianTokenData {
@@ -24,14 +29,9 @@ export interface GuardianTokenData {
24
29
  leasedAt: string;
25
30
  }
26
31
 
27
- function getXdgConfigHome(): string {
28
- return process.env.XDG_CONFIG_HOME?.trim() || join(homedir(), ".config");
29
- }
30
-
31
32
  function getGuardianTokenPath(assistantId: string): string {
32
33
  return join(
33
- getXdgConfigHome(),
34
- "vellum",
34
+ getConfigDir(getCurrentEnvironment()),
35
35
  "assistants",
36
36
  assistantId,
37
37
  "guardian-token.json",
@@ -39,7 +39,7 @@ function getGuardianTokenPath(assistantId: string): string {
39
39
  }
40
40
 
41
41
  function getPersistedDeviceIdPath(): string {
42
- return join(getXdgConfigHome(), "vellum", "device-id");
42
+ return join(getConfigDir(getCurrentEnvironment()), "device-id");
43
43
  }
44
44
 
45
45
  function hashWithSalt(input: string): string {
@@ -82,7 +82,7 @@ function getWindowsMachineGuid(): string | null {
82
82
  }
83
83
  }
84
84
 
85
- function getOrCreatePersistedDeviceId(): string {
85
+ export function getOrCreatePersistedDeviceId(): string {
86
86
  const path = getPersistedDeviceIdPath();
87
87
  try {
88
88
  const existing = readFileSync(path, "utf-8").trim();
@@ -161,7 +161,7 @@ export function saveGuardianToken(
161
161
  /**
162
162
  * Call POST /v1/guardian/init on the remote gateway to bootstrap a JWT
163
163
  * credential pair. The returned tokens are persisted locally under
164
- * `$XDG_CONFIG_HOME/vellum/assistants/<assistantId>/guardian-token.json`.
164
+ * `$XDG_CONFIG_HOME/vellum{-env}/assistants/<assistantId>/guardian-token.json`.
165
165
  */
166
166
  export async function leaseGuardianToken(
167
167
  gatewayUrl: string,
@@ -202,3 +202,64 @@ export async function leaseGuardianToken(
202
202
  saveGuardianToken(assistantId, tokenData);
203
203
  return tokenData;
204
204
  }
205
+
206
+ /**
207
+ * Copy a guardian token from a sibling environment's config directory into
208
+ * the current environment's dir when the current one is missing it.
209
+ *
210
+ * The CLI's per-environment config layout (`~/.config/vellum{-env}/`) scopes
211
+ * the lockfile and the guardian token by VELLUM_ENVIRONMENT. Lockfiles are
212
+ * cross-written at hatch time, but a guardian token is only written under
213
+ * the env the assistant was hatched in. If the user later wakes the same
214
+ * assistant under a different env (e.g. a freshly built desktop app ships
215
+ * with VELLUM_ENVIRONMENT=local while the original hatch was under dev),
216
+ * the app cannot locate a bearer token and falls into a 401 → auth-rate-
217
+ * limit → 429 cascade against the local gateway.
218
+ *
219
+ * Returns true if a token was seeded, false if a token was already present
220
+ * or no sibling env had one to copy.
221
+ */
222
+ export function seedGuardianTokenFromSiblingEnv(assistantId: string): boolean {
223
+ if (loadGuardianToken(assistantId) !== null) return false;
224
+
225
+ const currentEnvName = getCurrentEnvironment().name;
226
+ const destPath = getGuardianTokenPath(assistantId);
227
+
228
+ const candidates: { path: string; mtimeMs: number }[] = [];
229
+ for (const env of Object.values(SEEDS)) {
230
+ if (env.name === currentEnvName) continue;
231
+ const sibling = join(
232
+ getConfigDir(env),
233
+ "assistants",
234
+ assistantId,
235
+ "guardian-token.json",
236
+ );
237
+ try {
238
+ const stat = statSync(sibling);
239
+ candidates.push({ path: sibling, mtimeMs: stat.mtimeMs });
240
+ } catch {
241
+ continue;
242
+ }
243
+ }
244
+ candidates.sort((a, b) => b.mtimeMs - a.mtimeMs);
245
+
246
+ const now = Date.now();
247
+ for (const { path: sibling } of candidates) {
248
+ try {
249
+ const raw = readFileSync(sibling);
250
+ const parsed = JSON.parse(raw.toString("utf-8")) as GuardianTokenData;
251
+ const refreshExpiry = Date.parse(parsed.refreshTokenExpiresAt);
252
+ if (!Number.isFinite(refreshExpiry) || refreshExpiry <= now) continue;
253
+ const dir = dirname(destPath);
254
+ if (!existsSync(dir)) {
255
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
256
+ }
257
+ writeFileSync(destPath, raw, { mode: 0o600 });
258
+ chmodSync(destPath, 0o600);
259
+ return true;
260
+ } catch {
261
+ continue;
262
+ }
263
+ }
264
+ return false;
265
+ }
@@ -305,27 +305,32 @@ export async function hatchLocal(
305
305
  // IP which the daemon rejects as non-loopback.
306
306
  emitProgress(6, 7, "Securing connection...");
307
307
  const loopbackUrl = `http://127.0.0.1:${resources.gatewayPort}`;
308
- try {
309
- await leaseGuardianToken(loopbackUrl, instanceName);
310
- } catch (err) {
311
- console.error(`⚠️ Guardian token lease failed: ${err}`);
308
+ const maxLeaseAttempts = 3;
309
+ for (let attempt = 1; attempt <= maxLeaseAttempts; attempt++) {
310
+ try {
311
+ await leaseGuardianToken(loopbackUrl, instanceName);
312
+ break;
313
+ } catch (err) {
314
+ if (attempt < maxLeaseAttempts) {
315
+ const delayMs = 2000 * 2 ** (attempt - 1);
316
+ console.error(
317
+ `⚠️ Guardian token lease attempt ${attempt}/${maxLeaseAttempts} failed — retrying in ${delayMs / 1000}s: ${err}`,
318
+ );
319
+ await new Promise((r) => setTimeout(r, delayMs));
320
+ } else {
321
+ console.error(
322
+ `⚠️ Guardian token lease failed after ${maxLeaseAttempts} attempts: ${err}\n` +
323
+ ` The assistant is running but guardian-token.json was not written.\n` +
324
+ ` If the desktop app loses its stored credentials, re-hatch to recover.`,
325
+ );
326
+ }
327
+ }
312
328
  }
313
329
 
314
330
  // Auto-start ngrok if webhook integrations (e.g. Telegram, Twilio) are configured.
315
- // Set BASE_DATA_DIR so ngrok reads the correct instance config.
316
- const prevBaseDataDir = process.env.BASE_DATA_DIR;
317
- process.env.BASE_DATA_DIR = resources.instanceDir;
318
- const ngrokChild = await maybeStartNgrokTunnel(resources.gatewayPort);
319
- if (ngrokChild?.pid) {
320
- const ngrokPidFile = join(resources.instanceDir, ".vellum", "ngrok.pid");
321
- writeFileSync(ngrokPidFile, String(ngrokChild.pid));
322
- }
323
- if (prevBaseDataDir !== undefined) {
324
- process.env.BASE_DATA_DIR = prevBaseDataDir;
325
- } else {
326
- delete process.env.BASE_DATA_DIR;
327
- }
328
-
331
+ // Set BASE_DATA_DIR so ngrok reads the correct instance config. Keep the
332
+ // lockfile save/sync inside the same scope so syncConfigToLockfile() reads
333
+ // this instance's workspace/config.json rather than a stale default path.
329
334
  const localEntry: AssistantEntry = {
330
335
  assistantId: instanceName,
331
336
  runtimeUrl,
@@ -333,13 +338,29 @@ export async function hatchLocal(
333
338
  cloud: "local",
334
339
  species,
335
340
  hatchedAt: new Date().toISOString(),
336
- serviceGroupVersion: cliPkg.version ? `v${cliPkg.version}` : undefined,
337
341
  resources: { ...resources, signingKey },
338
342
  };
339
- emitProgress(7, 7, "Saving configuration...");
340
- saveAssistantEntry(localEntry);
341
- setActiveAssistant(instanceName);
342
- syncConfigToLockfile();
343
+
344
+ const prevBaseDataDir = process.env.BASE_DATA_DIR;
345
+ process.env.BASE_DATA_DIR = resources.instanceDir;
346
+ try {
347
+ const ngrokChild = await maybeStartNgrokTunnel(resources.gatewayPort);
348
+ if (ngrokChild?.pid) {
349
+ const ngrokPidFile = join(resources.instanceDir, ".vellum", "ngrok.pid");
350
+ writeFileSync(ngrokPidFile, String(ngrokChild.pid));
351
+ }
352
+
353
+ emitProgress(7, 7, "Saving configuration...");
354
+ saveAssistantEntry(localEntry);
355
+ setActiveAssistant(instanceName);
356
+ syncConfigToLockfile();
357
+ } finally {
358
+ if (prevBaseDataDir !== undefined) {
359
+ process.env.BASE_DATA_DIR = prevBaseDataDir;
360
+ } else {
361
+ delete process.env.BASE_DATA_DIR;
362
+ }
363
+ }
343
364
 
344
365
  if (process.env.VELLUM_DESKTOP_APP) {
345
366
  installCLISymlink();
package/src/lib/local.ts CHANGED
@@ -283,12 +283,18 @@ async function startDaemonFromSource(
283
283
  RUNTIME_HTTP_PORT: process.env.RUNTIME_HTTP_PORT || "7821",
284
284
  VELLUM_CLOUD: "local",
285
285
  VELLUM_DEV: "1",
286
+ VELLUM_ENVIRONMENT: process.env.VELLUM_ENVIRONMENT || "local",
286
287
  ...(options?.signingKey
287
288
  ? { ACTOR_TOKEN_SIGNING_KEY: options.signingKey }
288
289
  : {}),
289
290
  };
290
291
  if (resources) {
291
292
  env.BASE_DATA_DIR = resources.instanceDir;
293
+ env.GATEWAY_SECURITY_DIR = join(
294
+ resources.instanceDir,
295
+ ".vellum",
296
+ "protected",
297
+ );
292
298
  env.RUNTIME_HTTP_PORT = String(resources.daemonPort);
293
299
  env.GATEWAY_PORT = String(resources.gatewayPort);
294
300
  env.QDRANT_HTTP_PORT = String(resources.qdrantPort);
@@ -404,12 +410,18 @@ async function startDaemonWatchFromSource(
404
410
  ...process.env,
405
411
  RUNTIME_HTTP_PORT: process.env.RUNTIME_HTTP_PORT || "7821",
406
412
  VELLUM_DEV: "1",
413
+ VELLUM_ENVIRONMENT: process.env.VELLUM_ENVIRONMENT || "local",
407
414
  ...(options?.signingKey
408
415
  ? { ACTOR_TOKEN_SIGNING_KEY: options.signingKey }
409
416
  : {}),
410
417
  };
411
418
  if (resources) {
412
419
  env.BASE_DATA_DIR = resources.instanceDir;
420
+ env.GATEWAY_SECURITY_DIR = join(
421
+ resources.instanceDir,
422
+ ".vellum",
423
+ "protected",
424
+ );
413
425
  env.RUNTIME_HTTP_PORT = String(resources.daemonPort);
414
426
  env.GATEWAY_PORT = String(resources.gatewayPort);
415
427
  env.QDRANT_HTTP_PORT = String(resources.qdrantPort);
@@ -855,11 +867,16 @@ export async function startLocalDaemon(
855
867
  HOME: process.env.HOME || home,
856
868
  PATH: [...extraDirs, basePath].filter(Boolean).join(":"),
857
869
  };
858
- // Forward optional config env vars the daemon may need
870
+ // Forward optional config env vars the daemon may need.
871
+ // `VELLUM_ENVIRONMENT` must be forwarded so the daemon resolves
872
+ // env-scoped paths (device ID, platform/guardian tokens, XDG
873
+ // config dir) to the same location as the CLI that spawned it.
859
874
  for (const key of [
860
875
  "ANTHROPIC_API_KEY",
861
876
  "APP_VERSION",
862
877
  "BASE_DATA_DIR",
878
+ "GATEWAY_SECURITY_DIR",
879
+ "VELLUM_ENVIRONMENT",
863
880
  "VELLUM_PLATFORM_URL",
864
881
  "QDRANT_HTTP_PORT",
865
882
  "QDRANT_URL",
@@ -885,6 +902,11 @@ export async function startLocalDaemon(
885
902
  // all paths under the instance directory and listens on its own port.
886
903
  if (resources) {
887
904
  daemonEnv.BASE_DATA_DIR = resources.instanceDir;
905
+ daemonEnv.GATEWAY_SECURITY_DIR = join(
906
+ resources.instanceDir,
907
+ ".vellum",
908
+ "protected",
909
+ );
888
910
  daemonEnv.RUNTIME_HTTP_PORT = String(resources.daemonPort);
889
911
  daemonEnv.GATEWAY_PORT = String(resources.gatewayPort);
890
912
  daemonEnv.QDRANT_HTTP_PORT = String(resources.qdrantPort);
@@ -1043,10 +1065,30 @@ export async function startGateway(
1043
1065
  ...(options?.signingKey
1044
1066
  ? { ACTOR_TOKEN_SIGNING_KEY: options.signingKey }
1045
1067
  : {}),
1046
- ...(watch ? { VELLUM_DEV: "1" } : {}),
1047
- // Set BASE_DATA_DIR so the gateway loads the correct credentials and
1048
- // workspace config for this instance (mirrors the daemon env setup).
1049
- ...(resources ? { BASE_DATA_DIR: resources.instanceDir } : {}),
1068
+ ...(watch
1069
+ ? {
1070
+ VELLUM_DEV: "1",
1071
+ VELLUM_ENVIRONMENT: process.env.VELLUM_ENVIRONMENT || "local",
1072
+ }
1073
+ : {}),
1074
+ // Set VELLUM_WORKSPACE_DIR and GATEWAY_SECURITY_DIR so the gateway
1075
+ // loads the correct credentials and workspace config for this instance
1076
+ // (mirrors the daemon env setup).
1077
+ ...(resources
1078
+ ? {
1079
+ BASE_DATA_DIR: resources.instanceDir,
1080
+ VELLUM_WORKSPACE_DIR: join(
1081
+ resources.instanceDir,
1082
+ ".vellum",
1083
+ "workspace",
1084
+ ),
1085
+ GATEWAY_SECURITY_DIR: join(
1086
+ resources.instanceDir,
1087
+ ".vellum",
1088
+ "protected",
1089
+ ),
1090
+ }
1091
+ : {}),
1050
1092
  };
1051
1093
  if (publicUrl) {
1052
1094
  console.log(` Ingress URL: ${publicUrl}`);
@@ -2,6 +2,7 @@ import { existsSync, readFileSync } from "fs";
2
2
  import { homedir } from "os";
3
3
  import { join } from "path";
4
4
 
5
+ import { loadAllAssistants } from "./assistant-config.js";
5
6
  import { execOutput } from "./step-runner";
6
7
 
7
8
  export interface RemoteProcess {
@@ -72,20 +73,35 @@ export interface OrphanedProcess {
72
73
  export async function detectOrphanedProcesses(): Promise<OrphanedProcess[]> {
73
74
  const results: OrphanedProcess[] = [];
74
75
  const seenPids = new Set<string>();
75
- const vellumDir = join(homedir(), ".vellum");
76
76
 
77
- // Strategy 1: PID file scan
78
- const pidFiles: Array<{ file: string; name: string }> = [
79
- { file: join(vellumDir, "vellum.pid"), name: "assistant" },
80
- { file: join(vellumDir, "gateway.pid"), name: "gateway" },
81
- { file: join(vellumDir, "qdrant.pid"), name: "qdrant" },
82
- ];
77
+ // Collect every known local instance's `.vellum/` directory from the
78
+ // lockfile so orphan detection scans all containers under the current
79
+ // multi-instance data layout, not just the legacy `~/.vellum/` root.
80
+ const dirs = new Set<string>();
81
+ for (const entry of loadAllAssistants()) {
82
+ if (entry.cloud !== "local" || !entry.resources) continue;
83
+ dirs.add(join(entry.resources.instanceDir, ".vellum"));
84
+ }
85
+ // Preserve the legacy root scan for installs that predate multi-instance
86
+ // tracking. This catches orphans from a pre-upgrade `~/.vellum/` that
87
+ // may not have a lockfile entry at all.
88
+ dirs.add(join(homedir(), ".vellum"));
89
+
90
+ // Strategy 1: PID file scan — check every known data directory.
91
+ for (const dir of dirs) {
92
+ const pidFiles: Array<{ file: string; name: string }> = [
93
+ { file: join(dir, "vellum.pid"), name: "assistant" },
94
+ { file: join(dir, "gateway.pid"), name: "gateway" },
95
+ { file: join(dir, "qdrant.pid"), name: "qdrant" },
96
+ ];
83
97
 
84
- for (const { file, name } of pidFiles) {
85
- const pid = readPidFile(file);
86
- if (pid && isProcessAlive(pid)) {
87
- results.push({ name, pid, source: "pid file" });
88
- seenPids.add(pid);
98
+ for (const { file, name } of pidFiles) {
99
+ const pid = readPidFile(file);
100
+ if (!pid || seenPids.has(pid)) continue;
101
+ if (isProcessAlive(pid)) {
102
+ results.push({ name, pid, source: "pid file" });
103
+ seenPids.add(pid);
104
+ }
89
105
  }
90
106
  }
91
107