vellum 0.2.8 → 0.2.9

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 (55) hide show
  1. package/bun.lock +2 -2
  2. package/package.json +3 -2
  3. package/src/__tests__/config-schema.test.ts +0 -6
  4. package/src/__tests__/forbidden-legacy-symbols.test.ts +69 -0
  5. package/src/__tests__/gateway-only-enforcement.test.ts +91 -11
  6. package/src/__tests__/ingress-url-consistency.test.ts +214 -0
  7. package/src/__tests__/ipc-snapshot.test.ts +17 -16
  8. package/src/__tests__/oauth2-gateway-transport.test.ts +7 -1
  9. package/src/__tests__/public-ingress-urls.test.ts +50 -34
  10. package/src/__tests__/runtime-events-sse-parity.test.ts +343 -0
  11. package/src/__tests__/runtime-events-sse.test.ts +162 -0
  12. package/src/__tests__/twilio-provider.test.ts +1 -1
  13. package/src/__tests__/twilio-routes.test.ts +4 -4
  14. package/src/__tests__/twitter-auth-handler.test.ts +87 -2
  15. package/src/calls/call-domain.ts +8 -6
  16. package/src/calls/twilio-config.ts +2 -3
  17. package/src/config/bundled-skills/tasks/TOOLS.json +25 -0
  18. package/src/config/bundled-skills/tasks/tools/task-queue-run.ts +9 -0
  19. package/src/config/bundled-skills/transcribe/SKILL.md +25 -0
  20. package/src/config/bundled-skills/transcribe/TOOLS.json +32 -0
  21. package/src/config/bundled-skills/transcribe/tools/transcribe-media.ts +370 -0
  22. package/src/config/defaults.ts +1 -2
  23. package/src/config/schema.ts +2 -6
  24. package/src/config/vellum-skills/google-oauth-setup/SKILL.md +5 -4
  25. package/src/config/vellum-skills/slack-oauth-setup/SKILL.md +4 -2
  26. package/src/config/vellum-skills/telegram-setup/SKILL.md +3 -3
  27. package/src/daemon/handlers/config.ts +33 -50
  28. package/src/daemon/handlers/shared.ts +1 -0
  29. package/src/daemon/handlers/subagents.ts +85 -2
  30. package/src/daemon/handlers/twitter-auth.ts +31 -2
  31. package/src/daemon/ipc-contract-inventory.json +4 -4
  32. package/src/daemon/ipc-contract.ts +25 -21
  33. package/src/daemon/lifecycle.ts +9 -4
  34. package/src/daemon/server.ts +7 -0
  35. package/src/daemon/session-tool-setup.ts +1 -1
  36. package/src/inbound/public-ingress-urls.ts +36 -30
  37. package/src/memory/db.ts +132 -5
  38. package/src/memory/llm-usage-store.ts +0 -1
  39. package/src/memory/runs-store.ts +51 -3
  40. package/src/memory/schema.ts +2 -2
  41. package/src/runtime/gateway-client.ts +7 -1
  42. package/src/runtime/http-server.ts +95 -10
  43. package/src/runtime/routes/channel-routes.ts +7 -2
  44. package/src/runtime/routes/events-routes.ts +79 -0
  45. package/src/runtime/routes/run-routes.ts +43 -0
  46. package/src/runtime/run-orchestrator.ts +64 -7
  47. package/src/security/oauth-callback-registry.ts +10 -0
  48. package/src/security/oauth2.ts +41 -7
  49. package/src/subagent/manager.ts +3 -1
  50. package/src/tools/tasks/work-item-run.ts +78 -0
  51. package/src/util/platform.ts +1 -1
  52. package/src/work-items/work-item-runner.ts +171 -0
  53. package/src/__tests__/handlers-twilio-config.test.ts +0 -221
  54. package/src/calls/__tests__/twilio-webhook-urls.test.ts +0 -162
  55. package/src/calls/twilio-webhook-urls.ts +0 -47
@@ -42,6 +42,7 @@ export async function handleDeleteConversation(req: Request): Promise<Response>
42
42
  export async function handleChannelInbound(
43
43
  req: Request,
44
44
  processMessage?: MessageProcessor,
45
+ bearerToken?: string,
45
46
  ): Promise<Response> {
46
47
  const body = await req.json() as {
47
48
  sourceChannel?: string;
@@ -229,6 +230,7 @@ export async function handleChannelInbound(
229
230
  metadataHints,
230
231
  metadataUxBrief,
231
232
  replyCallbackUrl,
233
+ bearerToken,
232
234
  });
233
235
  }
234
236
 
@@ -250,6 +252,7 @@ interface BackgroundProcessingParams {
250
252
  metadataHints: string[];
251
253
  metadataUxBrief?: string;
252
254
  replyCallbackUrl?: string;
255
+ bearerToken?: string;
253
256
  }
254
257
 
255
258
  function processChannelMessageInBackground(params: BackgroundProcessingParams): void {
@@ -264,6 +267,7 @@ function processChannelMessageInBackground(params: BackgroundProcessingParams):
264
267
  metadataHints,
265
268
  metadataUxBrief,
266
269
  replyCallbackUrl,
270
+ bearerToken,
267
271
  } = params;
268
272
 
269
273
  (async () => {
@@ -285,7 +289,7 @@ function processChannelMessageInBackground(params: BackgroundProcessingParams):
285
289
  channelDeliveryStore.markProcessed(eventId);
286
290
 
287
291
  if (replyCallbackUrl) {
288
- await deliverReplyViaCallback(conversationId, externalChatId, replyCallbackUrl);
292
+ await deliverReplyViaCallback(conversationId, externalChatId, replyCallbackUrl, bearerToken);
289
293
  }
290
294
  } catch (err) {
291
295
  log.error({ err, conversationId }, 'Background channel message processing failed');
@@ -298,6 +302,7 @@ async function deliverReplyViaCallback(
298
302
  conversationId: string,
299
303
  externalChatId: string,
300
304
  callbackUrl: string,
305
+ bearerToken?: string,
301
306
  ): Promise<void> {
302
307
  const msgs = conversationStore.getMessages(conversationId);
303
308
  for (let i = msgs.length - 1; i >= 0; i--) {
@@ -320,7 +325,7 @@ async function deliverReplyViaCallback(
320
325
  chatId: externalChatId,
321
326
  text: rendered.text || undefined,
322
327
  attachments: replyAttachments.length > 0 ? replyAttachments : undefined,
323
- });
328
+ }, bearerToken);
324
329
  }
325
330
  break;
326
331
  }
@@ -0,0 +1,79 @@
1
+ /**
2
+ * Route handler for the assistant-events SSE endpoint.
3
+ *
4
+ * GET /v1/events?conversationKey=...
5
+ *
6
+ * Auth is enforced by RuntimeHttpServer before this handler is called.
7
+ * Subscribers receive all assistant events scoped to the given conversation.
8
+ */
9
+
10
+ import { getOrCreateConversation } from '../../memory/conversation-key-store.js';
11
+ import { assistantEventHub } from '../assistant-event-hub.js';
12
+ import { formatSseFrame } from '../assistant-event.js';
13
+ import type { AssistantEventSubscription } from '../assistant-event-hub.js';
14
+
15
+ /**
16
+ * Stream assistant events as Server-Sent Events for a specific conversation.
17
+ *
18
+ * Query params:
19
+ * conversationKey — required; scopes the stream to one conversation.
20
+ */
21
+ export function handleSubscribeAssistantEvents(
22
+ req: Request,
23
+ url: URL,
24
+ ): Response {
25
+ const conversationKey = url.searchParams.get('conversationKey');
26
+ if (!conversationKey) {
27
+ return Response.json({ error: 'conversationKey is required' }, { status: 400 });
28
+ }
29
+
30
+ const mapping = getOrCreateConversation(conversationKey);
31
+ const encoder = new TextEncoder();
32
+ let sub: AssistantEventSubscription | null = null;
33
+
34
+ // Allow up to 16 queued frames before treating the consumer as stalled.
35
+ // This absorbs normal token-stream bursts without prematurely closing the
36
+ // connection, while still shedding genuinely slow clients.
37
+ const stream = new ReadableStream({
38
+ start(controller) {
39
+ // 'self' is the assistantId that RunOrchestrator assigns to all HTTP-run events
40
+ // (see buildAssistantEvent('self', ...) in run-orchestrator.ts). This endpoint
41
+ // is part of the HTTP runtime API, so only HTTP-run events are relevant here.
42
+ // IPC/daemon events use a different assistantId ('default') and reach desktop
43
+ // clients through a separate channel — they are intentionally excluded.
44
+ sub = assistantEventHub.subscribe(
45
+ { assistantId: 'self', sessionId: mapping.conversationId },
46
+ (event) => {
47
+ try {
48
+ // Shed stalled consumers: desiredSize <= 0 means the 16-event buffer
49
+ // is full and the client isn't draining it.
50
+ if (controller.desiredSize !== null && controller.desiredSize <= 0) {
51
+ sub?.dispose();
52
+ try { controller.close(); } catch { /* already closed */ }
53
+ return;
54
+ }
55
+ controller.enqueue(encoder.encode(formatSseFrame(event)));
56
+ } catch {
57
+ sub?.dispose();
58
+ }
59
+ },
60
+ );
61
+
62
+ req.signal.addEventListener('abort', () => {
63
+ sub?.dispose();
64
+ try { controller.close(); } catch { /* already closed */ }
65
+ }, { once: true });
66
+ },
67
+ cancel() {
68
+ sub?.dispose();
69
+ },
70
+ }, new CountQueuingStrategy({ highWaterMark: 16 }));
71
+
72
+ return new Response(stream, {
73
+ headers: {
74
+ 'Content-Type': 'text/event-stream',
75
+ 'Cache-Control': 'no-cache',
76
+ 'Connection': 'keep-alive',
77
+ },
78
+ });
79
+ }
@@ -88,6 +88,7 @@ export function handleGetRun(
88
88
  status: run.status,
89
89
  messageId: run.messageId,
90
90
  pendingConfirmation: run.pendingConfirmation,
91
+ pendingSecret: run.pendingSecret,
91
92
  error: run.error,
92
93
  createdAt: new Date(run.createdAt).toISOString(),
93
94
  updatedAt: new Date(run.updatedAt).toISOString(),
@@ -217,3 +218,45 @@ export async function handleAddTrustRule(
217
218
  return Response.json({ error: 'Failed to add trust rule' }, { status: 500 });
218
219
  }
219
220
  }
221
+
222
+ export async function handleRunSecret(
223
+ runId: string,
224
+ req: Request,
225
+ runOrchestrator: RunOrchestrator,
226
+ ): Promise<Response> {
227
+ const run = runOrchestrator.getRun(runId);
228
+ if (!run) {
229
+ return Response.json({ error: 'Run not found' }, { status: 404 });
230
+ }
231
+
232
+ const body = await req.json() as {
233
+ value?: string;
234
+ delivery?: string;
235
+ };
236
+
237
+ const { value, delivery } = body;
238
+
239
+ if (delivery !== undefined && delivery !== 'store' && delivery !== 'transient_send') {
240
+ return Response.json(
241
+ { error: 'delivery must be "store" or "transient_send"' },
242
+ { status: 400 },
243
+ );
244
+ }
245
+
246
+ const result = runOrchestrator.submitSecret(
247
+ runId,
248
+ value,
249
+ delivery as 'store' | 'transient_send' | undefined,
250
+ );
251
+ if (result === 'run_not_found') {
252
+ return Response.json({ error: 'Run not found' }, { status: 404 });
253
+ }
254
+ if (result === 'no_pending_secret') {
255
+ return Response.json(
256
+ { error: 'No secret pending for this run' },
257
+ { status: 409 },
258
+ );
259
+ }
260
+
261
+ return Response.json({ accepted: true });
262
+ }
@@ -3,11 +3,14 @@
3
3
  *
4
4
  * A "run" wraps a single agent-loop execution, tracking its state through:
5
5
  * running → needs_confirmation → running → completed | failed
6
+ * running → needs_secret → running → completed | failed
6
7
  *
7
8
  * When a tool needs permission, the orchestrator intercepts the
8
9
  * confirmation_request from the session's prompter and records it in
9
- * the run store. The web UI can then poll the run status and submit
10
- * a decision via the /decision endpoint.
10
+ * the run store. Similarly, when a tool needs a secret (e.g.
11
+ * credential_store prompt), the orchestrator intercepts the
12
+ * secret_request and records it. The client can then poll the run
13
+ * status and submit a decision or secret via the respective endpoints.
11
14
  */
12
15
 
13
16
  import * as runsStore from '../memory/runs-store.js';
@@ -113,11 +116,10 @@ export class RunOrchestrator {
113
116
  };
114
117
 
115
118
 
116
- // Hook into session to intercept confirmation_request events.
117
- // When the prompter sends a confirmation_request, we record it in the
118
- // run store so the web UI can poll and submit a decision.
119
- // Do NOT set hasNoClient — run sessions have a client (the HTTP caller)
120
- // and confirmations are handled via the /runs/:id/decision endpoint.
119
+ // Hook into session to intercept confirmation_request and secret_request events.
120
+ // When the prompter sends one of these, we record it in the run store so
121
+ // the client can poll and submit a decision/secret via the respective endpoint.
122
+ // Do NOT set hasNoClient — run sessions have a client (the HTTP caller).
121
123
  let lastError: string | null = null;
122
124
  session.updateClient((msg: ServerMessage) => {
123
125
  if (msg.type === 'confirmation_request') {
@@ -138,6 +140,21 @@ export class RunOrchestrator {
138
140
  prompterRequestId: msg.requestId,
139
141
  session,
140
142
  });
143
+ } else if (msg.type === 'secret_request') {
144
+ runsStore.setRunSecret(run.id, {
145
+ requestId: msg.requestId,
146
+ service: msg.service,
147
+ field: msg.field,
148
+ label: msg.label,
149
+ description: msg.description,
150
+ placeholder: msg.placeholder,
151
+ purpose: msg.purpose,
152
+ allowOneTimeSend: msg.allowOneTimeSend,
153
+ });
154
+ this.pending.set(run.id, {
155
+ prompterRequestId: msg.requestId,
156
+ session,
157
+ });
141
158
  }
142
159
  // Mirror every outbound message to the assistant-events hub so SSE
143
160
  // subscribers receive the same payload parity as IPC clients.
@@ -236,4 +253,44 @@ export class RunOrchestrator {
236
253
  // the client doesn't mistakenly treat the decision as accepted.
237
254
  return 'no_pending_decision';
238
255
  }
256
+
257
+ /**
258
+ * Submit a secret value for a pending secret request.
259
+ *
260
+ * Returns:
261
+ * - `'applied'` – secret was forwarded to the session
262
+ * - `'run_not_found'` – no run exists with the given ID
263
+ * - `'no_pending_secret'` – run exists but is not awaiting a secret
264
+ */
265
+ submitSecret(
266
+ runId: string,
267
+ value?: string,
268
+ delivery?: 'store' | 'transient_send',
269
+ ): 'applied' | 'run_not_found' | 'no_pending_secret' {
270
+ const pendingState = this.pending.get(runId);
271
+ if (pendingState) {
272
+ runsStore.clearRunSecret(runId);
273
+ pendingState.session.handleSecretResponse(
274
+ pendingState.prompterRequestId,
275
+ value,
276
+ delivery,
277
+ );
278
+ this.pending.delete(runId);
279
+ return 'applied';
280
+ }
281
+
282
+ const run = runsStore.getRun(runId);
283
+ if (!run) return 'run_not_found';
284
+
285
+ if (run.status === 'needs_secret') {
286
+ runsStore.failRun(runId, 'Secret prompter timed out (no active handler)');
287
+ return 'applied';
288
+ }
289
+
290
+ if (run.status === 'completed' || run.status === 'failed') {
291
+ return 'applied';
292
+ }
293
+
294
+ return 'no_pending_secret';
295
+ }
239
296
  }
@@ -19,6 +19,15 @@ export function registerPendingCallback(
19
19
  reject: (error: Error) => void,
20
20
  ttlMs = DEFAULT_TTL_MS,
21
21
  ): void {
22
+ // Clear any existing entry for this state to prevent timer leaks and
23
+ // cross-callback timeouts when the same state is registered twice.
24
+ const existing = pendingCallbacks.get(state);
25
+ if (existing) {
26
+ clearTimeout(existing.timer);
27
+ existing.reject(new Error('OAuth callback superseded by new registration'));
28
+ pendingCallbacks.delete(state);
29
+ }
30
+
22
31
  const timer = setTimeout(() => {
23
32
  const entry = pendingCallbacks.get(state);
24
33
  if (entry) {
@@ -51,6 +60,7 @@ export function consumeCallbackError(state: string, error: string): boolean {
51
60
  export function clearAllCallbacks(): void {
52
61
  for (const entry of pendingCallbacks.values()) {
53
62
  clearTimeout(entry.timer);
63
+ entry.reject(new Error('OAuth callback registry cleared'));
54
64
  }
55
65
  pendingCallbacks.clear();
56
66
  }
@@ -144,19 +144,18 @@ async function exchangeCodeForTokens(
144
144
 
145
145
  /**
146
146
  * Determine which callback transport to use when not explicitly specified.
147
- * Uses gateway if ingress.publicBaseUrl is configured, otherwise loopback.
147
+ * Uses gateway if a public base URL is configured (ingress.publicBaseUrl or
148
+ * INGRESS_PUBLIC_BASE_URL), otherwise loopback.
148
149
  */
149
150
  function detectTransport(): 'loopback' | 'gateway' {
150
151
  try {
151
- // Dynamic import avoided — loadConfig is synchronous and already used elsewhere.
152
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');
153
154
  const appConfig = loadConfig();
154
- if (appConfig.ingress?.publicBaseUrl) {
155
- return 'gateway';
156
- }
155
+ getPublicBaseUrl(appConfig); // throws if no public URL configured
156
+ return 'gateway';
157
157
  } catch {
158
- // Config loading failed fall back to loopback
159
- log.debug('Config not available for transport auto-detection, defaulting to loopback');
158
+ log.debug('No public base URL configured for transport auto-detection, defaulting to loopback');
160
159
  }
161
160
  return 'loopback';
162
161
  }
@@ -319,6 +318,41 @@ export async function startOAuth2Flow(
319
318
  const codeChallenge = generateCodeChallenge(codeVerifier);
320
319
  const state = generateState();
321
320
 
321
+ // In gateway_only mode, enforce gateway transport and require a public ingress URL
322
+ let ingressMode: string | undefined;
323
+ try {
324
+ const { loadConfig } = require('../config/loader.js') as typeof import('../config/loader.js');
325
+ ingressMode = loadConfig().ingress.mode;
326
+ } 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';
331
+ }
332
+
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
+
322
356
  const transport = options?.callbackTransport ?? detectTransport();
323
357
  log.debug({ transport }, 'OAuth2 flow starting');
324
358
 
@@ -57,6 +57,7 @@ export interface SubagentNotificationInfo {
57
57
  label: string;
58
58
  status: 'completed' | 'failed' | 'aborted';
59
59
  error?: string;
60
+ conversationId?: string;
60
61
  }
61
62
 
62
63
  export type ParentNotifyCallback = (
@@ -299,7 +300,7 @@ export class SubagentManager {
299
300
  managed.state.config.parentSessionId,
300
301
  message,
301
302
  managed.parentSendToClient,
302
- { subagentId, label, status: 'aborted' },
303
+ { subagentId, label, status: 'aborted', conversationId: managed.state.conversationId },
303
304
  );
304
305
  } catch (err) {
305
306
  log.error({ subagentId, err }, 'Failed to notify parent about abort');
@@ -497,6 +498,7 @@ export class SubagentManager {
497
498
  subagentId: config.id,
498
499
  label: config.label,
499
500
  status: outcome,
501
+ conversationId: managed.state.conversationId,
500
502
  ...(outcome === 'failed' ? { error: managed.state.error ?? 'Unknown error' } : {}),
501
503
  };
502
504
 
@@ -0,0 +1,78 @@
1
+ import type { ToolContext, ToolExecutionResult } from '../types.js';
2
+ import { getWorkItem, listWorkItems, identifyEntityById, buildWorkItemMismatchError } from '../../work-items/work-item-store.js';
3
+ import { runWorkItemInBackground } from '../../work-items/work-item-runner.js';
4
+ import { getTask } from '../../tasks/task-store.js';
5
+
6
+ export async function executeTaskQueueRun(
7
+ input: Record<string, unknown>,
8
+ _context: ToolContext,
9
+ ): Promise<ToolExecutionResult> {
10
+ const workItemId = input.work_item_id as string | undefined;
11
+ const taskName = input.task_name as string | undefined;
12
+ const title = input.title as string | undefined;
13
+
14
+ if (!workItemId && !taskName && !title) {
15
+ return {
16
+ content: 'Error: Provide work_item_id, task_name, or title to identify the task to run.',
17
+ isError: true,
18
+ };
19
+ }
20
+
21
+ try {
22
+ let resolvedId: string | undefined;
23
+
24
+ if (workItemId) {
25
+ const item = getWorkItem(workItemId);
26
+ if (!item) {
27
+ const entity = identifyEntityById(workItemId);
28
+ if (entity.type === 'task_template') {
29
+ return {
30
+ content: `Error: "${workItemId}" is a task template ID, not a work item. Use task_list_show to find the work item ID.`,
31
+ isError: true,
32
+ };
33
+ }
34
+ return { content: `Error: No work item found with ID "${workItemId}".`, isError: true };
35
+ }
36
+ resolvedId = item.id;
37
+ } else {
38
+ // Search by task_name or title among active work items
39
+ const needle = (taskName ?? title)!.toLowerCase();
40
+ const allItems = listWorkItems();
41
+ const activeItems = allItems.filter((i) => !['archived', 'done'].includes(i.status));
42
+ const matches = activeItems.filter((i) => i.title.toLowerCase().includes(needle));
43
+
44
+ if (matches.length === 0) {
45
+ return {
46
+ content: `Error: No active work item matching "${taskName ?? title}". Use task_list_show to see your task queue.`,
47
+ isError: true,
48
+ };
49
+ }
50
+
51
+ if (matches.length > 1) {
52
+ const lines = [`Multiple work items match "${taskName ?? title}". Please specify by ID:`, ''];
53
+ for (const m of matches) {
54
+ lines.push(`- ${m.title} (ID: ${m.id}, status: ${m.status})`);
55
+ }
56
+ return { content: lines.join('\n'), isError: true };
57
+ }
58
+
59
+ resolvedId = matches[0].id;
60
+ }
61
+
62
+ const result = runWorkItemInBackground(resolvedId);
63
+
64
+ if (!result.success) {
65
+ return { content: `Error: ${result.error}`, isError: true };
66
+ }
67
+
68
+ const item = getWorkItem(resolvedId)!;
69
+ const task = getTask(item.taskId);
70
+ return {
71
+ content: `Started running task "${item.title}"${task ? ` (template: ${task.title})` : ''}. It will execute in the background. Use task_list_show to check progress.`,
72
+ isError: false,
73
+ };
74
+ } catch (err) {
75
+ const msg = err instanceof Error ? err.message : String(err);
76
+ return { content: `Error: ${msg}`, isError: true };
77
+ }
78
+ }
@@ -124,7 +124,7 @@ export function getTCPPort(): number {
124
124
  *
125
125
  * The flag-file check makes it easy to enable TCP in dev without restarting
126
126
  * the shell: `touch ~/.vellum/tcp-enabled && kill -USR1 <daemon-pid>`.
127
- * The macOS DaemonLauncher also sets the env var for bundled-binary deployments.
127
+ * The macOS CLI (AssistantCli) also sets the env var for bundled-binary deployments.
128
128
  */
129
129
  export function isTCPEnabled(): boolean {
130
130
  const override = process.env.VELLUM_DAEMON_TCP_ENABLED?.trim();
@@ -0,0 +1,171 @@
1
+ /**
2
+ * Module-level registry for running work items from tool context.
3
+ *
4
+ * The daemon server registers its `getOrCreateSession` and `broadcast`
5
+ * callbacks at startup. Tool implementations can then trigger async
6
+ * work item execution without needing direct access to HandlerContext.
7
+ */
8
+
9
+ import { getLogger } from '../util/logger.js';
10
+ import { getWorkItem, updateWorkItem, type WorkItemStatus } from './work-item-store.js';
11
+ import { getTask } from '../tasks/task-store.js';
12
+ import { runTask } from '../tasks/task-runner.js';
13
+ import { sanitizeToolList, getRegisteredToolNames } from '../tasks/tool-sanitizer.js';
14
+ import type { Session } from '../daemon/session.js';
15
+ import type { ServerMessage } from '../daemon/ipc-protocol.js';
16
+
17
+ const log = getLogger('work-item-runner');
18
+
19
+ // ── Daemon callback registry ─────────────────────────────────────────
20
+
21
+ interface DaemonCallbacks {
22
+ getOrCreateSession: (conversationId: string) => Promise<Session>;
23
+ broadcast: (msg: ServerMessage) => void;
24
+ }
25
+
26
+ let _callbacks: DaemonCallbacks | null = null;
27
+
28
+ export function registerDaemonCallbacks(callbacks: DaemonCallbacks): void {
29
+ _callbacks = callbacks;
30
+ }
31
+
32
+ // ── Public API ───────────────────────────────────────────────────────
33
+
34
+ function broadcastWorkItemStatus(broadcast: (msg: ServerMessage) => void, id: string): void {
35
+ const item = getWorkItem(id);
36
+ if (item) {
37
+ broadcast({
38
+ type: 'work_item_status_changed',
39
+ item: {
40
+ id: item.id,
41
+ taskId: item.taskId,
42
+ title: item.title,
43
+ status: item.status,
44
+ lastRunId: item.lastRunId,
45
+ lastRunConversationId: item.lastRunConversationId,
46
+ lastRunStatus: item.lastRunStatus,
47
+ updatedAt: item.updatedAt,
48
+ },
49
+ } as ServerMessage);
50
+ }
51
+ }
52
+
53
+ export interface RunWorkItemResult {
54
+ success: boolean;
55
+ error?: string;
56
+ errorCode?: string;
57
+ }
58
+
59
+ /**
60
+ * Run a work item in the background. Returns immediately after validation.
61
+ * The actual execution happens asynchronously.
62
+ *
63
+ * When called from a chat tool (e.g. Telegram), required tools are
64
+ * auto-approved since the user explicitly requested execution.
65
+ */
66
+ export function runWorkItemInBackground(workItemId: string): RunWorkItemResult {
67
+ if (!_callbacks) {
68
+ return { success: false, error: 'Daemon callbacks not registered', errorCode: 'not_initialized' };
69
+ }
70
+
71
+ const workItem = getWorkItem(workItemId);
72
+ if (!workItem) {
73
+ return { success: false, error: 'Work item not found', errorCode: 'not_found' };
74
+ }
75
+
76
+ if (workItem.status === 'running') {
77
+ return { success: false, error: 'Work item is already running', errorCode: 'already_running' };
78
+ }
79
+
80
+ const NON_RUNNABLE_STATUSES: readonly string[] = ['archived'];
81
+ if (NON_RUNNABLE_STATUSES.includes(workItem.status)) {
82
+ return { success: false, error: `Work item has status '${workItem.status}' and cannot be run`, errorCode: 'invalid_status' };
83
+ }
84
+
85
+ const task = getTask(workItem.taskId);
86
+ if (!task) {
87
+ return { success: false, error: `Associated task not found: ${workItem.taskId}`, errorCode: 'no_task' };
88
+ }
89
+
90
+ // Resolve required tools
91
+ let requiredTools: string[];
92
+ if (workItem.requiredTools !== null && workItem.requiredTools !== undefined) {
93
+ requiredTools = sanitizeToolList(JSON.parse(workItem.requiredTools));
94
+ } else {
95
+ requiredTools = task.requiredTools
96
+ ? sanitizeToolList(JSON.parse(task.requiredTools))
97
+ : getRegisteredToolNames();
98
+ }
99
+
100
+ // Auto-approve all required tools for chat-initiated runs.
101
+ // The user explicitly asked to run the task, so we treat that as consent.
102
+ const approvedTools = requiredTools;
103
+
104
+ // Set status to running
105
+ updateWorkItem(workItemId, { status: 'running' });
106
+
107
+ const { getOrCreateSession, broadcast } = _callbacks;
108
+
109
+ // Broadcast the running state
110
+ broadcastWorkItemStatus(broadcast, workItemId);
111
+ broadcast({ type: 'tasks_changed' } as ServerMessage);
112
+
113
+ // Execute asynchronously
114
+ let session: Awaited<ReturnType<typeof getOrCreateSession>> | null = null;
115
+ void (async () => {
116
+ try {
117
+ const result = await runTask(
118
+ { taskId: workItem.taskId, workingDir: process.cwd(), approvedTools },
119
+ async (conversationId, message, taskRunId) => {
120
+ if (!session) {
121
+ updateWorkItem(workItemId, { lastRunConversationId: conversationId });
122
+ session = await getOrCreateSession(conversationId);
123
+
124
+ broadcast({
125
+ type: 'task_run_thread_created',
126
+ conversationId,
127
+ workItemId,
128
+ title: workItem.title,
129
+ } as ServerMessage);
130
+ (session as unknown as { taskRunId?: string }).taskRunId = taskRunId;
131
+ (session as unknown as { headlessLock: boolean }).headlessLock = true;
132
+ }
133
+ await session.processMessage(message, [], (event) => {
134
+ broadcast(event);
135
+ });
136
+ },
137
+ );
138
+
139
+ if (session) {
140
+ (session as unknown as { headlessLock: boolean }).headlessLock = false;
141
+ }
142
+
143
+ const current = getWorkItem(workItemId);
144
+ if (current?.status !== 'cancelled') {
145
+ const finalStatus: WorkItemStatus = result.status === 'completed' ? 'awaiting_review' : 'failed';
146
+ updateWorkItem(workItemId, {
147
+ status: finalStatus,
148
+ lastRunId: result.taskRunId,
149
+ lastRunConversationId: result.conversationId,
150
+ lastRunStatus: result.status,
151
+ });
152
+ }
153
+
154
+ broadcastWorkItemStatus(broadcast, workItemId);
155
+ broadcast({ type: 'tasks_changed' } as ServerMessage);
156
+ } catch (err) {
157
+ if (session) {
158
+ (session as unknown as { headlessLock: boolean }).headlessLock = false;
159
+ }
160
+ log.error({ err, workItemId }, 'work item background run failed');
161
+ updateWorkItem(workItemId, {
162
+ status: 'failed',
163
+ lastRunStatus: 'failed',
164
+ });
165
+ broadcastWorkItemStatus(broadcast, workItemId);
166
+ broadcast({ type: 'tasks_changed' } as ServerMessage);
167
+ }
168
+ })();
169
+
170
+ return { success: true };
171
+ }