@vellumai/cli 0.10.12 → 0.11.0-dev.202607290139.f1746d4
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/node_modules/@vellumai/service-contracts/package.json +1 -0
- package/node_modules/@vellumai/service-contracts/src/__tests__/channels.test.ts +9 -1
- package/node_modules/@vellumai/service-contracts/src/__tests__/secret-detection.test.ts +449 -0
- package/node_modules/@vellumai/service-contracts/src/channels.ts +1 -0
- package/node_modules/@vellumai/service-contracts/src/secret-detection.ts +468 -0
- package/package.json +1 -1
- package/src/__tests__/drain.test.ts +337 -0
- package/src/__tests__/flags.test.ts +11 -11
- package/src/__tests__/local-hatch-lock.test.ts +163 -0
- package/src/__tests__/sleep.test.ts +171 -2
- package/src/__tests__/wake.test.ts +38 -1
- package/src/commands/flags.ts +3 -3
- package/src/commands/pair.ts +2 -2
- package/src/commands/sleep.ts +151 -2
- package/src/commands/wake.ts +47 -5
- package/src/lib/drain.ts +346 -0
- package/src/lib/flag-args.test.ts +3 -3
- package/src/lib/flag-args.ts +2 -2
- package/src/lib/hatch-local.ts +98 -86
- package/src/lib/local-hatch-lock.ts +322 -0
- package/src/lib/local.ts +20 -2
- package/src/lib/provider-secrets.ts +4 -0
- package/src/shared/provider-env-vars.ts +2 -0
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
import { execFileSync } from "child_process";
|
|
2
|
+
import { createHash, randomUUID } from "crypto";
|
|
3
|
+
import {
|
|
4
|
+
closeSync,
|
|
5
|
+
mkdirSync,
|
|
6
|
+
openSync,
|
|
7
|
+
readFileSync,
|
|
8
|
+
statSync,
|
|
9
|
+
unlinkSync,
|
|
10
|
+
utimesSync,
|
|
11
|
+
writeFileSync,
|
|
12
|
+
} from "fs";
|
|
13
|
+
import { hostname, userInfo } from "os";
|
|
14
|
+
import { dirname, join } from "path";
|
|
15
|
+
|
|
16
|
+
const DEFAULT_TIMEOUT_MS = 240_000;
|
|
17
|
+
const DEFAULT_RETRY_MS = 100;
|
|
18
|
+
const MAX_LOCK_AGE_MS = DEFAULT_TIMEOUT_MS - 60_000;
|
|
19
|
+
const HEARTBEAT_INTERVAL_MS = 30_000;
|
|
20
|
+
const LIVE_OWNER_RECOVERY_GRACE_MS = 10_000;
|
|
21
|
+
|
|
22
|
+
interface LocalHatchLockOptions {
|
|
23
|
+
lockPath?: string;
|
|
24
|
+
liveOwnerRecoveryGraceMs?: number;
|
|
25
|
+
timeoutMs?: number;
|
|
26
|
+
retryMs?: number;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
interface LockOwner {
|
|
30
|
+
pid: number;
|
|
31
|
+
processStartedAt?: string;
|
|
32
|
+
token: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
interface LockSnapshot {
|
|
36
|
+
owner?: LockOwner;
|
|
37
|
+
signature: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function resolveLocalHatchLockPath(): string {
|
|
41
|
+
const machineKey = createHash("sha256")
|
|
42
|
+
.update(hostname())
|
|
43
|
+
.digest("hex")
|
|
44
|
+
.slice(0, 12);
|
|
45
|
+
return join(
|
|
46
|
+
userInfo().homedir,
|
|
47
|
+
".cache",
|
|
48
|
+
"vellum",
|
|
49
|
+
"runtime",
|
|
50
|
+
machineKey,
|
|
51
|
+
"local-hatch.lock",
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function processIsAlive(pid: number): boolean {
|
|
56
|
+
try {
|
|
57
|
+
process.kill(pid, 0);
|
|
58
|
+
return true;
|
|
59
|
+
} catch (error) {
|
|
60
|
+
return (error as NodeJS.ErrnoException).code === "EPERM";
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function processStartedAt(pid: number): string | undefined {
|
|
65
|
+
try {
|
|
66
|
+
const value = execFileSync("ps", ["-o", "lstart=", "-p", String(pid)], {
|
|
67
|
+
encoding: "utf-8",
|
|
68
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
69
|
+
timeout: 1_000,
|
|
70
|
+
}).trim();
|
|
71
|
+
return value || undefined;
|
|
72
|
+
} catch {
|
|
73
|
+
return undefined;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function parseOwner(raw: string): LockOwner | undefined {
|
|
78
|
+
try {
|
|
79
|
+
const parsed = JSON.parse(raw) as {
|
|
80
|
+
pid?: unknown;
|
|
81
|
+
processStartedAt?: unknown;
|
|
82
|
+
token?: unknown;
|
|
83
|
+
};
|
|
84
|
+
if (
|
|
85
|
+
typeof parsed.pid === "number" &&
|
|
86
|
+
Number.isInteger(parsed.pid) &&
|
|
87
|
+
parsed.pid > 0 &&
|
|
88
|
+
typeof parsed.token === "string" &&
|
|
89
|
+
parsed.token.length > 0
|
|
90
|
+
) {
|
|
91
|
+
return {
|
|
92
|
+
pid: parsed.pid,
|
|
93
|
+
processStartedAt:
|
|
94
|
+
typeof parsed.processStartedAt === "string" &&
|
|
95
|
+
parsed.processStartedAt.length > 0
|
|
96
|
+
? parsed.processStartedAt
|
|
97
|
+
: undefined,
|
|
98
|
+
token: parsed.token,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
} catch {}
|
|
102
|
+
return undefined;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function readSnapshot(path: string): LockSnapshot | undefined {
|
|
106
|
+
try {
|
|
107
|
+
const raw = readFileSync(path, "utf-8");
|
|
108
|
+
return {
|
|
109
|
+
owner: parseOwner(raw),
|
|
110
|
+
signature: createHash("sha256").update(raw).digest("hex"),
|
|
111
|
+
};
|
|
112
|
+
} catch {
|
|
113
|
+
return undefined;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function readOwner(path: string): LockOwner | undefined {
|
|
118
|
+
return readSnapshot(path)?.owner;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function fileAgeMs(path: string): number | undefined {
|
|
122
|
+
try {
|
|
123
|
+
return Date.now() - statSync(path).mtimeMs;
|
|
124
|
+
} catch {
|
|
125
|
+
return undefined;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const expiredLiveOwners = new Map<string, number>();
|
|
130
|
+
|
|
131
|
+
function ownerIsActive(
|
|
132
|
+
path: string,
|
|
133
|
+
owner: LockOwner | undefined,
|
|
134
|
+
liveOwnerRecoveryGraceMs: number,
|
|
135
|
+
): boolean {
|
|
136
|
+
const ageMs = fileAgeMs(path);
|
|
137
|
+
if (!owner) {
|
|
138
|
+
return ageMs !== undefined && ageMs < MAX_LOCK_AGE_MS;
|
|
139
|
+
}
|
|
140
|
+
if (!processIsAlive(owner.pid)) {
|
|
141
|
+
return false;
|
|
142
|
+
}
|
|
143
|
+
if (owner.processStartedAt) {
|
|
144
|
+
const currentStartedAt = processStartedAt(owner.pid);
|
|
145
|
+
if (currentStartedAt) {
|
|
146
|
+
expiredLiveOwners.delete(owner.token);
|
|
147
|
+
return currentStartedAt === owner.processStartedAt;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
if (ageMs !== undefined && ageMs < MAX_LOCK_AGE_MS) {
|
|
151
|
+
expiredLiveOwners.delete(owner.token);
|
|
152
|
+
return true;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const firstExpiredAt = expiredLiveOwners.get(owner.token) ?? Date.now();
|
|
156
|
+
expiredLiveOwners.set(owner.token, firstExpiredAt);
|
|
157
|
+
if (Date.now() - firstExpiredAt < liveOwnerRecoveryGraceMs) {
|
|
158
|
+
return true;
|
|
159
|
+
}
|
|
160
|
+
return false;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function writeOwnerFile(path: string, owner: LockOwner): void {
|
|
164
|
+
const fd = openSync(path, "wx", 0o600);
|
|
165
|
+
try {
|
|
166
|
+
writeFileSync(fd, JSON.stringify(owner) + "\n");
|
|
167
|
+
} finally {
|
|
168
|
+
closeSync(fd);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function acquireRecoveryClaim(
|
|
173
|
+
lockPath: string,
|
|
174
|
+
staleSignature: string,
|
|
175
|
+
owner: LockOwner,
|
|
176
|
+
liveOwnerRecoveryGraceMs: number,
|
|
177
|
+
): (() => void) | undefined {
|
|
178
|
+
const recoveryKey = staleSignature.slice(0, 16);
|
|
179
|
+
for (let generation = 0; generation < 100; generation++) {
|
|
180
|
+
const claimPath = `${lockPath}.recovery-${recoveryKey}-${generation}`;
|
|
181
|
+
try {
|
|
182
|
+
writeOwnerFile(claimPath, owner);
|
|
183
|
+
return () => releaseLock(claimPath, owner.token);
|
|
184
|
+
} catch (error) {
|
|
185
|
+
if ((error as NodeJS.ErrnoException).code !== "EEXIST") {
|
|
186
|
+
throw error;
|
|
187
|
+
}
|
|
188
|
+
if (
|
|
189
|
+
ownerIsActive(claimPath, readOwner(claimPath), liveOwnerRecoveryGraceMs)
|
|
190
|
+
) {
|
|
191
|
+
return undefined;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
return undefined;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function removeStaleLock(
|
|
199
|
+
lockPath: string,
|
|
200
|
+
recoveryOwner: LockOwner,
|
|
201
|
+
liveOwnerRecoveryGraceMs: number,
|
|
202
|
+
): boolean {
|
|
203
|
+
const staleSnapshot = readSnapshot(lockPath);
|
|
204
|
+
if (!staleSnapshot) {
|
|
205
|
+
return true;
|
|
206
|
+
}
|
|
207
|
+
if (ownerIsActive(lockPath, staleSnapshot.owner, liveOwnerRecoveryGraceMs)) {
|
|
208
|
+
return false;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const releaseRecoveryClaim = acquireRecoveryClaim(
|
|
212
|
+
lockPath,
|
|
213
|
+
staleSnapshot.signature,
|
|
214
|
+
recoveryOwner,
|
|
215
|
+
liveOwnerRecoveryGraceMs,
|
|
216
|
+
);
|
|
217
|
+
if (!releaseRecoveryClaim) {
|
|
218
|
+
return false;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
try {
|
|
222
|
+
const currentSnapshot = readSnapshot(lockPath);
|
|
223
|
+
if (!currentSnapshot) {
|
|
224
|
+
return true;
|
|
225
|
+
}
|
|
226
|
+
if (currentSnapshot.signature !== staleSnapshot.signature) {
|
|
227
|
+
return false;
|
|
228
|
+
}
|
|
229
|
+
if (
|
|
230
|
+
ownerIsActive(lockPath, currentSnapshot.owner, liveOwnerRecoveryGraceMs)
|
|
231
|
+
) {
|
|
232
|
+
return false;
|
|
233
|
+
}
|
|
234
|
+
try {
|
|
235
|
+
unlinkSync(lockPath);
|
|
236
|
+
return true;
|
|
237
|
+
} catch (error) {
|
|
238
|
+
return (error as NodeJS.ErrnoException).code === "ENOENT";
|
|
239
|
+
}
|
|
240
|
+
} finally {
|
|
241
|
+
releaseRecoveryClaim();
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function releaseLock(lockPath: string, token: string): void {
|
|
246
|
+
if (readOwner(lockPath)?.token !== token) {
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
try {
|
|
250
|
+
unlinkSync(lockPath);
|
|
251
|
+
} catch (error) {
|
|
252
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
|
|
253
|
+
throw error;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function refreshLock(lockPath: string, token: string): void {
|
|
259
|
+
if (readOwner(lockPath)?.token !== token) {
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
try {
|
|
263
|
+
const now = new Date();
|
|
264
|
+
utimesSync(lockPath, now, now);
|
|
265
|
+
} catch {}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Serialize local assistant startup across CLI processes. Port availability is
|
|
270
|
+
* machine-wide even when callers use different lockfile or XDG directories,
|
|
271
|
+
* so allocation must remain exclusive until the new services have bound their
|
|
272
|
+
* ports and the assistant entry has been persisted.
|
|
273
|
+
*/
|
|
274
|
+
export async function withLocalHatchLock<T>(
|
|
275
|
+
action: () => Promise<T>,
|
|
276
|
+
options: LocalHatchLockOptions = {},
|
|
277
|
+
): Promise<T> {
|
|
278
|
+
const lockPath = options.lockPath ?? resolveLocalHatchLockPath();
|
|
279
|
+
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
280
|
+
const retryMs = options.retryMs ?? DEFAULT_RETRY_MS;
|
|
281
|
+
const liveOwnerRecoveryGraceMs =
|
|
282
|
+
options.liveOwnerRecoveryGraceMs ?? LIVE_OWNER_RECOVERY_GRACE_MS;
|
|
283
|
+
const deadline = Date.now() + timeoutMs;
|
|
284
|
+
const owner: LockOwner = {
|
|
285
|
+
pid: process.pid,
|
|
286
|
+
processStartedAt: processStartedAt(process.pid),
|
|
287
|
+
token: `${process.pid}-${randomUUID()}`,
|
|
288
|
+
};
|
|
289
|
+
|
|
290
|
+
mkdirSync(dirname(lockPath), { recursive: true, mode: 0o700 });
|
|
291
|
+
|
|
292
|
+
while (true) {
|
|
293
|
+
try {
|
|
294
|
+
writeOwnerFile(lockPath, owner);
|
|
295
|
+
} catch (error) {
|
|
296
|
+
if ((error as NodeJS.ErrnoException).code !== "EEXIST") {
|
|
297
|
+
throw error;
|
|
298
|
+
}
|
|
299
|
+
if (removeStaleLock(lockPath, owner, liveOwnerRecoveryGraceMs)) {
|
|
300
|
+
continue;
|
|
301
|
+
}
|
|
302
|
+
if (Date.now() >= deadline) {
|
|
303
|
+
throw new Error(
|
|
304
|
+
`Timed out waiting for another local assistant to finish starting (${lockPath})`,
|
|
305
|
+
);
|
|
306
|
+
}
|
|
307
|
+
await new Promise((resolve) => setTimeout(resolve, retryMs));
|
|
308
|
+
continue;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
const heartbeat = setInterval(
|
|
312
|
+
() => refreshLock(lockPath, owner.token),
|
|
313
|
+
HEARTBEAT_INTERVAL_MS,
|
|
314
|
+
);
|
|
315
|
+
try {
|
|
316
|
+
return await action();
|
|
317
|
+
} finally {
|
|
318
|
+
clearInterval(heartbeat);
|
|
319
|
+
releaseLock(lockPath, owner.token);
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
}
|
package/src/lib/local.ts
CHANGED
|
@@ -548,6 +548,7 @@ export function generateLocalSigningKey(): string {
|
|
|
548
548
|
type DaemonStartOptions = {
|
|
549
549
|
foreground?: boolean;
|
|
550
550
|
defaultWorkspaceConfigPath?: string;
|
|
551
|
+
requireReady?: boolean;
|
|
551
552
|
signingKey?: string;
|
|
552
553
|
};
|
|
553
554
|
|
|
@@ -597,7 +598,10 @@ function applyDaemonEnvOverrides(
|
|
|
597
598
|
applyIpcSocketDirOverride(env);
|
|
598
599
|
}
|
|
599
600
|
|
|
600
|
-
function logDaemonReadiness(
|
|
601
|
+
function logDaemonReadiness(
|
|
602
|
+
readiness: DaemonReadiness,
|
|
603
|
+
requireReady = false,
|
|
604
|
+
): void {
|
|
601
605
|
switch (readiness) {
|
|
602
606
|
case "ready":
|
|
603
607
|
console.log(" Assistant ready\n");
|
|
@@ -613,6 +617,11 @@ function logDaemonReadiness(readiness: DaemonReadiness): void {
|
|
|
613
617
|
);
|
|
614
618
|
break;
|
|
615
619
|
default:
|
|
620
|
+
if (requireReady) {
|
|
621
|
+
throw new Error(
|
|
622
|
+
"Assistant did not bind its local port within 60 seconds.",
|
|
623
|
+
);
|
|
624
|
+
}
|
|
616
625
|
console.log(
|
|
617
626
|
" ⚠️ Assistant did not become ready within 60s — continuing anyway\n",
|
|
618
627
|
);
|
|
@@ -1320,6 +1329,7 @@ export async function startLocalDaemon(
|
|
|
1320
1329
|
resources.daemonPort,
|
|
1321
1330
|
Date.now() + 60000,
|
|
1322
1331
|
),
|
|
1332
|
+
options?.requireReady,
|
|
1323
1333
|
);
|
|
1324
1334
|
}
|
|
1325
1335
|
return;
|
|
@@ -1362,6 +1372,7 @@ export async function startLocalDaemon(
|
|
|
1362
1372
|
// classifies it without blocking on an in-flight migration.
|
|
1363
1373
|
logDaemonReadiness(
|
|
1364
1374
|
await probeDaemonReadinessWithRetry(resources.daemonPort),
|
|
1375
|
+
options?.requireReady,
|
|
1365
1376
|
);
|
|
1366
1377
|
return;
|
|
1367
1378
|
}
|
|
@@ -1513,7 +1524,7 @@ export async function startLocalDaemon(
|
|
|
1513
1524
|
readiness = await probeDaemonReadiness(resources.daemonPort);
|
|
1514
1525
|
}
|
|
1515
1526
|
|
|
1516
|
-
logDaemonReadiness(readiness);
|
|
1527
|
+
logDaemonReadiness(readiness, options?.requireReady);
|
|
1517
1528
|
}
|
|
1518
1529
|
} else {
|
|
1519
1530
|
console.log("🔨 Starting local assistant...");
|
|
@@ -1535,6 +1546,7 @@ export async function startLocalDaemon(
|
|
|
1535
1546
|
resources.daemonPort,
|
|
1536
1547
|
Date.now() + 60000,
|
|
1537
1548
|
),
|
|
1549
|
+
options?.requireReady,
|
|
1538
1550
|
);
|
|
1539
1551
|
}
|
|
1540
1552
|
}
|
|
@@ -1547,6 +1559,7 @@ export async function startGateway(
|
|
|
1547
1559
|
signingKey?: string;
|
|
1548
1560
|
bootstrapSecret?: string;
|
|
1549
1561
|
envOverrides?: Record<string, string>;
|
|
1562
|
+
requireReady?: boolean;
|
|
1550
1563
|
},
|
|
1551
1564
|
): Promise<string> {
|
|
1552
1565
|
const effectiveGatewayPort = resources?.gatewayPort ?? GATEWAY_PORT;
|
|
@@ -1676,6 +1689,11 @@ export async function startGateway(
|
|
|
1676
1689
|
// connection-refused errors.
|
|
1677
1690
|
const ready = await waitForDaemonReady(effectiveGatewayPort, 30000);
|
|
1678
1691
|
if (!ready) {
|
|
1692
|
+
if (options?.requireReady) {
|
|
1693
|
+
throw new Error(
|
|
1694
|
+
"Assistant gateway did not bind its local port within 30 seconds.",
|
|
1695
|
+
);
|
|
1696
|
+
}
|
|
1679
1697
|
console.warn(
|
|
1680
1698
|
"⚠ Gateway started but health check did not respond within 30s",
|
|
1681
1699
|
);
|
|
@@ -76,6 +76,7 @@ const PROVIDER_LABELS: Record<LlmProviderId, string> = {
|
|
|
76
76
|
atlascloud: "Atlas Cloud",
|
|
77
77
|
together: "Together AI",
|
|
78
78
|
baseten: "Baseten",
|
|
79
|
+
poolside: "Poolside",
|
|
79
80
|
};
|
|
80
81
|
|
|
81
82
|
export function formatProviderName(provider: LlmProviderId): string {
|
|
@@ -194,6 +195,9 @@ export function inferProviderFromModel(model: string): string | undefined {
|
|
|
194
195
|
if (model.startsWith("thinkingmachines/")) {
|
|
195
196
|
return "baseten";
|
|
196
197
|
}
|
|
198
|
+
if (model.startsWith("poolside/")) {
|
|
199
|
+
return "poolside";
|
|
200
|
+
}
|
|
197
201
|
if (model.includes("/")) {
|
|
198
202
|
return "openrouter";
|
|
199
203
|
}
|
|
@@ -32,6 +32,7 @@ export const LLM_PROVIDER_ENV_VAR_NAMES: Record<string, string> = {
|
|
|
32
32
|
together: "TOGETHER_API_KEY",
|
|
33
33
|
litellm: "LITELLM_API_KEY",
|
|
34
34
|
baseten: "BASETEN_API_KEY",
|
|
35
|
+
poolside: "POOLSIDE_API_KEY",
|
|
35
36
|
};
|
|
36
37
|
|
|
37
38
|
/** Search-provider env var names. Mirrors `SEARCH_PROVIDER_CATALOG` BYOK entries. */
|
|
@@ -40,6 +41,7 @@ export const SEARCH_PROVIDER_ENV_VAR_NAMES: Record<string, string> = {
|
|
|
40
41
|
brave: "BRAVE_API_KEY",
|
|
41
42
|
tavily: "TAVILY_API_KEY",
|
|
42
43
|
firecrawl: "FIRECRAWL_API_KEY",
|
|
44
|
+
keenable: "KEENABLE_API_KEY",
|
|
43
45
|
};
|
|
44
46
|
|
|
45
47
|
/**
|