@vellumai/cli 0.4.55 → 0.4.56
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/bun.lock +3 -70
- package/package.json +2 -3
- package/src/__tests__/random-name.test.ts +24 -5
- package/src/adapters/install.sh +1 -1
- package/src/adapters/openclaw.ts +6 -3
- package/src/commands/client.ts +2 -3
- package/src/commands/hatch.ts +78 -155
- package/src/commands/pair.ts +2 -2
- package/src/commands/retire.ts +31 -7
- package/src/commands/wake.ts +25 -6
- package/src/components/DefaultMainScreen.tsx +1 -1
- package/src/lib/assistant-config.ts +9 -2
- package/src/lib/aws.ts +11 -37
- package/src/lib/constants.ts +7 -0
- package/src/lib/docker.ts +634 -279
- package/src/lib/gcp.ts +15 -14
- package/src/lib/guardian-token.ts +174 -0
- package/src/lib/health-check.ts +6 -30
- package/src/lib/local.ts +150 -27
- package/src/lib/platform-client.ts +24 -0
- package/src/lib/process.ts +1 -1
- package/src/lib/random-name.ts +17 -1
- package/src/lib/jwt.ts +0 -62
- package/src/lib/policy.ts +0 -7
package/src/lib/gcp.ts
CHANGED
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { randomBytes } from "crypto";
|
|
2
1
|
import { unlinkSync, writeFileSync } from "fs";
|
|
3
2
|
import { tmpdir, userInfo } from "os";
|
|
4
3
|
import { join } from "path";
|
|
@@ -7,7 +6,8 @@ import { saveAssistantEntry, setActiveAssistant } from "./assistant-config";
|
|
|
7
6
|
import type { AssistantEntry } from "./assistant-config";
|
|
8
7
|
import { FIREWALL_TAG, GATEWAY_PORT } from "./constants";
|
|
9
8
|
import type { Species } from "./constants";
|
|
10
|
-
import {
|
|
9
|
+
import { leaseGuardianToken } from "./guardian-token";
|
|
10
|
+
import { generateInstanceName } from "./random-name";
|
|
11
11
|
import { exec, execOutput } from "./step-runner";
|
|
12
12
|
|
|
13
13
|
export async function getActiveProject(): Promise<string> {
|
|
@@ -447,7 +447,6 @@ export async function hatchGcp(
|
|
|
447
447
|
name: string | null,
|
|
448
448
|
buildStartupScript: (
|
|
449
449
|
species: Species,
|
|
450
|
-
bearerToken: string,
|
|
451
450
|
sshUser: string,
|
|
452
451
|
anthropicApiKey: string,
|
|
453
452
|
instanceName: string,
|
|
@@ -467,12 +466,7 @@ export async function hatchGcp(
|
|
|
467
466
|
const project = process.env.GCP_PROJECT ?? (await getActiveProject());
|
|
468
467
|
let instanceName: string;
|
|
469
468
|
|
|
470
|
-
|
|
471
|
-
instanceName = name;
|
|
472
|
-
} else {
|
|
473
|
-
const suffix = generateRandomSuffix();
|
|
474
|
-
instanceName = `${species}-${suffix}`;
|
|
475
|
-
}
|
|
469
|
+
instanceName = generateInstanceName(species, name);
|
|
476
470
|
|
|
477
471
|
console.log(`\ud83e\udd5a Creating new assistant: ${instanceName}`);
|
|
478
472
|
console.log(` Species: ${species}`);
|
|
@@ -500,13 +494,11 @@ export async function hatchGcp(
|
|
|
500
494
|
console.log(
|
|
501
495
|
`\u26a0\ufe0f Instance name ${instanceName} already exists, generating a new name...`,
|
|
502
496
|
);
|
|
503
|
-
|
|
504
|
-
instanceName = `${species}-${suffix}`;
|
|
497
|
+
instanceName = generateInstanceName(species);
|
|
505
498
|
}
|
|
506
499
|
}
|
|
507
500
|
|
|
508
501
|
const sshUser = userInfo().username;
|
|
509
|
-
const bearerToken = randomBytes(32).toString("hex");
|
|
510
502
|
const hatchedBy = process.env.VELLUM_HATCHED_BY;
|
|
511
503
|
const anthropicApiKey = process.env.ANTHROPIC_API_KEY;
|
|
512
504
|
if (!anthropicApiKey) {
|
|
@@ -517,7 +509,6 @@ export async function hatchGcp(
|
|
|
517
509
|
}
|
|
518
510
|
const startupScript = await buildStartupScript(
|
|
519
511
|
species,
|
|
520
|
-
bearerToken,
|
|
521
512
|
sshUser,
|
|
522
513
|
anthropicApiKey,
|
|
523
514
|
instanceName,
|
|
@@ -637,7 +628,7 @@ export async function hatchGcp(
|
|
|
637
628
|
species === "vellum" &&
|
|
638
629
|
(await checkCurlFailure(instanceName, project, zone, account))
|
|
639
630
|
) {
|
|
640
|
-
const installScriptUrl = `${process.env.VELLUM_PLATFORM_URL ?? "https://
|
|
631
|
+
const installScriptUrl = `${process.env.VELLUM_PLATFORM_URL ?? "https://vellum.ai"}/install.sh`;
|
|
641
632
|
console.log(
|
|
642
633
|
`\ud83d\udd04 Detected install script curl failure for ${installScriptUrl}, attempting recovery...`,
|
|
643
634
|
);
|
|
@@ -654,6 +645,16 @@ export async function hatchGcp(
|
|
|
654
645
|
}
|
|
655
646
|
}
|
|
656
647
|
|
|
648
|
+
try {
|
|
649
|
+
const tokenData = await leaseGuardianToken(runtimeUrl, instanceName);
|
|
650
|
+
gcpEntry.bearerToken = tokenData.accessToken;
|
|
651
|
+
saveAssistantEntry(gcpEntry);
|
|
652
|
+
} catch (err) {
|
|
653
|
+
console.warn(
|
|
654
|
+
`\u26a0\ufe0f Could not lease guardian token: ${err instanceof Error ? err.message : err}`,
|
|
655
|
+
);
|
|
656
|
+
}
|
|
657
|
+
|
|
657
658
|
console.log("Instance details:");
|
|
658
659
|
console.log(` Name: ${instanceName}`);
|
|
659
660
|
console.log(` Project: ${project}`);
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import { execSync } from "node:child_process";
|
|
3
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
|
|
4
|
+
import { homedir, platform } from "os";
|
|
5
|
+
import { dirname, join } from "path";
|
|
6
|
+
|
|
7
|
+
const DEVICE_ID_SALT = "vellum-assistant-host-id";
|
|
8
|
+
|
|
9
|
+
export interface GuardianTokenData {
|
|
10
|
+
guardianPrincipalId: string;
|
|
11
|
+
accessToken: string;
|
|
12
|
+
accessTokenExpiresAt: string;
|
|
13
|
+
refreshToken: string;
|
|
14
|
+
refreshTokenExpiresAt: string;
|
|
15
|
+
refreshAfter: string;
|
|
16
|
+
isNew: boolean;
|
|
17
|
+
deviceId: string;
|
|
18
|
+
leasedAt: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function getXdgConfigHome(): string {
|
|
22
|
+
return process.env.XDG_CONFIG_HOME?.trim() || join(homedir(), ".config");
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function getGuardianTokenPath(assistantId: string): string {
|
|
26
|
+
return join(
|
|
27
|
+
getXdgConfigHome(),
|
|
28
|
+
"vellum",
|
|
29
|
+
"assistants",
|
|
30
|
+
assistantId,
|
|
31
|
+
"guardian-token.json",
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function getPersistedDeviceIdPath(): string {
|
|
36
|
+
return join(getXdgConfigHome(), "vellum", "device-id");
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function hashWithSalt(input: string): string {
|
|
40
|
+
return createHash("sha256")
|
|
41
|
+
.update(input + DEVICE_ID_SALT)
|
|
42
|
+
.digest("hex");
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function getMacOSPlatformUUID(): string | null {
|
|
46
|
+
try {
|
|
47
|
+
const output = execSync(
|
|
48
|
+
"ioreg -rd1 -c IOPlatformExpertDevice | awk '/IOPlatformUUID/{print $3}'",
|
|
49
|
+
{ encoding: "utf-8", timeout: 5000 },
|
|
50
|
+
).trim();
|
|
51
|
+
const uuid = output.replace(/"/g, "");
|
|
52
|
+
return uuid.length > 0 ? uuid : null;
|
|
53
|
+
} catch {
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function getLinuxMachineId(): string | null {
|
|
59
|
+
try {
|
|
60
|
+
return readFileSync("/etc/machine-id", "utf-8").trim() || null;
|
|
61
|
+
} catch {
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function getWindowsMachineGuid(): string | null {
|
|
67
|
+
try {
|
|
68
|
+
const output = execSync(
|
|
69
|
+
'reg query "HKLM\\SOFTWARE\\Microsoft\\Cryptography" /v MachineGuid',
|
|
70
|
+
{ encoding: "utf-8", timeout: 5000 },
|
|
71
|
+
).trim();
|
|
72
|
+
const match = output.match(/MachineGuid\s+REG_SZ\s+(.+)/);
|
|
73
|
+
return match?.[1]?.trim() ?? null;
|
|
74
|
+
} catch {
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function getOrCreatePersistedDeviceId(): string {
|
|
80
|
+
const path = getPersistedDeviceIdPath();
|
|
81
|
+
try {
|
|
82
|
+
const existing = readFileSync(path, "utf-8").trim();
|
|
83
|
+
if (existing.length > 0) {
|
|
84
|
+
return existing;
|
|
85
|
+
}
|
|
86
|
+
} catch {
|
|
87
|
+
// File doesn't exist yet
|
|
88
|
+
}
|
|
89
|
+
const newId = randomUUID();
|
|
90
|
+
const dir = dirname(path);
|
|
91
|
+
if (!existsSync(dir)) {
|
|
92
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
93
|
+
}
|
|
94
|
+
writeFileSync(path, newId + "\n", { mode: 0o600 });
|
|
95
|
+
return newId;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Compute a stable device identifier matching the native client conventions.
|
|
100
|
+
*
|
|
101
|
+
* - macOS: SHA-256 of IOPlatformUUID + salt (matches PairingQRCodeSheet.computeHostId)
|
|
102
|
+
* - Linux: SHA-256 of /etc/machine-id + salt
|
|
103
|
+
* - Windows: SHA-256 of HKLM MachineGuid + salt
|
|
104
|
+
* - Fallback: persisted random UUID in XDG config
|
|
105
|
+
*/
|
|
106
|
+
export function computeDeviceId(): string {
|
|
107
|
+
const os = platform();
|
|
108
|
+
|
|
109
|
+
if (os === "darwin") {
|
|
110
|
+
const uuid = getMacOSPlatformUUID();
|
|
111
|
+
if (uuid) return hashWithSalt(uuid);
|
|
112
|
+
} else if (os === "linux") {
|
|
113
|
+
const machineId = getLinuxMachineId();
|
|
114
|
+
if (machineId) return hashWithSalt(machineId);
|
|
115
|
+
} else if (os === "win32") {
|
|
116
|
+
const guid = getWindowsMachineGuid();
|
|
117
|
+
if (guid) return hashWithSalt(guid);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return getOrCreatePersistedDeviceId();
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function saveGuardianToken(
|
|
124
|
+
assistantId: string,
|
|
125
|
+
data: GuardianTokenData,
|
|
126
|
+
): void {
|
|
127
|
+
const tokenPath = getGuardianTokenPath(assistantId);
|
|
128
|
+
const dir = dirname(tokenPath);
|
|
129
|
+
if (!existsSync(dir)) {
|
|
130
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
131
|
+
}
|
|
132
|
+
writeFileSync(tokenPath, JSON.stringify(data, null, 2) + "\n", {
|
|
133
|
+
mode: 0o600,
|
|
134
|
+
});
|
|
135
|
+
chmodSync(tokenPath, 0o600);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Call POST /v1/guardian/init on the remote gateway to bootstrap a JWT
|
|
140
|
+
* credential pair. The returned tokens are persisted locally under
|
|
141
|
+
* `$XDG_CONFIG_HOME/vellum/assistants/<assistantId>/guardian-token.json`.
|
|
142
|
+
*/
|
|
143
|
+
export async function leaseGuardianToken(
|
|
144
|
+
gatewayUrl: string,
|
|
145
|
+
assistantId: string,
|
|
146
|
+
): Promise<GuardianTokenData> {
|
|
147
|
+
const deviceId = computeDeviceId();
|
|
148
|
+
const response = await fetch(`${gatewayUrl}/v1/guardian/init`, {
|
|
149
|
+
method: "POST",
|
|
150
|
+
headers: { "Content-Type": "application/json" },
|
|
151
|
+
body: JSON.stringify({ platform: "cli", deviceId }),
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
if (!response.ok) {
|
|
155
|
+
const body = await response.text();
|
|
156
|
+
throw new Error(`guardian/init failed (${response.status}): ${body}`);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const json = (await response.json()) as Record<string, unknown>;
|
|
160
|
+
const tokenData: GuardianTokenData = {
|
|
161
|
+
guardianPrincipalId: json.guardianPrincipalId as string,
|
|
162
|
+
accessToken: json.accessToken as string,
|
|
163
|
+
accessTokenExpiresAt: json.accessTokenExpiresAt as string,
|
|
164
|
+
refreshToken: json.refreshToken as string,
|
|
165
|
+
refreshTokenExpiresAt: json.refreshTokenExpiresAt as string,
|
|
166
|
+
refreshAfter: json.refreshAfter as string,
|
|
167
|
+
isNew: json.isNew as boolean,
|
|
168
|
+
deviceId,
|
|
169
|
+
leasedAt: new Date().toISOString(),
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
saveGuardianToken(assistantId, tokenData);
|
|
173
|
+
return tokenData;
|
|
174
|
+
}
|
package/src/lib/health-check.ts
CHANGED
|
@@ -10,32 +10,6 @@ export interface HealthCheckResult {
|
|
|
10
10
|
detail: string | null;
|
|
11
11
|
}
|
|
12
12
|
|
|
13
|
-
interface OrgListResponse {
|
|
14
|
-
results: { id: string }[];
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
async function fetchOrganizationId(
|
|
18
|
-
platformUrl: string,
|
|
19
|
-
token: string,
|
|
20
|
-
): Promise<{ orgId: string } | { error: string }> {
|
|
21
|
-
try {
|
|
22
|
-
const response = await fetch(`${platformUrl}/v1/organizations/`, {
|
|
23
|
-
headers: { "X-Session-Token": token },
|
|
24
|
-
});
|
|
25
|
-
if (!response.ok) {
|
|
26
|
-
return { error: `org lookup failed (${response.status})` };
|
|
27
|
-
}
|
|
28
|
-
const body = (await response.json()) as OrgListResponse;
|
|
29
|
-
const orgId = body.results?.[0]?.id;
|
|
30
|
-
if (!orgId) {
|
|
31
|
-
return { error: "no organization found" };
|
|
32
|
-
}
|
|
33
|
-
return { orgId };
|
|
34
|
-
} catch {
|
|
35
|
-
return { error: "org lookup unreachable" };
|
|
36
|
-
}
|
|
37
|
-
}
|
|
38
|
-
|
|
39
13
|
export async function checkManagedHealth(
|
|
40
14
|
runtimeUrl: string,
|
|
41
15
|
assistantId: string,
|
|
@@ -49,14 +23,16 @@ export async function checkManagedHealth(
|
|
|
49
23
|
};
|
|
50
24
|
}
|
|
51
25
|
|
|
52
|
-
|
|
53
|
-
|
|
26
|
+
let orgId: string;
|
|
27
|
+
try {
|
|
28
|
+
const { fetchOrganizationId } = await import("./platform-client.js");
|
|
29
|
+
orgId = await fetchOrganizationId(token);
|
|
30
|
+
} catch (err) {
|
|
54
31
|
return {
|
|
55
32
|
status: "error (auth)",
|
|
56
|
-
detail:
|
|
33
|
+
detail: err instanceof Error ? err.message : "org lookup failed",
|
|
57
34
|
};
|
|
58
35
|
}
|
|
59
|
-
const { orgId } = orgResult;
|
|
60
36
|
|
|
61
37
|
try {
|
|
62
38
|
const url = `${runtimeUrl}/v1/assistants/${encodeURIComponent(assistantId)}/healthz/`;
|
package/src/lib/local.ts
CHANGED
|
@@ -195,7 +195,9 @@ function resolveDaemonMainPath(assistantIndex: string): string {
|
|
|
195
195
|
async function startDaemonFromSource(
|
|
196
196
|
assistantIndex: string,
|
|
197
197
|
resources: LocalInstanceResources,
|
|
198
|
+
options?: { foreground?: boolean },
|
|
198
199
|
): Promise<void> {
|
|
200
|
+
const foreground = options?.foreground ?? false;
|
|
199
201
|
const daemonMainPath = resolveDaemonMainPath(assistantIndex);
|
|
200
202
|
|
|
201
203
|
// Ensure the directory containing PID/socket files exists. For named
|
|
@@ -207,7 +209,25 @@ async function startDaemonFromSource(
|
|
|
207
209
|
// --- Lifecycle guard: prevent split-brain daemon state ---
|
|
208
210
|
if (existsSync(pidFile)) {
|
|
209
211
|
try {
|
|
210
|
-
const
|
|
212
|
+
const content = readFileSync(pidFile, "utf-8").trim();
|
|
213
|
+
|
|
214
|
+
// Another caller is already spawning the daemon — wait for it
|
|
215
|
+
// instead of racing to spawn a duplicate.
|
|
216
|
+
if (content === "starting") {
|
|
217
|
+
console.log(
|
|
218
|
+
" Assistant is starting — waiting for it to become ready...",
|
|
219
|
+
);
|
|
220
|
+
if (await waitForDaemonReady(resources.daemonPort, 60000)) {
|
|
221
|
+
console.log(" Assistant is ready\n");
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
// The other spawn may have failed; clean up and proceed to spawn.
|
|
225
|
+
try {
|
|
226
|
+
unlinkSync(pidFile);
|
|
227
|
+
} catch {}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const pid = parseInt(content, 10);
|
|
211
231
|
if (!isNaN(pid)) {
|
|
212
232
|
try {
|
|
213
233
|
process.kill(pid, 0);
|
|
@@ -249,17 +269,33 @@ async function startDaemonFromSource(
|
|
|
249
269
|
delete env.QDRANT_URL;
|
|
250
270
|
}
|
|
251
271
|
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
272
|
+
// Write a sentinel PID file before spawning so concurrent hatch() calls
|
|
273
|
+
// detect the in-progress spawn and wait instead of racing.
|
|
274
|
+
writeFileSync(pidFile, "starting", "utf-8");
|
|
275
|
+
|
|
276
|
+
const child = foreground
|
|
277
|
+
? spawn("bun", ["run", daemonMainPath], {
|
|
278
|
+
stdio: "inherit",
|
|
279
|
+
env,
|
|
280
|
+
})
|
|
281
|
+
: (() => {
|
|
282
|
+
const daemonLogFd = openLogFile("hatch.log");
|
|
283
|
+
const c = spawn("bun", ["run", daemonMainPath], {
|
|
284
|
+
detached: true,
|
|
285
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
286
|
+
env,
|
|
287
|
+
});
|
|
288
|
+
pipeToLogFile(c, daemonLogFd, "daemon");
|
|
289
|
+
c.unref();
|
|
290
|
+
return c;
|
|
291
|
+
})();
|
|
260
292
|
|
|
261
293
|
if (child.pid) {
|
|
262
294
|
writeFileSync(pidFile, String(child.pid), "utf-8");
|
|
295
|
+
} else {
|
|
296
|
+
try {
|
|
297
|
+
unlinkSync(pidFile);
|
|
298
|
+
} catch {}
|
|
263
299
|
}
|
|
264
300
|
}
|
|
265
301
|
|
|
@@ -284,7 +320,25 @@ async function startDaemonWatchFromSource(
|
|
|
284
320
|
// If a daemon is already running, skip spawning a new one.
|
|
285
321
|
if (existsSync(pidFile)) {
|
|
286
322
|
try {
|
|
287
|
-
const
|
|
323
|
+
const content = readFileSync(pidFile, "utf-8").trim();
|
|
324
|
+
|
|
325
|
+
// Another caller is already spawning the daemon — wait for it
|
|
326
|
+
// instead of racing to spawn a duplicate.
|
|
327
|
+
if (content === "starting") {
|
|
328
|
+
console.log(
|
|
329
|
+
" Assistant is starting — waiting for it to become ready...",
|
|
330
|
+
);
|
|
331
|
+
if (await waitForDaemonReady(resources.daemonPort, 60000)) {
|
|
332
|
+
console.log(" Assistant is ready\n");
|
|
333
|
+
return;
|
|
334
|
+
}
|
|
335
|
+
// The other spawn may have failed; clean up and proceed to spawn.
|
|
336
|
+
try {
|
|
337
|
+
unlinkSync(pidFile);
|
|
338
|
+
} catch {}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
const pid = parseInt(content, 10);
|
|
288
342
|
if (!isNaN(pid)) {
|
|
289
343
|
try {
|
|
290
344
|
process.kill(pid, 0); // Check if alive
|
|
@@ -328,6 +382,10 @@ async function startDaemonWatchFromSource(
|
|
|
328
382
|
delete env.QDRANT_URL;
|
|
329
383
|
}
|
|
330
384
|
|
|
385
|
+
// Write a sentinel PID file before spawning so concurrent hatch() calls
|
|
386
|
+
// detect the in-progress spawn and wait instead of racing.
|
|
387
|
+
writeFileSync(pidFile, "starting", "utf-8");
|
|
388
|
+
|
|
331
389
|
const daemonLogFd = openLogFile("hatch.log");
|
|
332
390
|
const child = spawn("bun", ["--watch", "run", mainPath], {
|
|
333
391
|
detached: true,
|
|
@@ -338,8 +396,13 @@ async function startDaemonWatchFromSource(
|
|
|
338
396
|
child.unref();
|
|
339
397
|
const daemonPid = child.pid;
|
|
340
398
|
|
|
399
|
+
// Overwrite sentinel with real PID, or clean up on spawn failure.
|
|
341
400
|
if (daemonPid) {
|
|
342
401
|
writeFileSync(pidFile, String(daemonPid), "utf-8");
|
|
402
|
+
} else {
|
|
403
|
+
try {
|
|
404
|
+
unlinkSync(pidFile);
|
|
405
|
+
} catch {}
|
|
343
406
|
}
|
|
344
407
|
|
|
345
408
|
console.log(" Assistant started in watch mode (bun --watch)");
|
|
@@ -716,19 +779,39 @@ export function getLocalLanIPv4(): string | undefined {
|
|
|
716
779
|
}
|
|
717
780
|
|
|
718
781
|
/**
|
|
719
|
-
* Check whether watch-mode startup is possible
|
|
720
|
-
* files (bun --watch only works with .ts sources,
|
|
721
|
-
* Returns true when assistant source can be resolved,
|
|
782
|
+
* Check whether watch-mode startup is possible for the assistant daemon.
|
|
783
|
+
* Watch mode requires source files (bun --watch only works with .ts sources,
|
|
784
|
+
* not compiled binaries). Returns true when assistant source can be resolved,
|
|
785
|
+
* false otherwise.
|
|
722
786
|
*
|
|
723
787
|
* Use this before stopping a running assistant for a watch-mode restart — if
|
|
724
788
|
* watch mode isn't available (e.g. packaged desktop app without source), the
|
|
725
789
|
* caller should keep the existing process alive rather than killing it and
|
|
726
790
|
* failing.
|
|
727
791
|
*/
|
|
728
|
-
export function
|
|
792
|
+
export function isAssistantWatchModeAvailable(): boolean {
|
|
729
793
|
return resolveAssistantIndexPath() !== undefined;
|
|
730
794
|
}
|
|
731
795
|
|
|
796
|
+
/**
|
|
797
|
+
* Check whether watch-mode startup is possible for the gateway. Watch mode
|
|
798
|
+
* requires gateway source files (bun --watch only works with .ts sources).
|
|
799
|
+
* Returns true when the gateway source directory can be resolved, false
|
|
800
|
+
* otherwise.
|
|
801
|
+
*
|
|
802
|
+
* Use this before stopping a running gateway for a watch-mode restart — if
|
|
803
|
+
* watch mode isn't available, the caller should keep the existing process
|
|
804
|
+
* alive rather than killing it and failing.
|
|
805
|
+
*/
|
|
806
|
+
export function isGatewayWatchModeAvailable(): boolean {
|
|
807
|
+
try {
|
|
808
|
+
resolveGatewayDir();
|
|
809
|
+
return true;
|
|
810
|
+
} catch {
|
|
811
|
+
return false;
|
|
812
|
+
}
|
|
813
|
+
}
|
|
814
|
+
|
|
732
815
|
// NOTE: startLocalDaemon() is the CLI-side daemon lifecycle manager.
|
|
733
816
|
// It should eventually converge with
|
|
734
817
|
// assistant/src/daemon/daemon-control.ts::startDaemon which is the
|
|
@@ -736,7 +819,9 @@ export function isWatchModeAvailable(): boolean {
|
|
|
736
819
|
export async function startLocalDaemon(
|
|
737
820
|
watch: boolean = false,
|
|
738
821
|
resources: LocalInstanceResources,
|
|
822
|
+
options?: { foreground?: boolean },
|
|
739
823
|
): Promise<void> {
|
|
824
|
+
const foreground = options?.foreground ?? false;
|
|
740
825
|
// Check for a compiled daemon binary adjacent to the CLI executable.
|
|
741
826
|
// This covers both the desktop app (VELLUM_DESKTOP_APP) and the case where
|
|
742
827
|
// the user runs the compiled CLI directly from the terminal (e.g. via a
|
|
@@ -754,7 +839,26 @@ export async function startLocalDaemon(
|
|
|
754
839
|
let daemonAlive = false;
|
|
755
840
|
if (existsSync(pidFile)) {
|
|
756
841
|
try {
|
|
757
|
-
const
|
|
842
|
+
const content = readFileSync(pidFile, "utf-8").trim();
|
|
843
|
+
|
|
844
|
+
// Another caller is already spawning the daemon — wait for it
|
|
845
|
+
// instead of racing to spawn a duplicate.
|
|
846
|
+
if (content === "starting") {
|
|
847
|
+
console.log(
|
|
848
|
+
" Assistant is starting — waiting for it to become ready...",
|
|
849
|
+
);
|
|
850
|
+
if (await waitForDaemonReady(resources.daemonPort, 60000)) {
|
|
851
|
+
console.log(" Assistant is ready\n");
|
|
852
|
+
ensureBunInstalled();
|
|
853
|
+
return;
|
|
854
|
+
}
|
|
855
|
+
// The other spawn may have failed; clean up and proceed to spawn.
|
|
856
|
+
try {
|
|
857
|
+
unlinkSync(pidFile);
|
|
858
|
+
} catch {}
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
const pid = parseInt(content, 10);
|
|
758
862
|
if (!isNaN(pid)) {
|
|
759
863
|
try {
|
|
760
864
|
process.kill(pid, 0); // Check if alive
|
|
@@ -820,6 +924,7 @@ export async function startLocalDaemon(
|
|
|
820
924
|
"ANTHROPIC_API_KEY",
|
|
821
925
|
"APP_VERSION",
|
|
822
926
|
"BASE_DATA_DIR",
|
|
927
|
+
"PLATFORM_BASE_URL",
|
|
823
928
|
"QDRANT_HTTP_PORT",
|
|
824
929
|
"QDRANT_URL",
|
|
825
930
|
"RUNTIME_HTTP_PORT",
|
|
@@ -827,6 +932,7 @@ export async function startLocalDaemon(
|
|
|
827
932
|
"TMPDIR",
|
|
828
933
|
"USER",
|
|
829
934
|
"LANG",
|
|
935
|
+
"VELLUM_DEBUG",
|
|
830
936
|
]) {
|
|
831
937
|
if (process.env[key]) {
|
|
832
938
|
daemonEnv[key] = process.env[key]!;
|
|
@@ -842,21 +948,38 @@ export async function startLocalDaemon(
|
|
|
842
948
|
delete daemonEnv.QDRANT_URL;
|
|
843
949
|
}
|
|
844
950
|
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
951
|
+
// Write a sentinel PID file before spawning so concurrent hatch() calls
|
|
952
|
+
// see the file and fall through to the isDaemonResponsive() port check
|
|
953
|
+
// instead of racing to spawn a duplicate daemon.
|
|
954
|
+
writeFileSync(pidFile, "starting", "utf-8");
|
|
955
|
+
|
|
956
|
+
const child = foreground
|
|
957
|
+
? spawn(daemonBinary, [], {
|
|
958
|
+
cwd: dirname(daemonBinary),
|
|
959
|
+
stdio: "inherit",
|
|
960
|
+
env: daemonEnv,
|
|
961
|
+
})
|
|
962
|
+
: (() => {
|
|
963
|
+
const daemonLogFd = openLogFile("hatch.log");
|
|
964
|
+
const c = spawn(daemonBinary, [], {
|
|
965
|
+
cwd: dirname(daemonBinary),
|
|
966
|
+
detached: true,
|
|
967
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
968
|
+
env: daemonEnv,
|
|
969
|
+
});
|
|
970
|
+
pipeToLogFile(c, daemonLogFd, "daemon");
|
|
971
|
+
c.unref();
|
|
972
|
+
return c;
|
|
973
|
+
})();
|
|
854
974
|
const daemonPid = child.pid;
|
|
855
975
|
|
|
856
|
-
//
|
|
857
|
-
// and concurrent hatch() calls see it as alive.
|
|
976
|
+
// Overwrite sentinel with real PID, or clean up on spawn failure.
|
|
858
977
|
if (daemonPid) {
|
|
859
978
|
writeFileSync(pidFile, String(daemonPid), "utf-8");
|
|
979
|
+
} else {
|
|
980
|
+
try {
|
|
981
|
+
unlinkSync(pidFile);
|
|
982
|
+
} catch {}
|
|
860
983
|
}
|
|
861
984
|
}
|
|
862
985
|
|
|
@@ -919,7 +1042,7 @@ export async function startLocalDaemon(
|
|
|
919
1042
|
);
|
|
920
1043
|
}
|
|
921
1044
|
} else {
|
|
922
|
-
await startDaemonFromSource(assistantIndex, resources);
|
|
1045
|
+
await startDaemonFromSource(assistantIndex, resources, { foreground });
|
|
923
1046
|
|
|
924
1047
|
const daemonReady = await waitForDaemonReady(resources.daemonPort, 60000);
|
|
925
1048
|
if (daemonReady) {
|
|
@@ -55,6 +55,30 @@ export interface PlatformUser {
|
|
|
55
55
|
display: string;
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
+
interface OrganizationListResponse {
|
|
59
|
+
results: { id: string; name: string }[];
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export async function fetchOrganizationId(token: string): Promise<string> {
|
|
63
|
+
const url = `${getPlatformUrl()}/v1/organizations/`;
|
|
64
|
+
const response = await fetch(url, {
|
|
65
|
+
headers: { "X-Session-Token": token },
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
if (!response.ok) {
|
|
69
|
+
throw new Error(
|
|
70
|
+
`Failed to fetch organizations (${response.status}). Try logging in again.`,
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const body = (await response.json()) as OrganizationListResponse;
|
|
75
|
+
const orgId = body.results?.[0]?.id;
|
|
76
|
+
if (!orgId) {
|
|
77
|
+
throw new Error("No organization found for this account.");
|
|
78
|
+
}
|
|
79
|
+
return orgId;
|
|
80
|
+
}
|
|
81
|
+
|
|
58
82
|
interface AllauthSessionResponse {
|
|
59
83
|
status: number;
|
|
60
84
|
data: {
|
package/src/lib/process.ts
CHANGED
|
@@ -13,7 +13,7 @@ function isVellumProcess(pid: number): boolean {
|
|
|
13
13
|
timeout: 3000,
|
|
14
14
|
stdio: ["ignore", "pipe", "ignore"],
|
|
15
15
|
}).trim();
|
|
16
|
-
return /vellum-daemon|vellum-cli|vellum-gateway|@vellumai
|
|
16
|
+
return /vellum-daemon|vellum-cli|vellum-gateway|@vellumai|\/\.?vellum\/|\/daemon\/main|\/\.vellum\/.*qdrant\/bin\/qdrant/.test(
|
|
17
17
|
output,
|
|
18
18
|
);
|
|
19
19
|
} catch {
|
package/src/lib/random-name.ts
CHANGED
|
@@ -1,3 +1,7 @@
|
|
|
1
|
+
import { customAlphabet } from "nanoid";
|
|
2
|
+
|
|
3
|
+
const nanoidLower = customAlphabet("abcdefghijklmnopqrstuvwxyz0123456789", 6);
|
|
4
|
+
|
|
1
5
|
const ADJECTIVES = [
|
|
2
6
|
"brave",
|
|
3
7
|
"calm",
|
|
@@ -129,5 +133,17 @@ function randomElement<T>(arr: T[]): T {
|
|
|
129
133
|
}
|
|
130
134
|
|
|
131
135
|
export function generateRandomSuffix(): string {
|
|
132
|
-
return `${randomElement(ADJECTIVES)}-${randomElement(NOUNS)}`;
|
|
136
|
+
return `${randomElement(ADJECTIVES)}-${randomElement(NOUNS)}-${nanoidLower()}`;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Generate an instance name for a new assistant. Uses the explicit name if
|
|
141
|
+
* provided, otherwise produces `<species>-<adjective>-<noun>-<nanoid>`.
|
|
142
|
+
*/
|
|
143
|
+
export function generateInstanceName(
|
|
144
|
+
species: string,
|
|
145
|
+
explicitName?: string | null,
|
|
146
|
+
): string {
|
|
147
|
+
if (explicitName) return explicitName;
|
|
148
|
+
return `${species}-${generateRandomSuffix()}`;
|
|
133
149
|
}
|