@vellumai/cli 0.4.44 → 0.4.45

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.45",
4
4
  "description": "CLI tools for vellum-assistant",
5
5
  "type": "module",
6
6
  "exports": {
@@ -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,6 +47,7 @@ import {
47
47
  startGateway,
48
48
  stopLocalProcesses,
49
49
  } from "../lib/local";
50
+ import { maybeStartNgrokTunnel } from "../lib/ngrok";
50
51
  import { isProcessAlive } from "../lib/process";
51
52
  import { generateRandomSuffix } from "../lib/random-name";
52
53
  import { validateAssistantName } from "../lib/retire-archive";
@@ -266,7 +267,16 @@ function parseArgs(): HatchArgs {
266
267
  }
267
268
  }
268
269
 
269
- return { species, detached, keepAlive, name, remote, daemonOnly, restart, watch };
270
+ return {
271
+ species,
272
+ detached,
273
+ keepAlive,
274
+ name,
275
+ remote,
276
+ daemonOnly,
277
+ restart,
278
+ watch,
279
+ };
270
280
  }
271
281
 
272
282
  function formatElapsed(ms: number): string {
@@ -731,7 +741,13 @@ async function hatchLocal(
731
741
  resources = await allocateLocalResources(instanceName);
732
742
  }
733
743
 
734
- const logsDir = join(resources.instanceDir, ".vellum", "workspace", "data", "logs");
744
+ const logsDir = join(
745
+ resources.instanceDir,
746
+ ".vellum",
747
+ "workspace",
748
+ "data",
749
+ "logs",
750
+ );
735
751
  archiveLogFile("hatch.log", logsDir);
736
752
  resetLogFile("hatch.log");
737
753
 
@@ -754,6 +770,21 @@ async function hatchLocal(
754
770
  throw error;
755
771
  }
756
772
 
773
+ // Auto-start ngrok if webhook integrations (e.g. Telegram) are configured.
774
+ // Set BASE_DATA_DIR so ngrok reads the correct instance config.
775
+ const prevBaseDataDir = process.env.BASE_DATA_DIR;
776
+ process.env.BASE_DATA_DIR = resources.instanceDir;
777
+ const ngrokChild = await maybeStartNgrokTunnel(resources.gatewayPort);
778
+ if (ngrokChild?.pid) {
779
+ const ngrokPidFile = join(resources.instanceDir, ".vellum", "ngrok.pid");
780
+ writeFileSync(ngrokPidFile, String(ngrokChild.pid));
781
+ }
782
+ if (prevBaseDataDir !== undefined) {
783
+ process.env.BASE_DATA_DIR = prevBaseDataDir;
784
+ } else {
785
+ delete process.env.BASE_DATA_DIR;
786
+ }
787
+
757
788
  // Read the bearer token (JWT) written by the daemon so the CLI can
758
789
  // with the gateway (which requires auth by default). The daemon writes under
759
790
  // getRootDir() which resolves to <instanceDir>/.vellum/.
@@ -831,9 +862,7 @@ async function hatchLocal(
831
862
  consecutiveFailures++;
832
863
  }
833
864
  if (consecutiveFailures >= MAX_FAILURES) {
834
- console.log(
835
- "\n⚠️ Gateway stopped responding — shutting down.",
836
- );
865
+ console.log("\n⚠️ Gateway stopped responding — shutting down.");
837
866
  await stopLocalProcesses(resources);
838
867
  process.exit(1);
839
868
  }
@@ -849,8 +878,16 @@ export async function hatch(): Promise<void> {
849
878
  const cliVersion = getCliVersion();
850
879
  console.log(`@vellumai/cli v${cliVersion}`);
851
880
 
852
- const { species, detached, keepAlive, name, remote, daemonOnly, restart, watch } =
853
- parseArgs();
881
+ const {
882
+ species,
883
+ detached,
884
+ keepAlive,
885
+ name,
886
+ remote,
887
+ daemonOnly,
888
+ restart,
889
+ watch,
890
+ } = parseArgs();
854
891
 
855
892
  if (restart && remote !== "local") {
856
893
  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
 
@@ -50,6 +55,12 @@ function findDockerRoot(): DockerRoot {
50
55
  dir = parent;
51
56
  }
52
57
 
58
+ // macOS app bundle: Contents/MacOS/vellum-cli -> Contents/Resources/Dockerfile
59
+ const appResourcesDir = join(dirname(process.execPath), "..", "Resources");
60
+ if (existsSync(join(appResourcesDir, "Dockerfile"))) {
61
+ return { root: appResourcesDir, dockerfileDir: "." };
62
+ }
63
+
53
64
  // Fall back to Node module resolution for the `vellum` package
54
65
  try {
55
66
  const vellumPkgPath = _require.resolve("vellum/package.json");
@@ -152,19 +163,41 @@ export async function hatchDocker(
152
163
  name: string | null,
153
164
  watch: boolean,
154
165
  ): Promise<void> {
155
- const { root: repoRoot, dockerfileDir } = findDockerRoot();
166
+ resetLogFile("hatch.log");
167
+
168
+ let repoRoot: string;
169
+ let dockerfileDir: string;
170
+ try {
171
+ ({ root: repoRoot, dockerfileDir } = findDockerRoot());
172
+ } catch (err) {
173
+ const message = err instanceof Error ? err.message : String(err);
174
+ const logFd = openLogFile("hatch.log");
175
+ writeToLogFile(
176
+ logFd,
177
+ `[docker-hatch] ${new Date().toISOString()} ERROR\n${message}\n`,
178
+ );
179
+ closeLogFile(logFd);
180
+ console.error(message);
181
+ throw err;
182
+ }
183
+
156
184
  const instanceName = name ?? `${species}-${generateRandomSuffix()}`;
157
185
  const dockerfileName = watch ? "Dockerfile.development" : "Dockerfile";
158
186
  const dockerfile = join(dockerfileDir, dockerfileName);
159
187
  const dockerfilePath = join(repoRoot, dockerfile);
160
188
 
161
189
  if (!existsSync(dockerfilePath)) {
162
- console.error(`Error: ${dockerfile} not found at ${dockerfilePath}`);
190
+ const message = `Error: ${dockerfile} not found at ${dockerfilePath}`;
191
+ const logFd = openLogFile("hatch.log");
192
+ writeToLogFile(
193
+ logFd,
194
+ `[docker-hatch] ${new Date().toISOString()} ERROR\n${message}\n`,
195
+ );
196
+ closeLogFile(logFd);
197
+ console.error(message);
163
198
  process.exit(1);
164
199
  }
165
200
 
166
- resetLogFile("hatch.log");
167
-
168
201
  console.log(`🥚 Hatching Docker assistant: ${instanceName}`);
169
202
  console.log(` Species: ${species}`);
170
203
  console.log(` Dockerfile: ${dockerfile}`);
@@ -182,7 +215,10 @@ export async function hatchDocker(
182
215
  });
183
216
  } catch (err) {
184
217
  const message = err instanceof Error ? err.message : String(err);
185
- writeToLogFile(logFd, `[docker-build] ${new Date().toISOString()} ERROR\n${message}\n`);
218
+ writeToLogFile(
219
+ logFd,
220
+ `[docker-build] ${new Date().toISOString()} ERROR\n${message}\n`,
221
+ );
186
222
  closeLogFile(logFd);
187
223
  throw err;
188
224
  }
@@ -244,13 +280,21 @@ export async function hatchDocker(
244
280
  // requires an extra argument the Dockerfile doesn't include.
245
281
  const containerCmd: string[] =
246
282
  species !== "vellum"
247
- ? ["vellum", "hatch", species, ...(watch ? ["--watch"] : []), "--keep-alive"]
283
+ ? [
284
+ "vellum",
285
+ "hatch",
286
+ species,
287
+ ...(watch ? ["--watch"] : []),
288
+ "--keep-alive",
289
+ ]
248
290
  : [];
249
291
 
250
292
  // Always start the container detached so it keeps running after the CLI exits.
251
293
  runArgs.push("-d");
252
294
  console.log("🚀 Starting Docker container...");
253
- await exec("docker", [...runArgs, imageTag, ...containerCmd], { cwd: repoRoot });
295
+ await exec("docker", [...runArgs, imageTag, ...containerCmd], {
296
+ cwd: repoRoot,
297
+ });
254
298
 
255
299
  if (detached) {
256
300
  console.log("\n✅ Docker assistant hatched!\n");
@@ -304,7 +348,13 @@ export async function hatchDocker(
304
348
  child.on("close", (code) => {
305
349
  // The log tail may exit if the container stops before the sentinel
306
350
  // is seen, or we killed it after detecting the sentinel.
307
- if (code === 0 || code === null || code === 130 || code === 137 || code === 143) {
351
+ if (
352
+ code === 0 ||
353
+ code === null ||
354
+ code === 130 ||
355
+ code === 137 ||
356
+ code === 143
357
+ ) {
308
358
  resolve();
309
359
  } else {
310
360
  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
+ }