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