@vellumai/cli 0.4.42 → 0.4.43
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__/assistant-config.test.ts +33 -2
- package/src/__tests__/multi-local.test.ts +13 -22
- package/src/__tests__/sleep.test.ts +172 -0
- package/src/commands/client.ts +72 -10
- package/src/commands/hatch.ts +61 -15
- package/src/commands/ps.ts +25 -8
- package/src/commands/recover.ts +17 -8
- package/src/commands/retire.ts +14 -23
- package/src/commands/sleep.ts +88 -16
- package/src/commands/wake.ts +9 -7
- package/src/components/DefaultMainScreen.tsx +3 -83
- package/src/index.ts +0 -3
- package/src/lib/assistant-config.ts +17 -62
- package/src/lib/aws.ts +30 -1
- package/src/lib/docker.ts +319 -0
- package/src/lib/gcp.ts +53 -1
- package/src/lib/http-client.ts +114 -0
- package/src/lib/local.ts +96 -148
- package/src/lib/step-runner.ts +9 -1
- package/src/__tests__/skills-uninstall.test.ts +0 -203
- package/src/commands/skills.ts +0 -514
package/package.json
CHANGED
|
@@ -5,7 +5,7 @@ import { join } from "node:path";
|
|
|
5
5
|
|
|
6
6
|
// Point lockfile operations at a temp directory
|
|
7
7
|
const testDir = mkdtempSync(join(tmpdir(), "cli-assistant-config-test-"));
|
|
8
|
-
process.env.
|
|
8
|
+
process.env.VELLUM_LOCKFILE_DIR = testDir;
|
|
9
9
|
|
|
10
10
|
import {
|
|
11
11
|
loadLatestAssistant,
|
|
@@ -13,12 +13,13 @@ import {
|
|
|
13
13
|
removeAssistantEntry,
|
|
14
14
|
loadAllAssistants,
|
|
15
15
|
saveAssistantEntry,
|
|
16
|
+
getActiveAssistant,
|
|
16
17
|
type AssistantEntry,
|
|
17
18
|
} from "../lib/assistant-config.js";
|
|
18
19
|
|
|
19
20
|
afterAll(() => {
|
|
20
21
|
rmSync(testDir, { recursive: true, force: true });
|
|
21
|
-
delete process.env.
|
|
22
|
+
delete process.env.VELLUM_LOCKFILE_DIR;
|
|
22
23
|
});
|
|
23
24
|
|
|
24
25
|
function writeLockfile(data: unknown): void {
|
|
@@ -100,6 +101,36 @@ describe("assistant-config", () => {
|
|
|
100
101
|
expect(all.map((e) => e.assistantId)).toEqual(["a", "c"]);
|
|
101
102
|
});
|
|
102
103
|
|
|
104
|
+
test("removeAssistantEntry reassigns activeAssistant to remaining entry", () => {
|
|
105
|
+
writeLockfile({
|
|
106
|
+
assistants: [makeEntry("a"), makeEntry("b")],
|
|
107
|
+
activeAssistant: "a",
|
|
108
|
+
});
|
|
109
|
+
removeAssistantEntry("a");
|
|
110
|
+
expect(getActiveAssistant()).toBe("b");
|
|
111
|
+
expect(loadAllAssistants()).toHaveLength(1);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test("removeAssistantEntry clears activeAssistant when no entries remain", () => {
|
|
115
|
+
writeLockfile({
|
|
116
|
+
assistants: [makeEntry("only")],
|
|
117
|
+
activeAssistant: "only",
|
|
118
|
+
});
|
|
119
|
+
removeAssistantEntry("only");
|
|
120
|
+
expect(getActiveAssistant()).toBeNull();
|
|
121
|
+
expect(loadAllAssistants()).toHaveLength(0);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test("removeAssistantEntry preserves activeAssistant when removing a different entry", () => {
|
|
125
|
+
writeLockfile({
|
|
126
|
+
assistants: [makeEntry("a"), makeEntry("b"), makeEntry("c")],
|
|
127
|
+
activeAssistant: "a",
|
|
128
|
+
});
|
|
129
|
+
removeAssistantEntry("b");
|
|
130
|
+
expect(getActiveAssistant()).toBe("a");
|
|
131
|
+
expect(loadAllAssistants()).toHaveLength(2);
|
|
132
|
+
});
|
|
133
|
+
|
|
103
134
|
test("loadLatestAssistant returns null when empty", () => {
|
|
104
135
|
expect(loadLatestAssistant()).toBeNull();
|
|
105
136
|
});
|
|
@@ -4,9 +4,9 @@ import { tmpdir } from "node:os";
|
|
|
4
4
|
import { join } from "node:path";
|
|
5
5
|
|
|
6
6
|
// Create a temp directory that acts as a fake home, so allocateLocalResources()
|
|
7
|
-
//
|
|
7
|
+
// never touches the real ~/.vellum directory.
|
|
8
8
|
const testDir = mkdtempSync(join(tmpdir(), "cli-multi-local-test-"));
|
|
9
|
-
process.env.
|
|
9
|
+
process.env.VELLUM_LOCKFILE_DIR = testDir;
|
|
10
10
|
|
|
11
11
|
// Mock homedir() to return testDir — this isolates allocateLocalResources()
|
|
12
12
|
// which uses homedir() directly for instance directory creation.
|
|
@@ -31,7 +31,6 @@ mock.module("../lib/port-probe.js", () => ({
|
|
|
31
31
|
|
|
32
32
|
import {
|
|
33
33
|
allocateLocalResources,
|
|
34
|
-
defaultLocalResources,
|
|
35
34
|
resolveTargetAssistant,
|
|
36
35
|
setActiveAssistant,
|
|
37
36
|
getActiveAssistant,
|
|
@@ -47,7 +46,7 @@ import {
|
|
|
47
46
|
|
|
48
47
|
afterAll(() => {
|
|
49
48
|
rmSync(testDir, { recursive: true, force: true });
|
|
50
|
-
delete process.env.
|
|
49
|
+
delete process.env.VELLUM_LOCKFILE_DIR;
|
|
51
50
|
});
|
|
52
51
|
|
|
53
52
|
function writeLockfile(data: unknown): void {
|
|
@@ -95,14 +94,18 @@ describe("multi-local", () => {
|
|
|
95
94
|
});
|
|
96
95
|
|
|
97
96
|
describe("allocateLocalResources() produces non-conflicting ports", () => {
|
|
98
|
-
test("first instance
|
|
97
|
+
test("first instance gets XDG path and default ports", async () => {
|
|
99
98
|
// GIVEN no local assistants exist in the lockfile
|
|
100
99
|
|
|
101
100
|
// WHEN we allocate resources for the first instance
|
|
102
101
|
const res = await allocateLocalResources("instance-a");
|
|
103
102
|
|
|
104
|
-
// THEN it
|
|
105
|
-
expect(res.instanceDir).toBe(
|
|
103
|
+
// THEN it gets an XDG instance directory under the home dir
|
|
104
|
+
expect(res.instanceDir).toBe(
|
|
105
|
+
join(testDir, ".local", "share", "vellum", "assistants", "instance-a"),
|
|
106
|
+
);
|
|
107
|
+
|
|
108
|
+
// AND it gets the default ports since no other instances exist
|
|
106
109
|
expect(res.daemonPort).toBe(DEFAULT_DAEMON_PORT);
|
|
107
110
|
expect(res.gatewayPort).toBe(DEFAULT_GATEWAY_PORT);
|
|
108
111
|
expect(res.qdrantPort).toBe(DEFAULT_QDRANT_PORT);
|
|
@@ -156,18 +159,6 @@ describe("multi-local", () => {
|
|
|
156
159
|
});
|
|
157
160
|
});
|
|
158
161
|
|
|
159
|
-
describe("defaultLocalResources() returns legacy paths", () => {
|
|
160
|
-
test("instanceDir is homedir", () => {
|
|
161
|
-
const res = defaultLocalResources();
|
|
162
|
-
expect(res.instanceDir).toBe(testDir);
|
|
163
|
-
});
|
|
164
|
-
|
|
165
|
-
test("daemonPort is DEFAULT_DAEMON_PORT", () => {
|
|
166
|
-
const res = defaultLocalResources();
|
|
167
|
-
expect(res.daemonPort).toBe(DEFAULT_DAEMON_PORT);
|
|
168
|
-
});
|
|
169
|
-
});
|
|
170
|
-
|
|
171
162
|
describe("resolveTargetAssistant() priority chain", () => {
|
|
172
163
|
test("explicit name returns that entry", () => {
|
|
173
164
|
writeLockfile({
|
|
@@ -243,14 +234,14 @@ describe("multi-local", () => {
|
|
|
243
234
|
});
|
|
244
235
|
});
|
|
245
236
|
|
|
246
|
-
describe("removeAssistantEntry()
|
|
247
|
-
test("set active to foo, remove foo, verify active is
|
|
237
|
+
describe("removeAssistantEntry() reassigns activeAssistant on removal", () => {
|
|
238
|
+
test("set active to foo, remove foo, verify active is reassigned to bar", () => {
|
|
248
239
|
writeLockfile({
|
|
249
240
|
assistants: [makeEntry("foo"), makeEntry("bar")],
|
|
250
241
|
activeAssistant: "foo",
|
|
251
242
|
});
|
|
252
243
|
removeAssistantEntry("foo");
|
|
253
|
-
expect(getActiveAssistant()).
|
|
244
|
+
expect(getActiveAssistant()).toBe("bar");
|
|
254
245
|
});
|
|
255
246
|
|
|
256
247
|
test("set active to foo, remove bar, verify active is still foo", () => {
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import {
|
|
2
|
+
afterAll,
|
|
3
|
+
beforeEach,
|
|
4
|
+
describe,
|
|
5
|
+
expect,
|
|
6
|
+
mock,
|
|
7
|
+
spyOn,
|
|
8
|
+
test,
|
|
9
|
+
} from "bun:test";
|
|
10
|
+
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
|
|
11
|
+
import { tmpdir } from "node:os";
|
|
12
|
+
import { join } from "node:path";
|
|
13
|
+
|
|
14
|
+
// Create a temp directory and set VELLUM_LOCKFILE_DIR so the real
|
|
15
|
+
// assistant-config module reads/writes the lockfile here instead of ~/.
|
|
16
|
+
const testDir = mkdtempSync(join(tmpdir(), "sleep-command-test-"));
|
|
17
|
+
const assistantRootDir = join(testDir, ".vellum");
|
|
18
|
+
process.env.VELLUM_LOCKFILE_DIR = testDir;
|
|
19
|
+
|
|
20
|
+
const stopProcessByPidFileMock = mock(async () => true);
|
|
21
|
+
const isProcessAliveMock = mock((): { alive: boolean; pid: number | null } => ({
|
|
22
|
+
alive: false,
|
|
23
|
+
pid: null,
|
|
24
|
+
}));
|
|
25
|
+
|
|
26
|
+
mock.module("../lib/process.js", () => ({
|
|
27
|
+
isProcessAlive: isProcessAliveMock,
|
|
28
|
+
stopProcessByPidFile: stopProcessByPidFileMock,
|
|
29
|
+
}));
|
|
30
|
+
|
|
31
|
+
import { sleep } from "../commands/sleep.js";
|
|
32
|
+
import {
|
|
33
|
+
DEFAULT_DAEMON_PORT,
|
|
34
|
+
DEFAULT_GATEWAY_PORT,
|
|
35
|
+
DEFAULT_QDRANT_PORT,
|
|
36
|
+
} from "../lib/constants.js";
|
|
37
|
+
|
|
38
|
+
// Write a lockfile entry so the real resolveTargetAssistant() finds our test
|
|
39
|
+
// assistant without needing to mock the entire assistant-config module.
|
|
40
|
+
function writeLockfile(): void {
|
|
41
|
+
writeFileSync(
|
|
42
|
+
join(testDir, ".vellum.lock.json"),
|
|
43
|
+
JSON.stringify(
|
|
44
|
+
{
|
|
45
|
+
assistants: [
|
|
46
|
+
{
|
|
47
|
+
assistantId: "sleep-test",
|
|
48
|
+
runtimeUrl: `http://127.0.0.1:${DEFAULT_DAEMON_PORT}`,
|
|
49
|
+
cloud: "local",
|
|
50
|
+
resources: {
|
|
51
|
+
instanceDir: testDir,
|
|
52
|
+
daemonPort: DEFAULT_DAEMON_PORT,
|
|
53
|
+
gatewayPort: DEFAULT_GATEWAY_PORT,
|
|
54
|
+
qdrantPort: DEFAULT_QDRANT_PORT,
|
|
55
|
+
pidFile: join(assistantRootDir, "vellum.pid"),
|
|
56
|
+
},
|
|
57
|
+
},
|
|
58
|
+
],
|
|
59
|
+
activeAssistant: "sleep-test",
|
|
60
|
+
},
|
|
61
|
+
null,
|
|
62
|
+
2,
|
|
63
|
+
),
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function writeLeaseFile(callSessionIds: string[]): void {
|
|
68
|
+
mkdirSync(assistantRootDir, { recursive: true });
|
|
69
|
+
writeFileSync(
|
|
70
|
+
join(assistantRootDir, "active-call-leases.json"),
|
|
71
|
+
JSON.stringify(
|
|
72
|
+
{
|
|
73
|
+
version: 1,
|
|
74
|
+
leases: callSessionIds.map((callSessionId) => ({
|
|
75
|
+
callSessionId,
|
|
76
|
+
providerCallSid: null,
|
|
77
|
+
updatedAt: Date.now(),
|
|
78
|
+
})),
|
|
79
|
+
},
|
|
80
|
+
null,
|
|
81
|
+
2,
|
|
82
|
+
),
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
describe("sleep command", () => {
|
|
87
|
+
let originalArgv: string[];
|
|
88
|
+
|
|
89
|
+
beforeEach(() => {
|
|
90
|
+
originalArgv = [...process.argv];
|
|
91
|
+
isProcessAliveMock.mockReset();
|
|
92
|
+
isProcessAliveMock.mockReturnValue({ alive: false, pid: null });
|
|
93
|
+
stopProcessByPidFileMock.mockReset();
|
|
94
|
+
stopProcessByPidFileMock.mockResolvedValue(true);
|
|
95
|
+
rmSync(assistantRootDir, { recursive: true, force: true });
|
|
96
|
+
writeLockfile();
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
afterAll(() => {
|
|
100
|
+
process.argv = originalArgv;
|
|
101
|
+
rmSync(testDir, { recursive: true, force: true });
|
|
102
|
+
delete process.env.VELLUM_LOCKFILE_DIR;
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test("refuses normal sleep while an active call lease exists", async () => {
|
|
106
|
+
isProcessAliveMock.mockReturnValue({ alive: true, pid: 12345 });
|
|
107
|
+
writeLeaseFile(["call-active-1", "call-active-2"]);
|
|
108
|
+
process.argv = ["bun", "vellum", "sleep", "sleep-test"];
|
|
109
|
+
|
|
110
|
+
const consoleError = spyOn(console, "error").mockImplementation(() => {});
|
|
111
|
+
const exitMock = mock((code?: number) => {
|
|
112
|
+
throw new Error(`process.exit:${code}`);
|
|
113
|
+
});
|
|
114
|
+
const originalExit = process.exit;
|
|
115
|
+
process.exit = exitMock as unknown as typeof process.exit;
|
|
116
|
+
|
|
117
|
+
try {
|
|
118
|
+
await expect(sleep()).rejects.toThrow("process.exit:1");
|
|
119
|
+
expect(consoleError).toHaveBeenCalledWith(
|
|
120
|
+
expect.stringContaining("vellum sleep --force"),
|
|
121
|
+
);
|
|
122
|
+
} finally {
|
|
123
|
+
process.exit = originalExit;
|
|
124
|
+
consoleError.mockRestore();
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
expect(stopProcessByPidFileMock).not.toHaveBeenCalled();
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test("proceeds when assistant is not running even with stale lease file", async () => {
|
|
131
|
+
isProcessAliveMock.mockReturnValue({ alive: false, pid: null });
|
|
132
|
+
writeLeaseFile(["call-stale-1"]);
|
|
133
|
+
process.argv = ["bun", "vellum", "sleep", "sleep-test"];
|
|
134
|
+
|
|
135
|
+
const consoleLog = spyOn(console, "log").mockImplementation(() => {});
|
|
136
|
+
|
|
137
|
+
try {
|
|
138
|
+
await sleep();
|
|
139
|
+
} finally {
|
|
140
|
+
consoleLog.mockRestore();
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
expect(stopProcessByPidFileMock).toHaveBeenCalledTimes(2);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test("force stops the assistant even when an active call lease exists", async () => {
|
|
147
|
+
writeLeaseFile(["call-active-1"]);
|
|
148
|
+
process.argv = ["bun", "vellum", "sleep", "sleep-test", "--force"];
|
|
149
|
+
|
|
150
|
+
const consoleLog = spyOn(console, "log").mockImplementation(() => {});
|
|
151
|
+
|
|
152
|
+
try {
|
|
153
|
+
await sleep();
|
|
154
|
+
} finally {
|
|
155
|
+
consoleLog.mockRestore();
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
expect(stopProcessByPidFileMock).toHaveBeenCalledTimes(2);
|
|
159
|
+
expect(stopProcessByPidFileMock).toHaveBeenNthCalledWith(
|
|
160
|
+
1,
|
|
161
|
+
join(assistantRootDir, "vellum.pid"),
|
|
162
|
+
"assistant",
|
|
163
|
+
);
|
|
164
|
+
expect(stopProcessByPidFileMock).toHaveBeenNthCalledWith(
|
|
165
|
+
2,
|
|
166
|
+
join(assistantRootDir, "gateway.pid"),
|
|
167
|
+
"gateway",
|
|
168
|
+
undefined,
|
|
169
|
+
7000,
|
|
170
|
+
);
|
|
171
|
+
});
|
|
172
|
+
});
|
package/src/commands/client.ts
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
|
+
import { hostname } from "os";
|
|
2
|
+
|
|
1
3
|
import {
|
|
2
4
|
findAssistantByName,
|
|
3
5
|
getActiveAssistant,
|
|
4
6
|
loadLatestAssistant,
|
|
5
7
|
} from "../lib/assistant-config";
|
|
6
8
|
import { GATEWAY_PORT, type Species } from "../lib/constants";
|
|
9
|
+
import { getLocalLanIPv4, getMacLocalHostname } from "../lib/local";
|
|
7
10
|
|
|
8
11
|
const ANSI = {
|
|
9
12
|
reset: "\x1b[0m",
|
|
@@ -46,7 +49,7 @@ function parseArgs(): ParsedArgs {
|
|
|
46
49
|
}
|
|
47
50
|
}
|
|
48
51
|
|
|
49
|
-
let entry: ReturnType<typeof findAssistantByName
|
|
52
|
+
let entry: ReturnType<typeof findAssistantByName> = null;
|
|
50
53
|
if (positionalName) {
|
|
51
54
|
entry = findAssistantByName(positionalName);
|
|
52
55
|
if (!entry) {
|
|
@@ -55,15 +58,30 @@ function parseArgs(): ParsedArgs {
|
|
|
55
58
|
);
|
|
56
59
|
process.exit(1);
|
|
57
60
|
}
|
|
58
|
-
} else if (process.env.RUNTIME_URL) {
|
|
59
|
-
// Explicit env var — skip assistant resolution, will use env values below
|
|
60
|
-
entry = loadLatestAssistant();
|
|
61
61
|
} else {
|
|
62
|
-
|
|
63
|
-
|
|
62
|
+
const hasExplicitUrl =
|
|
63
|
+
process.env.RUNTIME_URL ||
|
|
64
|
+
flagArgs.includes("--url") ||
|
|
65
|
+
flagArgs.includes("-u");
|
|
64
66
|
const active = getActiveAssistant();
|
|
65
|
-
|
|
66
|
-
|
|
67
|
+
if (active) {
|
|
68
|
+
entry = findAssistantByName(active);
|
|
69
|
+
if (!entry && !hasExplicitUrl) {
|
|
70
|
+
console.error(
|
|
71
|
+
`Active assistant '${active}' not found in lockfile. Set an active assistant with 'vellum use <name>'.`,
|
|
72
|
+
);
|
|
73
|
+
process.exit(1);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
if (!entry && hasExplicitUrl) {
|
|
77
|
+
// URL provided but active assistant missing or unset — use latest for remaining defaults
|
|
78
|
+
entry = loadLatestAssistant();
|
|
79
|
+
} else if (!entry) {
|
|
80
|
+
console.error(
|
|
81
|
+
"No active assistant set. Set one with 'vellum use <name>' or specify a name: 'vellum client <name>'.",
|
|
82
|
+
);
|
|
83
|
+
process.exit(1);
|
|
84
|
+
}
|
|
67
85
|
}
|
|
68
86
|
|
|
69
87
|
let runtimeUrl =
|
|
@@ -90,7 +108,7 @@ function parseArgs(): ParsedArgs {
|
|
|
90
108
|
}
|
|
91
109
|
|
|
92
110
|
return {
|
|
93
|
-
runtimeUrl: runtimeUrl.replace(/\/+$/, ""),
|
|
111
|
+
runtimeUrl: maybeSwapToLocalhost(runtimeUrl.replace(/\/+$/, "")),
|
|
94
112
|
assistantId,
|
|
95
113
|
species,
|
|
96
114
|
bearerToken,
|
|
@@ -99,6 +117,50 @@ function parseArgs(): ParsedArgs {
|
|
|
99
117
|
};
|
|
100
118
|
}
|
|
101
119
|
|
|
120
|
+
/**
|
|
121
|
+
* If the hostname in `url` matches this machine's local DNS name, LAN IP, or
|
|
122
|
+
* raw hostname, replace it with 127.0.0.1 so the client avoids mDNS round-trips
|
|
123
|
+
* when talking to an assistant running on the same machine.
|
|
124
|
+
*/
|
|
125
|
+
function maybeSwapToLocalhost(url: string): string {
|
|
126
|
+
let parsed: URL;
|
|
127
|
+
try {
|
|
128
|
+
parsed = new URL(url);
|
|
129
|
+
} catch {
|
|
130
|
+
return url;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const urlHost = parsed.hostname.toLowerCase();
|
|
134
|
+
|
|
135
|
+
const localNames: string[] = [];
|
|
136
|
+
|
|
137
|
+
const host = hostname();
|
|
138
|
+
if (host) {
|
|
139
|
+
localNames.push(host.toLowerCase());
|
|
140
|
+
// Also consider the bare name without .local suffix
|
|
141
|
+
if (host.toLowerCase().endsWith(".local")) {
|
|
142
|
+
localNames.push(host.toLowerCase().slice(0, -".local".length));
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const macHost = getMacLocalHostname();
|
|
147
|
+
if (macHost) {
|
|
148
|
+
localNames.push(macHost.toLowerCase());
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const lanIp = getLocalLanIPv4();
|
|
152
|
+
if (lanIp) {
|
|
153
|
+
localNames.push(lanIp);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
if (localNames.includes(urlHost)) {
|
|
157
|
+
parsed.hostname = "127.0.0.1";
|
|
158
|
+
return parsed.toString().replace(/\/+$/, "");
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
return url;
|
|
162
|
+
}
|
|
163
|
+
|
|
102
164
|
function printUsage(): void {
|
|
103
165
|
console.log(`${ANSI.bold}vellum client${ANSI.reset} - Connect to a hatched assistant
|
|
104
166
|
|
|
@@ -106,7 +168,7 @@ ${ANSI.bold}USAGE:${ANSI.reset}
|
|
|
106
168
|
vellum client [name] [options]
|
|
107
169
|
|
|
108
170
|
${ANSI.bold}ARGUMENTS:${ANSI.reset}
|
|
109
|
-
[name] Instance name (default:
|
|
171
|
+
[name] Instance name (default: active)
|
|
110
172
|
|
|
111
173
|
${ANSI.bold}OPTIONS:${ANSI.reset}
|
|
112
174
|
-u, --url <url> Runtime URL
|
package/src/commands/hatch.ts
CHANGED
|
@@ -22,10 +22,10 @@ import cliPkg from "../../package.json";
|
|
|
22
22
|
import { buildOpenclawStartupScript } from "../adapters/openclaw";
|
|
23
23
|
import {
|
|
24
24
|
allocateLocalResources,
|
|
25
|
-
defaultLocalResources,
|
|
26
25
|
findAssistantByName,
|
|
27
26
|
loadAllAssistants,
|
|
28
27
|
saveAssistantEntry,
|
|
28
|
+
setActiveAssistant,
|
|
29
29
|
syncConfigToLockfile,
|
|
30
30
|
} from "../lib/assistant-config";
|
|
31
31
|
import type {
|
|
@@ -39,6 +39,7 @@ import {
|
|
|
39
39
|
VALID_SPECIES,
|
|
40
40
|
} from "../lib/constants";
|
|
41
41
|
import type { RemoteHost, Species } from "../lib/constants";
|
|
42
|
+
import { hatchDocker } from "../lib/docker";
|
|
42
43
|
import { hatchGcp } from "../lib/gcp";
|
|
43
44
|
import type { PollResult, WatchHatchingResult } from "../lib/gcp";
|
|
44
45
|
import {
|
|
@@ -169,6 +170,7 @@ const DEFAULT_REMOTE: RemoteHost = "local";
|
|
|
169
170
|
interface HatchArgs {
|
|
170
171
|
species: Species;
|
|
171
172
|
detached: boolean;
|
|
173
|
+
keepAlive: boolean;
|
|
172
174
|
name: string | null;
|
|
173
175
|
remote: RemoteHost;
|
|
174
176
|
daemonOnly: boolean;
|
|
@@ -180,6 +182,7 @@ function parseArgs(): HatchArgs {
|
|
|
180
182
|
const args = process.argv.slice(3);
|
|
181
183
|
let species: Species = DEFAULT_SPECIES;
|
|
182
184
|
let detached = false;
|
|
185
|
+
let keepAlive = false;
|
|
183
186
|
let name: string | null = null;
|
|
184
187
|
let remote: RemoteHost = DEFAULT_REMOTE;
|
|
185
188
|
let daemonOnly = false;
|
|
@@ -212,6 +215,9 @@ function parseArgs(): HatchArgs {
|
|
|
212
215
|
console.log(
|
|
213
216
|
" --watch Run assistant and gateway in watch mode (hot reload on source changes)",
|
|
214
217
|
);
|
|
218
|
+
console.log(
|
|
219
|
+
" --keep-alive Stay alive after hatch, exit when gateway stops",
|
|
220
|
+
);
|
|
215
221
|
process.exit(0);
|
|
216
222
|
} else if (arg === "-d") {
|
|
217
223
|
detached = true;
|
|
@@ -221,6 +227,8 @@ function parseArgs(): HatchArgs {
|
|
|
221
227
|
restart = true;
|
|
222
228
|
} else if (arg === "--watch") {
|
|
223
229
|
watch = true;
|
|
230
|
+
} else if (arg === "--keep-alive") {
|
|
231
|
+
keepAlive = true;
|
|
224
232
|
} else if (arg === "--name") {
|
|
225
233
|
const next = args[i + 1];
|
|
226
234
|
if (!next || next.startsWith("-")) {
|
|
@@ -251,13 +259,13 @@ function parseArgs(): HatchArgs {
|
|
|
251
259
|
species = arg as Species;
|
|
252
260
|
} else {
|
|
253
261
|
console.error(
|
|
254
|
-
`Error: Unknown argument '${arg}'. Valid options: ${VALID_SPECIES.join(", ")}, -d, --daemon-only, --restart, --watch, --name <name>, --remote <${VALID_REMOTE_HOSTS.join("|")}>`,
|
|
262
|
+
`Error: Unknown argument '${arg}'. Valid options: ${VALID_SPECIES.join(", ")}, -d, --daemon-only, --restart, --watch, --keep-alive, --name <name>, --remote <${VALID_REMOTE_HOSTS.join("|")}>`,
|
|
255
263
|
);
|
|
256
264
|
process.exit(1);
|
|
257
265
|
}
|
|
258
266
|
}
|
|
259
267
|
|
|
260
|
-
return { species, detached, name, remote, daemonOnly, restart, watch };
|
|
268
|
+
return { species, detached, keepAlive, name, remote, daemonOnly, restart, watch };
|
|
261
269
|
}
|
|
262
270
|
|
|
263
271
|
function formatElapsed(ms: number): string {
|
|
@@ -682,6 +690,7 @@ async function hatchLocal(
|
|
|
682
690
|
daemonOnly: boolean = false,
|
|
683
691
|
restart: boolean = false,
|
|
684
692
|
watch: boolean = false,
|
|
693
|
+
keepAlive: boolean = false,
|
|
685
694
|
): Promise<void> {
|
|
686
695
|
if (restart && !name && !process.env.VELLUM_ASSISTANT_NAME) {
|
|
687
696
|
console.error(
|
|
@@ -717,15 +726,10 @@ async function hatchLocal(
|
|
|
717
726
|
const existingEntry = findAssistantByName(instanceName);
|
|
718
727
|
if (existingEntry?.cloud === "local" && existingEntry.resources) {
|
|
719
728
|
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
729
|
} else {
|
|
724
730
|
resources = await allocateLocalResources(instanceName);
|
|
725
731
|
}
|
|
726
732
|
|
|
727
|
-
const baseDataDir = join(resources.instanceDir, ".vellum");
|
|
728
|
-
|
|
729
733
|
console.log(`🥚 Hatching local assistant: ${instanceName}`);
|
|
730
734
|
console.log(` Species: ${species}`);
|
|
731
735
|
console.log("");
|
|
@@ -761,7 +765,6 @@ async function hatchLocal(
|
|
|
761
765
|
assistantId: instanceName,
|
|
762
766
|
runtimeUrl,
|
|
763
767
|
localUrl: `http://127.0.0.1:${resources.gatewayPort}`,
|
|
764
|
-
baseDataDir,
|
|
765
768
|
bearerToken,
|
|
766
769
|
cloud: "local",
|
|
767
770
|
species,
|
|
@@ -770,6 +773,7 @@ async function hatchLocal(
|
|
|
770
773
|
};
|
|
771
774
|
if (!daemonOnly && !restart) {
|
|
772
775
|
saveAssistantEntry(localEntry);
|
|
776
|
+
setActiveAssistant(instanceName);
|
|
773
777
|
syncConfigToLockfile();
|
|
774
778
|
|
|
775
779
|
if (process.env.VELLUM_DESKTOP_APP) {
|
|
@@ -790,6 +794,46 @@ async function hatchLocal(
|
|
|
790
794
|
const localGatewayUrl = `http://127.0.0.1:${resources.gatewayPort}`;
|
|
791
795
|
await displayPairingQRCode(localGatewayUrl, bearerToken, runtimeUrl);
|
|
792
796
|
}
|
|
797
|
+
|
|
798
|
+
if (keepAlive) {
|
|
799
|
+
const gatewayHealthUrl = `http://127.0.0.1:${resources.gatewayPort}/healthz`;
|
|
800
|
+
const POLL_INTERVAL_MS = 5000;
|
|
801
|
+
const MAX_FAILURES = 3;
|
|
802
|
+
let consecutiveFailures = 0;
|
|
803
|
+
|
|
804
|
+
const shutdown = async (): Promise<void> => {
|
|
805
|
+
console.log("\nShutting down local processes...");
|
|
806
|
+
await stopLocalProcesses(resources);
|
|
807
|
+
process.exit(0);
|
|
808
|
+
};
|
|
809
|
+
|
|
810
|
+
process.on("SIGTERM", () => void shutdown());
|
|
811
|
+
process.on("SIGINT", () => void shutdown());
|
|
812
|
+
|
|
813
|
+
// Poll the gateway health endpoint until it stops responding.
|
|
814
|
+
while (true) {
|
|
815
|
+
await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS));
|
|
816
|
+
try {
|
|
817
|
+
const res = await fetch(gatewayHealthUrl, {
|
|
818
|
+
signal: AbortSignal.timeout(3000),
|
|
819
|
+
});
|
|
820
|
+
if (res.ok) {
|
|
821
|
+
consecutiveFailures = 0;
|
|
822
|
+
} else {
|
|
823
|
+
consecutiveFailures++;
|
|
824
|
+
}
|
|
825
|
+
} catch {
|
|
826
|
+
consecutiveFailures++;
|
|
827
|
+
}
|
|
828
|
+
if (consecutiveFailures >= MAX_FAILURES) {
|
|
829
|
+
console.log(
|
|
830
|
+
"\n⚠️ Gateway stopped responding — shutting down.",
|
|
831
|
+
);
|
|
832
|
+
await stopLocalProcesses(resources);
|
|
833
|
+
process.exit(1);
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
}
|
|
793
837
|
}
|
|
794
838
|
|
|
795
839
|
function getCliVersion(): string {
|
|
@@ -800,7 +844,7 @@ export async function hatch(): Promise<void> {
|
|
|
800
844
|
const cliVersion = getCliVersion();
|
|
801
845
|
console.log(`@vellumai/cli v${cliVersion}`);
|
|
802
846
|
|
|
803
|
-
const { species, detached, name, remote, daemonOnly, restart, watch } =
|
|
847
|
+
const { species, detached, keepAlive, name, remote, daemonOnly, restart, watch } =
|
|
804
848
|
parseArgs();
|
|
805
849
|
|
|
806
850
|
if (restart && remote !== "local") {
|
|
@@ -810,13 +854,15 @@ export async function hatch(): Promise<void> {
|
|
|
810
854
|
process.exit(1);
|
|
811
855
|
}
|
|
812
856
|
|
|
813
|
-
if (watch && remote !== "local") {
|
|
814
|
-
console.error(
|
|
857
|
+
if (watch && remote !== "local" && remote !== "docker") {
|
|
858
|
+
console.error(
|
|
859
|
+
"Error: --watch is only supported for local and docker hatch targets.",
|
|
860
|
+
);
|
|
815
861
|
process.exit(1);
|
|
816
862
|
}
|
|
817
863
|
|
|
818
864
|
if (remote === "local") {
|
|
819
|
-
await hatchLocal(species, name, daemonOnly, restart, watch);
|
|
865
|
+
await hatchLocal(species, name, daemonOnly, restart, watch, keepAlive);
|
|
820
866
|
return;
|
|
821
867
|
}
|
|
822
868
|
|
|
@@ -831,8 +877,8 @@ export async function hatch(): Promise<void> {
|
|
|
831
877
|
}
|
|
832
878
|
|
|
833
879
|
if (remote === "docker") {
|
|
834
|
-
|
|
835
|
-
|
|
880
|
+
await hatchDocker(species, detached, name, watch);
|
|
881
|
+
return;
|
|
836
882
|
}
|
|
837
883
|
|
|
838
884
|
console.error(`Error: Remote host '${remote}' is not yet supported.`);
|