@vellumai/cli 0.6.5 → 0.7.0

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.
Files changed (47) hide show
  1. package/AGENTS.md +8 -2
  2. package/package.json +1 -1
  3. package/src/__tests__/assistant-config.test.ts +1 -7
  4. package/src/__tests__/config-utils.test.ts +159 -0
  5. package/src/__tests__/env-drift.test.ts +10 -32
  6. package/src/__tests__/llm-provider-env-var-parity.test.ts +1 -21
  7. package/src/__tests__/multi-local.test.ts +0 -5
  8. package/src/__tests__/sleep.test.ts +1 -2
  9. package/src/__tests__/teleport.test.ts +919 -1255
  10. package/src/commands/env.ts +93 -0
  11. package/src/commands/events.ts +2 -0
  12. package/src/commands/exec.ts +40 -8
  13. package/src/commands/hatch.ts +6 -2
  14. package/src/commands/login.ts +89 -6
  15. package/src/commands/ps.ts +104 -20
  16. package/src/commands/retire.ts +23 -0
  17. package/src/commands/sleep.ts +5 -2
  18. package/src/commands/ssh.ts +15 -2
  19. package/src/commands/teleport.ts +447 -583
  20. package/src/commands/terminal.ts +225 -0
  21. package/src/commands/wake.ts +2 -1
  22. package/src/components/DefaultMainScreen.tsx +304 -152
  23. package/src/index.ts +6 -0
  24. package/src/lib/__tests__/docker.test.ts +50 -74
  25. package/src/lib/__tests__/job-polling.test.ts +278 -0
  26. package/src/lib/__tests__/local-runtime-client.test.ts +383 -0
  27. package/src/lib/__tests__/platform-client-signed-url.test.ts +405 -0
  28. package/src/lib/assistant-config.ts +12 -8
  29. package/src/lib/client-identity.ts +67 -0
  30. package/src/lib/config-utils.ts +97 -1
  31. package/src/lib/docker.ts +73 -75
  32. package/src/lib/environments/__tests__/paths.test.ts +2 -0
  33. package/src/lib/environments/resolve.ts +89 -7
  34. package/src/lib/environments/seeds.ts +8 -5
  35. package/src/lib/environments/types.ts +10 -0
  36. package/src/lib/hatch-local.ts +15 -120
  37. package/src/lib/health-check.ts +98 -0
  38. package/src/lib/job-polling.ts +195 -0
  39. package/src/lib/local-runtime-client.ts +178 -0
  40. package/src/lib/local.ts +139 -15
  41. package/src/lib/orphan-detection.ts +2 -35
  42. package/src/lib/platform-client.ts +215 -0
  43. package/src/lib/retire-local.ts +6 -2
  44. package/src/lib/terminal-client.ts +177 -0
  45. package/src/lib/terminal-session.ts +457 -0
  46. package/src/shared/provider-env-vars.ts +2 -3
  47. package/src/__tests__/orphan-detection.test.ts +0 -214
@@ -0,0 +1,405 @@
1
+ import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
2
+
3
+ import {
4
+ platformPollJobStatus,
5
+ platformRequestSignedUrl,
6
+ type UnifiedJobStatus,
7
+ } from "../platform-client.js";
8
+
9
+ const PLATFORM_URL = "https://platform.example.test";
10
+ const VAK_TOKEN = "vak_test_1234567890"; // API-key path skips org-ID fetch.
11
+ const SESSION_TOKEN = "session_test_1234567890"; // non-vak_ triggers org-ID lookup.
12
+
13
+ interface CapturedCall {
14
+ url: string;
15
+ method: string;
16
+ headers: Record<string, string>;
17
+ body: unknown;
18
+ }
19
+
20
+ function captureFetch(
21
+ responder: (call: CapturedCall) => Response | Promise<Response>,
22
+ ): {
23
+ calls: CapturedCall[];
24
+ fetchMock: typeof globalThis.fetch;
25
+ } {
26
+ const calls: CapturedCall[] = [];
27
+ const fetchMock = mock(
28
+ async (url: string | URL | Request, init?: RequestInit) => {
29
+ const urlStr = typeof url === "string" ? url : url.toString();
30
+ const rawHeaders = (init?.headers ?? {}) as
31
+ | Record<string, string>
32
+ | Headers;
33
+ const headers: Record<string, string> = {};
34
+ if (rawHeaders instanceof Headers) {
35
+ rawHeaders.forEach((v, k) => {
36
+ headers[k] = v;
37
+ });
38
+ } else {
39
+ Object.assign(headers, rawHeaders);
40
+ }
41
+ let parsedBody: unknown = undefined;
42
+ const b = init?.body;
43
+ if (typeof b === "string") {
44
+ try {
45
+ parsedBody = JSON.parse(b);
46
+ } catch {
47
+ parsedBody = b;
48
+ }
49
+ }
50
+ const call: CapturedCall = {
51
+ url: urlStr,
52
+ method: init?.method ?? "GET",
53
+ headers,
54
+ body: parsedBody,
55
+ };
56
+ calls.push(call);
57
+ return responder(call);
58
+ },
59
+ );
60
+ return { calls, fetchMock: fetchMock as unknown as typeof globalThis.fetch };
61
+ }
62
+
63
+ let originalFetch: typeof globalThis.fetch;
64
+ beforeEach(() => {
65
+ originalFetch = globalThis.fetch;
66
+ });
67
+ afterEach(() => {
68
+ globalThis.fetch = originalFetch;
69
+ });
70
+
71
+ describe("platformRequestSignedUrl", () => {
72
+ test("upload operation with just operation → posts correct body and parses response", async () => {
73
+ const { calls, fetchMock } = captureFetch(() => {
74
+ return new Response(
75
+ JSON.stringify({
76
+ url: "https://storage.example/signed/abc",
77
+ bundle_key: "bundles/abc.tar.gz",
78
+ expires_at: "2026-04-22T00:00:00Z",
79
+ }),
80
+ { status: 201, headers: { "Content-Type": "application/json" } },
81
+ );
82
+ });
83
+ globalThis.fetch = fetchMock;
84
+
85
+ const result = await platformRequestSignedUrl(
86
+ { operation: "upload" },
87
+ VAK_TOKEN,
88
+ PLATFORM_URL,
89
+ );
90
+
91
+ expect(result).toEqual({
92
+ url: "https://storage.example/signed/abc",
93
+ bundleKey: "bundles/abc.tar.gz",
94
+ expiresAt: "2026-04-22T00:00:00Z",
95
+ maxContentLength: undefined,
96
+ });
97
+ expect(calls).toHaveLength(1);
98
+ expect(calls[0]!.url).toBe(`${PLATFORM_URL}/v1/migrations/signed-url/`);
99
+ expect(calls[0]!.method).toBe("POST");
100
+ expect(calls[0]!.headers.Authorization).toBe(`Bearer ${VAK_TOKEN}`);
101
+ expect(calls[0]!.headers["Content-Type"]).toBe("application/json");
102
+ expect(calls[0]!.body).toEqual({ operation: "upload" });
103
+ });
104
+
105
+ test("upload operation with content_length + content_type passes them through", async () => {
106
+ const { calls, fetchMock } = captureFetch(() => {
107
+ return new Response(
108
+ JSON.stringify({
109
+ url: "https://storage.example/signed/xyz",
110
+ bundle_key: "bundles/xyz.tar.gz",
111
+ expires_at: "2026-04-22T01:00:00Z",
112
+ max_content_length: 10_000_000,
113
+ }),
114
+ { status: 201 },
115
+ );
116
+ });
117
+ globalThis.fetch = fetchMock;
118
+
119
+ const result = await platformRequestSignedUrl(
120
+ {
121
+ operation: "upload",
122
+ contentType: "application/octet-stream",
123
+ contentLength: 12345,
124
+ },
125
+ VAK_TOKEN,
126
+ PLATFORM_URL,
127
+ );
128
+
129
+ expect(result.maxContentLength).toBe(10_000_000);
130
+ expect(calls[0]!.body).toEqual({
131
+ operation: "upload",
132
+ content_type: "application/octet-stream",
133
+ content_length: 12345,
134
+ });
135
+ });
136
+
137
+ test("download operation with bundleKey → posts bundle_key and parses response", async () => {
138
+ const { calls, fetchMock } = captureFetch(() => {
139
+ return new Response(
140
+ JSON.stringify({
141
+ url: "https://storage.example/signed/dl-xyz",
142
+ bundle_key: "bundles/xyz.tar.gz",
143
+ expires_at: "2026-04-22T02:00:00Z",
144
+ }),
145
+ { status: 201 },
146
+ );
147
+ });
148
+ globalThis.fetch = fetchMock;
149
+
150
+ const result = await platformRequestSignedUrl(
151
+ { operation: "download", bundleKey: "bundles/xyz.tar.gz" },
152
+ VAK_TOKEN,
153
+ PLATFORM_URL,
154
+ );
155
+
156
+ expect(result.url).toBe("https://storage.example/signed/dl-xyz");
157
+ expect(result.bundleKey).toBe("bundles/xyz.tar.gz");
158
+ expect(calls[0]!.body).toEqual({
159
+ operation: "download",
160
+ bundle_key: "bundles/xyz.tar.gz",
161
+ });
162
+ });
163
+
164
+ test("401 → retries once and returns success on the retry", async () => {
165
+ let callCount = 0;
166
+ const { calls, fetchMock } = captureFetch(() => {
167
+ callCount += 1;
168
+ if (callCount === 1) {
169
+ return new Response(JSON.stringify({ detail: "unauthorized" }), {
170
+ status: 401,
171
+ });
172
+ }
173
+ return new Response(
174
+ JSON.stringify({
175
+ url: "https://storage.example/signed/after-retry",
176
+ bundle_key: "bundles/r.tar.gz",
177
+ expires_at: "2026-04-22T03:00:00Z",
178
+ }),
179
+ { status: 201 },
180
+ );
181
+ });
182
+ globalThis.fetch = fetchMock;
183
+
184
+ const result = await platformRequestSignedUrl(
185
+ { operation: "upload" },
186
+ VAK_TOKEN,
187
+ PLATFORM_URL,
188
+ );
189
+
190
+ expect(result.url).toBe("https://storage.example/signed/after-retry");
191
+ expect(calls).toHaveLength(2);
192
+ });
193
+
194
+ test("401 with session token → invalidates org-ID cache and re-fetches on retry", async () => {
195
+ // Session tokens (non-vak_) take the org-ID-fetch path. A 401 here
196
+ // frequently means the cached org ID is stale, so the retry must
197
+ // clear the cache and re-fetch before the second signed-url POST.
198
+ const orgIdCalls: string[] = [];
199
+ const signedUrlCalls: CapturedCall[] = [];
200
+ let orgIdFetchCount = 0;
201
+ let signedUrlCount = 0;
202
+
203
+ const fetchMock = mock(
204
+ async (url: string | URL | Request, init?: RequestInit) => {
205
+ const urlStr = typeof url === "string" ? url : url.toString();
206
+ const rawHeaders = (init?.headers ?? {}) as
207
+ | Record<string, string>
208
+ | Headers;
209
+ const headers: Record<string, string> = {};
210
+ if (rawHeaders instanceof Headers) {
211
+ rawHeaders.forEach((v, k) => {
212
+ headers[k] = v;
213
+ });
214
+ } else {
215
+ Object.assign(headers, rawHeaders);
216
+ }
217
+
218
+ if (urlStr.endsWith("/v1/organizations/")) {
219
+ orgIdFetchCount += 1;
220
+ orgIdCalls.push(urlStr);
221
+ const orgId = orgIdFetchCount === 1 ? "org-stale" : "org-fresh";
222
+ return new Response(
223
+ JSON.stringify({ results: [{ id: orgId, name: "Test Org" }] }),
224
+ { status: 200 },
225
+ );
226
+ }
227
+
228
+ if (urlStr.endsWith("/v1/migrations/signed-url/")) {
229
+ signedUrlCount += 1;
230
+ let parsedBody: unknown = undefined;
231
+ const b = init?.body;
232
+ if (typeof b === "string") {
233
+ try {
234
+ parsedBody = JSON.parse(b);
235
+ } catch {
236
+ parsedBody = b;
237
+ }
238
+ }
239
+ signedUrlCalls.push({
240
+ url: urlStr,
241
+ method: init?.method ?? "GET",
242
+ headers,
243
+ body: parsedBody,
244
+ });
245
+ if (signedUrlCount === 1) {
246
+ return new Response(JSON.stringify({ detail: "stale org" }), {
247
+ status: 401,
248
+ });
249
+ }
250
+ return new Response(
251
+ JSON.stringify({
252
+ url: "https://storage.example/signed/fresh",
253
+ bundle_key: "bundles/fresh.tar.gz",
254
+ expires_at: "2026-04-22T04:00:00Z",
255
+ }),
256
+ { status: 201 },
257
+ );
258
+ }
259
+
260
+ throw new Error(`Unexpected URL: ${urlStr}`);
261
+ },
262
+ );
263
+ globalThis.fetch = fetchMock as unknown as typeof globalThis.fetch;
264
+
265
+ const result = await platformRequestSignedUrl(
266
+ { operation: "upload" },
267
+ SESSION_TOKEN,
268
+ PLATFORM_URL,
269
+ );
270
+
271
+ // Both signed-url attempts were made and the retry succeeded.
272
+ expect(result.url).toBe("https://storage.example/signed/fresh");
273
+ expect(signedUrlCalls).toHaveLength(2);
274
+
275
+ // The cache was cleared after the 401, so the org-ID endpoint was
276
+ // hit a second time to fetch a fresh ID before the retry.
277
+ expect(orgIdFetchCount).toBe(2);
278
+ expect(orgIdCalls).toHaveLength(2);
279
+
280
+ // The first signed-url POST used the stale org ID, the second used
281
+ // the fresh one.
282
+ expect(signedUrlCalls[0]!.headers["Vellum-Organization-Id"]).toBe(
283
+ "org-stale",
284
+ );
285
+ expect(signedUrlCalls[1]!.headers["Vellum-Organization-Id"]).toBe(
286
+ "org-fresh",
287
+ );
288
+
289
+ // Session tokens use X-Session-Token, not Bearer.
290
+ expect(signedUrlCalls[0]!.headers["X-Session-Token"]).toBe(SESSION_TOKEN);
291
+ expect(signedUrlCalls[0]!.headers.Authorization).toBeUndefined();
292
+ });
293
+
294
+ test("503 → throws so callers can fall back to legacy inline upload", async () => {
295
+ const { fetchMock } = captureFetch(() => {
296
+ return new Response(JSON.stringify({ detail: "temporarily down" }), {
297
+ status: 503,
298
+ });
299
+ });
300
+ globalThis.fetch = fetchMock;
301
+
302
+ await expect(
303
+ platformRequestSignedUrl(
304
+ { operation: "upload" },
305
+ VAK_TOKEN,
306
+ PLATFORM_URL,
307
+ ),
308
+ ).rejects.toThrow(/503/);
309
+ });
310
+ });
311
+
312
+ describe("platformPollJobStatus", () => {
313
+ test("GET /v1/migrations/jobs/{jobId}/ parses processing", async () => {
314
+ const { calls, fetchMock } = captureFetch(() => {
315
+ return new Response(
316
+ JSON.stringify({
317
+ job_id: "job-1",
318
+ type: "export",
319
+ status: "processing",
320
+ }),
321
+ { status: 200 },
322
+ );
323
+ });
324
+ globalThis.fetch = fetchMock;
325
+
326
+ const status = await platformPollJobStatus(
327
+ "job-1",
328
+ VAK_TOKEN,
329
+ PLATFORM_URL,
330
+ );
331
+
332
+ expect(status).toEqual({
333
+ jobId: "job-1",
334
+ type: "export",
335
+ status: "processing",
336
+ } satisfies UnifiedJobStatus);
337
+ expect(calls[0]!.url).toBe(`${PLATFORM_URL}/v1/migrations/jobs/job-1/`);
338
+ expect(calls[0]!.method).toBe("GET");
339
+ });
340
+
341
+ test("parses complete with bundle_key + result", async () => {
342
+ const { fetchMock } = captureFetch(() => {
343
+ return new Response(
344
+ JSON.stringify({
345
+ job_id: "job-2",
346
+ type: "export",
347
+ status: "complete",
348
+ bundle_key: "bundles/done.tar.gz",
349
+ result: { files: 42 },
350
+ }),
351
+ { status: 200 },
352
+ );
353
+ });
354
+ globalThis.fetch = fetchMock;
355
+
356
+ const status = await platformPollJobStatus(
357
+ "job-2",
358
+ VAK_TOKEN,
359
+ PLATFORM_URL,
360
+ );
361
+
362
+ expect(status.status).toBe("complete");
363
+ if (status.status === "complete") {
364
+ expect(status.bundleKey).toBe("bundles/done.tar.gz");
365
+ expect(status.result).toEqual({ files: 42 });
366
+ }
367
+ });
368
+
369
+ test("parses failed with error", async () => {
370
+ const { fetchMock } = captureFetch(() => {
371
+ return new Response(
372
+ JSON.stringify({
373
+ job_id: "job-3",
374
+ type: "import",
375
+ status: "failed",
376
+ error: "bundle corrupt",
377
+ }),
378
+ { status: 200 },
379
+ );
380
+ });
381
+ globalThis.fetch = fetchMock;
382
+
383
+ const status = await platformPollJobStatus(
384
+ "job-3",
385
+ VAK_TOKEN,
386
+ PLATFORM_URL,
387
+ );
388
+
389
+ expect(status.status).toBe("failed");
390
+ if (status.status === "failed") {
391
+ expect(status.error).toBe("bundle corrupt");
392
+ }
393
+ });
394
+
395
+ test("404 → throws 'Migration job not found'", async () => {
396
+ const { fetchMock } = captureFetch(() => {
397
+ return new Response("{}", { status: 404 });
398
+ });
399
+ globalThis.fetch = fetchMock;
400
+
401
+ await expect(
402
+ platformPollJobStatus("missing", VAK_TOKEN, PLATFORM_URL),
403
+ ).rejects.toThrow(/Migration job not found/);
404
+ });
405
+ });
@@ -42,8 +42,6 @@ export interface LocalInstanceResources {
42
42
  qdrantPort: number;
43
43
  /** HTTP port for the CES (Claude Extension Server) */
44
44
  cesPort: number;
45
- /** Absolute path to the daemon PID file */
46
- pidFile: string;
47
45
  /** Persisted HMAC signing key (hex). Survives daemon/gateway restarts so
48
46
  * client actor tokens remain valid across `wake` cycles. */
49
47
  signingKey?: string;
@@ -114,6 +112,18 @@ export function getBaseDir(): string {
114
112
  return process.env.BASE_DATA_DIR?.trim() || homedir();
115
113
  }
116
114
 
115
+ /**
116
+ * Derive the daemon PID file path from a resources object. The PID file
117
+ * lives inside the instance's workspace directory. When no resources are
118
+ * available, falls back to `~/.vellum/workspace/vellum.pid`.
119
+ */
120
+ export function getDaemonPidPath(resources?: LocalInstanceResources): string {
121
+ const vellumDir = resources
122
+ ? join(resources.instanceDir, ".vellum")
123
+ : join(homedir(), ".vellum");
124
+ return join(vellumDir, "workspace", "vellum.pid");
125
+ }
126
+
117
127
  function readLockfile(): LockfileData {
118
128
  for (const lockfilePath of getLockfilePaths(getCurrentEnvironment())) {
119
129
  if (!existsSync(lockfilePath)) continue;
@@ -215,7 +225,6 @@ export function migrateLegacyEntry(raw: Record<string, unknown>): boolean {
215
225
  gatewayPort,
216
226
  qdrantPort: defaultPorts.qdrant,
217
227
  cesPort: defaultPorts.ces,
218
- pidFile: join(instanceDir, ".vellum", "vellum.pid"),
219
228
  };
220
229
  mutated = true;
221
230
  } else {
@@ -247,10 +256,6 @@ export function migrateLegacyEntry(raw: Record<string, unknown>): boolean {
247
256
  res.cesPort = defaultPorts.ces;
248
257
  mutated = true;
249
258
  }
250
- if (typeof res.pidFile !== "string") {
251
- res.pidFile = join(res.instanceDir as string, ".vellum", "vellum.pid");
252
- mutated = true;
253
- }
254
259
  }
255
260
 
256
261
  return mutated;
@@ -474,7 +479,6 @@ export async function allocateLocalResources(
474
479
  gatewayPort,
475
480
  qdrantPort,
476
481
  cesPort,
477
- pidFile: join(instanceDir, ".vellum", "vellum.pid"),
478
482
  };
479
483
  }
480
484
 
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Stable per-install client identity for the CLI.
3
+ *
4
+ * Generates a UUID on first use and persists it to
5
+ * `~/.config/vellum/client-id` so the daemon's ClientRegistry can
6
+ * track this terminal across SSE reconnects and CLI restarts.
7
+ */
8
+
9
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
10
+ import { randomUUID } from "crypto";
11
+ import { homedir } from "os";
12
+ import { join } from "path";
13
+
14
+ const CLI_INTERFACE_ID = "cli";
15
+
16
+ let cached: string | null = null;
17
+
18
+ function getConfigDir(): string {
19
+ const configHome = process.env.XDG_CONFIG_HOME || join(homedir(), ".config");
20
+ return join(configHome, "vellum");
21
+ }
22
+
23
+ /**
24
+ * Returns a stable UUID identifying this CLI installation.
25
+ * Generated once and persisted to `~/.config/vellum/client-id`.
26
+ */
27
+ export function getClientId(): string {
28
+ if (cached) return cached;
29
+
30
+ const configDir = getConfigDir();
31
+ const idFile = join(configDir, "client-id");
32
+
33
+ try {
34
+ if (existsSync(idFile)) {
35
+ const stored = readFileSync(idFile, "utf-8").trim();
36
+ if (stored) {
37
+ cached = stored;
38
+ return stored;
39
+ }
40
+ }
41
+ } catch {
42
+ /* best-effort read */
43
+ }
44
+
45
+ const id = randomUUID();
46
+ try {
47
+ mkdirSync(configDir, { recursive: true });
48
+ writeFileSync(idFile, id, "utf-8");
49
+ } catch {
50
+ /* best-effort persist — transient id still works for this session */
51
+ }
52
+
53
+ cached = id;
54
+ return id;
55
+ }
56
+
57
+ /**
58
+ * Headers that identify this CLI client to the assistant daemon.
59
+ * Attach to SSE streaming connections so the ClientRegistry can
60
+ * track connected clients and their capabilities.
61
+ */
62
+ export function getClientRegistrationHeaders(): Record<string, string> {
63
+ return {
64
+ "X-Vellum-Client-Id": getClientId(),
65
+ "X-Vellum-Interface-Id": CLI_INTERFACE_ID,
66
+ };
67
+ }
@@ -2,6 +2,11 @@ import { writeFileSync } from "fs";
2
2
  import { tmpdir } from "os";
3
3
  import { join } from "path";
4
4
 
5
+ const ANTHROPIC_PROVIDER = "anthropic";
6
+ const ANTHROPIC_DEFAULT_MODEL = "claude-opus-4-7";
7
+ const MAIN_AGENT_OPUS_MODEL = "claude-opus-4-7";
8
+ const MAIN_AGENT_OPUS_MAX_TOKENS = 32000;
9
+
5
10
  /**
6
11
  * Convert flat dot-notation key=value pairs into a nested config object.
7
12
  *
@@ -32,6 +37,20 @@ export function buildNestedConfig(
32
37
  return config;
33
38
  }
34
39
 
40
+ /**
41
+ * Build the first-boot workspace config overlay passed to the assistant during
42
+ * hatch. Anthropic onboarding sets `llm.default.model` to Sonnet so background
43
+ * fallback work stays cheaper, while the main conversation thread should remain
44
+ * on Opus via the same call-site override seeded by workspace migration 050.
45
+ */
46
+ export function buildInitialConfig(
47
+ configValues: Record<string, string>,
48
+ ): Record<string, unknown> {
49
+ const config = buildNestedConfig(configValues);
50
+ seedAnthropicMainAgentCallSite(config);
51
+ return config;
52
+ }
53
+
35
54
  /**
36
55
  * Write arbitrary key-value pairs to a temporary JSON file and return its
37
56
  * path. The caller passes this path to the daemon via the
@@ -49,7 +68,7 @@ export function writeInitialConfig(
49
68
  ): string | undefined {
50
69
  if (Object.keys(configValues).length === 0) return undefined;
51
70
 
52
- const config = buildNestedConfig(configValues);
71
+ const config = buildInitialConfig(configValues);
53
72
  const tempPath = join(
54
73
  tmpdir(),
55
74
  `vellum-default-workspace-config-${process.pid}-${Date.now()}.json`,
@@ -57,3 +76,80 @@ export function writeInitialConfig(
57
76
  writeFileSync(tempPath, JSON.stringify(config, null, 2) + "\n");
58
77
  return tempPath;
59
78
  }
79
+
80
+ function seedAnthropicMainAgentCallSite(config: Record<string, unknown>): void {
81
+ const llm = ensureObject(config, "llm");
82
+
83
+ const existingCallSites = readObject(llm.callSites);
84
+ if (existingCallSites !== null && "mainAgent" in existingCallSites) return;
85
+
86
+ const { provider, model } = resolveInitialMainAgentBaseSelection(llm);
87
+ if (provider !== ANTHROPIC_PROVIDER) return;
88
+
89
+ if (
90
+ model !== undefined &&
91
+ model !== ANTHROPIC_DEFAULT_MODEL &&
92
+ model !== MAIN_AGENT_OPUS_MODEL
93
+ ) {
94
+ return;
95
+ }
96
+
97
+ const callSites = ensureObject(llm, "callSites");
98
+
99
+ callSites.mainAgent = {
100
+ model: MAIN_AGENT_OPUS_MODEL,
101
+ maxTokens: MAIN_AGENT_OPUS_MAX_TOKENS,
102
+ };
103
+ }
104
+
105
+ function resolveInitialMainAgentBaseSelection(llm: Record<string, unknown>): {
106
+ provider: string;
107
+ model?: string;
108
+ } {
109
+ const defaultBlock = readObject(llm.default);
110
+ let provider = readString(defaultBlock?.provider) ?? ANTHROPIC_PROVIDER;
111
+ let model = readString(defaultBlock?.model);
112
+
113
+ const profiles = readObject(llm.profiles);
114
+ const activeProfileName = readString(llm.activeProfile);
115
+ const activeProfile =
116
+ profiles !== null && activeProfileName !== undefined
117
+ ? readObject(profiles[activeProfileName])
118
+ : null;
119
+
120
+ if (activeProfile !== null) {
121
+ provider = readString(activeProfile.provider) ?? provider;
122
+ model = readString(activeProfile.model) ?? model;
123
+ }
124
+
125
+ return model === undefined ? { provider } : { provider, model };
126
+ }
127
+
128
+ function ensureObject(
129
+ parent: Record<string, unknown>,
130
+ key: string,
131
+ ): Record<string, unknown> {
132
+ const existing = parent[key];
133
+ if (
134
+ existing != null &&
135
+ typeof existing === "object" &&
136
+ !Array.isArray(existing)
137
+ ) {
138
+ return existing as Record<string, unknown>;
139
+ }
140
+
141
+ const next: Record<string, unknown> = {};
142
+ parent[key] = next;
143
+ return next;
144
+ }
145
+
146
+ function readObject(value: unknown): Record<string, unknown> | null {
147
+ if (value == null || typeof value !== "object" || Array.isArray(value)) {
148
+ return null;
149
+ }
150
+ return value as Record<string, unknown>;
151
+ }
152
+
153
+ function readString(value: unknown): string | undefined {
154
+ return typeof value === "string" && value.length > 0 ? value : undefined;
155
+ }