@vellumai/cli 0.4.37 → 0.4.40
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/__tests__/multi-local.test.ts +275 -0
- package/src/__tests__/skills-uninstall.test.ts +3 -1
- package/src/commands/client.ts +23 -7
- package/src/commands/hatch.ts +38 -42
- package/src/commands/ps.ts +32 -12
- package/src/commands/retire.ts +48 -12
- package/src/commands/sleep.ts +25 -6
- package/src/commands/use.ts +44 -0
- package/src/commands/wake.ts +25 -16
- package/src/index.ts +5 -49
- package/src/lib/assistant-config.ts +226 -3
- package/src/lib/constants.ts +6 -0
- package/src/lib/local.ts +187 -49
- package/src/lib/status-emoji.ts +3 -0
package/package.json
CHANGED
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
import { describe, test, expect, beforeEach, afterAll, mock } from "bun:test";
|
|
2
|
+
import { mkdtempSync, rmSync, writeFileSync, readFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
|
|
6
|
+
// Create a temp directory that acts as a fake home, so allocateLocalResources()
|
|
7
|
+
// and defaultLocalResources() never touch the real ~/.vellum directory.
|
|
8
|
+
const testDir = mkdtempSync(join(tmpdir(), "cli-multi-local-test-"));
|
|
9
|
+
process.env.BASE_DATA_DIR = testDir;
|
|
10
|
+
|
|
11
|
+
// Mock homedir() to return testDir — this isolates allocateLocalResources()
|
|
12
|
+
// which uses homedir() directly for instance directory creation.
|
|
13
|
+
const realOs = await import("node:os");
|
|
14
|
+
mock.module("node:os", () => ({
|
|
15
|
+
...realOs,
|
|
16
|
+
homedir: () => testDir,
|
|
17
|
+
}));
|
|
18
|
+
// Also mock the bare "os" specifier since assistant-config.ts uses `from "os"`
|
|
19
|
+
mock.module("os", () => ({
|
|
20
|
+
...realOs,
|
|
21
|
+
homedir: () => testDir,
|
|
22
|
+
}));
|
|
23
|
+
|
|
24
|
+
// Mock probePort so we control which ports appear in-use without touching the network
|
|
25
|
+
const probePortMock = mock<(port: number, host?: string) => Promise<boolean>>(
|
|
26
|
+
() => Promise.resolve(false),
|
|
27
|
+
);
|
|
28
|
+
mock.module("../lib/port-probe.js", () => ({
|
|
29
|
+
probePort: probePortMock,
|
|
30
|
+
}));
|
|
31
|
+
|
|
32
|
+
import {
|
|
33
|
+
allocateLocalResources,
|
|
34
|
+
defaultLocalResources,
|
|
35
|
+
resolveTargetAssistant,
|
|
36
|
+
setActiveAssistant,
|
|
37
|
+
getActiveAssistant,
|
|
38
|
+
removeAssistantEntry,
|
|
39
|
+
saveAssistantEntry,
|
|
40
|
+
type AssistantEntry,
|
|
41
|
+
} from "../lib/assistant-config.js";
|
|
42
|
+
import { DEFAULT_DAEMON_PORT } from "../lib/constants.js";
|
|
43
|
+
|
|
44
|
+
afterAll(() => {
|
|
45
|
+
rmSync(testDir, { recursive: true, force: true });
|
|
46
|
+
delete process.env.BASE_DATA_DIR;
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
function writeLockfile(data: unknown): void {
|
|
50
|
+
writeFileSync(
|
|
51
|
+
join(testDir, ".vellum.lock.json"),
|
|
52
|
+
JSON.stringify(data, null, 2),
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function readLockfileRaw(): Record<string, unknown> {
|
|
57
|
+
return JSON.parse(
|
|
58
|
+
readFileSync(join(testDir, ".vellum.lock.json"), "utf-8"),
|
|
59
|
+
) as Record<string, unknown>;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const makeEntry = (
|
|
63
|
+
id: string,
|
|
64
|
+
cloud = "local",
|
|
65
|
+
extra?: Partial<AssistantEntry>,
|
|
66
|
+
): AssistantEntry => ({
|
|
67
|
+
assistantId: id,
|
|
68
|
+
runtimeUrl: `http://localhost:${DEFAULT_DAEMON_PORT}`,
|
|
69
|
+
cloud,
|
|
70
|
+
...extra,
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
function resetLockfile(): void {
|
|
74
|
+
try {
|
|
75
|
+
rmSync(join(testDir, ".vellum.lock.json"));
|
|
76
|
+
} catch {
|
|
77
|
+
// file may not exist
|
|
78
|
+
}
|
|
79
|
+
try {
|
|
80
|
+
rmSync(join(testDir, ".vellum.lockfile.json"));
|
|
81
|
+
} catch {
|
|
82
|
+
// file may not exist
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
describe("multi-local", () => {
|
|
87
|
+
beforeEach(() => {
|
|
88
|
+
resetLockfile();
|
|
89
|
+
probePortMock.mockReset();
|
|
90
|
+
probePortMock.mockImplementation(() => Promise.resolve(false));
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
describe("allocateLocalResources() produces non-conflicting ports", () => {
|
|
94
|
+
test("two instances get distinct ports and dirs when first instance ports are occupied", async () => {
|
|
95
|
+
// After the first allocation grabs its ports, simulate those ports
|
|
96
|
+
// being in-use so the second allocation must pick different ones.
|
|
97
|
+
const a = await allocateLocalResources("instance-a");
|
|
98
|
+
const occupiedPorts = new Set([
|
|
99
|
+
a.daemonPort,
|
|
100
|
+
a.gatewayPort,
|
|
101
|
+
a.qdrantPort,
|
|
102
|
+
]);
|
|
103
|
+
probePortMock.mockImplementation((port: number) =>
|
|
104
|
+
Promise.resolve(occupiedPorts.has(port)),
|
|
105
|
+
);
|
|
106
|
+
|
|
107
|
+
const b = await allocateLocalResources("instance-b");
|
|
108
|
+
|
|
109
|
+
// All six ports must be unique across both instances
|
|
110
|
+
const allPorts = [
|
|
111
|
+
a.daemonPort,
|
|
112
|
+
a.gatewayPort,
|
|
113
|
+
a.qdrantPort,
|
|
114
|
+
b.daemonPort,
|
|
115
|
+
b.gatewayPort,
|
|
116
|
+
b.qdrantPort,
|
|
117
|
+
];
|
|
118
|
+
expect(new Set(allPorts).size).toBe(6);
|
|
119
|
+
|
|
120
|
+
// Instance dirs must be distinct
|
|
121
|
+
expect(a.instanceDir).not.toBe(b.instanceDir);
|
|
122
|
+
expect(a.instanceDir).toContain("instance-a");
|
|
123
|
+
expect(b.instanceDir).toContain("instance-b");
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test("skips ports that probePort reports as in-use", async () => {
|
|
127
|
+
// Simulate the default ports being occupied
|
|
128
|
+
const portsInUse = new Set([
|
|
129
|
+
DEFAULT_DAEMON_PORT,
|
|
130
|
+
DEFAULT_DAEMON_PORT + 1,
|
|
131
|
+
]);
|
|
132
|
+
probePortMock.mockImplementation((port: number) =>
|
|
133
|
+
Promise.resolve(portsInUse.has(port)),
|
|
134
|
+
);
|
|
135
|
+
|
|
136
|
+
const res = await allocateLocalResources("probe-test");
|
|
137
|
+
expect(res.daemonPort).toBeGreaterThan(DEFAULT_DAEMON_PORT + 1);
|
|
138
|
+
expect(portsInUse.has(res.daemonPort)).toBe(false);
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
describe("defaultLocalResources() returns legacy paths", () => {
|
|
143
|
+
test("instanceDir is homedir", () => {
|
|
144
|
+
const res = defaultLocalResources();
|
|
145
|
+
expect(res.instanceDir).toBe(testDir);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test("daemonPort is DEFAULT_DAEMON_PORT", () => {
|
|
149
|
+
const res = defaultLocalResources();
|
|
150
|
+
expect(res.daemonPort).toBe(DEFAULT_DAEMON_PORT);
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
describe("resolveTargetAssistant() priority chain", () => {
|
|
155
|
+
test("explicit name returns that entry", () => {
|
|
156
|
+
writeLockfile({
|
|
157
|
+
assistants: [makeEntry("alpha"), makeEntry("beta")],
|
|
158
|
+
});
|
|
159
|
+
const result = resolveTargetAssistant("beta");
|
|
160
|
+
expect(result.assistantId).toBe("beta");
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
test("active assistant set returns the active entry", () => {
|
|
164
|
+
writeLockfile({
|
|
165
|
+
assistants: [makeEntry("alpha"), makeEntry("beta")],
|
|
166
|
+
activeAssistant: "alpha",
|
|
167
|
+
});
|
|
168
|
+
const result = resolveTargetAssistant();
|
|
169
|
+
expect(result.assistantId).toBe("alpha");
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
test("sole local assistant returns it", () => {
|
|
173
|
+
writeLockfile({
|
|
174
|
+
assistants: [makeEntry("only-one")],
|
|
175
|
+
});
|
|
176
|
+
const result = resolveTargetAssistant();
|
|
177
|
+
expect(result.assistantId).toBe("only-one");
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
test("multiple local assistants and no active throws with guidance", () => {
|
|
181
|
+
writeLockfile({
|
|
182
|
+
assistants: [makeEntry("x"), makeEntry("y")],
|
|
183
|
+
});
|
|
184
|
+
// resolveTargetAssistant calls process.exit(1) on ambiguity
|
|
185
|
+
const mockExit = mock(() => {
|
|
186
|
+
throw new Error("process.exit called");
|
|
187
|
+
});
|
|
188
|
+
const origExit = process.exit;
|
|
189
|
+
process.exit = mockExit as unknown as typeof process.exit;
|
|
190
|
+
try {
|
|
191
|
+
expect(() => resolveTargetAssistant()).toThrow("process.exit called");
|
|
192
|
+
expect(mockExit).toHaveBeenCalledWith(1);
|
|
193
|
+
} finally {
|
|
194
|
+
process.exit = origExit;
|
|
195
|
+
}
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
test("no local assistants throws", () => {
|
|
199
|
+
writeLockfile({ assistants: [] });
|
|
200
|
+
const mockExit = mock(() => {
|
|
201
|
+
throw new Error("process.exit called");
|
|
202
|
+
});
|
|
203
|
+
const origExit = process.exit;
|
|
204
|
+
process.exit = mockExit as unknown as typeof process.exit;
|
|
205
|
+
try {
|
|
206
|
+
expect(() => resolveTargetAssistant()).toThrow("process.exit called");
|
|
207
|
+
expect(mockExit).toHaveBeenCalledWith(1);
|
|
208
|
+
} finally {
|
|
209
|
+
process.exit = origExit;
|
|
210
|
+
}
|
|
211
|
+
});
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
describe("setActiveAssistant() / getActiveAssistant() round-trip", () => {
|
|
215
|
+
test("set active, read it back", () => {
|
|
216
|
+
writeLockfile({ assistants: [makeEntry("my-assistant")] });
|
|
217
|
+
setActiveAssistant("my-assistant");
|
|
218
|
+
expect(getActiveAssistant()).toBe("my-assistant");
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
test("lockfile is updated on disk", () => {
|
|
222
|
+
writeLockfile({ assistants: [makeEntry("disk-check")] });
|
|
223
|
+
setActiveAssistant("disk-check");
|
|
224
|
+
const raw = readLockfileRaw();
|
|
225
|
+
expect(raw.activeAssistant).toBe("disk-check");
|
|
226
|
+
});
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
describe("removeAssistantEntry() clears matching activeAssistant", () => {
|
|
230
|
+
test("set active to foo, remove foo, verify active is null", () => {
|
|
231
|
+
writeLockfile({
|
|
232
|
+
assistants: [makeEntry("foo"), makeEntry("bar")],
|
|
233
|
+
activeAssistant: "foo",
|
|
234
|
+
});
|
|
235
|
+
removeAssistantEntry("foo");
|
|
236
|
+
expect(getActiveAssistant()).toBeNull();
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
test("set active to foo, remove bar, verify active is still foo", () => {
|
|
240
|
+
writeLockfile({
|
|
241
|
+
assistants: [makeEntry("foo"), makeEntry("bar")],
|
|
242
|
+
activeAssistant: "foo",
|
|
243
|
+
});
|
|
244
|
+
removeAssistantEntry("bar");
|
|
245
|
+
expect(getActiveAssistant()).toBe("foo");
|
|
246
|
+
});
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
describe("remote non-regression", () => {
|
|
250
|
+
test("resolveTargetAssistant works with remote entries", () => {
|
|
251
|
+
writeLockfile({
|
|
252
|
+
assistants: [
|
|
253
|
+
makeEntry("my-remote", "gcp", {
|
|
254
|
+
runtimeUrl: "http://10.0.0.1:7821",
|
|
255
|
+
}),
|
|
256
|
+
],
|
|
257
|
+
activeAssistant: "my-remote",
|
|
258
|
+
});
|
|
259
|
+
const result = resolveTargetAssistant();
|
|
260
|
+
expect(result.assistantId).toBe("my-remote");
|
|
261
|
+
expect(result.cloud).toBe("gcp");
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
test("remote entries don't get resources applied", () => {
|
|
265
|
+
const remoteEntry = makeEntry("cloud-box", "aws", {
|
|
266
|
+
runtimeUrl: "http://10.0.0.2:7821",
|
|
267
|
+
});
|
|
268
|
+
writeLockfile({ assistants: [remoteEntry] });
|
|
269
|
+
// Save and reload to verify resources are not injected
|
|
270
|
+
saveAssistantEntry(remoteEntry);
|
|
271
|
+
const result = resolveTargetAssistant("cloud-box");
|
|
272
|
+
expect(result.resources).toBeUndefined();
|
|
273
|
+
});
|
|
274
|
+
});
|
|
275
|
+
});
|
|
@@ -49,7 +49,9 @@ beforeEach(() => {
|
|
|
49
49
|
afterEach(() => {
|
|
50
50
|
process.argv = originalArgv;
|
|
51
51
|
process.env.BASE_DATA_DIR = originalBaseDataDir;
|
|
52
|
-
process.exitCode =
|
|
52
|
+
// Bun treats `process.exitCode = undefined` as a no-op, so explicitly
|
|
53
|
+
// reset to 0 when the original value was not set.
|
|
54
|
+
process.exitCode = originalExitCode ?? 0;
|
|
53
55
|
rmSync(tempDir, { recursive: true, force: true });
|
|
54
56
|
});
|
|
55
57
|
|
package/src/commands/client.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import {
|
|
2
2
|
findAssistantByName,
|
|
3
|
+
getActiveAssistant,
|
|
3
4
|
loadLatestAssistant,
|
|
4
5
|
} from "../lib/assistant-config";
|
|
5
6
|
import { GATEWAY_PORT, type Species } from "../lib/constants";
|
|
@@ -45,16 +46,31 @@ function parseArgs(): ParsedArgs {
|
|
|
45
46
|
}
|
|
46
47
|
}
|
|
47
48
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
49
|
+
let entry: ReturnType<typeof findAssistantByName>;
|
|
50
|
+
if (positionalName) {
|
|
51
|
+
entry = findAssistantByName(positionalName);
|
|
52
|
+
if (!entry) {
|
|
53
|
+
console.error(
|
|
54
|
+
`No assistant instance found with name '${positionalName}'.`,
|
|
55
|
+
);
|
|
56
|
+
process.exit(1);
|
|
57
|
+
}
|
|
58
|
+
} else if (process.env.RUNTIME_URL) {
|
|
59
|
+
// Explicit env var — skip assistant resolution, will use env values below
|
|
60
|
+
entry = loadLatestAssistant();
|
|
61
|
+
} else {
|
|
62
|
+
// Respect active assistant when set, otherwise fall back to latest
|
|
63
|
+
// for backward compatibility with remote-only setups.
|
|
64
|
+
const active = getActiveAssistant();
|
|
65
|
+
const activeEntry = active ? findAssistantByName(active) : null;
|
|
66
|
+
entry = activeEntry ?? loadLatestAssistant();
|
|
54
67
|
}
|
|
55
68
|
|
|
56
69
|
let runtimeUrl =
|
|
57
|
-
process.env.RUNTIME_URL ||
|
|
70
|
+
process.env.RUNTIME_URL ||
|
|
71
|
+
entry?.localUrl ||
|
|
72
|
+
entry?.runtimeUrl ||
|
|
73
|
+
FALLBACK_RUNTIME_URL;
|
|
58
74
|
let assistantId =
|
|
59
75
|
process.env.ASSISTANT_ID || entry?.assistantId || FALLBACK_ASSISTANT_ID;
|
|
60
76
|
const bearerToken =
|
package/src/commands/hatch.ts
CHANGED
|
@@ -21,14 +21,19 @@ import cliPkg from "../../package.json";
|
|
|
21
21
|
|
|
22
22
|
import { buildOpenclawStartupScript } from "../adapters/openclaw";
|
|
23
23
|
import {
|
|
24
|
+
allocateLocalResources,
|
|
25
|
+
defaultLocalResources,
|
|
26
|
+
findAssistantByName,
|
|
24
27
|
loadAllAssistants,
|
|
25
28
|
saveAssistantEntry,
|
|
26
29
|
syncConfigToLockfile,
|
|
27
30
|
} from "../lib/assistant-config";
|
|
28
|
-
import type {
|
|
31
|
+
import type {
|
|
32
|
+
AssistantEntry,
|
|
33
|
+
LocalInstanceResources,
|
|
34
|
+
} from "../lib/assistant-config";
|
|
29
35
|
import { hatchAws } from "../lib/aws";
|
|
30
36
|
import {
|
|
31
|
-
GATEWAY_PORT,
|
|
32
37
|
SPECIES_CONFIG,
|
|
33
38
|
VALID_REMOTE_HOSTS,
|
|
34
39
|
VALID_SPECIES,
|
|
@@ -41,7 +46,6 @@ import {
|
|
|
41
46
|
startGateway,
|
|
42
47
|
stopLocalProcesses,
|
|
43
48
|
} from "../lib/local";
|
|
44
|
-
import { probePort } from "../lib/port-probe";
|
|
45
49
|
import { isProcessAlive } from "../lib/process";
|
|
46
50
|
import { generateRandomSuffix } from "../lib/random-name";
|
|
47
51
|
import { validateAssistantName } from "../lib/retire-archive";
|
|
@@ -583,6 +587,8 @@ async function waitForDaemonReady(
|
|
|
583
587
|
async function displayPairingQRCode(
|
|
584
588
|
runtimeUrl: string,
|
|
585
589
|
bearerToken: string | undefined,
|
|
590
|
+
/** External gateway URL for the QR payload. When omitted, runtimeUrl is used. */
|
|
591
|
+
externalGatewayUrl?: string,
|
|
586
592
|
): Promise<void> {
|
|
587
593
|
try {
|
|
588
594
|
const pairingRequestId = randomUUID();
|
|
@@ -609,7 +615,7 @@ async function displayPairingQRCode(
|
|
|
609
615
|
body: JSON.stringify({
|
|
610
616
|
pairingRequestId,
|
|
611
617
|
pairingSecret,
|
|
612
|
-
gatewayUrl: runtimeUrl,
|
|
618
|
+
gatewayUrl: externalGatewayUrl ?? runtimeUrl,
|
|
613
619
|
}),
|
|
614
620
|
});
|
|
615
621
|
|
|
@@ -628,7 +634,7 @@ async function displayPairingQRCode(
|
|
|
628
634
|
type: "vellum-daemon",
|
|
629
635
|
v: 4,
|
|
630
636
|
id: hostId,
|
|
631
|
-
g: runtimeUrl,
|
|
637
|
+
g: externalGatewayUrl ?? runtimeUrl,
|
|
632
638
|
pairingRequestId,
|
|
633
639
|
pairingSecret,
|
|
634
640
|
});
|
|
@@ -703,64 +709,49 @@ async function hatchLocal(
|
|
|
703
709
|
);
|
|
704
710
|
await stopLocalProcesses();
|
|
705
711
|
}
|
|
712
|
+
}
|
|
706
713
|
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
await Promise.all(
|
|
719
|
-
requiredPorts.map(async ({ name, port }) => {
|
|
720
|
-
if (await probePort(port)) {
|
|
721
|
-
conflicts.push(` - Port ${port} (${name}) is already in use`);
|
|
722
|
-
}
|
|
723
|
-
}),
|
|
724
|
-
);
|
|
725
|
-
if (conflicts.length > 0) {
|
|
726
|
-
throw new Error(
|
|
727
|
-
`Cannot hatch — required ports are already in use:\n${conflicts.join("\n")}\n\n` +
|
|
728
|
-
"Stop the conflicting processes or use environment variables to configure alternative ports " +
|
|
729
|
-
"(RUNTIME_HTTP_PORT, GATEWAY_PORT).",
|
|
730
|
-
);
|
|
731
|
-
}
|
|
714
|
+
// Reuse existing resources if re-hatching with --name that matches a known
|
|
715
|
+
// local assistant, otherwise allocate fresh per-instance ports and directories.
|
|
716
|
+
let resources: LocalInstanceResources;
|
|
717
|
+
const existingEntry = findAssistantByName(instanceName);
|
|
718
|
+
if (existingEntry?.cloud === "local" && existingEntry.resources) {
|
|
719
|
+
resources = existingEntry.resources;
|
|
720
|
+
} else if (restart && existingEntry?.cloud === "local") {
|
|
721
|
+
// Legacy entry without resources — use default paths to match existing layout
|
|
722
|
+
resources = defaultLocalResources();
|
|
723
|
+
} else {
|
|
724
|
+
resources = await allocateLocalResources(instanceName);
|
|
732
725
|
}
|
|
733
726
|
|
|
734
|
-
const baseDataDir = join(
|
|
735
|
-
process.env.BASE_DATA_DIR?.trim() ||
|
|
736
|
-
(process.env.HOME ?? userInfo().homedir),
|
|
737
|
-
".vellum",
|
|
738
|
-
);
|
|
727
|
+
const baseDataDir = join(resources.instanceDir, ".vellum");
|
|
739
728
|
|
|
740
729
|
console.log(`🥚 Hatching local assistant: ${instanceName}`);
|
|
741
730
|
console.log(` Species: ${species}`);
|
|
742
731
|
console.log("");
|
|
743
732
|
|
|
744
|
-
await startLocalDaemon(watch);
|
|
733
|
+
await startLocalDaemon(watch, resources);
|
|
745
734
|
|
|
746
735
|
let runtimeUrl: string;
|
|
747
736
|
try {
|
|
748
|
-
runtimeUrl = await startGateway(instanceName, watch);
|
|
737
|
+
runtimeUrl = await startGateway(instanceName, watch, resources);
|
|
749
738
|
} catch (error) {
|
|
750
739
|
// Gateway failed — stop the daemon we just started so we don't leave
|
|
751
740
|
// orphaned processes with no lock file entry.
|
|
752
741
|
console.error(
|
|
753
742
|
`\n❌ Gateway startup failed — stopping assistant to avoid orphaned processes.`,
|
|
754
743
|
);
|
|
755
|
-
await stopLocalProcesses();
|
|
744
|
+
await stopLocalProcesses(resources);
|
|
756
745
|
throw error;
|
|
757
746
|
}
|
|
758
747
|
|
|
759
748
|
// Read the bearer token (JWT) written by the daemon so the CLI can
|
|
760
|
-
//
|
|
749
|
+
// with the gateway (which requires auth by default). The daemon writes under
|
|
750
|
+
// getRootDir() which resolves to <instanceDir>/.vellum/.
|
|
761
751
|
let bearerToken: string | undefined;
|
|
762
752
|
try {
|
|
763
|
-
const
|
|
753
|
+
const tokenPath = join(resources.instanceDir, ".vellum", "http-token");
|
|
754
|
+
const token = readFileSync(tokenPath, "utf-8").trim();
|
|
764
755
|
if (token) bearerToken = token;
|
|
765
756
|
} catch {
|
|
766
757
|
// Token file may not exist if daemon started without HTTP server
|
|
@@ -769,11 +760,13 @@ async function hatchLocal(
|
|
|
769
760
|
const localEntry: AssistantEntry = {
|
|
770
761
|
assistantId: instanceName,
|
|
771
762
|
runtimeUrl,
|
|
763
|
+
localUrl: `http://127.0.0.1:${resources.gatewayPort}`,
|
|
772
764
|
baseDataDir,
|
|
773
765
|
bearerToken,
|
|
774
766
|
cloud: "local",
|
|
775
767
|
species,
|
|
776
768
|
hatchedAt: new Date().toISOString(),
|
|
769
|
+
resources,
|
|
777
770
|
};
|
|
778
771
|
if (!daemonOnly && !restart) {
|
|
779
772
|
saveAssistantEntry(localEntry);
|
|
@@ -791,8 +784,11 @@ async function hatchLocal(
|
|
|
791
784
|
console.log(` Runtime: ${runtimeUrl}`);
|
|
792
785
|
console.log("");
|
|
793
786
|
|
|
794
|
-
//
|
|
795
|
-
|
|
787
|
+
// Use loopback for HTTP calls (health check + pairing register) since
|
|
788
|
+
// mDNS hostnames may not resolve on the local machine, but keep the
|
|
789
|
+
// external runtimeUrl in the QR payload so iOS devices can reach it.
|
|
790
|
+
const localGatewayUrl = `http://127.0.0.1:${resources.gatewayPort}`;
|
|
791
|
+
await displayPairingQRCode(localGatewayUrl, bearerToken, runtimeUrl);
|
|
796
792
|
}
|
|
797
793
|
}
|
|
798
794
|
|
package/src/commands/ps.ts
CHANGED
|
@@ -3,20 +3,18 @@ import { homedir } from "os";
|
|
|
3
3
|
import { join } from "path";
|
|
4
4
|
|
|
5
5
|
import {
|
|
6
|
+
defaultLocalResources,
|
|
6
7
|
findAssistantByName,
|
|
8
|
+
getActiveAssistant,
|
|
7
9
|
loadAllAssistants,
|
|
8
10
|
type AssistantEntry,
|
|
9
11
|
} from "../lib/assistant-config";
|
|
10
|
-
import { GATEWAY_PORT } from "../lib/constants";
|
|
11
12
|
import { checkHealth } from "../lib/health-check";
|
|
12
13
|
import { pgrepExact } from "../lib/pgrep";
|
|
13
14
|
import { probePort } from "../lib/port-probe";
|
|
14
15
|
import { withStatusEmoji } from "../lib/status-emoji";
|
|
15
16
|
import { execOutput } from "../lib/step-runner";
|
|
16
17
|
|
|
17
|
-
const RUNTIME_HTTP_PORT = Number(process.env.RUNTIME_HTTP_PORT) || 7821;
|
|
18
|
-
const QDRANT_PORT = 6333;
|
|
19
|
-
|
|
20
18
|
// ── Table formatting helpers ────────────────────────────────────
|
|
21
19
|
|
|
22
20
|
interface TableRow {
|
|
@@ -218,25 +216,26 @@ function formatDetectionInfo(proc: DetectedProcess): string {
|
|
|
218
216
|
}
|
|
219
217
|
|
|
220
218
|
async function getLocalProcesses(entry: AssistantEntry): Promise<TableRow[]> {
|
|
221
|
-
const
|
|
219
|
+
const resources = entry.resources ?? defaultLocalResources();
|
|
220
|
+
const vellumDir = join(resources.instanceDir, ".vellum");
|
|
222
221
|
|
|
223
222
|
const specs: ProcessSpec[] = [
|
|
224
223
|
{
|
|
225
224
|
name: "assistant",
|
|
226
225
|
pgrepName: "vellum-daemon",
|
|
227
|
-
port:
|
|
228
|
-
pidFile:
|
|
226
|
+
port: resources.daemonPort,
|
|
227
|
+
pidFile: resources.pidFile,
|
|
229
228
|
},
|
|
230
229
|
{
|
|
231
230
|
name: "qdrant",
|
|
232
231
|
pgrepName: "qdrant",
|
|
233
|
-
port:
|
|
232
|
+
port: resources.qdrantPort,
|
|
234
233
|
pidFile: join(vellumDir, "workspace", "data", "qdrant", "qdrant.pid"),
|
|
235
234
|
},
|
|
236
235
|
{
|
|
237
236
|
name: "gateway",
|
|
238
237
|
pgrepName: "vellum-gateway",
|
|
239
|
-
port:
|
|
238
|
+
port: resources.gatewayPort,
|
|
240
239
|
pidFile: join(vellumDir, "gateway.pid"),
|
|
241
240
|
},
|
|
242
241
|
{
|
|
@@ -355,6 +354,7 @@ async function detectOrphanedProcesses(): Promise<OrphanedProcess[]> {
|
|
|
355
354
|
|
|
356
355
|
async function listAllAssistants(): Promise<void> {
|
|
357
356
|
const assistants = loadAllAssistants();
|
|
357
|
+
const activeId = getActiveAssistant();
|
|
358
358
|
|
|
359
359
|
if (assistants.length === 0) {
|
|
360
360
|
console.log("No assistants found.");
|
|
@@ -381,9 +381,10 @@ async function listAllAssistants(): Promise<void> {
|
|
|
381
381
|
const infoParts = [a.runtimeUrl];
|
|
382
382
|
if (a.cloud) infoParts.push(`cloud: ${a.cloud}`);
|
|
383
383
|
if (a.species) infoParts.push(`species: ${a.species}`);
|
|
384
|
+
const prefix = a.assistantId === activeId ? "* " : " ";
|
|
384
385
|
|
|
385
386
|
return {
|
|
386
|
-
name: a.assistantId,
|
|
387
|
+
name: prefix + a.assistantId,
|
|
387
388
|
status: withStatusEmoji("checking..."),
|
|
388
389
|
info: infoParts.join(" | "),
|
|
389
390
|
};
|
|
@@ -403,15 +404,34 @@ async function listAllAssistants(): Promise<void> {
|
|
|
403
404
|
|
|
404
405
|
await Promise.all(
|
|
405
406
|
assistants.map(async (a, rowIndex) => {
|
|
406
|
-
|
|
407
|
+
// For local assistants, check if the daemon process is alive before
|
|
408
|
+
// hitting the health endpoint. If the PID file is missing or the
|
|
409
|
+
// process isn't running, the assistant is sleeping — skip the
|
|
410
|
+
// network health check to avoid a misleading "unreachable" status.
|
|
411
|
+
let health: { status: string; detail: string | null };
|
|
412
|
+
const resources =
|
|
413
|
+
a.resources ??
|
|
414
|
+
(a.cloud === "local" ? defaultLocalResources() : undefined);
|
|
415
|
+
if (a.cloud === "local" && resources) {
|
|
416
|
+
const pid = readPidFile(resources.pidFile);
|
|
417
|
+
const alive = pid !== null && isProcessAlive(pid);
|
|
418
|
+
if (!alive) {
|
|
419
|
+
health = { status: "sleeping", detail: null };
|
|
420
|
+
} else {
|
|
421
|
+
health = await checkHealth(a.localUrl ?? a.runtimeUrl, a.bearerToken);
|
|
422
|
+
}
|
|
423
|
+
} else {
|
|
424
|
+
health = await checkHealth(a.localUrl ?? a.runtimeUrl, a.bearerToken);
|
|
425
|
+
}
|
|
407
426
|
|
|
408
427
|
const infoParts = [a.runtimeUrl];
|
|
409
428
|
if (a.cloud) infoParts.push(`cloud: ${a.cloud}`);
|
|
410
429
|
if (a.species) infoParts.push(`species: ${a.species}`);
|
|
411
430
|
if (health.detail) infoParts.push(health.detail);
|
|
412
431
|
|
|
432
|
+
const prefix = a.assistantId === activeId ? "* " : " ";
|
|
413
433
|
const updatedRow: TableRow = {
|
|
414
|
-
name: a.assistantId,
|
|
434
|
+
name: prefix + a.assistantId,
|
|
415
435
|
status: withStatusEmoji(health.status),
|
|
416
436
|
info: infoParts.join(" | "),
|
|
417
437
|
};
|