@vellumai/cli 0.4.43 → 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/__tests__/assistant-config.test.ts +249 -1
- package/src/__tests__/multi-local.test.ts +6 -5
- package/src/commands/clean.ts +34 -0
- package/src/commands/hatch.ts +48 -6
- package/src/commands/ps.ts +8 -104
- package/src/commands/wake.ts +17 -1
- package/src/components/DefaultMainScreen.tsx +234 -105
- package/src/index.ts +3 -0
- package/src/lib/assistant-config.ts +144 -6
- package/src/lib/aws.ts +0 -1
- package/src/lib/docker.ts +59 -7
- package/src/lib/gcp.ts +0 -52
- package/src/lib/local.ts +38 -20
- package/src/lib/ngrok.ts +92 -0
- package/src/lib/orphan-detection.ts +103 -0
- package/src/lib/xdg-log.ts +47 -3
|
@@ -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[];
|
|
@@ -152,6 +153,19 @@ interface HealthResponse {
|
|
|
152
153
|
message?: string;
|
|
153
154
|
}
|
|
154
155
|
|
|
156
|
+
/** Extract human-readable message from a daemon JSON error response. */
|
|
157
|
+
function friendlyErrorMessage(status: number, body: string): string {
|
|
158
|
+
try {
|
|
159
|
+
const parsed = JSON.parse(body) as { error?: { message?: string } };
|
|
160
|
+
if (parsed?.error?.message) {
|
|
161
|
+
return parsed.error.message;
|
|
162
|
+
}
|
|
163
|
+
} catch {
|
|
164
|
+
// Not JSON — fall through
|
|
165
|
+
}
|
|
166
|
+
return `HTTP ${status}: ${body || "Unknown error"}`;
|
|
167
|
+
}
|
|
168
|
+
|
|
155
169
|
async function runtimeRequest<T>(
|
|
156
170
|
baseUrl: string,
|
|
157
171
|
assistantId: string,
|
|
@@ -171,7 +185,7 @@ async function runtimeRequest<T>(
|
|
|
171
185
|
|
|
172
186
|
if (!response.ok) {
|
|
173
187
|
const body = await response.text().catch(() => "");
|
|
174
|
-
throw new Error(
|
|
188
|
+
throw new Error(friendlyErrorMessage(response.status, body));
|
|
175
189
|
}
|
|
176
190
|
|
|
177
191
|
if (response.status === 204) {
|
|
@@ -359,13 +373,7 @@ async function handleConfirmationPrompt(
|
|
|
359
373
|
const index = await chatApp.showSelection("Tool Approval", options);
|
|
360
374
|
|
|
361
375
|
if (index === 0) {
|
|
362
|
-
await submitDecision(
|
|
363
|
-
baseUrl,
|
|
364
|
-
assistantId,
|
|
365
|
-
requestId,
|
|
366
|
-
"allow",
|
|
367
|
-
bearerToken,
|
|
368
|
-
);
|
|
376
|
+
await submitDecision(baseUrl, assistantId, requestId, "allow", bearerToken);
|
|
369
377
|
chatApp.addStatus("\u2714 Allowed", "green");
|
|
370
378
|
return;
|
|
371
379
|
}
|
|
@@ -394,13 +402,7 @@ async function handleConfirmationPrompt(
|
|
|
394
402
|
return;
|
|
395
403
|
}
|
|
396
404
|
|
|
397
|
-
await submitDecision(
|
|
398
|
-
baseUrl,
|
|
399
|
-
assistantId,
|
|
400
|
-
requestId,
|
|
401
|
-
"deny",
|
|
402
|
-
bearerToken,
|
|
403
|
-
);
|
|
405
|
+
await submitDecision(baseUrl, assistantId, requestId, "deny", bearerToken);
|
|
404
406
|
chatApp.addStatus("\u2718 Denied", "yellow");
|
|
405
407
|
}
|
|
406
408
|
|
|
@@ -437,13 +439,7 @@ async function handlePatternSelection(
|
|
|
437
439
|
return;
|
|
438
440
|
}
|
|
439
441
|
|
|
440
|
-
await submitDecision(
|
|
441
|
-
baseUrl,
|
|
442
|
-
assistantId,
|
|
443
|
-
requestId,
|
|
444
|
-
"deny",
|
|
445
|
-
bearerToken,
|
|
446
|
-
);
|
|
442
|
+
await submitDecision(baseUrl, assistantId, requestId, "deny", bearerToken);
|
|
447
443
|
chatApp.addStatus("\u2718 Denied", "yellow");
|
|
448
444
|
}
|
|
449
445
|
|
|
@@ -491,13 +487,7 @@ async function handleScopeSelection(
|
|
|
491
487
|
return;
|
|
492
488
|
}
|
|
493
489
|
|
|
494
|
-
await submitDecision(
|
|
495
|
-
baseUrl,
|
|
496
|
-
assistantId,
|
|
497
|
-
requestId,
|
|
498
|
-
"deny",
|
|
499
|
-
bearerToken,
|
|
500
|
-
);
|
|
490
|
+
await submitDecision(baseUrl, assistantId, requestId, "deny", bearerToken);
|
|
501
491
|
chatApp.addStatus("\u2718 Denied", "yellow");
|
|
502
492
|
}
|
|
503
493
|
|
|
@@ -629,6 +619,10 @@ function HelpDisplay(): ReactElement {
|
|
|
629
619
|
return (
|
|
630
620
|
<Box flexDirection="column">
|
|
631
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>
|
|
632
626
|
<Text>
|
|
633
627
|
{" /doctor [question] "}
|
|
634
628
|
<Text dimColor>Run diagnostics on the remote instance via SSH</Text>
|
|
@@ -1252,7 +1246,6 @@ function ChatApp({
|
|
|
1252
1246
|
|
|
1253
1247
|
const showSpinner = useCallback((text: string) => {
|
|
1254
1248
|
setSpinnerText(text);
|
|
1255
|
-
setInputFocused(false);
|
|
1256
1249
|
}, []);
|
|
1257
1250
|
|
|
1258
1251
|
const hideSpinner = useCallback(() => {
|
|
@@ -1488,79 +1481,6 @@ function ChatApp({
|
|
|
1488
1481
|
return;
|
|
1489
1482
|
}
|
|
1490
1483
|
|
|
1491
|
-
if (trimmed === "/pair") {
|
|
1492
|
-
h.showSpinner("Generating pairing credentials...");
|
|
1493
|
-
|
|
1494
|
-
const isConnected = await ensureConnected();
|
|
1495
|
-
if (!isConnected) {
|
|
1496
|
-
h.hideSpinner();
|
|
1497
|
-
h.showError("Cannot pair — not connected to the assistant runtime.");
|
|
1498
|
-
return;
|
|
1499
|
-
}
|
|
1500
|
-
|
|
1501
|
-
try {
|
|
1502
|
-
const pairingRequestId = randomUUID();
|
|
1503
|
-
const pairingSecret = randomBytes(32).toString("hex");
|
|
1504
|
-
const gatewayUrl = runtimeUrl;
|
|
1505
|
-
|
|
1506
|
-
// Call /pairing/register on the gateway (dedicated pairing proxy route)
|
|
1507
|
-
const registerUrl = `${runtimeUrl}/pairing/register`;
|
|
1508
|
-
const registerRes = await fetch(registerUrl, {
|
|
1509
|
-
method: "POST",
|
|
1510
|
-
headers: {
|
|
1511
|
-
"Content-Type": "application/json",
|
|
1512
|
-
...(bearerToken
|
|
1513
|
-
? { Authorization: `Bearer ${bearerToken}` }
|
|
1514
|
-
: {}),
|
|
1515
|
-
},
|
|
1516
|
-
body: JSON.stringify({
|
|
1517
|
-
pairingRequestId,
|
|
1518
|
-
pairingSecret,
|
|
1519
|
-
gatewayUrl,
|
|
1520
|
-
}),
|
|
1521
|
-
});
|
|
1522
|
-
|
|
1523
|
-
if (!registerRes.ok) {
|
|
1524
|
-
const body = await registerRes.text().catch(() => "");
|
|
1525
|
-
throw new Error(
|
|
1526
|
-
`HTTP ${registerRes.status}: ${body || registerRes.statusText}`,
|
|
1527
|
-
);
|
|
1528
|
-
}
|
|
1529
|
-
|
|
1530
|
-
const hostId = createHash("sha256")
|
|
1531
|
-
.update(hostname() + userInfo().username)
|
|
1532
|
-
.digest("hex");
|
|
1533
|
-
const payload = JSON.stringify({
|
|
1534
|
-
type: "vellum-daemon",
|
|
1535
|
-
v: 4,
|
|
1536
|
-
id: hostId,
|
|
1537
|
-
g: gatewayUrl,
|
|
1538
|
-
pairingRequestId,
|
|
1539
|
-
pairingSecret,
|
|
1540
|
-
});
|
|
1541
|
-
|
|
1542
|
-
const qrString = await new Promise<string>((resolve) => {
|
|
1543
|
-
qrcode.generate(payload, { small: true }, (code: string) => {
|
|
1544
|
-
resolve(code);
|
|
1545
|
-
});
|
|
1546
|
-
});
|
|
1547
|
-
|
|
1548
|
-
h.hideSpinner();
|
|
1549
|
-
h.addStatus(
|
|
1550
|
-
`Pairing Ready\n\n` +
|
|
1551
|
-
`Scan this QR code with the Vellum iOS app:\n\n` +
|
|
1552
|
-
`${qrString}\n` +
|
|
1553
|
-
`This pairing request expires in 5 minutes. Run /pair again to generate a new one.`,
|
|
1554
|
-
);
|
|
1555
|
-
} catch (err) {
|
|
1556
|
-
h.hideSpinner();
|
|
1557
|
-
h.showError(
|
|
1558
|
-
`Pairing failed: ${err instanceof Error ? err.message : err}`,
|
|
1559
|
-
);
|
|
1560
|
-
}
|
|
1561
|
-
return;
|
|
1562
|
-
}
|
|
1563
|
-
|
|
1564
1484
|
if (trimmed === "/retire") {
|
|
1565
1485
|
if (!project || !zone) {
|
|
1566
1486
|
h.showError(
|
|
@@ -1714,6 +1634,214 @@ function ChatApp({
|
|
|
1714
1634
|
return;
|
|
1715
1635
|
}
|
|
1716
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
|
+
|
|
1717
1845
|
if (!trimmed.startsWith("/")) {
|
|
1718
1846
|
const userMsg: RuntimeMessage = {
|
|
1719
1847
|
id: "local-user-" + Date.now(),
|
|
@@ -1758,7 +1886,8 @@ function ChatApp({
|
|
|
1758
1886
|
clearTimeout(timeoutId);
|
|
1759
1887
|
h.setBusy(false);
|
|
1760
1888
|
h.hideSpinner();
|
|
1761
|
-
const errorMsg =
|
|
1889
|
+
const errorMsg =
|
|
1890
|
+
sendErr instanceof Error ? sendErr.message : String(sendErr);
|
|
1762
1891
|
h.showError(errorMsg);
|
|
1763
1892
|
chatLogRef.current.push({ role: "error", content: errorMsg });
|
|
1764
1893
|
return;
|
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");
|
|
@@ -16,7 +16,9 @@ import { probePort } from "./port-probe.js";
|
|
|
16
16
|
*/
|
|
17
17
|
export interface LocalInstanceResources {
|
|
18
18
|
/**
|
|
19
|
-
* Instance-specific data root
|
|
19
|
+
* Instance-specific data root. The first local assistant uses `~` (home
|
|
20
|
+
* directory) with default ports. Subsequent instances are placed under
|
|
21
|
+
* `~/.local/share/vellum/assistants/<name>/`.
|
|
20
22
|
* The daemon's `.vellum/` directory lives inside it.
|
|
21
23
|
*/
|
|
22
24
|
instanceDir: string;
|
|
@@ -28,6 +30,7 @@ export interface LocalInstanceResources {
|
|
|
28
30
|
qdrantPort: number;
|
|
29
31
|
/** Absolute path to the daemon PID file */
|
|
30
32
|
pidFile: string;
|
|
33
|
+
[key: string]: unknown;
|
|
31
34
|
}
|
|
32
35
|
|
|
33
36
|
export interface AssistantEntry {
|
|
@@ -48,10 +51,11 @@ export interface AssistantEntry {
|
|
|
48
51
|
hatchedAt?: string;
|
|
49
52
|
/** Per-instance resource config. Present for local entries in multi-instance setups. */
|
|
50
53
|
resources?: LocalInstanceResources;
|
|
54
|
+
[key: string]: unknown;
|
|
51
55
|
}
|
|
52
56
|
|
|
53
57
|
interface LockfileData {
|
|
54
|
-
assistants?:
|
|
58
|
+
assistants?: Record<string, unknown>[];
|
|
55
59
|
activeAssistant?: string;
|
|
56
60
|
platformBaseUrl?: string;
|
|
57
61
|
[key: string]: unknown;
|
|
@@ -92,14 +96,132 @@ function writeLockfile(data: LockfileData): void {
|
|
|
92
96
|
writeFileSync(lockfilePath, JSON.stringify(data, null, 2) + "\n");
|
|
93
97
|
}
|
|
94
98
|
|
|
99
|
+
/**
|
|
100
|
+
* Try to extract a port number from a URL string (e.g. `http://localhost:7830`).
|
|
101
|
+
* Returns undefined if the URL is malformed or has no explicit port.
|
|
102
|
+
*/
|
|
103
|
+
function parsePortFromUrl(url: unknown): number | undefined {
|
|
104
|
+
if (typeof url !== "string") return undefined;
|
|
105
|
+
try {
|
|
106
|
+
const parsed = new URL(url);
|
|
107
|
+
const port = parseInt(parsed.port, 10);
|
|
108
|
+
return isNaN(port) ? undefined : port;
|
|
109
|
+
} catch {
|
|
110
|
+
return undefined;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Detect and migrate legacy lockfile entries to the current format.
|
|
116
|
+
*
|
|
117
|
+
* Legacy entries stored `baseDataDir` as a top-level field. The current
|
|
118
|
+
* format nests this under `resources.instanceDir`. This function also
|
|
119
|
+
* synthesises a full `resources` object when one is missing by inferring
|
|
120
|
+
* ports from the entry's `runtimeUrl` and falling back to defaults.
|
|
121
|
+
*
|
|
122
|
+
* Returns `true` if the entry was mutated (so the caller can persist).
|
|
123
|
+
*/
|
|
124
|
+
export function migrateLegacyEntry(raw: Record<string, unknown>): boolean {
|
|
125
|
+
if (typeof raw.cloud === "string" && raw.cloud !== "local") {
|
|
126
|
+
return false;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
let mutated = false;
|
|
130
|
+
|
|
131
|
+
// Migrate top-level `baseDataDir` → `resources.instanceDir`
|
|
132
|
+
if (typeof raw.baseDataDir === "string" && raw.baseDataDir) {
|
|
133
|
+
if (!raw.resources || typeof raw.resources !== "object") {
|
|
134
|
+
raw.resources = {};
|
|
135
|
+
}
|
|
136
|
+
const res = raw.resources as Record<string, unknown>;
|
|
137
|
+
if (!res.instanceDir) {
|
|
138
|
+
res.instanceDir = raw.baseDataDir;
|
|
139
|
+
mutated = true;
|
|
140
|
+
}
|
|
141
|
+
delete raw.baseDataDir;
|
|
142
|
+
mutated = true;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Synthesise missing `resources` for local entries
|
|
146
|
+
if (!raw.resources || typeof raw.resources !== "object") {
|
|
147
|
+
const gatewayPort =
|
|
148
|
+
parsePortFromUrl(raw.runtimeUrl) ?? DEFAULT_GATEWAY_PORT;
|
|
149
|
+
const instanceDir = join(
|
|
150
|
+
homedir(),
|
|
151
|
+
".local",
|
|
152
|
+
"share",
|
|
153
|
+
"vellum",
|
|
154
|
+
"assistants",
|
|
155
|
+
typeof raw.assistantId === "string" ? raw.assistantId : "default",
|
|
156
|
+
);
|
|
157
|
+
raw.resources = {
|
|
158
|
+
instanceDir,
|
|
159
|
+
daemonPort: DEFAULT_DAEMON_PORT,
|
|
160
|
+
gatewayPort,
|
|
161
|
+
qdrantPort: DEFAULT_QDRANT_PORT,
|
|
162
|
+
pidFile: join(instanceDir, ".vellum", "vellum.pid"),
|
|
163
|
+
};
|
|
164
|
+
mutated = true;
|
|
165
|
+
} else {
|
|
166
|
+
// Backfill any missing fields on an existing partial `resources` object
|
|
167
|
+
const res = raw.resources as Record<string, unknown>;
|
|
168
|
+
if (!res.instanceDir) {
|
|
169
|
+
res.instanceDir = join(
|
|
170
|
+
homedir(),
|
|
171
|
+
".local",
|
|
172
|
+
"share",
|
|
173
|
+
"vellum",
|
|
174
|
+
"assistants",
|
|
175
|
+
typeof raw.assistantId === "string" ? raw.assistantId : "default",
|
|
176
|
+
);
|
|
177
|
+
mutated = true;
|
|
178
|
+
}
|
|
179
|
+
if (typeof res.daemonPort !== "number") {
|
|
180
|
+
res.daemonPort = DEFAULT_DAEMON_PORT;
|
|
181
|
+
mutated = true;
|
|
182
|
+
}
|
|
183
|
+
if (typeof res.gatewayPort !== "number") {
|
|
184
|
+
res.gatewayPort =
|
|
185
|
+
parsePortFromUrl(raw.runtimeUrl) ?? DEFAULT_GATEWAY_PORT;
|
|
186
|
+
mutated = true;
|
|
187
|
+
}
|
|
188
|
+
if (typeof res.qdrantPort !== "number") {
|
|
189
|
+
res.qdrantPort = DEFAULT_QDRANT_PORT;
|
|
190
|
+
mutated = true;
|
|
191
|
+
}
|
|
192
|
+
if (typeof res.pidFile !== "string") {
|
|
193
|
+
res.pidFile = join(
|
|
194
|
+
res.instanceDir as string,
|
|
195
|
+
".vellum",
|
|
196
|
+
"vellum.pid",
|
|
197
|
+
);
|
|
198
|
+
mutated = true;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
return mutated;
|
|
203
|
+
}
|
|
204
|
+
|
|
95
205
|
function readAssistants(): AssistantEntry[] {
|
|
96
206
|
const data = readLockfile();
|
|
97
207
|
const entries = data.assistants;
|
|
98
208
|
if (!Array.isArray(entries)) {
|
|
99
209
|
return [];
|
|
100
210
|
}
|
|
211
|
+
|
|
212
|
+
let migrated = false;
|
|
213
|
+
for (const entry of entries) {
|
|
214
|
+
if (migrateLegacyEntry(entry)) {
|
|
215
|
+
migrated = true;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
if (migrated) {
|
|
220
|
+
writeLockfile(data);
|
|
221
|
+
}
|
|
222
|
+
|
|
101
223
|
return entries.filter(
|
|
102
|
-
(e) =>
|
|
224
|
+
(e): e is AssistantEntry =>
|
|
103
225
|
typeof e.assistantId === "string" && typeof e.runtimeUrl === "string",
|
|
104
226
|
);
|
|
105
227
|
}
|
|
@@ -131,14 +253,14 @@ export function findAssistantByName(name: string): AssistantEntry | null {
|
|
|
131
253
|
export function removeAssistantEntry(assistantId: string): void {
|
|
132
254
|
const data = readLockfile();
|
|
133
255
|
const entries = (data.assistants ?? []).filter(
|
|
134
|
-
(e
|
|
256
|
+
(e) => e.assistantId !== assistantId,
|
|
135
257
|
);
|
|
136
258
|
data.assistants = entries;
|
|
137
259
|
// Reassign active assistant if it matches the removed entry
|
|
138
260
|
if (data.activeAssistant === assistantId) {
|
|
139
261
|
const remaining = entries[0];
|
|
140
262
|
if (remaining) {
|
|
141
|
-
data.activeAssistant = remaining.assistantId;
|
|
263
|
+
data.activeAssistant = String(remaining.assistantId);
|
|
142
264
|
} else {
|
|
143
265
|
delete data.activeAssistant;
|
|
144
266
|
}
|
|
@@ -229,12 +351,28 @@ async function findAvailablePort(
|
|
|
229
351
|
|
|
230
352
|
/**
|
|
231
353
|
* Allocate an isolated set of resources for a named local instance.
|
|
232
|
-
*
|
|
354
|
+
* The first local assistant uses the home directory with default ports.
|
|
355
|
+
* Subsequent assistants are placed under
|
|
233
356
|
* `~/.local/share/vellum/assistants/<name>/` with scanned ports.
|
|
234
357
|
*/
|
|
235
358
|
export async function allocateLocalResources(
|
|
236
359
|
instanceName: string,
|
|
237
360
|
): Promise<LocalInstanceResources> {
|
|
361
|
+
// First local assistant gets the home directory with default ports.
|
|
362
|
+
const existingLocals = loadAllAssistants().filter((e) => e.cloud === "local");
|
|
363
|
+
if (existingLocals.length === 0) {
|
|
364
|
+
const home = homedir();
|
|
365
|
+
const vellumDir = join(home, ".vellum");
|
|
366
|
+
mkdirSync(vellumDir, { recursive: true });
|
|
367
|
+
return {
|
|
368
|
+
instanceDir: home,
|
|
369
|
+
daemonPort: DEFAULT_DAEMON_PORT,
|
|
370
|
+
gatewayPort: DEFAULT_GATEWAY_PORT,
|
|
371
|
+
qdrantPort: DEFAULT_QDRANT_PORT,
|
|
372
|
+
pidFile: join(vellumDir, "vellum.pid"),
|
|
373
|
+
};
|
|
374
|
+
}
|
|
375
|
+
|
|
238
376
|
const instanceDir = join(
|
|
239
377
|
homedir(),
|
|
240
378
|
".local",
|