@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.
- package/CONTRIBUTING.md +5 -0
- package/package.json +2 -2
- package/src/adapters/install.sh +24 -11
- package/src/commands/clean.ts +34 -0
- package/src/commands/hatch.ts +62 -8
- 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 +75 -10
- 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
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vellumai/cli",
|
|
3
|
-
"version": "0.4.
|
|
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
|
-
"
|
|
14
|
+
"vellum": "./src/index.ts"
|
|
15
15
|
},
|
|
16
16
|
"scripts": {
|
|
17
17
|
"format": "prettier --write .",
|
package/src/adapters/install.sh
CHANGED
|
@@ -179,27 +179,30 @@ configure_shell_profile() {
|
|
|
179
179
|
done
|
|
180
180
|
}
|
|
181
181
|
|
|
182
|
-
# Create a symlink so
|
|
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
|
-
|
|
187
|
-
|
|
188
|
-
|
|
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
|
|
195
|
+
# Skip if the command is already resolvable outside of ~/.bun/bin
|
|
193
196
|
local resolved
|
|
194
|
-
resolved=$(command -v
|
|
195
|
-
if [ -n "$resolved" ] && [ "$resolved" != "$
|
|
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 "$
|
|
202
|
-
success "Symlinked /usr/local/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 "$
|
|
211
|
-
success "Symlinked $local_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
|
+
}
|
package/src/commands/hatch.ts
CHANGED
|
@@ -47,7 +47,9 @@ import {
|
|
|
47
47
|
startGateway,
|
|
48
48
|
stopLocalProcesses,
|
|
49
49
|
} from "../lib/local";
|
|
50
|
-
import {
|
|
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 {
|
|
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(
|
|
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 {
|
|
853
|
-
|
|
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(
|
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
|
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
? [
|
|
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], {
|
|
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 (
|
|
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
|
+
}
|