vellum 0.2.7 → 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 (76) hide show
  1. package/bun.lock +4 -4
  2. package/package.json +4 -3
  3. package/src/__tests__/asset-materialize-tool.test.ts +2 -2
  4. package/src/__tests__/checker.test.ts +104 -0
  5. package/src/__tests__/config-schema.test.ts +0 -6
  6. package/src/__tests__/forbidden-legacy-symbols.test.ts +69 -0
  7. package/src/__tests__/gateway-only-enforcement.test.ts +538 -0
  8. package/src/__tests__/ingress-url-consistency.test.ts +214 -0
  9. package/src/__tests__/ipc-snapshot.test.ts +17 -5
  10. package/src/__tests__/oauth-callback-registry.test.ts +85 -0
  11. package/src/__tests__/oauth2-gateway-transport.test.ts +304 -0
  12. package/src/__tests__/provider-commit-message-generator.test.ts +51 -12
  13. package/src/__tests__/public-ingress-urls.test.ts +222 -0
  14. package/src/__tests__/runtime-events-sse-parity.test.ts +343 -0
  15. package/src/__tests__/runtime-events-sse.test.ts +162 -0
  16. package/src/__tests__/tool-executor.test.ts +88 -0
  17. package/src/__tests__/turn-commit.test.ts +64 -0
  18. package/src/__tests__/twilio-provider.test.ts +1 -1
  19. package/src/__tests__/twilio-routes.test.ts +4 -4
  20. package/src/__tests__/twitter-auth-handler.test.ts +87 -2
  21. package/src/calls/call-domain.ts +8 -6
  22. package/src/calls/twilio-config.ts +18 -3
  23. package/src/calls/twilio-routes.ts +10 -2
  24. package/src/config/bundled-skills/tasks/TOOLS.json +25 -0
  25. package/src/config/bundled-skills/tasks/tools/task-queue-run.ts +9 -0
  26. package/src/config/bundled-skills/transcribe/SKILL.md +25 -0
  27. package/src/config/bundled-skills/transcribe/TOOLS.json +32 -0
  28. package/src/config/bundled-skills/transcribe/tools/transcribe-media.ts +370 -0
  29. package/src/config/defaults.ts +4 -1
  30. package/src/config/schema.ts +30 -6
  31. package/src/config/system-prompt.ts +1 -1
  32. package/src/config/types.ts +1 -0
  33. package/src/config/vellum-skills/google-oauth-setup/SKILL.md +5 -4
  34. package/src/config/vellum-skills/slack-oauth-setup/SKILL.md +4 -2
  35. package/src/config/vellum-skills/telegram-setup/SKILL.md +3 -3
  36. package/src/daemon/computer-use-session.ts +2 -1
  37. package/src/daemon/handlers/config.ts +49 -17
  38. package/src/daemon/handlers/sessions.ts +2 -2
  39. package/src/daemon/handlers/shared.ts +1 -0
  40. package/src/daemon/handlers/subagents.ts +85 -2
  41. package/src/daemon/handlers/twitter-auth.ts +31 -2
  42. package/src/daemon/handlers/work-items.ts +1 -1
  43. package/src/daemon/ipc-contract-inventory.json +8 -4
  44. package/src/daemon/ipc-contract.ts +34 -15
  45. package/src/daemon/lifecycle.ts +9 -4
  46. package/src/daemon/server.ts +7 -0
  47. package/src/daemon/session-tool-setup.ts +8 -1
  48. package/src/inbound/public-ingress-urls.ts +112 -0
  49. package/src/memory/attachments-store.ts +0 -1
  50. package/src/memory/channel-delivery-store.ts +0 -1
  51. package/src/memory/conversation-key-store.ts +0 -1
  52. package/src/memory/db.ts +472 -148
  53. package/src/memory/llm-usage-store.ts +0 -1
  54. package/src/memory/runs-store.ts +51 -6
  55. package/src/memory/schema.ts +2 -6
  56. package/src/runtime/gateway-client.ts +7 -1
  57. package/src/runtime/http-server.ts +174 -7
  58. package/src/runtime/routes/channel-routes.ts +7 -2
  59. package/src/runtime/routes/events-routes.ts +79 -0
  60. package/src/runtime/routes/run-routes.ts +43 -0
  61. package/src/runtime/run-orchestrator.ts +64 -7
  62. package/src/security/oauth-callback-registry.ts +66 -0
  63. package/src/security/oauth2.ts +208 -58
  64. package/src/subagent/manager.ts +3 -1
  65. package/src/swarm/backend-claude-code.ts +1 -1
  66. package/src/tools/assets/search.ts +1 -36
  67. package/src/tools/claude-code/claude-code.ts +3 -3
  68. package/src/tools/tasks/work-item-list.ts +16 -2
  69. package/src/tools/tasks/work-item-run.ts +78 -0
  70. package/src/util/platform.ts +1 -1
  71. package/src/work-items/work-item-runner.ts +171 -0
  72. package/src/workspace/provider-commit-message-generator.ts +39 -23
  73. package/src/workspace/turn-commit.ts +6 -2
  74. package/src/__tests__/handlers-twilio-config.test.ts +0 -221
  75. package/src/calls/__tests__/twilio-webhook-urls.test.ts +0 -162
  76. package/src/calls/twilio-webhook-urls.ts +0 -50
@@ -19,7 +19,7 @@ import type {
19
19
  ReminderCancel,
20
20
  ShareToSlackRequest,
21
21
  SlackWebhookConfigRequest,
22
- TwilioWebhookConfigRequest,
22
+ IngressConfigRequest,
23
23
  VercelApiConfigRequest,
24
24
  TwitterIntegrationConfigRequest,
25
25
  } from '../ipc-protocol.js';
@@ -89,11 +89,12 @@ export function handleModelSet(
89
89
  // Suppress the file watcher callback — handleModelSet already does
90
90
  // the full reload sequence; a redundant watcher-triggered reload
91
91
  // would incorrectly evict sessions created after this method returns.
92
+ const wasSuppressed = ctx.suppressConfigReload;
92
93
  ctx.setSuppressConfigReload(true);
93
94
  try {
94
95
  saveRawConfig(raw);
95
96
  } catch (err) {
96
- ctx.setSuppressConfigReload(false);
97
+ ctx.setSuppressConfigReload(wasSuppressed);
97
98
  throw err;
98
99
  }
99
100
  const existingSuppressTimer = ctx.debounceTimers.get('__suppress_reset__');
@@ -138,11 +139,12 @@ export function handleImageGenModelSet(
138
139
  const raw = loadRawConfig();
139
140
  raw.imageGenModel = msg.model;
140
141
 
142
+ const wasSuppressed = ctx.suppressConfigReload;
141
143
  ctx.setSuppressConfigReload(true);
142
144
  try {
143
145
  saveRawConfig(raw);
144
146
  } catch (err) {
145
- ctx.setSuppressConfigReload(false);
147
+ ctx.setSuppressConfigReload(wasSuppressed);
146
148
  throw err;
147
149
  }
148
150
  const existingSuppressTimer = ctx.debounceTimers.get('__suppress_reset__');
@@ -165,9 +167,9 @@ export function handleAddTrustRule(
165
167
  ): void {
166
168
  try {
167
169
  addRule(msg.toolName, msg.pattern, msg.scope, msg.decision);
168
- log.info({ tool: msg.toolName, pattern: msg.pattern, scope: msg.scope, decision: msg.decision }, 'Trust rule added via client');
170
+ log.info({ toolName: msg.toolName, pattern: msg.pattern, scope: msg.scope, decision: msg.decision }, 'Trust rule added via client');
169
171
  } catch (err) {
170
- log.error({ err }, 'Failed to add trust rule');
172
+ log.error({ err, toolName: msg.toolName, pattern: msg.pattern, scope: msg.scope }, 'Failed to add trust rule via client');
171
173
  }
172
174
  }
173
175
 
@@ -397,25 +399,40 @@ export function handleSlackWebhookConfig(
397
399
  }
398
400
  }
399
401
 
400
- export function handleTwilioWebhookConfig(
401
- msg: TwilioWebhookConfigRequest,
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}`;
406
+ }
407
+
408
+ export function handleIngressConfig(
409
+ msg: IngressConfigRequest,
402
410
  socket: net.Socket,
403
411
  ctx: HandlerContext,
404
412
  ): void {
413
+ const localGatewayTarget = computeLocalGatewayTarget();
405
414
  try {
406
415
  if (msg.action === 'get') {
407
416
  const raw = loadRawConfig();
408
- const webhookBaseUrl = (raw?.calls as Record<string, unknown>)?.webhookBaseUrl as string ?? '';
409
- ctx.send(socket, { type: 'twilio_webhook_config_response', webhookBaseUrl, success: true });
417
+ const ingress = (raw?.ingress ?? {}) as Record<string, unknown>;
418
+ const publicBaseUrl = (ingress.publicBaseUrl as string) ?? '';
419
+ ctx.send(socket, { type: 'ingress_config_response', publicBaseUrl, localGatewayTarget, success: true });
410
420
  } else if (msg.action === 'set') {
411
- const value = (msg.webhookBaseUrl ?? '').trim().replace(/\/+$/, '');
421
+ const value = (msg.publicBaseUrl ?? '').trim().replace(/\/+$/, '');
412
422
  const raw = loadRawConfig();
413
- const calls = (raw?.calls ?? {}) as Record<string, unknown>;
414
- calls.webhookBaseUrl = value || undefined;
423
+
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.
429
+ const ingress = (raw?.ingress ?? {}) as Record<string, unknown>;
430
+ ingress.publicBaseUrl = value || undefined;
431
+
415
432
  const wasSuppressed = ctx.suppressConfigReload;
416
433
  ctx.setSuppressConfigReload(true);
417
434
  try {
418
- saveRawConfig({ ...raw, calls });
435
+ saveRawConfig({ ...raw, ingress });
419
436
  } catch (err) {
420
437
  ctx.setSuppressConfigReload(wasSuppressed);
421
438
  throw err;
@@ -424,11 +441,26 @@ export function handleTwilioWebhookConfig(
424
441
  if (existingSuppressTimer) clearTimeout(existingSuppressTimer);
425
442
  const resetTimer = setTimeout(() => { ctx.setSuppressConfigReload(false); }, CONFIG_RELOAD_DEBOUNCE_MS);
426
443
  ctx.debounceTimers.set('__suppress_reset__', resetTimer);
427
- ctx.send(socket, { type: 'twilio_webhook_config_response', webhookBaseUrl: 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 });
458
+ } else {
459
+ ctx.send(socket, { type: 'ingress_config_response', publicBaseUrl: '', localGatewayTarget, success: false, error: `Unknown action: ${String((msg as unknown as Record<string, unknown>).action)}` });
428
460
  }
429
461
  } catch (err) {
430
462
  const message = err instanceof Error ? err.message : String(err);
431
- ctx.send(socket, { type: 'twilio_webhook_config_response', webhookBaseUrl: '', success: false, error: message });
463
+ ctx.send(socket, { type: 'ingress_config_response', publicBaseUrl: '', localGatewayTarget, success: false, error: message });
432
464
  }
433
465
  }
434
466
 
@@ -565,7 +597,7 @@ export function handleTwitterIntegrationConfig(
565
597
  type: 'twitter_integration_config_response',
566
598
  success: false,
567
599
  managedAvailable: false,
568
- localClientConfigured: false,
600
+ localClientConfigured: !!previousClientId,
569
601
  connected: false,
570
602
  error: 'Failed to store client secret in secure storage',
571
603
  });
@@ -657,7 +689,7 @@ export const configHandlers = defineHandlers({
657
689
  reminder_cancel: handleReminderCancel,
658
690
  share_to_slack: handleShareToSlack,
659
691
  slack_webhook_config: handleSlackWebhookConfig,
660
- twilio_webhook_config: handleTwilioWebhookConfig,
692
+ ingress_config: handleIngressConfig,
661
693
  vercel_api_config: handleVercelApiConfig,
662
694
  twitter_integration_config: handleTwitterIntegrationConfig,
663
695
  env_vars_request: (_msg, socket, ctx) => handleEnvVarsRequest(socket, ctx),
@@ -145,7 +145,7 @@ export function handleConfirmationResponse(
145
145
  ctx.touchSession(sessionId);
146
146
  session.handleConfirmationResponse(
147
147
  msg.requestId,
148
- msg.decision as 'allow' | 'always_allow' | 'deny',
148
+ msg.decision,
149
149
  msg.selectedPattern,
150
150
  msg.selectedScope,
151
151
  );
@@ -158,7 +158,7 @@ export function handleConfirmationResponse(
158
158
  if (cuSession.hasPendingConfirmation(msg.requestId)) {
159
159
  cuSession.handleConfirmationResponse(
160
160
  msg.requestId,
161
- msg.decision as 'allow' | 'always_allow' | 'deny',
161
+ msg.decision,
162
162
  msg.selectedPattern,
163
163
  msg.selectedScope,
164
164
  );
@@ -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;
@@ -426,8 +426,8 @@ export async function handleWorkItemRunTask(
426
426
  // Execute task asynchronously — lazily create a session inside the callback
427
427
  // using the conversationId provided by runTask, so the session references
428
428
  // the conversation that was actually inserted into the database.
429
+ let session: Awaited<ReturnType<typeof ctx.getOrCreateSession>> | null = null;
429
430
  try {
430
- let session: Awaited<ReturnType<typeof ctx.getOrCreateSession>> | null = null;
431
431
  const result = await runTask(
432
432
  { taskId: workItem.taskId, workingDir: process.cwd(), approvedTools },
433
433
  async (conversationId, message, taskRunId) => {
@@ -32,6 +32,7 @@
32
32
  "HistoryRequest",
33
33
  "HomeBaseGetRequest",
34
34
  "ImageGenModelSetRequest",
35
+ "IngressConfigRequest",
35
36
  "IntegrationConnectRequest",
36
37
  "IntegrationDisconnectRequest",
37
38
  "IntegrationListRequest",
@@ -75,12 +76,12 @@
75
76
  "SkillsUpdateRequest",
76
77
  "SlackWebhookConfigRequest",
77
78
  "SubagentAbortRequest",
79
+ "SubagentDetailRequest",
78
80
  "SubagentMessageRequest",
79
81
  "SubagentStatusRequest",
80
82
  "SuggestionRequest",
81
83
  "TaskSubmit",
82
84
  "TrustRulesList",
83
- "TwilioWebhookConfigRequest",
84
85
  "TwitterAuthStartRequest",
85
86
  "TwitterAuthStatusRequest",
86
87
  "TwitterIntegrationConfigRequest",
@@ -142,6 +143,7 @@
142
143
  "GetSigningIdentityRequest",
143
144
  "HistoryResponse",
144
145
  "HomeBaseGetResponse",
146
+ "IngressConfigResponse",
145
147
  "IntegrationConnectResult",
146
148
  "IntegrationListResponse",
147
149
  "IpcBlobProbeResult",
@@ -179,6 +181,7 @@
179
181
  "SkillsListResponse",
180
182
  "SkillsOperationResponse",
181
183
  "SlackWebhookConfigResponse",
184
+ "SubagentDetailResponse",
182
185
  "SubagentEvent",
183
186
  "SubagentSpawned",
184
187
  "SubagentStatusChanged",
@@ -192,7 +195,6 @@
192
195
  "ToolUseStart",
193
196
  "TraceEvent",
194
197
  "TrustRulesListResponse",
195
- "TwilioWebhookConfigResponse",
196
198
  "TwitterAuthResult",
197
199
  "TwitterAuthStatusResponse",
198
200
  "TwitterIntegrationConfigResponse",
@@ -255,6 +257,7 @@
255
257
  "history_request",
256
258
  "home_base_get",
257
259
  "image_gen_model_set",
260
+ "ingress_config",
258
261
  "integration_connect",
259
262
  "integration_disconnect",
260
263
  "integration_list",
@@ -298,12 +301,12 @@
298
301
  "skills_update",
299
302
  "slack_webhook_config",
300
303
  "subagent_abort",
304
+ "subagent_detail_request",
301
305
  "subagent_message",
302
306
  "subagent_status",
303
307
  "suggestion_request",
304
308
  "task_submit",
305
309
  "trust_rules_list",
306
- "twilio_webhook_config",
307
310
  "twitter_auth_start",
308
311
  "twitter_auth_status",
309
312
  "twitter_integration_config",
@@ -365,6 +368,7 @@
365
368
  "get_signing_identity",
366
369
  "history_response",
367
370
  "home_base_get_response",
371
+ "ingress_config_response",
368
372
  "integration_connect_result",
369
373
  "integration_list_response",
370
374
  "ipc_blob_probe_result",
@@ -402,6 +406,7 @@
402
406
  "skills_operation_response",
403
407
  "skills_state_changed",
404
408
  "slack_webhook_config_response",
409
+ "subagent_detail_response",
405
410
  "subagent_event",
406
411
  "subagent_spawned",
407
412
  "subagent_status_changed",
@@ -415,7 +420,6 @@
415
420
  "tool_use_start",
416
421
  "trace_event",
417
422
  "trust_rules_list_response",
418
- "twilio_webhook_config_response",
419
423
  "twitter_auth_result",
420
424
  "twitter_auth_status_response",
421
425
  "twitter_integration_config_response",
@@ -472,10 +472,10 @@ export interface SlackWebhookConfigRequest {
472
472
  webhookUrl?: string;
473
473
  }
474
474
 
475
- export interface TwilioWebhookConfigRequest {
476
- type: 'twilio_webhook_config';
475
+ export interface IngressConfigRequest {
476
+ type: 'ingress_config';
477
477
  action: 'get' | 'set';
478
- webhookBaseUrl?: string;
478
+ publicBaseUrl?: string;
479
479
  }
480
480
 
481
481
  export interface VercelApiConfigRequest {
@@ -941,7 +941,7 @@ export type ClientMessage =
941
941
  | ShareAppCloudRequest
942
942
  | ShareToSlackRequest
943
943
  | SlackWebhookConfigRequest
944
- | TwilioWebhookConfigRequest
944
+ | IngressConfigRequest
945
945
  | VercelApiConfigRequest
946
946
  | TwitterIntegrationConfigRequest
947
947
  | TwitterAuthStartRequest
@@ -978,11 +978,8 @@ export type ClientMessage =
978
978
  | WorkItemCancelRequest
979
979
  | SubagentAbortRequest
980
980
  | SubagentStatusRequest
981
- | SubagentMessageRequest;
982
-
983
- // ── Legacy integration IPC stubs ────────────────────────────────────
984
- // The macOS Settings panel still sends these messages. Stub types keep
985
- // the dispatch map happy until the client-side migration lands.
981
+ | SubagentMessageRequest
982
+ | SubagentDetailRequest;
986
983
 
987
984
  export interface IntegrationListRequest {
988
985
  type: 'integration_list';
@@ -1227,6 +1224,7 @@ export interface HistoryResponse {
1227
1224
  label: string;
1228
1225
  status: 'completed' | 'failed' | 'aborted';
1229
1226
  error?: string;
1227
+ conversationId?: string;
1230
1228
  };
1231
1229
  }>;
1232
1230
  }
@@ -1690,9 +1688,11 @@ export interface SlackWebhookConfigResponse {
1690
1688
  error?: string;
1691
1689
  }
1692
1690
 
1693
- export interface TwilioWebhookConfigResponse {
1694
- type: 'twilio_webhook_config_response';
1695
- webhookBaseUrl: string;
1691
+ export interface IngressConfigResponse {
1692
+ type: 'ingress_config_response';
1693
+ publicBaseUrl: string;
1694
+ /** Read-only gateway target computed from GATEWAY_PORT env var (default 7830) + loopback host. */
1695
+ localGatewayTarget: string;
1696
1696
  success: boolean;
1697
1697
  error?: string;
1698
1698
  }
@@ -2015,7 +2015,7 @@ export interface WorkItemDeleteResponse {
2015
2015
  success: boolean;
2016
2016
  }
2017
2017
 
2018
- export type WorkItemRunTaskErrorCode = 'not_found' | 'already_running' | 'invalid_status' | 'no_task';
2018
+ export type WorkItemRunTaskErrorCode = 'not_found' | 'already_running' | 'invalid_status' | 'no_task' | 'permission_required';
2019
2019
 
2020
2020
  export interface WorkItemRunTaskResponse {
2021
2021
  type: 'work_item_run_task_response';
@@ -2180,7 +2180,7 @@ export type ServerMessage =
2180
2180
  | GalleryInstallResponse
2181
2181
  | ShareToSlackResponse
2182
2182
  | SlackWebhookConfigResponse
2183
- | TwilioWebhookConfigResponse
2183
+ | IngressConfigResponse
2184
2184
  | VercelApiConfigResponse
2185
2185
  | TwitterIntegrationConfigResponse
2186
2186
  | TwitterAuthResult
@@ -2219,7 +2219,8 @@ export type ServerMessage =
2219
2219
  | OpenTasksWindow
2220
2220
  | SubagentSpawned
2221
2221
  | SubagentStatusChanged
2222
- | SubagentEvent;
2222
+ | SubagentEvent
2223
+ | SubagentDetailResponse;
2223
2224
 
2224
2225
  // === Subagent IPC ─────────────────────────────────────────────────────
2225
2226
 
@@ -2239,6 +2240,18 @@ export interface SubagentStatusChanged {
2239
2240
  usage?: UsageStats;
2240
2241
  }
2241
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
+
2242
2255
  /** Wraps any ServerMessage emitted by a subagent session for routing to the client. */
2243
2256
  export interface SubagentEvent {
2244
2257
  type: 'subagent_event';
@@ -2265,6 +2278,12 @@ export interface SubagentMessageRequest {
2265
2278
  content: string;
2266
2279
  }
2267
2280
 
2281
+ export interface SubagentDetailRequest {
2282
+ type: 'subagent_detail_request';
2283
+ subagentId: string;
2284
+ conversationId: string;
2285
+ }
2286
+
2268
2287
  // === Contract schema ===
2269
2288
 
2270
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) => {
@@ -14,6 +14,9 @@ import type { PermissionPrompter } from '../permissions/prompter.js';
14
14
  import type { SecretPrompter } from '../permissions/secret-prompter.js';
15
15
  import { addRule, findHighestPriorityRule } from '../permissions/trust-store.js';
16
16
  import { generateAllowlistOptions, generateScopeOptions, normalizeWebFetchUrl } from '../permissions/checker.js';
17
+ import { getLogger } from '../util/logger.js';
18
+
19
+ const log = getLogger('session-tool-setup');
17
20
  import { getAllToolDefinitions } from '../tools/registry.js';
18
21
  import { allUiSurfaceTools } from '../tools/ui-surface/definitions.js';
19
22
  import { coreAppProxyTools } from '../tools/apps/definitions.js';
@@ -248,10 +251,12 @@ export function createToolExecutor(
248
251
  req.principal,
249
252
  );
250
253
  if ((response.decision === 'always_allow' || response.decision === 'always_allow_high_risk') && response.selectedPattern && response.selectedScope) {
254
+ log.info({ toolName: 'cc:' + req.toolName, pattern: response.selectedPattern, scope: response.selectedScope, highRisk: response.decision === 'always_allow_high_risk' }, 'Persisting always-allow trust rule');
251
255
  addRule('cc:' + req.toolName, response.selectedPattern, response.selectedScope, 'allow', 100,
252
256
  response.decision === 'always_allow_high_risk' ? { allowHighRisk: true } : undefined);
253
257
  }
254
258
  if (response.decision === 'always_deny' && response.selectedPattern && response.selectedScope) {
259
+ log.info({ toolName: 'cc:' + req.toolName, pattern: response.selectedPattern, scope: response.selectedScope }, 'Persisting always-deny trust rule');
255
260
  addRule('cc:' + req.toolName, response.selectedPattern, response.selectedScope, 'deny');
256
261
  }
257
262
  return {
@@ -277,7 +282,7 @@ export function createToolExecutor(
277
282
 
278
283
  // Broadcast tasks_changed so connected clients (e.g. macOS Tasks window)
279
284
  // auto-refresh when the LLM mutates the task queue via tools
280
- 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) {
281
286
  broadcastToAllClients?.({ type: 'tasks_changed' });
282
287
  }
283
288
 
@@ -440,10 +445,12 @@ export function createProxyApprovalCallback(
440
445
 
441
446
  // Persist trust rule if the user chose "always allow" or "always deny"
442
447
  if ((response.decision === 'always_allow' || response.decision === 'always_allow_high_risk') && response.selectedPattern && response.selectedScope) {
448
+ log.info({ toolName, pattern: response.selectedPattern, scope: response.selectedScope, highRisk: response.decision === 'always_allow_high_risk' }, 'Persisting always-allow trust rule (proxy)');
443
449
  addRule(toolName, response.selectedPattern, response.selectedScope, 'allow', 100,
444
450
  response.decision === 'always_allow_high_risk' ? { allowHighRisk: true } : undefined);
445
451
  }
446
452
  if (response.decision === 'always_deny' && response.selectedPattern && response.selectedScope) {
453
+ log.info({ toolName, pattern: response.selectedPattern, scope: response.selectedScope }, 'Persisting always-deny trust rule (proxy)');
447
454
  addRule(toolName, response.selectedPattern, response.selectedScope, 'deny');
448
455
  }
449
456