@vellumai/cli 0.3.19 → 0.3.20

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.3.19",
3
+ "version": "0.3.20",
4
4
  "description": "CLI tools for vellum-assistant",
5
5
  "type": "module",
6
6
  "exports": {
@@ -2,6 +2,8 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
2
  import { homedir } from "node:os";
3
3
  import { dirname, join } from "node:path";
4
4
 
5
+ import { syncConfigToLockfile } from "../lib/assistant-config";
6
+
5
7
  interface AllowlistConfig {
6
8
  values?: string[];
7
9
  prefixes?: string[];
@@ -176,6 +178,7 @@ export function config(): void {
176
178
  }
177
179
  setNestedValue(raw, key, parsed);
178
180
  saveRawConfig(raw);
181
+ syncConfigToLockfile();
179
182
  console.log(`Set ${key} = ${JSON.stringify(parsed)}`);
180
183
  break;
181
184
  }
@@ -1,4 +1,4 @@
1
- import { existsSync, lstatSync, mkdirSync, readFileSync, readlinkSync, symlinkSync, unlinkSync } from "fs";
1
+ import { appendFileSync, existsSync, lstatSync, mkdirSync, readFileSync, readlinkSync, symlinkSync, unlinkSync } from "fs";
2
2
  import { homedir, userInfo } from "os";
3
3
  import { join } from "path";
4
4
 
@@ -6,7 +6,7 @@ import { join } from "path";
6
6
  import cliPkg from "../../package.json";
7
7
 
8
8
  import { buildOpenclawStartupScript } from "../adapters/openclaw";
9
- import { loadAllAssistants, saveAssistantEntry } from "../lib/assistant-config";
9
+ import { loadAllAssistants, saveAssistantEntry, syncConfigToLockfile } from "../lib/assistant-config";
10
10
  import type { AssistantEntry } from "../lib/assistant-config";
11
11
  import { hatchAws } from "../lib/aws";
12
12
  import {
@@ -392,12 +392,11 @@ function watchHatchingDesktop(
392
392
  });
393
393
  }
394
394
 
395
- function installCLISymlink(): void {
396
- const cliBinary = process.execPath;
397
- if (!cliBinary || !existsSync(cliBinary)) return;
398
-
399
- const symlinkPath = "/usr/local/bin/vellum";
400
-
395
+ /**
396
+ * Attempts to place a symlink at the given path pointing to cliBinary.
397
+ * Returns true if the symlink was created (or already correct), false on failure.
398
+ */
399
+ function trySymlink(cliBinary: string, symlinkPath: string): boolean {
401
400
  try {
402
401
  // Use lstatSync (not existsSync) to detect dangling symlinks —
403
402
  // existsSync follows symlinks and returns false for broken links.
@@ -405,30 +404,80 @@ function installCLISymlink(): void {
405
404
  const stats = lstatSync(symlinkPath);
406
405
  if (!stats.isSymbolicLink()) {
407
406
  // Real file — don't overwrite (developer's local install)
408
- return;
407
+ return false;
409
408
  }
410
409
  // Already a symlink — skip if it already points to our binary
411
410
  const dest = readlinkSync(symlinkPath);
412
- if (dest === cliBinary) return;
411
+ if (dest === cliBinary) return true;
413
412
  // Stale or dangling symlink — remove before creating new one
414
413
  unlinkSync(symlinkPath);
415
414
  } catch (e) {
416
- if ((e as NodeJS.ErrnoException)?.code !== "ENOENT") throw e;
415
+ if ((e as NodeJS.ErrnoException)?.code !== "ENOENT") return false;
417
416
  // Path doesn't exist — proceed to create symlink
418
417
  }
419
418
 
420
- const dir = "/usr/local/bin";
419
+ const dir = join(symlinkPath, "..");
421
420
  if (!existsSync(dir)) {
422
421
  mkdirSync(dir, { recursive: true });
423
422
  }
424
423
  symlinkSync(cliBinary, symlinkPath);
425
- console.log(` Symlinked ${symlinkPath} → ${cliBinary}`);
424
+ return true;
425
+ } catch {
426
+ return false;
427
+ }
428
+ }
429
+
430
+ /**
431
+ * Ensures ~/.local/bin is present in the user's shell profile so that
432
+ * symlinks placed there are on PATH in new terminal sessions.
433
+ */
434
+ function ensureLocalBinInShellProfile(localBinDir: string): void {
435
+ const shell = process.env.SHELL ?? "";
436
+ const home = homedir();
437
+ // Determine the appropriate shell profile to modify
438
+ const profilePath = shell.endsWith("/zsh")
439
+ ? join(home, ".zshrc")
440
+ : shell.endsWith("/bash")
441
+ ? join(home, ".bash_profile")
442
+ : null;
443
+ if (!profilePath) return;
444
+
445
+ try {
446
+ const contents = existsSync(profilePath) ? readFileSync(profilePath, "utf-8") : "";
447
+ // Check if ~/.local/bin is already referenced in PATH exports
448
+ if (contents.includes(localBinDir)) return;
449
+ const line = `\nexport PATH="${localBinDir}:\$PATH"\n`;
450
+ appendFileSync(profilePath, line);
451
+ console.log(` Added ${localBinDir} to ${profilePath}`);
426
452
  } catch {
427
- // Permission denied or other error not critical
428
- console.log(` ⚠ Could not create symlink at ${symlinkPath} (run with sudo or create manually)`);
453
+ // Not critical user can add it manually
429
454
  }
430
455
  }
431
456
 
457
+ function installCLISymlink(): void {
458
+ const cliBinary = process.execPath;
459
+ if (!cliBinary || !existsSync(cliBinary)) return;
460
+
461
+ // Preferred location — works on most Macs where /usr/local/bin exists
462
+ const preferredPath = "/usr/local/bin/vellum";
463
+ if (trySymlink(cliBinary, preferredPath)) {
464
+ console.log(` Symlinked ${preferredPath} → ${cliBinary}`);
465
+ return;
466
+ }
467
+
468
+ // Fallback — use ~/.local/bin which is user-writable and doesn't need root.
469
+ // On some Macs /usr/local doesn't exist and creating it requires admin privileges.
470
+ const localBinDir = join(homedir(), ".local", "bin");
471
+ const fallbackPath = join(localBinDir, "vellum");
472
+ if (trySymlink(cliBinary, fallbackPath)) {
473
+ console.log(` Symlinked ${fallbackPath} → ${cliBinary}`);
474
+ ensureLocalBinInShellProfile(localBinDir);
475
+ return;
476
+ }
477
+
478
+ console.log(` ⚠ Could not create symlink for vellum CLI (tried ${preferredPath} and ${fallbackPath})`);
479
+ }
480
+
432
481
  async function hatchLocal(species: Species, name: string | null, daemonOnly: boolean = false): Promise<void> {
433
482
  const instanceName =
434
483
  name ?? process.env.VELLUM_ASSISTANT_NAME ?? `${species}-${generateRandomSuffix()}`;
@@ -487,6 +536,7 @@ async function hatchLocal(species: Species, name: string | null, daemonOnly: boo
487
536
  };
488
537
  if (!daemonOnly) {
489
538
  saveAssistantEntry(localEntry);
539
+ syncConfigToLockfile();
490
540
 
491
541
  if (process.env.VELLUM_DESKTOP_APP) {
492
542
  installCLISymlink();
@@ -7,10 +7,16 @@ import {
7
7
  loadAllAssistants,
8
8
  type AssistantEntry,
9
9
  } from "../lib/assistant-config";
10
+ import { GATEWAY_PORT } from "../lib/constants";
10
11
  import { checkHealth } from "../lib/health-check";
12
+ import { pgrepExact } from "../lib/pgrep";
13
+ import { probePort } from "../lib/port-probe";
11
14
  import { withStatusEmoji } from "../lib/status-emoji";
12
15
  import { execOutput } from "../lib/step-runner";
13
16
 
17
+ const RUNTIME_HTTP_PORT = Number(process.env.RUNTIME_HTTP_PORT) || 7821;
18
+ const QDRANT_PORT = 6333;
19
+
14
20
  // ── Table formatting helpers ────────────────────────────────────
15
21
 
16
22
  interface TableRow {
@@ -149,62 +155,86 @@ async function getRemoteProcessesCustom(
149
155
  ]);
150
156
  }
151
157
 
152
- function checkPidFile(pidFile: string): { status: string; pid: string | null } {
153
- if (!existsSync(pidFile)) {
154
- return { status: "not running", pid: null };
155
- }
158
+ interface ProcessSpec {
159
+ name: string;
160
+ pgrepName: string;
161
+ port: number;
162
+ pidFile: string;
163
+ }
164
+
165
+ function readPidFile(pidFile: string): string | null {
166
+ if (!existsSync(pidFile)) return null;
156
167
  const pid = readFileSync(pidFile, "utf-8").trim();
168
+ return pid || null;
169
+ }
170
+
171
+ function isProcessAlive(pid: string): boolean {
157
172
  try {
158
173
  process.kill(parseInt(pid, 10), 0);
159
- return { status: "running", pid };
174
+ return true;
160
175
  } catch {
161
- return { status: "not running", pid };
176
+ return false;
162
177
  }
163
178
  }
164
179
 
165
- async function getLocalProcesses(entry: AssistantEntry): Promise<TableRow[]> {
166
- const vellumDir = entry.baseDataDir ?? join(homedir(), ".vellum");
167
- const rows: TableRow[] = [];
168
-
169
- // Check daemon PID
170
- const daemon = checkPidFile(join(vellumDir, "vellum.pid"));
171
- rows.push({
172
- name: "daemon",
173
- status: withStatusEmoji(daemon.status),
174
- info: daemon.pid ? `PID ${daemon.pid}` : "no PID file",
175
- });
180
+ interface DetectedProcess {
181
+ name: string;
182
+ pid: string | null;
183
+ port: number;
184
+ running: boolean;
185
+ }
176
186
 
177
- // Check qdrant PID
178
- const qdrant = checkPidFile(join(vellumDir, "workspace", "data", "qdrant", "qdrant.pid"));
179
- rows.push({
180
- name: "qdrant",
181
- status: withStatusEmoji(qdrant.status),
182
- info: qdrant.pid ? `PID ${qdrant.pid} | port 6333` : "no PID file",
183
- });
187
+ async function detectProcess(spec: ProcessSpec): Promise<DetectedProcess> {
188
+ // Tier 1: pgrep by process title
189
+ const pids = await pgrepExact(spec.pgrepName);
190
+ if (pids.length > 0) {
191
+ return { name: spec.name, pid: pids[0], port: spec.port, running: true };
192
+ }
184
193
 
185
- // Check gateway PID
186
- const gateway = checkPidFile(join(vellumDir, "gateway.pid"));
187
- rows.push({
188
- name: "gateway",
189
- status: withStatusEmoji(gateway.status),
190
- info: gateway.pid ? `PID ${gateway.pid} | port 7830` : "no PID file",
191
- });
194
+ // Tier 2: TCP port probe
195
+ const listening = await probePort(spec.port);
196
+ if (listening) {
197
+ const filePid = readPidFile(spec.pidFile);
198
+ return {
199
+ name: spec.name,
200
+ pid: filePid,
201
+ port: spec.port,
202
+ running: true,
203
+ };
204
+ }
192
205
 
193
- // If no PID files found, fall back to health check
194
- const allMissingPid = !daemon.pid && !qdrant.pid && !gateway.pid;
195
- if (allMissingPid) {
196
- const health = await checkHealth(entry.runtimeUrl);
197
- if (health.status === "healthy" || health.status === "ok") {
198
- rows.length = 0;
199
- rows.push({
200
- name: "daemon",
201
- status: withStatusEmoji("running"),
202
- info: "no PID file (detected via health check)",
203
- });
204
- }
206
+ // Tier 3: PID file fallback
207
+ const filePid = readPidFile(spec.pidFile);
208
+ if (filePid && isProcessAlive(filePid)) {
209
+ return { name: spec.name, pid: filePid, port: spec.port, running: true };
205
210
  }
206
211
 
207
- return rows;
212
+ return { name: spec.name, pid: null, port: spec.port, running: false };
213
+ }
214
+
215
+ function formatDetectionInfo(proc: DetectedProcess): string {
216
+ const parts: string[] = [];
217
+ if (proc.pid) parts.push(`PID ${proc.pid}`);
218
+ parts.push(`port ${proc.port}`);
219
+ return parts.join(" | ");
220
+ }
221
+
222
+ async function getLocalProcesses(entry: AssistantEntry): Promise<TableRow[]> {
223
+ const vellumDir = entry.baseDataDir ?? join(homedir(), ".vellum");
224
+
225
+ const specs: ProcessSpec[] = [
226
+ { name: "daemon", pgrepName: "vellum-daemon", port: RUNTIME_HTTP_PORT, pidFile: join(vellumDir, "vellum.pid") },
227
+ { name: "qdrant", pgrepName: "qdrant", port: QDRANT_PORT, pidFile: join(vellumDir, "workspace", "data", "qdrant", "qdrant.pid") },
228
+ { name: "gateway", pgrepName: "vellum-gateway", port: GATEWAY_PORT, pidFile: join(vellumDir, "gateway.pid") },
229
+ ];
230
+
231
+ const results = await Promise.all(specs.map(detectProcess));
232
+
233
+ return results.map((proc) => ({
234
+ name: proc.name,
235
+ status: withStatusEmoji(proc.running ? "running" : "not running"),
236
+ info: proc.running ? formatDetectionInfo(proc) : "not detected",
237
+ }));
208
238
  }
209
239
 
210
240
  async function showAssistantProcesses(entry: AssistantEntry): Promise<void> {
@@ -270,10 +300,10 @@ async function detectOrphanedProcesses(): Promise<OrphanedProcess[]> {
270
300
  ];
271
301
 
272
302
  for (const { file, name } of pidFiles) {
273
- const result = checkPidFile(file);
274
- if (result.status === "running" && result.pid) {
275
- results.push({ name, pid: result.pid, source: "pid file" });
276
- seenPids.add(result.pid);
303
+ const pid = readPidFile(file);
304
+ if (pid && isProcessAlive(pid)) {
305
+ results.push({ name, pid, source: "pid file" });
306
+ seenPids.add(pid);
277
307
  }
278
308
  }
279
309
 
@@ -11,6 +11,7 @@ import { callDoctorDaemon, type ChatLogEntry } from "../lib/doctor-client";
11
11
  import { checkHealth } from "../lib/health-check";
12
12
  import { statusEmoji, withStatusEmoji } from "../lib/status-emoji";
13
13
  import TextInput from "./TextInput";
14
+ import { Tooltip } from "./Tooltip";
14
15
 
15
16
  export const ANSI = {
16
17
  reset: "\x1b[0m",
@@ -682,7 +683,9 @@ function DefaultMainScreen({
682
683
  </Box>
683
684
  <Text dimColor>{"─".repeat(totalWidth)}</Text>
684
685
  <Text> </Text>
685
- <Text dimColor> ? for shortcuts</Text>
686
+ <Tooltip text="Type ? or /help for available commands" delay={1000}>
687
+ <Text dimColor> ? for shortcuts</Text>
688
+ </Tooltip>
686
689
  <Text> </Text>
687
690
  </Box>
688
691
  );
@@ -841,7 +844,7 @@ function SelectionWindow({
841
844
  );
842
845
  })}
843
846
  <Text>{"\u2514" + "\u2500".repeat(windowWidth - 2) + "\u2518"}</Text>
844
- <Text dimColor>{" \u2191/\u2193 navigate Enter select Esc cancel"}</Text>
847
+ <Tooltip text="\u2191/\u2193 navigate Enter select Esc cancel" delay={1000} />
845
848
  </Box>
846
849
  );
847
850
  }
@@ -901,7 +904,7 @@ function SecretInputWindow({
901
904
  {"\u2502"}
902
905
  </Text>
903
906
  <Text>{"\u2514" + "\u2500".repeat(windowWidth - 2) + "\u2518"}</Text>
904
- <Text dimColor>{" Enter submit Esc cancel"}</Text>
907
+ <Tooltip text="Enter submit Esc cancel" delay={1000} />
905
908
  </Box>
906
909
  );
907
910
  }
@@ -984,13 +987,16 @@ function ChatApp({
984
987
  const terminalRows = stdout.rows || 24;
985
988
  const terminalColumns = stdout.columns || 80;
986
989
  const headerHeight = calculateHeaderHeight(species);
990
+ const tooltipBubbleHeight = 3;
991
+ const showTooltip = inputFocused && inputValue.length === 0;
992
+ const tooltipHeight = showTooltip ? tooltipBubbleHeight : 0;
987
993
  const bottomHeight = selection
988
- ? selection.options.length + 3
994
+ ? selection.options.length + 3 + tooltipBubbleHeight
989
995
  : secretInput
990
- ? 5
996
+ ? 5 + tooltipBubbleHeight
991
997
  : spinnerText
992
998
  ? 4
993
- : 3;
999
+ : 3 + tooltipHeight;
994
1000
  const availableRows = Math.max(3, terminalRows - headerHeight - bottomHeight);
995
1001
 
996
1002
  const addMessage = useCallback((msg: RuntimeMessage) => {
@@ -1814,6 +1820,12 @@ function ChatApp({
1814
1820
 
1815
1821
  {!selection && !secretInput ? (
1816
1822
  <Box flexDirection="column">
1823
+ <Tooltip
1824
+ text="Type a message or /help for commands"
1825
+ visible={inputFocused && inputValue.length === 0}
1826
+ position="above"
1827
+ delay={1000}
1828
+ />
1817
1829
  <Text dimColor>{"\u2500".repeat(terminalColumns)}</Text>
1818
1830
  <Box paddingLeft={1}>
1819
1831
  <Text color="green" bold>
@@ -0,0 +1,66 @@
1
+ import { useEffect, useState, type ReactElement, type ReactNode } from "react";
2
+ import { Box, Text } from "ink";
3
+
4
+ export interface TooltipProps {
5
+ /** The tooltip text to display */
6
+ text: string;
7
+ /** Whether the tooltip trigger condition is active. When true, starts the delay timer. */
8
+ visible?: boolean;
9
+ /** Position relative to children: "above" or "below" */
10
+ position?: "above" | "below";
11
+ /** Delay in ms before showing the tooltip (default: 1000) */
12
+ delay?: number;
13
+ /** Children to wrap */
14
+ children?: ReactNode;
15
+ }
16
+
17
+ /**
18
+ * A Codex-style tooltip component for Ink terminal UI.
19
+ *
20
+ * Wraps children and shows a styled tooltip bubble (rounded border, bold text)
21
+ * after a configurable delay when `visible` is true.
22
+ *
23
+ * Usage:
24
+ * <Tooltip text="Attach files" visible={isFocused}>
25
+ * <Text>📎</Text>
26
+ * </Tooltip>
27
+ */
28
+ export function Tooltip({
29
+ text,
30
+ visible = true,
31
+ position = "below",
32
+ delay = 1000,
33
+ children,
34
+ }: TooltipProps): ReactElement | null {
35
+ const [show, setShow] = useState(false);
36
+
37
+ useEffect(() => {
38
+ if (!visible) {
39
+ setShow(false);
40
+ return;
41
+ }
42
+
43
+ const timer = setTimeout(() => setShow(true), delay);
44
+ return () => clearTimeout(timer);
45
+ }, [visible, delay]);
46
+
47
+ const bubble = show ? (
48
+ <Box borderStyle="round" borderColor="gray" alignSelf="flex-start">
49
+ <Text bold> {text} </Text>
50
+ </Box>
51
+ ) : null;
52
+
53
+ if (!children) {
54
+ return bubble ?? null;
55
+ }
56
+
57
+ return (
58
+ <Box flexDirection="column">
59
+ {position === "above" && bubble}
60
+ {children}
61
+ {position === "below" && bubble}
62
+ </Box>
63
+ );
64
+ }
65
+
66
+ export default Tooltip;
@@ -20,6 +20,7 @@ export interface AssistantEntry {
20
20
 
21
21
  interface LockfileData {
22
22
  assistants?: AssistantEntry[];
23
+ platformBaseUrl?: string;
23
24
  [key: string]: unknown;
24
25
  }
25
26
 
@@ -102,3 +103,23 @@ export function saveAssistantEntry(entry: AssistantEntry): void {
102
103
  entries.unshift(entry);
103
104
  writeAssistants(entries);
104
105
  }
106
+
107
+ /**
108
+ * Read the assistant config file and sync client-relevant values to the
109
+ * lockfile. This lets external tools (e.g. vel) discover the platform URL
110
+ * without importing the assistant config schema.
111
+ */
112
+ export function syncConfigToLockfile(): void {
113
+ const configPath = join(getBaseDir(), ".vellum", "workspace", "config.json");
114
+ if (!existsSync(configPath)) return;
115
+
116
+ try {
117
+ const raw = JSON.parse(readFileSync(configPath, "utf-8")) as Record<string, unknown>;
118
+ const platform = raw.platform as Record<string, unknown> | undefined;
119
+ const data = readLockfile();
120
+ data.platformBaseUrl = (platform?.baseUrl as string) || undefined;
121
+ writeLockfile(data);
122
+ } catch {
123
+ // Config file unreadable — skip sync
124
+ }
125
+ }
package/src/lib/local.ts CHANGED
@@ -1,6 +1,7 @@
1
- import { spawn } from "child_process";
1
+ import { execFileSync, spawn } from "child_process";
2
2
  import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "fs";
3
3
  import { createRequire } from "module";
4
+ import { createConnection } from "net";
4
5
  import { homedir } from "os";
5
6
  import { dirname, join } from "path";
6
7
 
@@ -10,6 +11,35 @@ import { stopProcessByPidFile } from "./process.js";
10
11
 
11
12
  const _require = createRequire(import.meta.url);
12
13
 
14
+ function isAssistantSourceDir(dir: string): boolean {
15
+ const pkgPath = join(dir, "package.json");
16
+ if (!existsSync(pkgPath) || !existsSync(join(dir, "src", "index.ts"))) return false;
17
+ try {
18
+ const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
19
+ return pkg.name === "@vellumai/assistant";
20
+ } catch {
21
+ return false;
22
+ }
23
+ }
24
+
25
+ function findAssistantSourceFrom(startDir: string): string | undefined {
26
+ let current = startDir;
27
+ while (true) {
28
+ if (isAssistantSourceDir(current)) {
29
+ return current;
30
+ }
31
+ const nestedCandidate = join(current, "assistant");
32
+ if (isAssistantSourceDir(nestedCandidate)) {
33
+ return nestedCandidate;
34
+ }
35
+ const parent = dirname(current);
36
+ if (parent === current) {
37
+ return undefined;
38
+ }
39
+ current = parent;
40
+ }
41
+ }
42
+
13
43
  function isGatewaySourceDir(dir: string): boolean {
14
44
  const pkgPath = join(dir, "package.json");
15
45
  if (!existsSync(pkgPath) || !existsSync(join(dir, "src", "index.ts"))) return false;
@@ -39,6 +69,83 @@ function findGatewaySourceFromCwd(): string | undefined {
39
69
  }
40
70
  }
41
71
 
72
+ function resolveAssistantIndexPath(): string | undefined {
73
+ // Source tree layout: cli/src/lib/ -> ../../.. -> repo root -> assistant/src/index.ts
74
+ const sourceTreeIndex = join(import.meta.dir, "..", "..", "..", "assistant", "src", "index.ts");
75
+ if (existsSync(sourceTreeIndex)) {
76
+ return sourceTreeIndex;
77
+ }
78
+
79
+ // bunx layout: @vellumai/cli/src/lib/ -> ../../../.. -> node_modules/vellum/src/index.ts
80
+ const bunxIndex = join(import.meta.dir, "..", "..", "..", "..", "vellum", "src", "index.ts");
81
+ if (existsSync(bunxIndex)) {
82
+ return bunxIndex;
83
+ }
84
+
85
+ const cwdSourceDir = findAssistantSourceFrom(process.cwd());
86
+ if (cwdSourceDir) {
87
+ return join(cwdSourceDir, "src", "index.ts");
88
+ }
89
+
90
+ const execSourceDir = findAssistantSourceFrom(dirname(process.execPath));
91
+ if (execSourceDir) {
92
+ return join(execSourceDir, "src", "index.ts");
93
+ }
94
+
95
+ try {
96
+ const vellumPkgPath = _require.resolve("vellum/package.json");
97
+ const resolved = join(dirname(vellumPkgPath), "src", "index.ts");
98
+ if (existsSync(resolved)) {
99
+ return resolved;
100
+ }
101
+ } catch {
102
+ // resolution failed
103
+ }
104
+
105
+ return undefined;
106
+ }
107
+
108
+ async function waitForSocketFile(socketPath: string, timeoutMs = 15000): Promise<boolean> {
109
+ if (existsSync(socketPath)) return true;
110
+
111
+ const start = Date.now();
112
+ while (Date.now() - start < timeoutMs) {
113
+ if (existsSync(socketPath)) {
114
+ return true;
115
+ }
116
+ await new Promise((r) => setTimeout(r, 100));
117
+ }
118
+
119
+ return existsSync(socketPath);
120
+ }
121
+
122
+ async function startDaemonFromSource(assistantIndex: string): Promise<void> {
123
+ const env: Record<string, string | undefined> = {
124
+ ...process.env,
125
+ RUNTIME_HTTP_PORT: process.env.RUNTIME_HTTP_PORT || "7821",
126
+ };
127
+ // Preserve TCP listener flag when falling back from bundled desktop daemon
128
+ if (process.env.VELLUM_DESKTOP_APP) {
129
+ env.VELLUM_DAEMON_TCP_ENABLED = process.env.VELLUM_DAEMON_TCP_ENABLED || "1";
130
+ }
131
+
132
+ const child = spawn("bun", ["run", assistantIndex, "daemon", "start"], {
133
+ stdio: "inherit",
134
+ env,
135
+ });
136
+
137
+ await new Promise<void>((resolve, reject) => {
138
+ child.on("close", (code) => {
139
+ if (code === 0) {
140
+ resolve();
141
+ } else {
142
+ reject(new Error(`Daemon start exited with code ${code}`));
143
+ }
144
+ });
145
+ child.on("error", reject);
146
+ });
147
+ }
148
+
42
149
  function resolveGatewayDir(): string {
43
150
  const override = process.env.VELLUM_GATEWAY_DIR?.trim();
44
151
  if (override) {
@@ -95,6 +202,51 @@ function readWorkspaceIngressPublicBaseUrl(): string | undefined {
95
202
  }
96
203
  }
97
204
 
205
+ /** Use lsof to discover the PID of the process listening on a Unix socket.
206
+ * Returns the PID if found, undefined otherwise. */
207
+ function findSocketOwnerPid(socketPath: string): number | undefined {
208
+ try {
209
+ const output = execFileSync("lsof", ["-U", "-a", "-F", "p", "--", socketPath], {
210
+ encoding: "utf-8",
211
+ timeout: 3000,
212
+ stdio: ["ignore", "pipe", "ignore"],
213
+ });
214
+ // lsof -F p outputs lines like "p1234" — extract the first PID
215
+ const match = output.match(/^p(\d+)/m);
216
+ if (match) {
217
+ const pid = parseInt(match[1], 10);
218
+ if (!isNaN(pid)) return pid;
219
+ }
220
+ } catch {
221
+ // lsof not available or failed — cannot recover PID
222
+ }
223
+ return undefined;
224
+ }
225
+
226
+ /** Try a TCP connect to the Unix socket. Returns true if the handshake
227
+ * completes within the timeout — false on connection refused, timeout,
228
+ * or missing socket file. */
229
+ function isSocketResponsive(socketPath: string, timeoutMs = 1500): Promise<boolean> {
230
+ if (!existsSync(socketPath)) return Promise.resolve(false);
231
+ return new Promise((resolve) => {
232
+ const socket = createConnection(socketPath);
233
+ const timer = setTimeout(() => {
234
+ socket.destroy();
235
+ resolve(false);
236
+ }, timeoutMs);
237
+ socket.on("connect", () => {
238
+ clearTimeout(timer);
239
+ socket.destroy();
240
+ resolve(true);
241
+ });
242
+ socket.on("error", () => {
243
+ clearTimeout(timer);
244
+ socket.destroy();
245
+ resolve(false);
246
+ });
247
+ });
248
+ }
249
+
98
250
  async function discoverPublicUrl(): Promise<string | undefined> {
99
251
  const cloud = process.env.VELLUM_CLOUD;
100
252
  if (!cloud || cloud === "local") {
@@ -172,7 +324,23 @@ export async function startLocalDaemon(): Promise<void> {
172
324
  }
173
325
 
174
326
  if (!daemonAlive) {
175
- // Remove stale socket so we can detect the fresh one
327
+ // The PID file was stale or missing, but a daemon with a different PID
328
+ // may still be listening on the socket (e.g. if the PID file was
329
+ // overwritten by a crashed restart attempt). Check before deleting.
330
+ if (await isSocketResponsive(socketFile)) {
331
+ // Restore PID tracking so lifecycle commands (sleep, retire,
332
+ // stopLocalProcesses) can manage this daemon process.
333
+ const ownerPid = findSocketOwnerPid(socketFile);
334
+ if (ownerPid) {
335
+ writeFileSync(pidFile, String(ownerPid), "utf-8");
336
+ console.log(` Daemon socket is responsive (pid ${ownerPid}) — skipping restart\n`);
337
+ } else {
338
+ console.log(" Daemon socket is responsive — skipping restart\n");
339
+ }
340
+ return;
341
+ }
342
+
343
+ // Socket is unresponsive or missing — safe to clean up and start fresh.
176
344
  try { unlinkSync(socketFile); } catch {}
177
345
 
178
346
  console.log("🔨 Starting daemon...");
@@ -223,17 +391,22 @@ export async function startLocalDaemon(): Promise<void> {
223
391
  }
224
392
 
225
393
  // Wait for socket at ~/.vellum/vellum.sock (up to 15s)
226
- if (!existsSync(socketFile)) {
227
- const maxWait = 15000;
228
- const start = Date.now();
229
- while (Date.now() - start < maxWait) {
230
- if (existsSync(socketFile)) {
231
- break;
232
- }
233
- await new Promise((r) => setTimeout(r, 100));
394
+ let socketReady = await waitForSocketFile(socketFile, 15000);
395
+
396
+ // Dev fallback: if the bundled daemon did not create a socket in time,
397
+ // fall back to source daemon startup so local `./build.sh run` still works.
398
+ if (!socketReady) {
399
+ const assistantIndex = resolveAssistantIndexPath();
400
+ if (assistantIndex) {
401
+ console.log(" Bundled daemon socket not ready after 15s — falling back to source daemon...");
402
+ // Kill the bundled daemon to avoid two processes competing for the same socket/port
403
+ await stopProcessByPidFile(pidFile, "bundled daemon", [socketFile]);
404
+ await startDaemonFromSource(assistantIndex);
405
+ socketReady = await waitForSocketFile(socketFile, 15000);
234
406
  }
235
407
  }
236
- if (existsSync(socketFile)) {
408
+
409
+ if (socketReady) {
237
410
  console.log(" Daemon socket ready\n");
238
411
  } else {
239
412
  console.log(" ⚠️ Daemon socket did not appear within 15s — continuing anyway\n");
@@ -241,50 +414,14 @@ export async function startLocalDaemon(): Promise<void> {
241
414
  } else {
242
415
  console.log("🔨 Starting local daemon...");
243
416
 
244
- // Source tree layout: cli/src/commands/ -> ../../.. -> repo root -> assistant/src/index.ts
245
- const sourceTreeIndex = join(import.meta.dir, "..", "..", "..", "assistant", "src", "index.ts");
246
- // bunx layout: @vellumai/cli/src/commands/ -> ../../../.. -> node_modules/ -> vellum/src/index.ts
247
- const bunxIndex = join(import.meta.dir, "..", "..", "..", "..", "vellum", "src", "index.ts");
248
- let assistantIndex = sourceTreeIndex;
249
-
250
- if (!existsSync(assistantIndex)) {
251
- assistantIndex = bunxIndex;
252
- }
253
-
254
- if (!existsSync(assistantIndex)) {
255
- try {
256
- const vellumPkgPath = _require.resolve("vellum/package.json");
257
- assistantIndex = join(dirname(vellumPkgPath), "src", "index.ts");
258
- } catch {
259
- // resolve failed, will fall through to existsSync check below
260
- }
261
- }
262
-
263
- if (!existsSync(assistantIndex)) {
417
+ const assistantIndex = resolveAssistantIndexPath();
418
+ if (!assistantIndex) {
264
419
  throw new Error(
265
420
  "vellum-daemon binary not found and assistant source not available.\n" +
266
421
  " Ensure the daemon binary is bundled alongside the CLI, or run from the source tree.",
267
422
  );
268
423
  }
269
-
270
- const child = spawn("bun", ["run", assistantIndex, "daemon", "start"], {
271
- stdio: "inherit",
272
- env: {
273
- ...process.env,
274
- RUNTIME_HTTP_PORT: process.env.RUNTIME_HTTP_PORT || "7821",
275
- },
276
- });
277
-
278
- await new Promise<void>((resolve, reject) => {
279
- child.on("close", (code) => {
280
- if (code === 0) {
281
- resolve();
282
- } else {
283
- reject(new Error(`Daemon start exited with code ${code}`));
284
- }
285
- });
286
- child.on("error", reject);
287
- });
424
+ await startDaemonFromSource(assistantIndex);
288
425
  }
289
426
  }
290
427
 
@@ -0,0 +1,10 @@
1
+ import { execOutput } from "./step-runner";
2
+
3
+ export async function pgrepExact(name: string): Promise<string[]> {
4
+ try {
5
+ const output = await execOutput("pgrep", ["-x", name]);
6
+ return output.trim().split("\n").filter(Boolean);
7
+ } catch {
8
+ return [];
9
+ }
10
+ }
@@ -0,0 +1,23 @@
1
+ import { Socket } from "node:net";
2
+
3
+ const PROBE_TIMEOUT_MS = 750;
4
+
5
+ export function probePort(port: number, host = "127.0.0.1"): Promise<boolean> {
6
+ return new Promise((resolve) => {
7
+ const socket = new Socket();
8
+ socket.setTimeout(PROBE_TIMEOUT_MS);
9
+ socket.once("connect", () => {
10
+ socket.destroy();
11
+ resolve(true);
12
+ });
13
+ socket.once("timeout", () => {
14
+ socket.destroy();
15
+ resolve(false);
16
+ });
17
+ socket.once("error", () => {
18
+ socket.destroy();
19
+ resolve(false);
20
+ });
21
+ socket.connect(port, host);
22
+ });
23
+ }