@vellumai/cli 0.10.12 → 0.11.0-dev.202607290043.3b26b6b

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.
@@ -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(readiness: DaemonReadiness): void {
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
  /**