vellum 0.2.9 → 0.2.11

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 (61) hide show
  1. package/bun.lock +2 -2
  2. package/package.json +2 -2
  3. package/scripts/capture-x-graphql.ts +1 -18
  4. package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +110 -0
  5. package/src/__tests__/call-bridge.test.ts +40 -0
  6. package/src/__tests__/call-state.test.ts +41 -0
  7. package/src/__tests__/forbidden-legacy-symbols.test.ts +8 -6
  8. package/src/__tests__/gateway-only-enforcement.test.ts +13 -89
  9. package/src/__tests__/home-base-bootstrap.test.ts +13 -8
  10. package/src/__tests__/intent-routing.test.ts +2 -5
  11. package/src/__tests__/ipc-snapshot.test.ts +49 -0
  12. package/src/__tests__/onboarding-starter-tasks.test.ts +12 -2
  13. package/src/__tests__/prebuilt-home-base-seed.test.ts +9 -5
  14. package/src/__tests__/relay-server.test.ts +55 -0
  15. package/src/__tests__/skills.test.ts +83 -0
  16. package/src/__tests__/system-prompt.test.ts +2 -24
  17. package/src/__tests__/twilio-provider.test.ts +36 -0
  18. package/src/__tests__/twilio-routes.test.ts +108 -0
  19. package/src/calls/call-orchestrator.ts +25 -5
  20. package/src/calls/call-state.ts +23 -0
  21. package/src/calls/relay-server.ts +56 -1
  22. package/src/calls/twilio-config.ts +9 -13
  23. package/src/calls/twilio-provider.ts +6 -1
  24. package/src/calls/twilio-routes.ts +10 -1
  25. package/src/cli/core-commands.ts +12 -4
  26. package/src/config/bundled-skills/app-builder/SKILL.md +57 -1
  27. package/src/config/bundled-skills/document/SKILL.md +11 -3
  28. package/src/config/bundled-skills/followups/icon.svg +24 -0
  29. package/src/config/bundled-skills/messaging/SKILL.md +7 -3
  30. package/src/config/bundled-skills/public-ingress/SKILL.md +183 -0
  31. package/src/config/bundled-skills/self-upgrade/SKILL.md +4 -10
  32. package/src/config/defaults.ts +1 -1
  33. package/src/config/schema.ts +4 -7
  34. package/src/config/system-prompt.ts +64 -360
  35. package/src/config/vellum-skills/google-oauth-setup/SKILL.md +5 -1
  36. package/src/config/vellum-skills/slack-oauth-setup/SKILL.md +5 -1
  37. package/src/config/vellum-skills/telegram-setup/SKILL.md +2 -1
  38. package/src/daemon/handlers/config.ts +20 -9
  39. package/src/daemon/handlers/home-base.ts +3 -2
  40. package/src/daemon/handlers/identity.ts +127 -0
  41. package/src/daemon/handlers/index.ts +4 -0
  42. package/src/daemon/handlers/workspace-files.ts +75 -0
  43. package/src/daemon/ipc-contract-inventory.json +16 -4
  44. package/src/daemon/ipc-contract.ts +62 -2
  45. package/src/daemon/lifecycle.ts +16 -0
  46. package/src/daemon/session-notifiers.ts +29 -0
  47. package/src/daemon/session-surfaces.ts +5 -2
  48. package/src/daemon/session-tool-setup.ts +15 -4
  49. package/src/home-base/bootstrap.ts +3 -1
  50. package/src/home-base/prebuilt/seed.ts +16 -5
  51. package/src/inbound/public-ingress-urls.ts +15 -4
  52. package/src/runtime/http-server.ts +123 -20
  53. package/src/security/oauth2.ts +19 -161
  54. package/src/tools/browser/auto-navigate.ts +2 -2
  55. package/src/tools/browser/x-auto-navigate.ts +1 -1
  56. package/src/tools/claude-code/claude-code.ts +1 -1
  57. package/src/tools/system/version.ts +43 -0
  58. package/src/tools/tasks/work-item-run.ts +1 -1
  59. package/src/tools/terminal/parser.ts +29 -7
  60. package/src/tools/tool-manifest.ts +2 -0
  61. package/src/tools/ui-surface/definitions.ts +9 -2
@@ -5,11 +5,13 @@
5
5
  * `RUNTIME_HTTP_PORT` is set (default: disabled).
6
6
  */
7
7
 
8
- import { existsSync, readFileSync, statfsSync } from 'node:fs';
9
- import { resolve } from 'node:path';
8
+ import { existsSync, readFileSync, statSync, statfsSync } from 'node:fs';
9
+ import { resolve, join, dirname } from 'node:path';
10
+ import { fileURLToPath } from 'node:url';
10
11
  import { timingSafeEqual } from 'node:crypto';
11
12
  import { ConfigError, IngressBlockedError } from '../util/errors.js';
12
13
  import { getLogger } from '../util/logger.js';
14
+ import { getWorkspacePromptPath } from '../util/platform.js';
13
15
  import { TwilioConversationRelayProvider } from '../calls/twilio-provider.js';
14
16
  import { loadConfig } from '../config/loader.js';
15
17
  import { getPublicBaseUrl } from '../inbound/public-ingress-urls.js';
@@ -378,6 +380,7 @@ export class RuntimeHttpServer {
378
380
  log.info({ callSessionId, code, reason: reason?.toString() }, 'ConversationRelay WebSocket closed');
379
381
  if (callSessionId) {
380
382
  const connection = activeRelayConnections.get(callSessionId);
383
+ connection?.handleTransportClosed(code, reason?.toString());
381
384
  connection?.destroy();
382
385
  activeRelayConnections.delete(callSessionId);
383
386
  }
@@ -395,16 +398,9 @@ export class RuntimeHttpServer {
395
398
  }
396
399
 
397
400
  // Startup guard: log gateway-only mode warnings
398
- try {
399
- const config = loadConfig();
400
- if (config.ingress.mode === 'gateway_only') {
401
- log.info('Running in gateway-only ingress mode. Direct webhook routes disabled.');
402
- if (!isLoopbackHost(this.hostname)) {
403
- log.warn('gateway-only mode is enabled but RUNTIME_HTTP_HOST is not bound to loopback. This may expose the runtime to direct public access.');
404
- }
405
- }
406
- } catch {
407
- // Config loading may fail during startup — don't block server start
401
+ log.info('Running in gateway-only ingress mode. Direct webhook routes disabled.');
402
+ if (!isLoopbackHost(this.hostname)) {
403
+ log.warn('RUNTIME_HTTP_HOST is not bound to loopback. This may expose the runtime to direct public access.');
408
404
  }
409
405
 
410
406
  log.info({ port: this.actualPort, hostname: this.hostname, auth: !!this.bearerToken }, 'Runtime HTTP server listening');
@@ -445,14 +441,13 @@ export class RuntimeHttpServer {
445
441
  // WebSocket upgrade for ConversationRelay — before auth check because
446
442
  // Twilio WebSocket connections don't use bearer tokens.
447
443
  if (path.startsWith('/v1/calls/relay') && req.headers.get('upgrade')?.toLowerCase() === 'websocket') {
448
- // In gateway_only mode, only allow relay connections from private network peers.
444
+ // Only allow relay connections from private network peers.
449
445
  // Primary check: actual peer address (cannot be spoofed) — accepts loopback
450
446
  // and RFC 1918/4193 private addresses to support container deployments.
451
447
  // Secondary check: Origin header (defense in depth).
452
- const config = loadConfig();
453
- if (config.ingress.mode === 'gateway_only' && (!isPrivateNetworkPeer(server, req) || !isPrivateNetworkOrigin(req))) {
448
+ if (!isPrivateNetworkPeer(server, req) || !isPrivateNetworkOrigin(req)) {
454
449
  return Response.json(
455
- { error: 'Direct relay access disabled in gateway-only mode', code: 'GATEWAY_ONLY' },
450
+ { error: 'Direct relay access disabled only private network peers allowed', code: 'GATEWAY_ONLY' },
456
451
  { status: 403 },
457
452
  );
458
453
  }
@@ -486,11 +481,10 @@ export class RuntimeHttpServer {
486
481
  if (resolvedTwilioSubpath && req.method === 'POST') {
487
482
  const twilioSubpath = resolvedTwilioSubpath;
488
483
 
489
- // In gateway_only mode, block direct Twilio webhook routes
490
- const ingressConfig = loadConfig();
491
- if (ingressConfig.ingress.mode === 'gateway_only' && GATEWAY_ONLY_BLOCKED_SUBPATHS.has(twilioSubpath)) {
484
+ // Block direct Twilio webhook routes — must go through the gateway
485
+ if (GATEWAY_ONLY_BLOCKED_SUBPATHS.has(twilioSubpath)) {
492
486
  return Response.json(
493
- { error: 'Direct webhook access disabled in gateway-only mode. Use the gateway.', code: 'GATEWAY_ONLY' },
487
+ { error: 'Direct webhook access disabled. Use the gateway.', code: 'GATEWAY_ONLY' },
494
488
  { status: 410 },
495
489
  );
496
490
  }
@@ -619,6 +613,19 @@ export class RuntimeHttpServer {
619
613
  return this.handleHealth();
620
614
  }
621
615
 
616
+ if (endpoint === 'conversations' && req.method === 'GET') {
617
+ const limit = Number(url.searchParams.get('limit') ?? 50);
618
+ const conversations = conversationStore.listConversations(limit);
619
+ return Response.json({
620
+ sessions: conversations.map((c) => ({
621
+ id: c.id,
622
+ title: c.title ?? 'Untitled',
623
+ updatedAt: c.updatedAt,
624
+ threadType: c.threadType === 'private' ? 'private' : 'standard',
625
+ })),
626
+ });
627
+ }
628
+
622
629
  if (endpoint === 'messages' && req.method === 'GET') {
623
630
  return handleListMessages(url, this.interfacesDir);
624
631
  }
@@ -770,6 +777,10 @@ export class RuntimeHttpServer {
770
777
  return await handleConnectAction(fakeReq);
771
778
  }
772
779
 
780
+ if (endpoint === 'identity' && req.method === 'GET') {
781
+ return this.handleGetIdentity();
782
+ }
783
+
773
784
  if (endpoint === 'events' && req.method === 'GET') {
774
785
  return handleSubscribeAssistantEvents(req, url);
775
786
  }
@@ -925,6 +936,98 @@ export class RuntimeHttpServer {
925
936
  }
926
937
  }
927
938
 
939
+ private handleGetIdentity(): Response {
940
+ const identityPath = getWorkspacePromptPath('IDENTITY.md');
941
+ if (!existsSync(identityPath)) {
942
+ return Response.json({ error: 'IDENTITY.md not found' }, { status: 404 });
943
+ }
944
+
945
+ const content = readFileSync(identityPath, 'utf-8');
946
+ const fields: Record<string, string> = {};
947
+ for (const line of content.split('\n')) {
948
+ const trimmed = line.trim();
949
+ const lower = trimmed.toLowerCase();
950
+ const extract = (prefix: string): string | null => {
951
+ if (!lower.startsWith(prefix)) return null;
952
+ return trimmed.split(':**').pop()?.trim() ?? null;
953
+ };
954
+
955
+ const name = extract('- **name:**');
956
+ if (name) { fields.name = name; continue; }
957
+ const role = extract('- **role:**');
958
+ if (role) { fields.role = role; continue; }
959
+ const personality = extract('- **personality:**') ?? extract('- **vibe:**');
960
+ if (personality) { fields.personality = personality; continue; }
961
+ const emoji = extract('- **emoji:**');
962
+ if (emoji) { fields.emoji = emoji; continue; }
963
+ const home = extract('- **home:**');
964
+ if (home) { fields.home = home; continue; }
965
+ }
966
+
967
+ // Read version from package.json
968
+ let version: string | undefined;
969
+ try {
970
+ const pkgPath = join(dirname(fileURLToPath(import.meta.url)), '../../package.json');
971
+ const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'));
972
+ version = pkg.version;
973
+ } catch {
974
+ // ignore
975
+ }
976
+
977
+ // Read createdAt from IDENTITY.md file birthtime
978
+ let createdAt: string | undefined;
979
+ try {
980
+ const stats = statSync(identityPath);
981
+ createdAt = stats.birthtime.toISOString();
982
+ } catch {
983
+ // ignore
984
+ }
985
+
986
+ // Read lockfile for assistantId, cloud, and originSystem
987
+ let assistantId: string | undefined;
988
+ let cloud: string | undefined;
989
+ let originSystem: string | undefined;
990
+ try {
991
+ const homedir = process.env.HOME ?? process.env.USERPROFILE ?? '';
992
+ const lockfilePaths = [
993
+ join(homedir, '.vellum.lock.json'),
994
+ join(homedir, '.vellum.lockfile.json'),
995
+ ];
996
+ for (const lockPath of lockfilePaths) {
997
+ if (!existsSync(lockPath)) continue;
998
+ const lockData = JSON.parse(readFileSync(lockPath, 'utf-8'));
999
+ const assistants = lockData.assistants as Array<Record<string, unknown>> | undefined;
1000
+ if (assistants && assistants.length > 0) {
1001
+ // Use the most recently hatched assistant
1002
+ const sorted = [...assistants].sort((a, b) => {
1003
+ const dateA = new Date(a.hatchedAt as string || 0).getTime();
1004
+ const dateB = new Date(b.hatchedAt as string || 0).getTime();
1005
+ return dateB - dateA;
1006
+ });
1007
+ const latest = sorted[0];
1008
+ assistantId = latest.assistantId as string | undefined;
1009
+ cloud = latest.cloud as string | undefined;
1010
+ originSystem = cloud === 'local' ? 'local' : cloud;
1011
+ }
1012
+ break;
1013
+ }
1014
+ } catch {
1015
+ // ignore — lockfile may not exist
1016
+ }
1017
+
1018
+ return Response.json({
1019
+ name: fields.name ?? '',
1020
+ role: fields.role ?? '',
1021
+ personality: fields.personality ?? '',
1022
+ emoji: fields.emoji ?? '',
1023
+ home: fields.home ?? '',
1024
+ version,
1025
+ assistantId,
1026
+ createdAt,
1027
+ originSystem,
1028
+ });
1029
+ }
1030
+
928
1031
  private handleHealth(): Response {
929
1032
  return Response.json({
930
1033
  status: 'healthy',
@@ -1,9 +1,9 @@
1
1
  /**
2
2
  * General-purpose OAuth2 Authorization Code flow with PKCE.
3
3
  *
4
- * Supports two callback transports:
5
- * - loopback: spins up a local HTTP server on 127.0.0.1 (default when no public URL configured)
6
- * - gateway: uses the gateway's OAuth callback route + in-memory registry (when ingress.publicBaseUrl is set)
4
+ * Uses the gateway callback transport: OAuth callbacks route through the
5
+ * gateway's OAuth callback route + in-memory registry (requires
6
+ * ingress.publicBaseUrl to be configured).
7
7
  *
8
8
  * Moved from integrations/oauth2.ts. Types that were in integrations/types.ts
9
9
  * are now inlined here since the integration framework is removed.
@@ -138,122 +138,6 @@ async function exchangeCodeForTokens(
138
138
  return { tokens, grantedScopes, rawTokenResponse: tokenData };
139
139
  }
140
140
 
141
- // ---------------------------------------------------------------------------
142
- // Transport auto-detection
143
- // ---------------------------------------------------------------------------
144
-
145
- /**
146
- * Determine which callback transport to use when not explicitly specified.
147
- * Uses gateway if a public base URL is configured (ingress.publicBaseUrl or
148
- * INGRESS_PUBLIC_BASE_URL), otherwise loopback.
149
- */
150
- function detectTransport(): 'loopback' | 'gateway' {
151
- try {
152
- const { loadConfig } = require('../config/loader.js') as typeof import('../config/loader.js');
153
- const { getPublicBaseUrl } = require('../inbound/public-ingress-urls.js') as typeof import('../inbound/public-ingress-urls.js');
154
- const appConfig = loadConfig();
155
- getPublicBaseUrl(appConfig); // throws if no public URL configured
156
- return 'gateway';
157
- } catch {
158
- log.debug('No public base URL configured for transport auto-detection, defaulting to loopback');
159
- }
160
- return 'loopback';
161
- }
162
-
163
- // ---------------------------------------------------------------------------
164
- // Loopback transport
165
- // ---------------------------------------------------------------------------
166
-
167
- async function runLoopbackFlow(
168
- config: OAuth2Config,
169
- callbacks: OAuth2FlowCallbacks,
170
- codeVerifier: string,
171
- codeChallenge: string,
172
- state: string,
173
- ): Promise<OAuth2FlowResult> {
174
- let resolveCode: (value: { code: string; returnedState: string }) => void;
175
- let rejectCode: (reason: Error) => void;
176
-
177
- const codePromise = new Promise<{ code: string; returnedState: string }>((resolve, reject) => {
178
- resolveCode = resolve;
179
- rejectCode = reject;
180
- });
181
-
182
- const FLOW_TIMEOUT_MS = 120_000;
183
-
184
- const timeout = setTimeout(() => {
185
- rejectCode(new Error('OAuth2 flow timed out waiting for user authorization'));
186
- }, FLOW_TIMEOUT_MS);
187
-
188
- const server = Bun.serve({
189
- hostname: '127.0.0.1',
190
- port: 0,
191
- fetch(req) {
192
- const url = new URL(req.url);
193
- if (url.pathname !== '/callback') {
194
- return new Response('Not found', { status: 404 });
195
- }
196
-
197
- const error = url.searchParams.get('error');
198
- if (error) {
199
- const desc = url.searchParams.get('error_description') ?? error;
200
- rejectCode(new Error(`OAuth2 authorization denied: ${desc}`));
201
- return new Response(
202
- '<html><body><h2>Authorization denied</h2><p>You can close this tab.</p></body></html>',
203
- { headers: { 'Content-Type': 'text/html' } },
204
- );
205
- }
206
-
207
- const code = url.searchParams.get('code');
208
- const returnedState = url.searchParams.get('state');
209
- if (!code || !returnedState) {
210
- rejectCode(new Error('OAuth2 callback missing code or state'));
211
- return new Response('Missing code or state', { status: 400 });
212
- }
213
-
214
- if (returnedState !== state) {
215
- rejectCode(new Error('OAuth2 state mismatch — possible CSRF attack'));
216
- return new Response('State mismatch', { status: 400 });
217
- }
218
-
219
- resolveCode({ code, returnedState });
220
- return new Response(
221
- '<html><body><h2>Authorization successful!</h2><p>You can close this tab and return to Vellum.</p></body></html>',
222
- { headers: { 'Content-Type': 'text/html' } },
223
- );
224
- },
225
- });
226
-
227
- const redirectUri = `http://127.0.0.1:${server.port}/callback`;
228
-
229
- try {
230
- const usePKCE = !config.clientSecret;
231
- const authParams = new URLSearchParams({
232
- ...config.extraParams,
233
- client_id: config.clientId,
234
- redirect_uri: redirectUri,
235
- response_type: 'code',
236
- scope: config.scopes.join(' '),
237
- state,
238
- ...(usePKCE ? { code_challenge: codeChallenge, code_challenge_method: 'S256' } : {}),
239
- });
240
-
241
- const authUrl = `${config.authUrl}?${authParams}`;
242
- callbacks.openUrl(authUrl);
243
-
244
- const { code, returnedState } = await codePromise;
245
-
246
- if (returnedState !== state) {
247
- throw new Error('OAuth2 state mismatch — possible CSRF attack');
248
- }
249
-
250
- return await exchangeCodeForTokens(config, code, redirectUri, codeVerifier);
251
- } finally {
252
- clearTimeout(timeout);
253
- server.stop(true);
254
- }
255
- }
256
-
257
141
  // ---------------------------------------------------------------------------
258
142
  // Gateway transport
259
143
  // ---------------------------------------------------------------------------
@@ -302,12 +186,9 @@ async function runGatewayFlow(
302
186
  /**
303
187
  * Run a full OAuth2 authorization code flow with PKCE support.
304
188
  *
305
- * Supports two callback transports:
306
- * - loopback (default): local HTTP server on 127.0.0.1
307
- * - gateway: callback via the gateway's OAuth route + in-memory registry
308
- *
309
- * Transport is auto-detected based on ingress.publicBaseUrl config unless
310
- * explicitly specified via options.callbackTransport.
189
+ * Uses the gateway callback transport, which routes OAuth callbacks through
190
+ * the gateway's OAuth route + in-memory registry. Requires a public ingress
191
+ * URL to be configured.
311
192
  */
312
193
  export async function startOAuth2Flow(
313
194
  config: OAuth2Config,
@@ -318,49 +199,26 @@ export async function startOAuth2Flow(
318
199
  const codeChallenge = generateCodeChallenge(codeVerifier);
319
200
  const state = generateState();
320
201
 
321
- // In gateway_only mode, enforce gateway transport and require a public ingress URL
322
- let ingressMode: string | undefined;
202
+ // Always enforce gateway transport and require a public ingress URL
203
+ let hasPublicUrl = false;
323
204
  try {
324
205
  const { loadConfig } = require('../config/loader.js') as typeof import('../config/loader.js');
325
- ingressMode = loadConfig().ingress.mode;
206
+ const { getPublicBaseUrl } = require('../inbound/public-ingress-urls.js') as typeof import('../inbound/public-ingress-urls.js');
207
+ getPublicBaseUrl(loadConfig());
208
+ hasPublicUrl = true;
326
209
  } catch {
327
- // Fail closed: if config can't be loaded (e.g., malformed config.json), default to the
328
- // most restrictive mode to prevent loopback fallback from creating a fail-open path.
329
- log.warn('Failed to load config for OAuth ingress mode detection; defaulting to gateway_only (fail closed)');
330
- ingressMode = 'gateway_only';
210
+ // No public URL configured
331
211
  }
332
212
 
333
- if (ingressMode === 'gateway_only') {
334
- // Verify a public ingress URL is configured; fail fast with actionable error if not
335
- let hasPublicUrl = false;
336
- try {
337
- const { loadConfig } = require('../config/loader.js') as typeof import('../config/loader.js');
338
- const { getPublicBaseUrl } = require('../inbound/public-ingress-urls.js') as typeof import('../inbound/public-ingress-urls.js');
339
- getPublicBaseUrl(loadConfig());
340
- hasPublicUrl = true;
341
- } catch {
342
- // No public URL configured
343
- }
344
-
345
- if (!hasPublicUrl) {
346
- throw new Error(
347
- 'OAuth requires a public ingress URL in gateway-only mode. Set ingress.publicBaseUrl or INGRESS_PUBLIC_BASE_URL so OAuth callbacks can route through the gateway.',
348
- );
349
- }
350
-
351
- // In gateway_only mode, always use gateway transport — never fall back to loopback
352
- log.debug({ transport: 'gateway' }, 'OAuth2 flow starting (gateway_only mode)');
353
- return runGatewayFlow(config, callbacks, codeVerifier, codeChallenge, state);
354
- }
355
-
356
- const transport = options?.callbackTransport ?? detectTransport();
357
- log.debug({ transport }, 'OAuth2 flow starting');
358
-
359
- if (transport === 'gateway') {
360
- return runGatewayFlow(config, callbacks, codeVerifier, codeChallenge, state);
213
+ if (!hasPublicUrl) {
214
+ throw new Error(
215
+ 'OAuth requires a public ingress URL. Set ingress.publicBaseUrl or INGRESS_PUBLIC_BASE_URL so OAuth callbacks can route through the gateway.',
216
+ );
361
217
  }
362
218
 
363
- return runLoopbackFlow(config, callbacks, codeVerifier, codeChallenge, state);
219
+ // Always use gateway transport — never fall back to loopback
220
+ log.debug({ transport: 'gateway' }, 'OAuth2 flow starting');
221
+ return runGatewayFlow(config, callbacks, codeVerifier, codeChallenge, state);
364
222
  }
365
223
 
366
224
  /**
@@ -38,7 +38,7 @@ class MiniCDP {
38
38
  const cb = this.callbacks.get(msg.id);
39
39
  if (cb) {
40
40
  this.callbacks.delete(msg.id);
41
- msg.error ? cb.reject(new Error(msg.error.message)) : cb.resolve(msg.result);
41
+ if (msg.error) { cb.reject(new Error(msg.error.message)); } else { cb.resolve(msg.result); }
42
42
  }
43
43
  }
44
44
  };
@@ -130,7 +130,7 @@ export async function autoNavigate(domain: string, abortSignal?: { aborted: bool
130
130
  await sleep(SCROLL_WAIT_MS);
131
131
 
132
132
  // Discover internal links from the current page
133
- let discoveredLinks = await discoverInternalLinks(cdp, domain);
133
+ const discoveredLinks = await discoverInternalLinks(cdp, domain);
134
134
  log.info({ count: discoveredLinks.length }, 'Discovered internal links from root');
135
135
 
136
136
  // Visit discovered pages
@@ -35,7 +35,7 @@ class MiniCDP {
35
35
  const cb = this.callbacks.get(msg.id);
36
36
  if (cb) {
37
37
  this.callbacks.delete(msg.id);
38
- msg.error ? cb.reject(new Error(msg.error.message)) : cb.resolve(msg.result);
38
+ if (msg.error) { cb.reject(new Error(msg.error.message)); } else { cb.resolve(msg.result); }
39
39
  }
40
40
  }
41
41
  };
@@ -156,7 +156,7 @@ export const claudeCodeTool: Tool = {
156
156
  return { behavior: 'allow' as const };
157
157
  }
158
158
 
159
- // For tools that need approval, bridge to Velly's confirmation flow
159
+ // For tools that need approval, bridge to Vellum's confirmation flow
160
160
  if (!context.requestConfirmation) {
161
161
  log.warn({ toolName }, 'Claude Code tool requires approval but no requestConfirmation callback available');
162
162
  return { behavior: 'deny' as const, message: 'Tool approval not available in this context' };
@@ -0,0 +1,43 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+
4
+ import { RiskLevel } from '../../permissions/types.js';
5
+ import type { ToolDefinition } from '../../providers/types.js';
6
+ import { registerTool } from '../registry.js';
7
+ import type { Tool, ToolContext, ToolExecutionResult } from '../types.js';
8
+
9
+ function readPackageVersion(): string {
10
+ try {
11
+ const pkgPath = join(import.meta.dir, '../../../package.json');
12
+ const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8')) as { version?: string };
13
+ return pkg.version ?? 'unknown';
14
+ } catch {
15
+ return 'unknown';
16
+ }
17
+ }
18
+
19
+ class VersionTool implements Tool {
20
+ name = 'version';
21
+ description = 'Return the current version of the Vellum assistant daemon.';
22
+ category = 'system';
23
+ defaultRiskLevel = RiskLevel.Low;
24
+
25
+ getDefinition(): ToolDefinition {
26
+ return {
27
+ name: this.name,
28
+ description: this.description,
29
+ input_schema: {
30
+ type: 'object',
31
+ properties: {},
32
+ required: [],
33
+ },
34
+ };
35
+ }
36
+
37
+ async execute(_input: Record<string, unknown>, _context: ToolContext): Promise<ToolExecutionResult> {
38
+ const version = readPackageVersion();
39
+ return { content: `Vellum assistant version: ${version}`, isError: false };
40
+ }
41
+ }
42
+
43
+ registerTool(new VersionTool());
@@ -1,5 +1,5 @@
1
1
  import type { ToolContext, ToolExecutionResult } from '../types.js';
2
- import { getWorkItem, listWorkItems, identifyEntityById, buildWorkItemMismatchError } from '../../work-items/work-item-store.js';
2
+ import { getWorkItem, listWorkItems, identifyEntityById } from '../../work-items/work-item-store.js';
3
3
  import { runWorkItemInBackground } from '../../work-items/work-item-runner.js';
4
4
  import { getTask } from '../../tasks/task-store.js';
5
5
 
@@ -1,5 +1,5 @@
1
- import { join } from 'node:path';
2
- import { readFileSync } from 'node:fs';
1
+ import { join, dirname } from 'node:path';
2
+ import { readFileSync, existsSync } from 'node:fs';
3
3
  import { createHash } from 'node:crypto';
4
4
  import { getLogger } from '../../util/logger.js';
5
5
  import { IntegrityError } from '../../util/errors.js';
@@ -75,11 +75,31 @@ function verifyWasmChecksum(filePath: string, label: string): void {
75
75
  let parserInstance: Parser | null = null;
76
76
  let initPromise: Promise<void> | null = null;
77
77
 
78
+ /**
79
+ * Locate a WASM file from a dependency package.
80
+ *
81
+ * In development / `bunx` the file lives under `node_modules/` relative
82
+ * to the source tree. In compiled Bun binaries `import.meta.dirname`
83
+ * points into the virtual `/$bunfs/` filesystem where binary assets
84
+ * don't exist — fall back to:
85
+ * 1. `../Resources/<file>` (macOS .app bundle layout)
86
+ * 2. Next to the compiled binary (process.execPath)
87
+ * This matches the pattern used by docker.ts for Dockerfile.sandbox.
88
+ */
78
89
  function findWasmPath(pkg: string, file: string): string {
79
- return join(
80
- import.meta.dirname ?? __dirname,
81
- '..', '..', '..', 'node_modules', pkg, file,
82
- );
90
+ const dir = import.meta.dirname ?? __dirname;
91
+ const sourcePath = join(dir, '..', '..', '..', 'node_modules', pkg, file);
92
+
93
+ if (!existsSync(sourcePath) && dir.startsWith('/$bunfs/')) {
94
+ const execDir = dirname(process.execPath);
95
+ // macOS .app bundle: binary is in Contents/MacOS/, resources in Contents/Resources/
96
+ const resourcesPath = join(execDir, '..', 'Resources', file);
97
+ if (existsSync(resourcesPath)) return resourcesPath;
98
+ // Fallback: next to the binary itself (non-app-bundle deployments)
99
+ return join(execDir, file);
100
+ }
101
+
102
+ return sourcePath;
83
103
  }
84
104
 
85
105
  async function ensureParser(): Promise<Parser> {
@@ -93,7 +113,9 @@ async function ensureParser(): Promise<Parser> {
93
113
  verifyWasmChecksum(treeSitterWasm, 'web-tree-sitter.wasm');
94
114
  verifyWasmChecksum(bashWasmPath, 'tree-sitter-bash.wasm');
95
115
 
96
- await Parser.init();
116
+ await Parser.init({
117
+ locateFile: () => treeSitterWasm,
118
+ });
97
119
 
98
120
  const Bash = await Language.load(bashWasmPath);
99
121
  const parser = new Parser();
@@ -35,6 +35,7 @@ export async function loadEagerModules(): Promise<void> {
35
35
  await import('./calls/call-start.js');
36
36
  await import('./calls/call-status.js');
37
37
  await import('./calls/call-end.js');
38
+ await import('./system/version.js');
38
39
  }
39
40
 
40
41
  // Tool names registered by the eager modules above. Listed explicitly so
@@ -57,6 +58,7 @@ export const eagerModuleToolNames: string[] = [
57
58
  'call_start',
58
59
  'call_status',
59
60
  'call_end',
61
+ 'version',
60
62
  ];
61
63
 
62
64
  // ── Explicit tool instances ─────────────────────────────────────────
@@ -26,7 +26,7 @@ function proxyExecute(): Promise<ToolExecutionResult> {
26
26
  export const uiShowTool: Tool = {
27
27
  name: 'ui_show',
28
28
  description:
29
- 'Show a UI surface to the user. Use display: "inline" (default) to embed in chat, or "panel" for a floating window.\n\n' +
29
+ 'Show structured data or UI to the user. Use for displaying weather, flights, stock prices, quick tables, cards, lists, forms, or any temporary data visualization. Use display: "inline" (default) to embed in chat, or "panel" for a floating window. For long-form writing use the document skill instead; for interactive apps use the app-builder skill instead.\n\n' +
30
30
  'Supported surface types:\n' +
31
31
  '- card: Informational card with title, subtitle, body text, and optional metadata key-value pairs. ' +
32
32
  'Cards support an optional template field for specialized native rendering. ' +
@@ -54,7 +54,14 @@ export const uiShowTool: Tool = {
54
54
  'data shape: { prompt: string, acceptedTypes?: string[], maxFiles?: number }\n\n' +
55
55
  'Action payload conventions:\n' +
56
56
  '- Multi-select tables: use `window.vellum.sendAction(actionId, { selectedIds: [...] })` to send selected row IDs\n' +
57
- '- Bulk actions: include `selectedRows` array with full row data for context',
57
+ '- Bulk actions: include `selectedRows` array with full row data for context\n\n' +
58
+ 'Presenting choices: When the user needs to make a choice or provide structured input, prefer interactive surfaces over plain text. ' +
59
+ 'Use list (2-8 options, single select), form (structured input with typed fields), confirmation (destructive/important actions), or table (data review with selectable rows).\n\n' +
60
+ 'Tool chaining: After gathering data via tools (web search, browser, APIs), synthesize results into a visual output. ' +
61
+ 'Exception: get_weather automatically renders its own surface — do NOT call ui_show or app_create after get_weather, just respond with a brief summary.\n\n' +
62
+ 'Task progress for multi-step workflows: Create a card with template "task_progress" and templateData containing steps. ' +
63
+ 'As each step completes, call ui_update to patch data.templateData (not top-level fields). ' +
64
+ 'Set templateData.status to "completed" or "failed" when done.',
58
65
  category: 'ui-surface',
59
66
  defaultRiskLevel: RiskLevel.Low,
60
67
  executionMode: 'proxy',