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
@@ -895,9 +895,6 @@ export const CallsConfigSchema = z.object({
895
895
  error: `calls.provider must be one of: ${VALID_CALL_PROVIDERS.join(', ')}`,
896
896
  })
897
897
  .default('twilio'),
898
- webhookBaseUrl: z
899
- .string({ error: 'calls.webhookBaseUrl must be a string' })
900
- .default(''),
901
898
  maxDurationSeconds: z
902
899
  .number({ error: 'calls.maxDurationSeconds must be a number' })
903
900
  .int('calls.maxDurationSeconds must be an integer')
@@ -934,7 +931,7 @@ export const IngressConfigSchema = z.object({
934
931
  .enum(VALID_INGRESS_MODES, {
935
932
  error: `ingress.mode must be one of: ${VALID_INGRESS_MODES.join(', ')}`,
936
933
  })
937
- .default('compat'),
934
+ .default('gateway_only'),
938
935
  });
939
936
 
940
937
  export const AssistantConfigSchema = z.object({
@@ -1175,7 +1172,6 @@ export const AssistantConfigSchema = z.object({
1175
1172
  calls: CallsConfigSchema.default({
1176
1173
  enabled: true,
1177
1174
  provider: 'twilio',
1178
- webhookBaseUrl: '',
1179
1175
  maxDurationSeconds: 3600,
1180
1176
  userConsultTimeoutSeconds: 120,
1181
1177
  disclosure: {
@@ -1188,7 +1184,7 @@ export const AssistantConfigSchema = z.object({
1188
1184
  }),
1189
1185
  ingress: IngressConfigSchema.default({
1190
1186
  publicBaseUrl: '',
1191
- mode: 'compat',
1187
+ mode: 'gateway_only',
1192
1188
  }),
1193
1189
  }).superRefine((config, ctx) => {
1194
1190
  if (config.contextWindow.targetInputTokens >= config.contextWindow.maxInputTokens) {
@@ -124,7 +124,7 @@ Tell the user: "Consent screen is configured! Almost there — just need to crea
124
124
 
125
125
  > **Create OAuth Credentials**
126
126
  >
127
- > I'm about to create OAuth Desktop credentials for Vellum Assistant. This generates a client ID that Vellum uses to initiate the authorization flow. No secret keys are involved we use the secure PKCE method.
127
+ > I'm about to create OAuth Web Application credentials for Vellum Assistant. This generates a client ID that Vellum uses to initiate the authorization flow. The redirect URI will point to the gateway's OAuth callback endpoint.
128
128
 
129
129
  Wait for the user to approve. If they decline, explain that credentials are the final step needed and offer to try again or cancel.
130
130
 
@@ -133,8 +133,9 @@ Once approved, navigate to `https://console.cloud.google.com/apis/credentials?pr
133
133
  Use `browser_click` on "+ Create Credentials" at the top, then select "OAuth client ID" from the dropdown.
134
134
 
135
135
  Take a `browser_snapshot` and fill in:
136
- 1. **Application type:** Select "Desktop app" from the dropdown
137
- 2. **Name:** "Vellum Assistant Desktop"
136
+ 1. **Application type:** Select "Web application" from the dropdown
137
+ 2. **Name:** "Vellum Assistant"
138
+ 3. **Authorized redirect URIs:** Click "Add URI" and enter `${ingress.publicBaseUrl}/webhooks/oauth/callback` (e.g. `https://abc123.ngrok-free.app/webhooks/oauth/callback`). Read the `ingress.publicBaseUrl` value from the assistant's workspace config (Settings > Public Ingress) or the `INGRESS_PUBLIC_BASE_URL` environment variable.
138
139
 
139
140
  Use `browser_click` on the "Create" button.
140
141
 
@@ -179,7 +180,7 @@ Summarize what was accomplished:
179
180
  - Created a Google Cloud project (or used an existing one)
180
181
  - Enabled the Gmail API and Google Calendar API
181
182
  - Configured the OAuth consent screen with appropriate scopes (including calendar)
182
- - Created OAuth Desktop credentials using secure PKCE
183
+ - Created OAuth Web Application credentials with gateway callback redirect URI
183
184
  - Connected your Gmail and Google Calendar accounts
184
185
 
185
186
  ## Error Handling
@@ -85,14 +85,16 @@ Tell the user: "Permissions configured! Now let's set up the redirect URL and ge
85
85
 
86
86
  Navigate to the "OAuth & Permissions" page if not already there.
87
87
 
88
+ The redirect URL must point to the gateway's OAuth callback endpoint. Determine the URL by reading the `ingress.publicBaseUrl` value from the assistant's workspace config (Settings > Public Ingress) or the `INGRESS_PUBLIC_BASE_URL` environment variable. The callback path is `/webhooks/oauth/callback`.
89
+
88
90
  In the "Redirect URLs" section:
89
91
  1. Click "Add New Redirect URL"
90
- 2. Enter `http://127.0.0.1:0/callback` — Vellum will use a random port on localhost
92
+ 2. Enter `${ingress.publicBaseUrl}/webhooks/oauth/callback` (e.g. `https://abc123.ngrok-free.app/webhooks/oauth/callback`)
91
93
  3. Click "Add" then "Save URLs"
92
94
 
93
95
  Take a `browser_snapshot` to confirm.
94
96
 
95
- Tell the user: "Redirect URL configured."
97
+ Tell the user: "Redirect URL configured. Make sure your tunnel is running and `ingress.publicBaseUrl` is set in Settings so the callback can reach the gateway."
96
98
 
97
99
  ## Step 5: Extract Client ID and Client Secret
98
100
 
@@ -5,14 +5,14 @@ user-invocable: true
5
5
  metadata: {"vellum": {"emoji": "\ud83e\udd16"}}
6
6
  ---
7
7
 
8
- You are helping your user connect a Telegram bot to the Vellum Assistant gateway. When this skill is invoked, walk through each step below using only existing tools.
8
+ You are helping your user connect a Telegram bot to the Vellum Assistant gateway. Telegram webhooks are received exclusively by the gateway (the public ingress boundary) — they never hit the assistant runtime directly. When this skill is invoked, walk through each step below using only existing tools.
9
9
 
10
10
  ## What You Need
11
11
 
12
12
  1. **Bot token** from Telegram's @BotFather (the user provides this)
13
- 2. **Gateway webhook URL** where the gateway receives webhooks (e.g. `https://their-domain/webhooks/telegram`)
13
+ 2. **Gateway webhook URL** derived from the canonical ingress setting: `${ingress.publicBaseUrl}/webhooks/telegram`. The gateway is the only publicly reachable endpoint; Telegram sends webhooks to the gateway, which validates and forwards them to the assistant runtime internally. If `ingress.publicBaseUrl` is configured (Settings UI > Public Ingress, or `INGRESS_PUBLIC_BASE_URL` env var), use it to auto-derive the webhook URL. If it is not configured, ask the user to set it before proceeding.
14
14
 
15
- If the user has already provided the bot token in the conversation, use it directly. Otherwise, ask for it. Always confirm the gateway webhook URL if not provided.
15
+ If the user has already provided the bot token in the conversation, use it directly. Otherwise, ask for it.
16
16
 
17
17
  ## Setup Steps
18
18
 
@@ -19,7 +19,6 @@ import type {
19
19
  ReminderCancel,
20
20
  ShareToSlackRequest,
21
21
  SlackWebhookConfigRequest,
22
- TwilioWebhookConfigRequest,
23
22
  IngressConfigRequest,
24
23
  VercelApiConfigRequest,
25
24
  TwitterIntegrationConfigRequest,
@@ -90,11 +89,12 @@ export function handleModelSet(
90
89
  // Suppress the file watcher callback — handleModelSet already does
91
90
  // the full reload sequence; a redundant watcher-triggered reload
92
91
  // would incorrectly evict sessions created after this method returns.
92
+ const wasSuppressed = ctx.suppressConfigReload;
93
93
  ctx.setSuppressConfigReload(true);
94
94
  try {
95
95
  saveRawConfig(raw);
96
96
  } catch (err) {
97
- ctx.setSuppressConfigReload(false);
97
+ ctx.setSuppressConfigReload(wasSuppressed);
98
98
  throw err;
99
99
  }
100
100
  const existingSuppressTimer = ctx.debounceTimers.get('__suppress_reset__');
@@ -139,11 +139,12 @@ export function handleImageGenModelSet(
139
139
  const raw = loadRawConfig();
140
140
  raw.imageGenModel = msg.model;
141
141
 
142
+ const wasSuppressed = ctx.suppressConfigReload;
142
143
  ctx.setSuppressConfigReload(true);
143
144
  try {
144
145
  saveRawConfig(raw);
145
146
  } catch (err) {
146
- ctx.setSuppressConfigReload(false);
147
+ ctx.setSuppressConfigReload(wasSuppressed);
147
148
  throw err;
148
149
  }
149
150
  const existingSuppressTimer = ctx.debounceTimers.get('__suppress_reset__');
@@ -398,41 +399,10 @@ export function handleSlackWebhookConfig(
398
399
  }
399
400
  }
400
401
 
401
- export function handleTwilioWebhookConfig(
402
- msg: TwilioWebhookConfigRequest,
403
- socket: net.Socket,
404
- ctx: HandlerContext,
405
- ): void {
406
- try {
407
- if (msg.action === 'get') {
408
- const raw = loadRawConfig();
409
- const webhookBaseUrl = (raw?.calls as Record<string, unknown>)?.webhookBaseUrl as string ?? '';
410
- ctx.send(socket, { type: 'twilio_webhook_config_response', webhookBaseUrl, success: true });
411
- } else if (msg.action === 'set') {
412
- const value = (msg.webhookBaseUrl ?? '').trim().replace(/\/+$/, '');
413
- const raw = loadRawConfig();
414
- const calls = (raw?.calls ?? {}) as Record<string, unknown>;
415
- calls.webhookBaseUrl = value || undefined;
416
- const wasSuppressed = ctx.suppressConfigReload;
417
- ctx.setSuppressConfigReload(true);
418
- try {
419
- saveRawConfig({ ...raw, calls });
420
- } catch (err) {
421
- ctx.setSuppressConfigReload(wasSuppressed);
422
- throw err;
423
- }
424
- const existingSuppressTimer = ctx.debounceTimers.get('__suppress_reset__');
425
- if (existingSuppressTimer) clearTimeout(existingSuppressTimer);
426
- const resetTimer = setTimeout(() => { ctx.setSuppressConfigReload(false); }, CONFIG_RELOAD_DEBOUNCE_MS);
427
- ctx.debounceTimers.set('__suppress_reset__', resetTimer);
428
- ctx.send(socket, { type: 'twilio_webhook_config_response', webhookBaseUrl: value, success: true });
429
- } else {
430
- ctx.send(socket, { type: 'twilio_webhook_config_response', webhookBaseUrl: '', success: false, error: `Unknown action: ${String((msg as unknown as Record<string, unknown>).action)}` });
431
- }
432
- } catch (err) {
433
- const message = err instanceof Error ? err.message : String(err);
434
- ctx.send(socket, { type: 'twilio_webhook_config_response', webhookBaseUrl: '', success: false, error: message });
435
- }
402
+ function computeLocalGatewayTarget(): string {
403
+ const portRaw = process.env.GATEWAY_PORT || '7830';
404
+ const port = Number(portRaw) || 7830;
405
+ return `http://127.0.0.1:${port}`;
436
406
  }
437
407
 
438
408
  export function handleIngressConfig(
@@ -440,28 +410,29 @@ export function handleIngressConfig(
440
410
  socket: net.Socket,
441
411
  ctx: HandlerContext,
442
412
  ): void {
413
+ const localGatewayTarget = computeLocalGatewayTarget();
443
414
  try {
444
415
  if (msg.action === 'get') {
445
416
  const raw = loadRawConfig();
446
417
  const ingress = (raw?.ingress ?? {}) as Record<string, unknown>;
447
418
  const publicBaseUrl = (ingress.publicBaseUrl as string) ?? '';
448
- ctx.send(socket, { type: 'ingress_config_response', publicBaseUrl, success: true });
419
+ ctx.send(socket, { type: 'ingress_config_response', publicBaseUrl, localGatewayTarget, success: true });
449
420
  } else if (msg.action === 'set') {
450
421
  const value = (msg.publicBaseUrl ?? '').trim().replace(/\/+$/, '');
451
422
  const raw = loadRawConfig();
452
423
 
453
- // Update ingress.publicBaseUrl
424
+ // Update ingress.publicBaseUrl — this is the single source of truth for
425
+ // the canonical public ingress URL. The gateway receives this value via
426
+ // the INGRESS_PUBLIC_BASE_URL env var at spawn time (see hatch.ts).
427
+ // A gateway restart is required for the new value to take effect in
428
+ // inbound Twilio signature validation.
454
429
  const ingress = (raw?.ingress ?? {}) as Record<string, unknown>;
455
430
  ingress.publicBaseUrl = value || undefined;
456
431
 
457
- // Also update calls.webhookBaseUrl for backward compat
458
- const calls = (raw?.calls ?? {}) as Record<string, unknown>;
459
- calls.webhookBaseUrl = value || undefined;
460
-
461
432
  const wasSuppressed = ctx.suppressConfigReload;
462
433
  ctx.setSuppressConfigReload(true);
463
434
  try {
464
- saveRawConfig({ ...raw, ingress, calls });
435
+ saveRawConfig({ ...raw, ingress });
465
436
  } catch (err) {
466
437
  ctx.setSuppressConfigReload(wasSuppressed);
467
438
  throw err;
@@ -470,13 +441,26 @@ export function handleIngressConfig(
470
441
  if (existingSuppressTimer) clearTimeout(existingSuppressTimer);
471
442
  const resetTimer = setTimeout(() => { ctx.setSuppressConfigReload(false); }, CONFIG_RELOAD_DEBOUNCE_MS);
472
443
  ctx.debounceTimers.set('__suppress_reset__', resetTimer);
473
- ctx.send(socket, { type: 'ingress_config_response', publicBaseUrl: value, success: true });
444
+
445
+ // Propagate to the gateway's process environment so it picks up the
446
+ // new URL on its next config load. For the local-deployment path the
447
+ // gateway runs as a child process that inherited the assistant's env,
448
+ // so updating process.env here ensures the value is visible when the
449
+ // gateway is restarted (e.g. by the self-upgrade skill or a manual
450
+ // `pkill -f gateway`).
451
+ if (value) {
452
+ process.env.INGRESS_PUBLIC_BASE_URL = value;
453
+ }
454
+ // When cleared, do NOT delete the env var — the original env-provided
455
+ // value (if any) serves as a fallback per the documented precedence model.
456
+
457
+ ctx.send(socket, { type: 'ingress_config_response', publicBaseUrl: value, localGatewayTarget, success: true });
474
458
  } else {
475
- ctx.send(socket, { type: 'ingress_config_response', publicBaseUrl: '', success: false, error: `Unknown action: ${String((msg as unknown as Record<string, unknown>).action)}` });
459
+ ctx.send(socket, { type: 'ingress_config_response', publicBaseUrl: '', localGatewayTarget, success: false, error: `Unknown action: ${String((msg as unknown as Record<string, unknown>).action)}` });
476
460
  }
477
461
  } catch (err) {
478
462
  const message = err instanceof Error ? err.message : String(err);
479
- ctx.send(socket, { type: 'ingress_config_response', publicBaseUrl: '', success: false, error: message });
463
+ ctx.send(socket, { type: 'ingress_config_response', publicBaseUrl: '', localGatewayTarget, success: false, error: message });
480
464
  }
481
465
  }
482
466
 
@@ -613,7 +597,7 @@ export function handleTwitterIntegrationConfig(
613
597
  type: 'twitter_integration_config_response',
614
598
  success: false,
615
599
  managedAvailable: false,
616
- localClientConfigured: false,
600
+ localClientConfigured: !!previousClientId,
617
601
  connected: false,
618
602
  error: 'Failed to store client secret in secure storage',
619
603
  });
@@ -705,7 +689,6 @@ export const configHandlers = defineHandlers({
705
689
  reminder_cancel: handleReminderCancel,
706
690
  share_to_slack: handleShareToSlack,
707
691
  slack_webhook_config: handleSlackWebhookConfig,
708
- twilio_webhook_config: handleTwilioWebhookConfig,
709
692
  ingress_config: handleIngressConfig,
710
693
  vercel_api_config: handleVercelApiConfig,
711
694
  twitter_integration_config: handleTwitterIntegrationConfig,
@@ -75,6 +75,7 @@ export interface SubagentNotificationData {
75
75
  label: string;
76
76
  status: 'completed' | 'failed' | 'aborted';
77
77
  error?: string;
78
+ conversationId?: string;
78
79
  }
79
80
 
80
81
  export interface ParsedHistoryMessage {
@@ -3,10 +3,11 @@
3
3
  */
4
4
 
5
5
  import * as net from 'node:net';
6
- import type { SubagentAbortRequest, SubagentStatusRequest, SubagentMessageRequest } from '../ipc-protocol.js';
6
+ import type { SubagentAbortRequest, SubagentStatusRequest, SubagentMessageRequest, SubagentDetailRequest } from '../ipc-protocol.js';
7
+ import * as conversationStore from '../../memory/conversation-store.js';
7
8
  import type { HandlerContext } from './shared.js';
8
9
  import { getSubagentManager } from '../../subagent/index.js';
9
- import { log, defineHandlers } from './shared.js';
10
+ import { log, defineHandlers, isRecord } from './shared.js';
10
11
 
11
12
  export function handleSubagentAbort(
12
13
  msg: SubagentAbortRequest,
@@ -120,8 +121,90 @@ export function handleSubagentMessage(
120
121
  }
121
122
  }
122
123
 
124
+ export function handleSubagentDetailRequest(
125
+ msg: SubagentDetailRequest,
126
+ socket: net.Socket,
127
+ ctx: HandlerContext,
128
+ ): void {
129
+ // Ownership check: reject if the socket has no bound session.
130
+ const callerSessionId = ctx.socketToSession.get(socket);
131
+ if (!callerSessionId) {
132
+ log.warn({ subagentId: msg.subagentId }, 'Detail request rejected: socket has no bound session');
133
+ return;
134
+ }
135
+
136
+ // If the subagent is still in memory, verify the caller owns it.
137
+ // After daemon restart getState() returns null — we allow the request
138
+ // since the conversationId itself acts as a capability token (the client
139
+ // only knows it because it was sent in a prior subagent_notification).
140
+ const manager = getSubagentManager();
141
+ const state = manager.getState(msg.subagentId);
142
+ if (state && state.config.parentSessionId !== callerSessionId) {
143
+ log.warn({ subagentId: msg.subagentId, callerSessionId }, 'Detail request rejected: subagent not owned by caller');
144
+ return;
145
+ }
146
+
147
+ const subagentMsgs = conversationStore.getMessages(msg.conversationId);
148
+
149
+ // Extract objective from the first user message
150
+ let objective: string | undefined;
151
+ const firstUser = subagentMsgs.find(m => m.role === 'user');
152
+ if (firstUser) {
153
+ try {
154
+ const parsed = JSON.parse(firstUser.content);
155
+ if (Array.isArray(parsed)) {
156
+ const textBlock = parsed.find((b: Record<string, unknown>) => isRecord(b) && b.type === 'text');
157
+ if (textBlock && typeof textBlock.text === 'string') {
158
+ objective = textBlock.text;
159
+ }
160
+ }
161
+ } catch { /* ignore */ }
162
+ }
163
+
164
+ // Extract events from both assistant and user messages.
165
+ // Subagent conversations are not consolidated, so tool_result blocks
166
+ // live in separate user-role messages following the assistant's tool_use.
167
+ const events: Array<{ type: string; content: string; toolName?: string; isError?: boolean }> = [];
168
+ const pendingTools = new Map<string, string>();
169
+ for (const m of subagentMsgs) {
170
+ if (m.role !== 'assistant' && m.role !== 'user') continue;
171
+ let content: unknown[];
172
+ try {
173
+ const parsed = JSON.parse(m.content);
174
+ content = Array.isArray(parsed) ? parsed : [];
175
+ } catch { continue; }
176
+
177
+ for (const block of content) {
178
+ if (!isRecord(block) || typeof block.type !== 'string') continue;
179
+ if (m.role === 'assistant' && block.type === 'text' && typeof block.text === 'string') {
180
+ events.push({ type: 'text', content: block.text });
181
+ } else if (block.type === 'tool_use') {
182
+ const name = typeof block.name === 'string' ? block.name : 'unknown';
183
+ const input = isRecord(block.input) ? block.input as Record<string, unknown> : {};
184
+ const id = typeof block.id === 'string' ? block.id : '';
185
+ events.push({ type: 'tool_use', content: JSON.stringify(input), toolName: name });
186
+ if (id) pendingTools.set(id, name);
187
+ } else if (block.type === 'tool_result') {
188
+ const toolUseId = typeof block.tool_use_id === 'string' ? block.tool_use_id : '';
189
+ const resultContent = typeof block.content === 'string' ? block.content : '';
190
+ const isError = block.is_error === true;
191
+ const toolName = toolUseId ? pendingTools.get(toolUseId) : undefined;
192
+ events.push({ type: 'tool_result', content: resultContent, toolName: toolName ?? 'unknown', isError });
193
+ }
194
+ }
195
+ }
196
+
197
+ ctx.send(socket, {
198
+ type: 'subagent_detail_response',
199
+ subagentId: msg.subagentId,
200
+ objective,
201
+ events,
202
+ });
203
+ }
204
+
123
205
  export const subagentHandlers = defineHandlers({
124
206
  subagent_abort: handleSubagentAbort,
125
207
  subagent_status: handleSubagentStatus,
126
208
  subagent_message: handleSubagentMessage,
209
+ subagent_detail_request: handleSubagentDetailRequest,
127
210
  });
@@ -1,8 +1,10 @@
1
1
  import * as net from 'node:net';
2
- import { loadRawConfig } from '../../config/loader.js';
2
+ import { loadRawConfig, loadConfig } from '../../config/loader.js';
3
3
  import { getSecureKey, setSecureKey, deleteSecureKey } from '../../security/secure-keys.js';
4
4
  import { startOAuth2Flow } from '../../security/oauth2.js';
5
+ import { getPublicBaseUrl } from '../../inbound/public-ingress-urls.js';
5
6
  import { upsertCredentialMetadata, getCredentialMetadata } from '../../tools/credentials/metadata-store.js';
7
+ import { ConfigError } from '../../util/errors.js';
6
8
  import type { TwitterAuthStartRequest, TwitterAuthStatusRequest } from '../ipc-protocol.js';
7
9
  import { log, defineHandlers, type HandlerContext } from './shared.js';
8
10
  import type { OAuth2Config } from '../../security/oauth2.js';
@@ -36,6 +38,33 @@ export async function handleTwitterAuthStart(
36
38
 
37
39
  const clientSecret = getSecureKey('credential:integration:twitter:oauth_client_secret') || undefined;
38
40
 
41
+ // Fail fast if no public ingress URL is configured — Twitter OAuth
42
+ // callbacks must route through the gateway, never via loopback.
43
+ let config;
44
+ try {
45
+ config = loadConfig();
46
+ } catch (err) {
47
+ const detail = err instanceof ConfigError ? err.message : String(err);
48
+ ctx.send(socket, {
49
+ type: 'twitter_auth_result',
50
+ success: false,
51
+ error: `Unable to load config: ${detail}`,
52
+ });
53
+ return;
54
+ }
55
+
56
+ try {
57
+ getPublicBaseUrl(config);
58
+ } catch {
59
+ ctx.send(socket, {
60
+ type: 'twitter_auth_result',
61
+ success: false,
62
+ error:
63
+ 'Set ingress.publicBaseUrl (or INGRESS_PUBLIC_BASE_URL) so OAuth callbacks can route through /webhooks/oauth/callback on the gateway.',
64
+ });
65
+ return;
66
+ }
67
+
39
68
  const oauthConfig: OAuth2Config = {
40
69
  authUrl: 'https://twitter.com/i/oauth2/authorize',
41
70
  tokenUrl: 'https://api.x.com/2/oauth2/token',
@@ -49,7 +78,7 @@ export async function handleTwitterAuthStart(
49
78
  openUrl: (url: string) => {
50
79
  ctx.send(socket, { type: 'open_url', url });
51
80
  },
52
- });
81
+ }, { callbackTransport: 'gateway' });
53
82
 
54
83
  // Verify identity via Twitter API before persisting any tokens
55
84
  let accountInfo: string;
@@ -76,12 +76,12 @@
76
76
  "SkillsUpdateRequest",
77
77
  "SlackWebhookConfigRequest",
78
78
  "SubagentAbortRequest",
79
+ "SubagentDetailRequest",
79
80
  "SubagentMessageRequest",
80
81
  "SubagentStatusRequest",
81
82
  "SuggestionRequest",
82
83
  "TaskSubmit",
83
84
  "TrustRulesList",
84
- "TwilioWebhookConfigRequest",
85
85
  "TwitterAuthStartRequest",
86
86
  "TwitterAuthStatusRequest",
87
87
  "TwitterIntegrationConfigRequest",
@@ -181,6 +181,7 @@
181
181
  "SkillsListResponse",
182
182
  "SkillsOperationResponse",
183
183
  "SlackWebhookConfigResponse",
184
+ "SubagentDetailResponse",
184
185
  "SubagentEvent",
185
186
  "SubagentSpawned",
186
187
  "SubagentStatusChanged",
@@ -194,7 +195,6 @@
194
195
  "ToolUseStart",
195
196
  "TraceEvent",
196
197
  "TrustRulesListResponse",
197
- "TwilioWebhookConfigResponse",
198
198
  "TwitterAuthResult",
199
199
  "TwitterAuthStatusResponse",
200
200
  "TwitterIntegrationConfigResponse",
@@ -301,12 +301,12 @@
301
301
  "skills_update",
302
302
  "slack_webhook_config",
303
303
  "subagent_abort",
304
+ "subagent_detail_request",
304
305
  "subagent_message",
305
306
  "subagent_status",
306
307
  "suggestion_request",
307
308
  "task_submit",
308
309
  "trust_rules_list",
309
- "twilio_webhook_config",
310
310
  "twitter_auth_start",
311
311
  "twitter_auth_status",
312
312
  "twitter_integration_config",
@@ -406,6 +406,7 @@
406
406
  "skills_operation_response",
407
407
  "skills_state_changed",
408
408
  "slack_webhook_config_response",
409
+ "subagent_detail_response",
409
410
  "subagent_event",
410
411
  "subagent_spawned",
411
412
  "subagent_status_changed",
@@ -419,7 +420,6 @@
419
420
  "tool_use_start",
420
421
  "trace_event",
421
422
  "trust_rules_list_response",
422
- "twilio_webhook_config_response",
423
423
  "twitter_auth_result",
424
424
  "twitter_auth_status_response",
425
425
  "twitter_integration_config_response",
@@ -472,12 +472,6 @@ export interface SlackWebhookConfigRequest {
472
472
  webhookUrl?: string;
473
473
  }
474
474
 
475
- export interface TwilioWebhookConfigRequest {
476
- type: 'twilio_webhook_config';
477
- action: 'get' | 'set';
478
- webhookBaseUrl?: string;
479
- }
480
-
481
475
  export interface IngressConfigRequest {
482
476
  type: 'ingress_config';
483
477
  action: 'get' | 'set';
@@ -947,7 +941,6 @@ export type ClientMessage =
947
941
  | ShareAppCloudRequest
948
942
  | ShareToSlackRequest
949
943
  | SlackWebhookConfigRequest
950
- | TwilioWebhookConfigRequest
951
944
  | IngressConfigRequest
952
945
  | VercelApiConfigRequest
953
946
  | TwitterIntegrationConfigRequest
@@ -985,11 +978,8 @@ export type ClientMessage =
985
978
  | WorkItemCancelRequest
986
979
  | SubagentAbortRequest
987
980
  | SubagentStatusRequest
988
- | SubagentMessageRequest;
989
-
990
- // ── Legacy integration IPC stubs ────────────────────────────────────
991
- // The macOS Settings panel still sends these messages. Stub types keep
992
- // the dispatch map happy until the client-side migration lands.
981
+ | SubagentMessageRequest
982
+ | SubagentDetailRequest;
993
983
 
994
984
  export interface IntegrationListRequest {
995
985
  type: 'integration_list';
@@ -1234,6 +1224,7 @@ export interface HistoryResponse {
1234
1224
  label: string;
1235
1225
  status: 'completed' | 'failed' | 'aborted';
1236
1226
  error?: string;
1227
+ conversationId?: string;
1237
1228
  };
1238
1229
  }>;
1239
1230
  }
@@ -1697,16 +1688,11 @@ export interface SlackWebhookConfigResponse {
1697
1688
  error?: string;
1698
1689
  }
1699
1690
 
1700
- export interface TwilioWebhookConfigResponse {
1701
- type: 'twilio_webhook_config_response';
1702
- webhookBaseUrl: string;
1703
- success: boolean;
1704
- error?: string;
1705
- }
1706
-
1707
1691
  export interface IngressConfigResponse {
1708
1692
  type: 'ingress_config_response';
1709
1693
  publicBaseUrl: string;
1694
+ /** Read-only gateway target computed from GATEWAY_PORT env var (default 7830) + loopback host. */
1695
+ localGatewayTarget: string;
1710
1696
  success: boolean;
1711
1697
  error?: string;
1712
1698
  }
@@ -2194,7 +2180,6 @@ export type ServerMessage =
2194
2180
  | GalleryInstallResponse
2195
2181
  | ShareToSlackResponse
2196
2182
  | SlackWebhookConfigResponse
2197
- | TwilioWebhookConfigResponse
2198
2183
  | IngressConfigResponse
2199
2184
  | VercelApiConfigResponse
2200
2185
  | TwitterIntegrationConfigResponse
@@ -2234,7 +2219,8 @@ export type ServerMessage =
2234
2219
  | OpenTasksWindow
2235
2220
  | SubagentSpawned
2236
2221
  | SubagentStatusChanged
2237
- | SubagentEvent;
2222
+ | SubagentEvent
2223
+ | SubagentDetailResponse;
2238
2224
 
2239
2225
  // === Subagent IPC ─────────────────────────────────────────────────────
2240
2226
 
@@ -2254,6 +2240,18 @@ export interface SubagentStatusChanged {
2254
2240
  usage?: UsageStats;
2255
2241
  }
2256
2242
 
2243
+ export interface SubagentDetailResponse {
2244
+ type: 'subagent_detail_response';
2245
+ subagentId: string;
2246
+ objective?: string;
2247
+ events: Array<{
2248
+ type: string;
2249
+ content: string;
2250
+ toolName?: string;
2251
+ isError?: boolean;
2252
+ }>;
2253
+ }
2254
+
2257
2255
  /** Wraps any ServerMessage emitted by a subagent session for routing to the client. */
2258
2256
  export interface SubagentEvent {
2259
2257
  type: 'subagent_event';
@@ -2280,6 +2278,12 @@ export interface SubagentMessageRequest {
2280
2278
  content: string;
2281
2279
  }
2282
2280
 
2281
+ export interface SubagentDetailRequest {
2282
+ type: 'subagent_detail_request';
2283
+ subagentId: string;
2284
+ conversationId: string;
2285
+ }
2286
+
2283
2287
  // === Contract schema ===
2284
2288
 
2285
2289
  export interface IPCContractSchema {
@@ -316,6 +316,14 @@ export async function runDaemon(): Promise<void> {
316
316
  await initializeTools();
317
317
  log.info('Daemon startup: providers and tools initialized');
318
318
 
319
+ // Start the IPC socket BEFORE Qdrant so that clients can connect
320
+ // immediately. Qdrant startup can take 30+ seconds (binary download,
321
+ // /readyz polling) which previously blocked the socket from appearing.
322
+ log.info('Daemon startup: starting DaemonServer (IPC socket)');
323
+ const server = new DaemonServer();
324
+ await server.start();
325
+ log.info('Daemon startup: DaemonServer started');
326
+
319
327
  // Initialize Qdrant vector store — non-fatal so the daemon stays up without it
320
328
  const qdrantUrl = process.env.QDRANT_URL?.trim() || config.memory.qdrant.url;
321
329
  log.info({ qdrantUrl }, 'Daemon startup: initializing Qdrant');
@@ -336,10 +344,7 @@ export async function runDaemon(): Promise<void> {
336
344
  log.warn({ err }, 'Qdrant failed to start — memory features will be unavailable');
337
345
  }
338
346
 
339
- log.info('Daemon startup: starting DaemonServer (IPC socket)');
340
- const server = new DaemonServer();
341
- await server.start();
342
- log.info('Daemon startup: DaemonServer started, starting memory worker');
347
+ log.info('Daemon startup: starting memory worker');
343
348
  const memoryWorker = startMemoryJobsWorker();
344
349
  // Initialize watcher engine and register providers
345
350
  registerWatcherProvider(gmailProvider);
@@ -39,6 +39,7 @@ import { getSubagentManager } from '../subagent/index.js';
39
39
  import { tryHandlePendingCallAnswer } from '../calls/call-bridge.js';
40
40
  import { resolveSlash } from './session-slash.js';
41
41
  import { createUserMessage, createAssistantMessage } from '../agent/message-types.js';
42
+ import { registerDaemonCallbacks } from '../work-items/work-item-runner.js';
42
43
 
43
44
  const log = getLogger('server');
44
45
 
@@ -184,6 +185,12 @@ export class DaemonServer {
184
185
 
185
186
  this.evictor.start();
186
187
 
188
+ // Register daemon callbacks so tools can trigger work item execution
189
+ registerDaemonCallbacks({
190
+ getOrCreateSession: (conversationId) => this.getOrCreateSession(conversationId),
191
+ broadcast: (msg) => this.broadcast(msg),
192
+ });
193
+
187
194
  ensureBlobDir();
188
195
  this.blobSweepTimer = setInterval(() => {
189
196
  sweepStaleBlobs(30 * 60 * 1000).catch((err) => {
@@ -282,7 +282,7 @@ export function createToolExecutor(
282
282
 
283
283
  // Broadcast tasks_changed so connected clients (e.g. macOS Tasks window)
284
284
  // auto-refresh when the LLM mutates the task queue via tools
285
- if ((name === 'task_list_add' || name === 'task_list_update' || name === 'task_list_remove') && !result.isError) {
285
+ if ((name === 'task_list_add' || name === 'task_list_update' || name === 'task_list_remove' || name === 'task_queue_run') && !result.isError) {
286
286
  broadcastToAllClients?.({ type: 'tasks_changed' });
287
287
  }
288
288