@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.
- package/CONTRIBUTING.md +5 -0
- package/package.json +1 -1
- package/src/commands/clean.ts +34 -0
- package/src/commands/hatch.ts +44 -7
- package/src/commands/ps.ts +8 -104
- package/src/commands/wake.ts +17 -1
- package/src/components/DefaultMainScreen.tsx +218 -103
- package/src/index.ts +3 -0
- package/src/lib/aws.ts +0 -1
- package/src/lib/docker.ts +59 -9
- package/src/lib/gcp.ts +0 -52
- package/src/lib/local.ts +16 -0
- package/src/lib/ngrok.ts +92 -0
- package/src/lib/orphan-detection.ts +103 -0
package/CONTRIBUTING.md
ADDED
|
@@ -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
|
@@ -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
|
+
}
|
package/src/commands/hatch.ts
CHANGED
|
@@ -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 {
|
|
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(
|
|
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 {
|
|
853
|
-
|
|
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(
|
package/src/commands/ps.ts
CHANGED
|
@@ -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 \`
|
|
296
|
+
`\nHint: Run \`vellum clean\` to clean up orphaned processes.`,
|
|
393
297
|
);
|
|
394
298
|
}
|
|
395
299
|
|
package/src/commands/wake.ts
CHANGED
|
@@ -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 =
|
|
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
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
? [
|
|
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], {
|
|
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 (
|
|
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
|
+
}
|