@vellumai/cli 0.4.44 → 0.4.46

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.
@@ -0,0 +1,5 @@
1
+ # CLI Package — Contributing Guidelines
2
+
3
+ ## Module Boundaries
4
+
5
+ - **Commands must not import from other commands.** Shared logic belongs in `src/lib/`. If two commands need the same function, extract it into an appropriate lib module rather than importing across `src/commands/` files.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/cli",
3
- "version": "0.4.44",
3
+ "version": "0.4.46",
4
4
  "description": "CLI tools for vellum-assistant",
5
5
  "type": "module",
6
6
  "exports": {
@@ -11,7 +11,7 @@
11
11
  "./src/commands/*": "./src/commands/*.ts"
12
12
  },
13
13
  "bin": {
14
- "assistant": "./src/index.ts"
14
+ "vellum": "./src/index.ts"
15
15
  },
16
16
  "scripts": {
17
17
  "format": "prettier --write .",
@@ -179,27 +179,30 @@ configure_shell_profile() {
179
179
  done
180
180
  }
181
181
 
182
- # Create a symlink so `vellum` is available without ~/.bun/bin in PATH.
182
+ # Create a symlink so a CLI command is available without ~/.bun/bin in PATH.
183
183
  # Tries /usr/local/bin first (works on most systems), falls back to
184
184
  # ~/.local/bin (user-writable, no sudo needed).
185
185
  # This is best-effort — failure must not abort the install script.
186
- symlink_vellum() {
187
- local vellum_bin="$HOME/.bun/bin/vellum"
188
- if [ ! -f "$vellum_bin" ]; then
186
+ #
187
+ # Usage: symlink_cli <command_name>
188
+ symlink_cli() {
189
+ local cmd_name="$1"
190
+ local cmd_bin="$HOME/.bun/bin/$cmd_name"
191
+ if [ ! -f "$cmd_bin" ]; then
189
192
  return 0
190
193
  fi
191
194
 
192
- # Skip if vellum is already resolvable outside of ~/.bun/bin
195
+ # Skip if the command is already resolvable outside of ~/.bun/bin
193
196
  local resolved
194
- resolved=$(command -v vellum 2>/dev/null || true)
195
- if [ -n "$resolved" ] && [ "$resolved" != "$vellum_bin" ]; then
197
+ resolved=$(command -v "$cmd_name" 2>/dev/null || true)
198
+ if [ -n "$resolved" ] && [ "$resolved" != "$cmd_bin" ]; then
196
199
  return 0
197
200
  fi
198
201
 
199
202
  # Try /usr/local/bin (may need sudo on some systems)
200
203
  if [ -d "/usr/local/bin" ] && [ -w "/usr/local/bin" ]; then
201
- if ln -sf "$vellum_bin" /usr/local/bin/vellum 2>/dev/null; then
202
- success "Symlinked /usr/local/bin/vellum → $vellum_bin"
204
+ if ln -sf "$cmd_bin" "/usr/local/bin/$cmd_name" 2>/dev/null; then
205
+ success "Symlinked /usr/local/bin/$cmd_name → $cmd_bin"
203
206
  return 0
204
207
  fi
205
208
  fi
@@ -207,8 +210,8 @@ symlink_vellum() {
207
210
  # Fallback: ~/.local/bin
208
211
  local local_bin="$HOME/.local/bin"
209
212
  mkdir -p "$local_bin" 2>/dev/null || true
210
- if ln -sf "$vellum_bin" "$local_bin/vellum" 2>/dev/null; then
211
- success "Symlinked $local_bin/vellum → $vellum_bin"
213
+ if ln -sf "$cmd_bin" "$local_bin/$cmd_name" 2>/dev/null; then
214
+ success "Symlinked $local_bin/$cmd_name → $cmd_bin"
212
215
  # Ensure ~/.local/bin is in PATH in shell profile
213
216
  for profile in "$HOME/.zshrc" "$HOME/.bashrc" "$HOME/.bash_profile"; do
214
217
  if [ -f "$profile" ] && ! grep -q "$local_bin" "$profile" 2>/dev/null; then
@@ -221,6 +224,11 @@ symlink_vellum() {
221
224
  return 0
222
225
  }
223
226
 
227
+ symlink_vellum() {
228
+ symlink_cli "vellum"
229
+ symlink_cli "assistant"
230
+ }
231
+
224
232
  # Write a small sourceable env file to ~/.config/vellum/env so callers can
225
233
  # pick up PATH changes without restarting their shell:
226
234
  # curl -fsSL https://assistant.vellum.ai/install.sh | bash && . ~/.config/vellum/env
@@ -265,6 +273,11 @@ main() {
265
273
  install_vellum
266
274
  symlink_vellum
267
275
 
276
+ # Verify the assistant CLI is available
277
+ if ! command -v assistant >/dev/null 2>&1; then
278
+ info "Note: 'assistant' command may require opening a new terminal session"
279
+ fi
280
+
268
281
  # Write a sourceable env file so the quickstart one-liner can pick up
269
282
  # PATH changes in the caller's shell:
270
283
  # curl ... | bash && . ~/.config/vellum/env
@@ -0,0 +1,34 @@
1
+ import { detectOrphanedProcesses } from "../lib/orphan-detection";
2
+ import { stopProcess } from "../lib/process";
3
+
4
+ export async function clean(): Promise<void> {
5
+ const args = process.argv.slice(3);
6
+ if (args.includes("--help") || args.includes("-h")) {
7
+ console.log("Usage: vellum clean");
8
+ console.log("");
9
+ console.log("Kill all orphaned vellum processes that are not tracked by any assistant.");
10
+ process.exit(0);
11
+ }
12
+
13
+ const orphans = await detectOrphanedProcesses();
14
+
15
+ if (orphans.length === 0) {
16
+ console.log("No orphaned processes found.");
17
+ return;
18
+ }
19
+
20
+ console.log(`Found ${orphans.length} orphaned process${orphans.length === 1 ? "" : "es"}.\n`);
21
+
22
+ let killed = 0;
23
+ for (const orphan of orphans) {
24
+ const pid = parseInt(orphan.pid, 10);
25
+ const stopped = await stopProcess(pid, `${orphan.name} (PID ${orphan.pid})`);
26
+ if (stopped) {
27
+ killed++;
28
+ }
29
+ }
30
+
31
+ console.log(
32
+ `\nCleaned up ${killed} process${killed === 1 ? "" : "es"}.`,
33
+ );
34
+ }
@@ -47,7 +47,9 @@ import {
47
47
  startGateway,
48
48
  stopLocalProcesses,
49
49
  } from "../lib/local";
50
- import { isProcessAlive } from "../lib/process";
50
+ import { maybeStartNgrokTunnel } from "../lib/ngrok";
51
+ import { detectOrphanedProcesses } from "../lib/orphan-detection";
52
+ import { isProcessAlive, stopProcess } from "../lib/process";
51
53
  import { generateRandomSuffix } from "../lib/random-name";
52
54
  import { validateAssistantName } from "../lib/retire-archive";
53
55
  import { archiveLogFile, resetLogFile } from "../lib/xdg-log";
@@ -266,7 +268,16 @@ function parseArgs(): HatchArgs {
266
268
  }
267
269
  }
268
270
 
269
- return { species, detached, keepAlive, name, remote, daemonOnly, restart, watch };
271
+ return {
272
+ species,
273
+ detached,
274
+ keepAlive,
275
+ name,
276
+ remote,
277
+ daemonOnly,
278
+ restart,
279
+ watch,
280
+ };
270
281
  }
271
282
 
272
283
  function formatElapsed(ms: number): string {
@@ -721,6 +732,22 @@ async function hatchLocal(
721
732
  }
722
733
  }
723
734
 
735
+ // On desktop, scan the process table for orphaned vellum processes that
736
+ // are not tracked by any PID file or lock file entry and kill them before
737
+ // starting new ones. This prevents resource leaks when the desktop app
738
+ // crashes or is force-quit without a clean shutdown.
739
+ if (IS_DESKTOP) {
740
+ const orphans = await detectOrphanedProcesses();
741
+ if (orphans.length > 0) {
742
+ desktopLog(
743
+ `🧹 Found ${orphans.length} orphaned process${orphans.length === 1 ? "" : "es"} — cleaning up...`,
744
+ );
745
+ for (const orphan of orphans) {
746
+ await stopProcess(parseInt(orphan.pid, 10), `${orphan.name} (PID ${orphan.pid})`);
747
+ }
748
+ }
749
+ }
750
+
724
751
  // Reuse existing resources if re-hatching with --name that matches a known
725
752
  // local assistant, otherwise allocate fresh per-instance ports and directories.
726
753
  let resources: LocalInstanceResources;
@@ -731,7 +758,13 @@ async function hatchLocal(
731
758
  resources = await allocateLocalResources(instanceName);
732
759
  }
733
760
 
734
- const logsDir = join(resources.instanceDir, ".vellum", "workspace", "data", "logs");
761
+ const logsDir = join(
762
+ resources.instanceDir,
763
+ ".vellum",
764
+ "workspace",
765
+ "data",
766
+ "logs",
767
+ );
735
768
  archiveLogFile("hatch.log", logsDir);
736
769
  resetLogFile("hatch.log");
737
770
 
@@ -754,6 +787,21 @@ async function hatchLocal(
754
787
  throw error;
755
788
  }
756
789
 
790
+ // Auto-start ngrok if webhook integrations (e.g. Telegram) are configured.
791
+ // Set BASE_DATA_DIR so ngrok reads the correct instance config.
792
+ const prevBaseDataDir = process.env.BASE_DATA_DIR;
793
+ process.env.BASE_DATA_DIR = resources.instanceDir;
794
+ const ngrokChild = await maybeStartNgrokTunnel(resources.gatewayPort);
795
+ if (ngrokChild?.pid) {
796
+ const ngrokPidFile = join(resources.instanceDir, ".vellum", "ngrok.pid");
797
+ writeFileSync(ngrokPidFile, String(ngrokChild.pid));
798
+ }
799
+ if (prevBaseDataDir !== undefined) {
800
+ process.env.BASE_DATA_DIR = prevBaseDataDir;
801
+ } else {
802
+ delete process.env.BASE_DATA_DIR;
803
+ }
804
+
757
805
  // Read the bearer token (JWT) written by the daemon so the CLI can
758
806
  // with the gateway (which requires auth by default). The daemon writes under
759
807
  // getRootDir() which resolves to <instanceDir>/.vellum/.
@@ -831,9 +879,7 @@ async function hatchLocal(
831
879
  consecutiveFailures++;
832
880
  }
833
881
  if (consecutiveFailures >= MAX_FAILURES) {
834
- console.log(
835
- "\n⚠️ Gateway stopped responding — shutting down.",
836
- );
882
+ console.log("\n⚠️ Gateway stopped responding — shutting down.");
837
883
  await stopLocalProcesses(resources);
838
884
  process.exit(1);
839
885
  }
@@ -849,8 +895,16 @@ export async function hatch(): Promise<void> {
849
895
  const cliVersion = getCliVersion();
850
896
  console.log(`@vellumai/cli v${cliVersion}`);
851
897
 
852
- const { species, detached, keepAlive, name, remote, daemonOnly, restart, watch } =
853
- parseArgs();
898
+ const {
899
+ species,
900
+ detached,
901
+ keepAlive,
902
+ name,
903
+ remote,
904
+ daemonOnly,
905
+ restart,
906
+ watch,
907
+ } = parseArgs();
854
908
 
855
909
  if (restart && remote !== "local") {
856
910
  console.error(
@@ -1,5 +1,3 @@
1
- import { existsSync, readFileSync } from "fs";
2
- import { homedir } from "os";
3
1
  import { join } from "path";
4
2
 
5
3
  import {
@@ -9,6 +7,13 @@ import {
9
7
  type AssistantEntry,
10
8
  } from "../lib/assistant-config";
11
9
  import { checkHealth } from "../lib/health-check";
10
+ import {
11
+ classifyProcess,
12
+ detectOrphanedProcesses,
13
+ isProcessAlive,
14
+ parseRemotePs,
15
+ readPidFile,
16
+ } from "../lib/orphan-detection";
12
17
  import { pgrepExact } from "../lib/pgrep";
13
18
  import { probePort } from "../lib/port-probe";
14
19
  import { withStatusEmoji } from "../lib/status-emoji";
@@ -77,40 +82,6 @@ const REMOTE_PS_CMD = [
77
82
  "| grep -v grep",
78
83
  ].join(" ");
79
84
 
80
- interface RemoteProcess {
81
- pid: string;
82
- ppid: string;
83
- command: string;
84
- }
85
-
86
- function classifyProcess(command: string): string {
87
- if (/qdrant/.test(command)) return "qdrant";
88
- if (/vellum-gateway/.test(command)) return "gateway";
89
- if (/openclaw/.test(command)) return "openclaw-adapter";
90
- if (/vellum-daemon/.test(command)) return "assistant";
91
- if (/daemon\s+(start|restart)/.test(command)) return "assistant";
92
- // Exclude macOS desktop app processes — their path contains .app/Contents/MacOS/
93
- // but they are not background service processes.
94
- if (/\.app\/Contents\/MacOS\//.test(command)) return "unknown";
95
- if (/vellum/.test(command)) return "vellum";
96
- return "unknown";
97
- }
98
-
99
- function parseRemotePs(output: string): RemoteProcess[] {
100
- return output
101
- .trim()
102
- .split("\n")
103
- .filter((line) => line.trim().length > 0)
104
- .map((line) => {
105
- const trimmed = line.trim();
106
- const parts = trimmed.split(/\s+/);
107
- const pid = parts[0];
108
- const ppid = parts[1];
109
- const command = parts.slice(2).join(" ");
110
- return { pid, ppid, command };
111
- });
112
- }
113
-
114
85
  function extractHostFromUrl(url: string): string {
115
86
  try {
116
87
  const parsed = new URL(url);
@@ -157,21 +128,6 @@ interface ProcessSpec {
157
128
  pidFile: string;
158
129
  }
159
130
 
160
- function readPidFile(pidFile: string): string | null {
161
- if (!existsSync(pidFile)) return null;
162
- const pid = readFileSync(pidFile, "utf-8").trim();
163
- return pid || null;
164
- }
165
-
166
- function isProcessAlive(pid: string): boolean {
167
- try {
168
- process.kill(parseInt(pid, 10), 0);
169
- return true;
170
- } catch {
171
- return false;
172
- }
173
- }
174
-
175
131
  interface DetectedProcess {
176
132
  name: string;
177
133
  pid: string | null;
@@ -318,57 +274,6 @@ async function showAssistantProcesses(entry: AssistantEntry): Promise<void> {
318
274
  printTable(rows);
319
275
  }
320
276
 
321
- // ── Orphaned process detection ──────────────────────────────────
322
-
323
- interface OrphanedProcess {
324
- name: string;
325
- pid: string;
326
- source: string;
327
- }
328
-
329
- async function detectOrphanedProcesses(): Promise<OrphanedProcess[]> {
330
- const results: OrphanedProcess[] = [];
331
- const seenPids = new Set<string>();
332
- const vellumDir = join(homedir(), ".vellum");
333
-
334
- // Strategy 1: PID file scan
335
- const pidFiles: Array<{ file: string; name: string }> = [
336
- { file: join(vellumDir, "vellum.pid"), name: "assistant" },
337
- { file: join(vellumDir, "gateway.pid"), name: "gateway" },
338
- { file: join(vellumDir, "qdrant.pid"), name: "qdrant" },
339
- ];
340
-
341
- for (const { file, name } of pidFiles) {
342
- const pid = readPidFile(file);
343
- if (pid && isProcessAlive(pid)) {
344
- results.push({ name, pid, source: "pid file" });
345
- seenPids.add(pid);
346
- }
347
- }
348
-
349
- // Strategy 2: Process table scan
350
- try {
351
- const output = await execOutput("sh", [
352
- "-c",
353
- "ps ax -o pid=,ppid=,args= | grep -E 'vellum|vellum-gateway|qdrant|openclaw' | grep -v grep",
354
- ]);
355
- const procs = parseRemotePs(output);
356
- const ownPid = String(process.pid);
357
-
358
- for (const p of procs) {
359
- if (p.pid === ownPid || seenPids.has(p.pid)) continue;
360
- const type = classifyProcess(p.command);
361
- if (type === "unknown") continue;
362
- results.push({ name: type, pid: p.pid, source: "process table" });
363
- seenPids.add(p.pid);
364
- }
365
- } catch {
366
- // grep exits 1 when no matches found — ignore
367
- }
368
-
369
- return results;
370
- }
371
-
372
277
  // ── List all assistants (no arg) ────────────────────────────────
373
278
 
374
279
  async function listAllAssistants(): Promise<void> {
@@ -387,9 +292,8 @@ async function listAllAssistants(): Promise<void> {
387
292
  info: `PID ${o.pid} (from ${o.source})`,
388
293
  }));
389
294
  printTable(rows);
390
- const pids = orphans.map((o) => o.pid).join(" ");
391
295
  console.log(
392
- `\nHint: Run \`kill ${pids}\` to clean up orphaned processes.`,
296
+ `\nHint: Run \`vellum clean\` to clean up orphaned processes.`,
393
297
  );
394
298
  }
395
299
 
@@ -1,9 +1,10 @@
1
- import { existsSync, readFileSync } from "fs";
1
+ import { existsSync, readFileSync, writeFileSync } from "fs";
2
2
  import { join } from "path";
3
3
 
4
4
  import { resolveTargetAssistant } from "../lib/assistant-config.js";
5
5
  import { isProcessAlive, stopProcessByPidFile } from "../lib/process";
6
6
  import { startLocalDaemon, startGateway } from "../lib/local";
7
+ import { maybeStartNgrokTunnel } from "../lib/ngrok";
7
8
 
8
9
  export async function wake(): Promise<void> {
9
10
  const args = process.argv.slice(3);
@@ -95,5 +96,20 @@ export async function wake(): Promise<void> {
95
96
  }
96
97
  }
97
98
 
99
+ // Auto-start ngrok if webhook integrations (e.g. Telegram) are configured.
100
+ // Set BASE_DATA_DIR so ngrok reads the correct instance config.
101
+ const prevBaseDataDir = process.env.BASE_DATA_DIR;
102
+ process.env.BASE_DATA_DIR = resources.instanceDir;
103
+ const ngrokChild = await maybeStartNgrokTunnel(resources.gatewayPort);
104
+ if (ngrokChild?.pid) {
105
+ const ngrokPidFile = join(resources.instanceDir, ".vellum", "ngrok.pid");
106
+ writeFileSync(ngrokPidFile, String(ngrokChild.pid));
107
+ }
108
+ if (prevBaseDataDir !== undefined) {
109
+ process.env.BASE_DATA_DIR = prevBaseDataDir;
110
+ } else {
111
+ delete process.env.BASE_DATA_DIR;
112
+ }
113
+
98
114
  console.log("Wake complete.");
99
115
  }
@@ -34,6 +34,7 @@ export const ANSI = {
34
34
  } as const;
35
35
 
36
36
  export const SLASH_COMMANDS = [
37
+ "/btw",
37
38
  "/clear",
38
39
  "/doctor",
39
40
  "/exit",
@@ -98,7 +99,7 @@ const MIN_FEED_ROWS = 3;
98
99
  // Feed item height estimation
99
100
  const TOOL_CALL_CHROME_LINES = 2; // header (┌) + footer (└)
100
101
  const MESSAGE_SPACING = 1;
101
- const HELP_DISPLAY_HEIGHT = 6;
102
+ const HELP_DISPLAY_HEIGHT = 8;
102
103
 
103
104
  interface ListMessagesResponse {
104
105
  messages: RuntimeMessage[];
@@ -372,13 +373,7 @@ async function handleConfirmationPrompt(
372
373
  const index = await chatApp.showSelection("Tool Approval", options);
373
374
 
374
375
  if (index === 0) {
375
- await submitDecision(
376
- baseUrl,
377
- assistantId,
378
- requestId,
379
- "allow",
380
- bearerToken,
381
- );
376
+ await submitDecision(baseUrl, assistantId, requestId, "allow", bearerToken);
382
377
  chatApp.addStatus("\u2714 Allowed", "green");
383
378
  return;
384
379
  }
@@ -407,13 +402,7 @@ async function handleConfirmationPrompt(
407
402
  return;
408
403
  }
409
404
 
410
- await submitDecision(
411
- baseUrl,
412
- assistantId,
413
- requestId,
414
- "deny",
415
- bearerToken,
416
- );
405
+ await submitDecision(baseUrl, assistantId, requestId, "deny", bearerToken);
417
406
  chatApp.addStatus("\u2718 Denied", "yellow");
418
407
  }
419
408
 
@@ -450,13 +439,7 @@ async function handlePatternSelection(
450
439
  return;
451
440
  }
452
441
 
453
- await submitDecision(
454
- baseUrl,
455
- assistantId,
456
- requestId,
457
- "deny",
458
- bearerToken,
459
- );
442
+ await submitDecision(baseUrl, assistantId, requestId, "deny", bearerToken);
460
443
  chatApp.addStatus("\u2718 Denied", "yellow");
461
444
  }
462
445
 
@@ -504,13 +487,7 @@ async function handleScopeSelection(
504
487
  return;
505
488
  }
506
489
 
507
- await submitDecision(
508
- baseUrl,
509
- assistantId,
510
- requestId,
511
- "deny",
512
- bearerToken,
513
- );
490
+ await submitDecision(baseUrl, assistantId, requestId, "deny", bearerToken);
514
491
  chatApp.addStatus("\u2718 Denied", "yellow");
515
492
  }
516
493
 
@@ -642,6 +619,10 @@ function HelpDisplay(): ReactElement {
642
619
  return (
643
620
  <Box flexDirection="column">
644
621
  <Text bold>Commands:</Text>
622
+ <Text>
623
+ {" /btw <question> "}
624
+ <Text dimColor>Ask a side question while the assistant is working</Text>
625
+ </Text>
645
626
  <Text>
646
627
  {" /doctor [question] "}
647
628
  <Text dimColor>Run diagnostics on the remote instance via SSH</Text>
@@ -1265,7 +1246,6 @@ function ChatApp({
1265
1246
 
1266
1247
  const showSpinner = useCallback((text: string) => {
1267
1248
  setSpinnerText(text);
1268
- setInputFocused(false);
1269
1249
  }, []);
1270
1250
 
1271
1251
  const hideSpinner = useCallback(() => {
@@ -1501,79 +1481,6 @@ function ChatApp({
1501
1481
  return;
1502
1482
  }
1503
1483
 
1504
- if (trimmed === "/pair") {
1505
- h.showSpinner("Generating pairing credentials...");
1506
-
1507
- const isConnected = await ensureConnected();
1508
- if (!isConnected) {
1509
- h.hideSpinner();
1510
- h.showError("Cannot pair — not connected to the assistant runtime.");
1511
- return;
1512
- }
1513
-
1514
- try {
1515
- const pairingRequestId = randomUUID();
1516
- const pairingSecret = randomBytes(32).toString("hex");
1517
- const gatewayUrl = runtimeUrl;
1518
-
1519
- // Call /pairing/register on the gateway (dedicated pairing proxy route)
1520
- const registerUrl = `${runtimeUrl}/pairing/register`;
1521
- const registerRes = await fetch(registerUrl, {
1522
- method: "POST",
1523
- headers: {
1524
- "Content-Type": "application/json",
1525
- ...(bearerToken
1526
- ? { Authorization: `Bearer ${bearerToken}` }
1527
- : {}),
1528
- },
1529
- body: JSON.stringify({
1530
- pairingRequestId,
1531
- pairingSecret,
1532
- gatewayUrl,
1533
- }),
1534
- });
1535
-
1536
- if (!registerRes.ok) {
1537
- const body = await registerRes.text().catch(() => "");
1538
- throw new Error(
1539
- `HTTP ${registerRes.status}: ${body || registerRes.statusText}`,
1540
- );
1541
- }
1542
-
1543
- const hostId = createHash("sha256")
1544
- .update(hostname() + userInfo().username)
1545
- .digest("hex");
1546
- const payload = JSON.stringify({
1547
- type: "vellum-daemon",
1548
- v: 4,
1549
- id: hostId,
1550
- g: gatewayUrl,
1551
- pairingRequestId,
1552
- pairingSecret,
1553
- });
1554
-
1555
- const qrString = await new Promise<string>((resolve) => {
1556
- qrcode.generate(payload, { small: true }, (code: string) => {
1557
- resolve(code);
1558
- });
1559
- });
1560
-
1561
- h.hideSpinner();
1562
- h.addStatus(
1563
- `Pairing Ready\n\n` +
1564
- `Scan this QR code with the Vellum iOS app:\n\n` +
1565
- `${qrString}\n` +
1566
- `This pairing request expires in 5 minutes. Run /pair again to generate a new one.`,
1567
- );
1568
- } catch (err) {
1569
- h.hideSpinner();
1570
- h.showError(
1571
- `Pairing failed: ${err instanceof Error ? err.message : err}`,
1572
- );
1573
- }
1574
- return;
1575
- }
1576
-
1577
1484
  if (trimmed === "/retire") {
1578
1485
  if (!project || !zone) {
1579
1486
  h.showError(
@@ -1727,6 +1634,214 @@ function ChatApp({
1727
1634
  return;
1728
1635
  }
1729
1636
 
1637
+ // If a connection attempt is already in progress, don't silently drop input
1638
+ if (connectingRef.current) {
1639
+ h.addStatus(
1640
+ "Still connecting — please wait a moment and try again.",
1641
+ "yellow",
1642
+ );
1643
+ return;
1644
+ }
1645
+
1646
+ if (trimmed.startsWith("/btw ")) {
1647
+ const question = trimmed.slice(5).trim();
1648
+ if (!question) return;
1649
+
1650
+ h.addStatus(`/btw ${question}`, "gray");
1651
+
1652
+ const isConnected = await ensureConnected();
1653
+ if (!isConnected) return;
1654
+
1655
+ try {
1656
+ const res = await fetch(
1657
+ `${runtimeUrl}/v1/assistants/${assistantId}/btw`,
1658
+ {
1659
+ method: "POST",
1660
+ headers: {
1661
+ "Content-Type": "application/json",
1662
+ ...(bearerToken
1663
+ ? { Authorization: `Bearer ${bearerToken}` }
1664
+ : {}),
1665
+ },
1666
+ body: JSON.stringify({
1667
+ conversationKey: assistantId,
1668
+ content: question,
1669
+ }),
1670
+ signal: AbortSignal.timeout(30_000),
1671
+ },
1672
+ );
1673
+
1674
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
1675
+
1676
+ let fullText = "";
1677
+ let sseError = "";
1678
+ const reader = res.body?.getReader();
1679
+ const decoder = new TextDecoder();
1680
+ if (reader) {
1681
+ let buffer = "";
1682
+ let currentEvent = "";
1683
+ while (true) {
1684
+ const { done, value } = await reader.read();
1685
+ if (done) break;
1686
+ buffer += decoder.decode(value, { stream: true });
1687
+ const lines = buffer.split("\n");
1688
+ buffer = lines.pop() ?? "";
1689
+ for (const line of lines) {
1690
+ if (line.startsWith("event: ")) {
1691
+ currentEvent = line.slice(7).trim();
1692
+ } else if (line.startsWith("data: ")) {
1693
+ try {
1694
+ const data = JSON.parse(line.slice(6));
1695
+ if (currentEvent === "btw_error" || data.error) {
1696
+ sseError = data.error ?? data.text ?? "Unknown error";
1697
+ } else if (data.text) {
1698
+ fullText += data.text;
1699
+ }
1700
+ } catch {
1701
+ /* skip malformed */
1702
+ }
1703
+ } else if (line.trim() === "") {
1704
+ // Empty line marks end of SSE event; reset event type
1705
+ currentEvent = "";
1706
+ }
1707
+ }
1708
+ }
1709
+ }
1710
+ if (sseError) {
1711
+ h.showError(`/btw: ${sseError}`);
1712
+ } else {
1713
+ h.addStatus(fullText || "No response");
1714
+ }
1715
+ } catch (err) {
1716
+ h.showError(
1717
+ `/btw failed: ${err instanceof Error ? err.message : err}`,
1718
+ );
1719
+ }
1720
+ return;
1721
+ }
1722
+
1723
+ if (trimmed === "/pair") {
1724
+ h.showSpinner("Generating pairing credentials...");
1725
+
1726
+ const isConnected = await ensureConnected();
1727
+ if (!isConnected) {
1728
+ h.hideSpinner();
1729
+ h.showError("Cannot pair — not connected to the assistant runtime.");
1730
+ return;
1731
+ }
1732
+
1733
+ try {
1734
+ const pairingRequestId = randomUUID();
1735
+ const pairingSecret = randomBytes(32).toString("hex");
1736
+ const gatewayUrl = runtimeUrl;
1737
+
1738
+ // Call /pairing/register on the gateway (dedicated pairing proxy route)
1739
+ const registerUrl = `${runtimeUrl}/pairing/register`;
1740
+ const registerRes = await fetch(registerUrl, {
1741
+ method: "POST",
1742
+ headers: {
1743
+ "Content-Type": "application/json",
1744
+ ...(bearerToken
1745
+ ? { Authorization: `Bearer ${bearerToken}` }
1746
+ : {}),
1747
+ },
1748
+ body: JSON.stringify({
1749
+ pairingRequestId,
1750
+ pairingSecret,
1751
+ gatewayUrl,
1752
+ }),
1753
+ });
1754
+
1755
+ if (!registerRes.ok) {
1756
+ const body = await registerRes.text().catch(() => "");
1757
+ throw new Error(
1758
+ `HTTP ${registerRes.status}: ${body || registerRes.statusText}`,
1759
+ );
1760
+ }
1761
+
1762
+ const hostId = createHash("sha256")
1763
+ .update(hostname() + userInfo().username)
1764
+ .digest("hex");
1765
+ const payload = JSON.stringify({
1766
+ type: "vellum-daemon",
1767
+ v: 4,
1768
+ id: hostId,
1769
+ g: gatewayUrl,
1770
+ pairingRequestId,
1771
+ pairingSecret,
1772
+ });
1773
+
1774
+ const qrString = await new Promise<string>((resolve) => {
1775
+ qrcode.generate(payload, { small: true }, (code: string) => {
1776
+ resolve(code);
1777
+ });
1778
+ });
1779
+
1780
+ h.hideSpinner();
1781
+ h.addStatus(
1782
+ `Pairing Ready\n\n` +
1783
+ `Scan this QR code with the Vellum iOS app:\n\n` +
1784
+ `${qrString}\n` +
1785
+ `This pairing request expires in 5 minutes. Run /pair again to generate a new one.`,
1786
+ );
1787
+ } catch (err) {
1788
+ h.hideSpinner();
1789
+ h.showError(
1790
+ `Pairing failed: ${err instanceof Error ? err.message : err}`,
1791
+ );
1792
+ }
1793
+ return;
1794
+ }
1795
+
1796
+ if (busyRef.current) {
1797
+ // /btw is already handled above this block
1798
+ if (!trimmed.startsWith("/")) {
1799
+ const userMsg: RuntimeMessage = {
1800
+ id: "local-user-" + Date.now(),
1801
+ role: "user",
1802
+ content: trimmed,
1803
+ timestamp: new Date().toISOString(),
1804
+ };
1805
+ h.addMessage(userMsg);
1806
+ }
1807
+ const isConnected = await ensureConnected();
1808
+ if (!isConnected) {
1809
+ h.showError("Cannot send — not connected to the assistant.");
1810
+ setInputFocused(true);
1811
+ return;
1812
+ }
1813
+ try {
1814
+ const controller = new AbortController();
1815
+ const timeoutId = setTimeout(
1816
+ () => controller.abort(),
1817
+ SEND_TIMEOUT_MS,
1818
+ );
1819
+ const sendResult = await sendMessage(
1820
+ runtimeUrl,
1821
+ assistantId,
1822
+ trimmed,
1823
+ controller.signal,
1824
+ bearerToken,
1825
+ );
1826
+ clearTimeout(timeoutId);
1827
+ if (sendResult.accepted) {
1828
+ chatLogRef.current.push({ role: "user", content: trimmed });
1829
+ h.addStatus(
1830
+ "Message queued — will be processed after current response",
1831
+ "gray",
1832
+ );
1833
+ } else {
1834
+ h.showError("Message was not accepted by the assistant");
1835
+ }
1836
+ } catch (err) {
1837
+ h.showError(
1838
+ `Failed to queue message: ${err instanceof Error ? err.message : String(err)}`,
1839
+ );
1840
+ }
1841
+ setInputFocused(true);
1842
+ return;
1843
+ }
1844
+
1730
1845
  if (!trimmed.startsWith("/")) {
1731
1846
  const userMsg: RuntimeMessage = {
1732
1847
  id: "local-user-" + Date.now(),
package/src/index.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  #!/usr/bin/env bun
2
2
 
3
3
  import cliPkg from "../package.json";
4
+ import { clean } from "./commands/clean";
4
5
  import { client } from "./commands/client";
5
6
  import { hatch } from "./commands/hatch";
6
7
  import { login, logout, whoami } from "./commands/login";
@@ -15,6 +16,7 @@ import { use } from "./commands/use";
15
16
  import { wake } from "./commands/wake";
16
17
 
17
18
  const commands = {
19
+ clean,
18
20
  client,
19
21
  hatch,
20
22
  login,
@@ -46,6 +48,7 @@ async function main() {
46
48
  console.log("Usage: vellum <command> [options]");
47
49
  console.log("");
48
50
  console.log("Commands:");
51
+ console.log(" clean Kill orphaned vellum processes");
49
52
  console.log(" client Connect to a hatched assistant");
50
53
  console.log(" hatch Create a new assistant instance");
51
54
  console.log(" login Log in to the Vellum platform");
package/src/lib/aws.ts CHANGED
@@ -513,7 +513,6 @@ export async function hatchAws(
513
513
  const awsEntry: AssistantEntry = {
514
514
  assistantId: instanceName,
515
515
  runtimeUrl,
516
- bearerToken,
517
516
  cloud: "aws",
518
517
  instanceId,
519
518
  region,
package/src/lib/docker.ts CHANGED
@@ -10,7 +10,12 @@ import type { Species } from "./constants";
10
10
  import { discoverPublicUrl } from "./local";
11
11
  import { generateRandomSuffix } from "./random-name";
12
12
  import { exec, execOutput } from "./step-runner";
13
- import { closeLogFile, openLogFile, resetLogFile, writeToLogFile } from "./xdg-log";
13
+ import {
14
+ closeLogFile,
15
+ openLogFile,
16
+ resetLogFile,
17
+ writeToLogFile,
18
+ } from "./xdg-log";
14
19
 
15
20
  const _require = createRequire(import.meta.url);
16
21
 
@@ -26,7 +31,7 @@ interface DockerRoot {
26
31
  * Dockerfiles live under `meta/`, but when installed as an npm package they
27
32
  * are at the package root.
28
33
  */
29
- function findDockerRoot(): DockerRoot {
34
+ function findDockerRoot(developmentMode: boolean = false): DockerRoot {
30
35
  // Source tree: cli/src/lib/ -> repo root (Dockerfiles in meta/)
31
36
  const sourceTreeRoot = join(import.meta.dir, "..", "..", "..");
32
37
  if (existsSync(join(sourceTreeRoot, "meta", "Dockerfile"))) {
@@ -50,6 +55,27 @@ function findDockerRoot(): DockerRoot {
50
55
  dir = parent;
51
56
  }
52
57
 
58
+ // In development mode, walk up from the executable path to find the repo
59
+ // root. This handles the macOS app bundle case where the binary lives inside
60
+ // the repo at e.g. clients/macos/dist/Vellum.app/Contents/MacOS/.
61
+ if (developmentMode) {
62
+ let execDir = dirname(process.execPath);
63
+ while (true) {
64
+ if (existsSync(join(execDir, "meta", "Dockerfile.development"))) {
65
+ return { root: execDir, dockerfileDir: "meta" };
66
+ }
67
+ const parent = dirname(execDir);
68
+ if (parent === execDir) break;
69
+ execDir = parent;
70
+ }
71
+ }
72
+
73
+ // macOS app bundle: Contents/MacOS/vellum-cli -> Contents/Resources/Dockerfile
74
+ const appResourcesDir = join(dirname(process.execPath), "..", "Resources");
75
+ if (existsSync(join(appResourcesDir, "Dockerfile"))) {
76
+ return { root: appResourcesDir, dockerfileDir: "." };
77
+ }
78
+
53
79
  // Fall back to Node module resolution for the `vellum` package
54
80
  try {
55
81
  const vellumPkgPath = _require.resolve("vellum/package.json");
@@ -152,19 +178,41 @@ export async function hatchDocker(
152
178
  name: string | null,
153
179
  watch: boolean,
154
180
  ): Promise<void> {
155
- const { root: repoRoot, dockerfileDir } = findDockerRoot();
181
+ resetLogFile("hatch.log");
182
+
183
+ let repoRoot: string;
184
+ let dockerfileDir: string;
185
+ try {
186
+ ({ root: repoRoot, dockerfileDir } = findDockerRoot(watch));
187
+ } catch (err) {
188
+ const message = err instanceof Error ? err.message : String(err);
189
+ const logFd = openLogFile("hatch.log");
190
+ writeToLogFile(
191
+ logFd,
192
+ `[docker-hatch] ${new Date().toISOString()} ERROR\n${message}\n`,
193
+ );
194
+ closeLogFile(logFd);
195
+ console.error(message);
196
+ throw err;
197
+ }
198
+
156
199
  const instanceName = name ?? `${species}-${generateRandomSuffix()}`;
157
200
  const dockerfileName = watch ? "Dockerfile.development" : "Dockerfile";
158
201
  const dockerfile = join(dockerfileDir, dockerfileName);
159
202
  const dockerfilePath = join(repoRoot, dockerfile);
160
203
 
161
204
  if (!existsSync(dockerfilePath)) {
162
- console.error(`Error: ${dockerfile} not found at ${dockerfilePath}`);
205
+ const message = `Error: ${dockerfile} not found at ${dockerfilePath}`;
206
+ const logFd = openLogFile("hatch.log");
207
+ writeToLogFile(
208
+ logFd,
209
+ `[docker-hatch] ${new Date().toISOString()} ERROR\n${message}\n`,
210
+ );
211
+ closeLogFile(logFd);
212
+ console.error(message);
163
213
  process.exit(1);
164
214
  }
165
215
 
166
- resetLogFile("hatch.log");
167
-
168
216
  console.log(`🥚 Hatching Docker assistant: ${instanceName}`);
169
217
  console.log(` Species: ${species}`);
170
218
  console.log(` Dockerfile: ${dockerfile}`);
@@ -182,7 +230,10 @@ export async function hatchDocker(
182
230
  });
183
231
  } catch (err) {
184
232
  const message = err instanceof Error ? err.message : String(err);
185
- writeToLogFile(logFd, `[docker-build] ${new Date().toISOString()} ERROR\n${message}\n`);
233
+ writeToLogFile(
234
+ logFd,
235
+ `[docker-build] ${new Date().toISOString()} ERROR\n${message}\n`,
236
+ );
186
237
  closeLogFile(logFd);
187
238
  throw err;
188
239
  }
@@ -244,13 +295,21 @@ export async function hatchDocker(
244
295
  // requires an extra argument the Dockerfile doesn't include.
245
296
  const containerCmd: string[] =
246
297
  species !== "vellum"
247
- ? ["vellum", "hatch", species, ...(watch ? ["--watch"] : []), "--keep-alive"]
298
+ ? [
299
+ "vellum",
300
+ "hatch",
301
+ species,
302
+ ...(watch ? ["--watch"] : []),
303
+ "--keep-alive",
304
+ ]
248
305
  : [];
249
306
 
250
307
  // Always start the container detached so it keeps running after the CLI exits.
251
308
  runArgs.push("-d");
252
309
  console.log("🚀 Starting Docker container...");
253
- await exec("docker", [...runArgs, imageTag, ...containerCmd], { cwd: repoRoot });
310
+ await exec("docker", [...runArgs, imageTag, ...containerCmd], {
311
+ cwd: repoRoot,
312
+ });
254
313
 
255
314
  if (detached) {
256
315
  console.log("\n✅ Docker assistant hatched!\n");
@@ -304,7 +363,13 @@ export async function hatchDocker(
304
363
  child.on("close", (code) => {
305
364
  // The log tail may exit if the container stops before the sentinel
306
365
  // is seen, or we killed it after detecting the sentinel.
307
- if (code === 0 || code === null || code === 130 || code === 137 || code === 143) {
366
+ if (
367
+ code === 0 ||
368
+ code === null ||
369
+ code === 130 ||
370
+ code === 137 ||
371
+ code === 143
372
+ ) {
308
373
  resolve();
309
374
  } else {
310
375
  reject(new Error(`Docker container exited with code ${code}`));
package/src/lib/gcp.ts CHANGED
@@ -441,45 +441,6 @@ async function recoverFromCurlFailure(
441
441
  } catch {}
442
442
  }
443
443
 
444
- async function fetchRemoteBearerToken(
445
- instanceName: string,
446
- project: string,
447
- zone: string,
448
- sshUser: string,
449
- account?: string,
450
- ): Promise<string | null> {
451
- try {
452
- const remoteCmd =
453
- 'cat ~/.vellum.lock.json 2>/dev/null || cat ~/.vellum.lockfile.json 2>/dev/null || echo "{}"';
454
- const args = [
455
- "compute",
456
- "ssh",
457
- `${sshUser}@${instanceName}`,
458
- `--project=${project}`,
459
- `--zone=${zone}`,
460
- "--quiet",
461
- "--ssh-flag=-o StrictHostKeyChecking=no",
462
- "--ssh-flag=-o UserKnownHostsFile=/dev/null",
463
- "--ssh-flag=-o ConnectTimeout=10",
464
- "--ssh-flag=-o LogLevel=ERROR",
465
- `--command=${remoteCmd}`,
466
- ];
467
- if (account) args.push(`--account=${account}`);
468
- const output = await execOutput("gcloud", args);
469
- const data = JSON.parse(output.trim());
470
- const assistants = data.assistants;
471
- if (Array.isArray(assistants) && assistants.length > 0) {
472
- const token = assistants[0].bearerToken;
473
- if (typeof token === "string" && token) {
474
- return token;
475
- }
476
- }
477
- return null;
478
- } catch {
479
- return null;
480
- }
481
- }
482
-
483
444
  export async function hatchGcp(
484
445
  species: Species,
485
446
  detached: boolean,
@@ -629,7 +590,6 @@ export async function hatchGcp(
629
590
  const gcpEntry: AssistantEntry = {
630
591
  assistantId: instanceName,
631
592
  runtimeUrl,
632
- bearerToken,
633
593
  cloud: "gcp",
634
594
  project,
635
595
  zone,
@@ -694,18 +654,6 @@ export async function hatchGcp(
694
654
  }
695
655
  }
696
656
 
697
- const remoteBearerToken = await fetchRemoteBearerToken(
698
- instanceName,
699
- project,
700
- zone,
701
- sshUser,
702
- account,
703
- );
704
- if (remoteBearerToken) {
705
- gcpEntry.bearerToken = remoteBearerToken;
706
- saveAssistantEntry(gcpEntry);
707
- }
708
-
709
657
  console.log("Instance details:");
710
658
  console.log(` Name: ${instanceName}`);
711
659
  console.log(` Project: ${project}`);
package/src/lib/local.ts CHANGED
@@ -1046,4 +1046,20 @@ export async function stopLocalProcesses(
1046
1046
 
1047
1047
  const gatewayPidFile = join(vellumDir, "gateway.pid");
1048
1048
  await stopProcessByPidFile(gatewayPidFile, "gateway", undefined, 7000);
1049
+
1050
+ // Kill ngrok directly by PID rather than using stopProcessByPidFile, because
1051
+ // isVellumProcess() checks for /vellum|@vellumai|--vellum-gateway/ which
1052
+ // won't match the ngrok binary — resulting in a no-op that leaves ngrok running.
1053
+ const ngrokPidFile = join(vellumDir, "ngrok.pid");
1054
+ if (existsSync(ngrokPidFile)) {
1055
+ try {
1056
+ const pid = parseInt(readFileSync(ngrokPidFile, "utf-8").trim(), 10);
1057
+ if (!isNaN(pid)) {
1058
+ try {
1059
+ process.kill(pid, "SIGTERM");
1060
+ } catch {}
1061
+ }
1062
+ unlinkSync(ngrokPidFile);
1063
+ } catch {}
1064
+ }
1049
1065
  }
package/src/lib/ngrok.ts CHANGED
@@ -115,6 +115,7 @@ export async function findExistingTunnel(
115
115
  */
116
116
  export function startNgrokProcess(targetPort: number): ChildProcess {
117
117
  const child = spawn("ngrok", ["http", String(targetPort), "--log=stdout"], {
118
+ detached: true,
118
119
  stdio: ["ignore", "pipe", "pipe"],
119
120
  });
120
121
  return child;
@@ -168,6 +169,97 @@ function clearIngressUrl(): void {
168
169
  saveRawConfig(config);
169
170
  }
170
171
 
172
+ /**
173
+ * Check whether any webhook-based integrations (e.g. Telegram) are configured
174
+ * that require a public ingress URL.
175
+ */
176
+ function hasWebhookIntegrationsConfigured(): boolean {
177
+ try {
178
+ const config = loadRawConfig();
179
+ const telegram = config.telegram as Record<string, unknown> | undefined;
180
+ if (telegram?.botUsername) return true;
181
+ return false;
182
+ } catch {
183
+ return false;
184
+ }
185
+ }
186
+
187
+ /**
188
+ * Check whether a non-ngrok ingress URL is already configured (e.g. custom
189
+ * domain or cloud deployment), meaning ngrok is not needed.
190
+ */
191
+ function hasNonNgrokIngressUrl(): boolean {
192
+ try {
193
+ const config = loadRawConfig();
194
+ const ingress = config.ingress as Record<string, unknown> | undefined;
195
+ const publicBaseUrl = ingress?.publicBaseUrl;
196
+ if (!publicBaseUrl || typeof publicBaseUrl !== "string") return false;
197
+ return !publicBaseUrl.includes("ngrok");
198
+ } catch {
199
+ return false;
200
+ }
201
+ }
202
+
203
+ /**
204
+ * Auto-start an ngrok tunnel if webhook integrations are configured and no
205
+ * non-ngrok ingress URL is present. Designed to be called during daemon/gateway
206
+ * startup. Non-fatal: if ngrok is unavailable or fails, startup continues.
207
+ *
208
+ * Returns the spawned ngrok child process (for PID tracking) or null.
209
+ */
210
+ export async function maybeStartNgrokTunnel(
211
+ targetPort: number,
212
+ ): Promise<ChildProcess | null> {
213
+ if (!hasWebhookIntegrationsConfigured()) return null;
214
+ if (hasNonNgrokIngressUrl()) return null;
215
+
216
+ const version = getNgrokVersion();
217
+ if (!version) return null;
218
+
219
+ // Reuse an existing tunnel if one is already running
220
+ const existingUrl = await findExistingTunnel(targetPort);
221
+ if (existingUrl) {
222
+ console.log(` Found existing ngrok tunnel: ${existingUrl}`);
223
+ saveIngressUrl(existingUrl);
224
+ return null;
225
+ }
226
+
227
+ console.log(` Starting ngrok tunnel for webhook integrations...`);
228
+ const ngrokProcess = startNgrokProcess(targetPort);
229
+
230
+ // Pipe output for debugging but don't block on it
231
+ ngrokProcess.stdout?.on("data", (data: Buffer) => {
232
+ const line = data.toString().trim();
233
+ if (line) console.log(`[ngrok] ${line}`);
234
+ });
235
+ ngrokProcess.stderr?.on("data", (data: Buffer) => {
236
+ const line = data.toString().trim();
237
+ if (line) console.error(`[ngrok] ${line}`);
238
+ });
239
+
240
+ try {
241
+ const publicUrl = await waitForNgrokUrl();
242
+ saveIngressUrl(publicUrl);
243
+ console.log(` Tunnel established: ${publicUrl}`);
244
+
245
+ // Detach the ngrok process so the CLI (hatch/wake) can exit without
246
+ // keeping it alive. Remove stdout/stderr listeners and unref all handles.
247
+ ngrokProcess.stdout?.removeAllListeners("data");
248
+ ngrokProcess.stderr?.removeAllListeners("data");
249
+ ngrokProcess.stdout?.destroy();
250
+ ngrokProcess.stderr?.destroy();
251
+ ngrokProcess.unref();
252
+
253
+ return ngrokProcess;
254
+ } catch {
255
+ console.warn(
256
+ ` ⚠ Could not start ngrok tunnel. Webhook integrations may not work until you run \`vellum tunnel\`.`,
257
+ );
258
+ if (!ngrokProcess.killed) ngrokProcess.kill("SIGTERM");
259
+ return null;
260
+ }
261
+ }
262
+
171
263
  /**
172
264
  * Run the ngrok tunnel workflow: check installation, find or start a tunnel,
173
265
  * save the public URL to config, and block until exit or signal.
@@ -0,0 +1,103 @@
1
+ import { existsSync, readFileSync } from "fs";
2
+ import { homedir } from "os";
3
+ import { join } from "path";
4
+
5
+ import { execOutput } from "./step-runner";
6
+
7
+ export interface RemoteProcess {
8
+ pid: string;
9
+ ppid: string;
10
+ command: string;
11
+ }
12
+
13
+ export function classifyProcess(command: string): string {
14
+ if (/qdrant/.test(command)) return "qdrant";
15
+ if (/vellum-gateway/.test(command)) return "gateway";
16
+ if (/openclaw/.test(command)) return "openclaw-adapter";
17
+ if (/vellum-daemon/.test(command)) return "assistant";
18
+ if (/daemon\s+(start|restart)/.test(command)) return "assistant";
19
+ // Exclude macOS desktop app processes — their path contains .app/Contents/MacOS/
20
+ // but they are not background service processes.
21
+ if (/\.app\/Contents\/MacOS\//.test(command)) return "unknown";
22
+ if (/vellum/.test(command)) return "vellum";
23
+ return "unknown";
24
+ }
25
+
26
+ export function parseRemotePs(output: string): RemoteProcess[] {
27
+ return output
28
+ .trim()
29
+ .split("\n")
30
+ .filter((line) => line.trim().length > 0)
31
+ .map((line) => {
32
+ const trimmed = line.trim();
33
+ const parts = trimmed.split(/\s+/);
34
+ const pid = parts[0];
35
+ const ppid = parts[1];
36
+ const command = parts.slice(2).join(" ");
37
+ return { pid, ppid, command };
38
+ });
39
+ }
40
+
41
+ export function readPidFile(pidFile: string): string | null {
42
+ if (!existsSync(pidFile)) return null;
43
+ const pid = readFileSync(pidFile, "utf-8").trim();
44
+ return pid || null;
45
+ }
46
+
47
+ export function isProcessAlive(pid: string): boolean {
48
+ try {
49
+ process.kill(parseInt(pid, 10), 0);
50
+ return true;
51
+ } catch {
52
+ return false;
53
+ }
54
+ }
55
+
56
+ export interface OrphanedProcess {
57
+ name: string;
58
+ pid: string;
59
+ source: string;
60
+ }
61
+
62
+ export async function detectOrphanedProcesses(): Promise<OrphanedProcess[]> {
63
+ const results: OrphanedProcess[] = [];
64
+ const seenPids = new Set<string>();
65
+ const vellumDir = join(homedir(), ".vellum");
66
+
67
+ // Strategy 1: PID file scan
68
+ const pidFiles: Array<{ file: string; name: string }> = [
69
+ { file: join(vellumDir, "vellum.pid"), name: "assistant" },
70
+ { file: join(vellumDir, "gateway.pid"), name: "gateway" },
71
+ { file: join(vellumDir, "qdrant.pid"), name: "qdrant" },
72
+ ];
73
+
74
+ for (const { file, name } of pidFiles) {
75
+ const pid = readPidFile(file);
76
+ if (pid && isProcessAlive(pid)) {
77
+ results.push({ name, pid, source: "pid file" });
78
+ seenPids.add(pid);
79
+ }
80
+ }
81
+
82
+ // Strategy 2: Process table scan
83
+ try {
84
+ const output = await execOutput("sh", [
85
+ "-c",
86
+ "ps ax -o pid=,ppid=,args= | grep -E 'vellum|vellum-gateway|qdrant|openclaw' | grep -v grep",
87
+ ]);
88
+ const procs = parseRemotePs(output);
89
+ const ownPid = String(process.pid);
90
+
91
+ for (const p of procs) {
92
+ if (p.pid === ownPid || seenPids.has(p.pid)) continue;
93
+ const type = classifyProcess(p.command);
94
+ if (type === "unknown") continue;
95
+ results.push({ name: type, pid: p.pid, source: "process table" });
96
+ seenPids.add(p.pid);
97
+ }
98
+ } catch {
99
+ // grep exits 1 when no matches found — ignore
100
+ }
101
+
102
+ return results;
103
+ }