vellum 0.2.12 → 0.2.14

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 (209) hide show
  1. package/README.md +32 -0
  2. package/bun.lock +2 -2
  3. package/docs/skills.md +4 -4
  4. package/package.json +2 -2
  5. package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +213 -3
  6. package/src/__tests__/app-git-history.test.ts +176 -0
  7. package/src/__tests__/app-git-service.test.ts +169 -0
  8. package/src/__tests__/assistant-events-sse-hardening.test.ts +315 -0
  9. package/src/__tests__/browser-skill-baseline-tool-payload.test.ts +8 -8
  10. package/src/__tests__/browser-skill-endstate.test.ts +6 -6
  11. package/src/__tests__/call-bridge.test.ts +105 -13
  12. package/src/__tests__/call-domain.test.ts +163 -0
  13. package/src/__tests__/call-orchestrator.test.ts +171 -0
  14. package/src/__tests__/call-routes-http.test.ts +246 -6
  15. package/src/__tests__/channel-approval-routes.test.ts +438 -0
  16. package/src/__tests__/channel-approval.test.ts +266 -0
  17. package/src/__tests__/channel-approvals.test.ts +393 -0
  18. package/src/__tests__/channel-delivery-store.test.ts +447 -0
  19. package/src/__tests__/checker.test.ts +607 -1048
  20. package/src/__tests__/cli.test.ts +1 -56
  21. package/src/__tests__/config-schema.test.ts +402 -5
  22. package/src/__tests__/conflict-intent-tokenization.test.ts +141 -0
  23. package/src/__tests__/conflict-policy.test.ts +121 -0
  24. package/src/__tests__/conflict-store.test.ts +2 -0
  25. package/src/__tests__/contacts-tools.test.ts +3 -3
  26. package/src/__tests__/contradiction-checker.test.ts +99 -1
  27. package/src/__tests__/credential-security-invariants.test.ts +22 -6
  28. package/src/__tests__/credential-vault-unit.test.ts +780 -0
  29. package/src/__tests__/elevenlabs-client.test.ts +271 -0
  30. package/src/__tests__/ephemeral-permissions.test.ts +73 -23
  31. package/src/__tests__/filesystem-tools.test.ts +579 -0
  32. package/src/__tests__/gateway-only-enforcement.test.ts +114 -4
  33. package/src/__tests__/handlers-add-trust-rule-metadata.test.ts +202 -0
  34. package/src/__tests__/handlers-cu-observation-blob.test.ts +2 -1
  35. package/src/__tests__/handlers-ipc-blob-probe.test.ts +2 -1
  36. package/src/__tests__/handlers-slack-config.test.ts +2 -1
  37. package/src/__tests__/handlers-telegram-config.test.ts +855 -0
  38. package/src/__tests__/handlers-twitter-config.test.ts +141 -1
  39. package/src/__tests__/hooks-runner.test.ts +6 -2
  40. package/src/__tests__/host-file-edit-tool.test.ts +124 -0
  41. package/src/__tests__/host-file-read-tool.test.ts +62 -0
  42. package/src/__tests__/host-file-write-tool.test.ts +59 -0
  43. package/src/__tests__/host-shell-tool.test.ts +251 -0
  44. package/src/__tests__/ingress-reconcile.test.ts +581 -0
  45. package/src/__tests__/ipc-snapshot.test.ts +100 -41
  46. package/src/__tests__/ipc-validate.test.ts +50 -0
  47. package/src/__tests__/key-migration.test.ts +23 -0
  48. package/src/__tests__/memory-regressions.test.ts +99 -0
  49. package/src/__tests__/memory-retrieval.benchmark.test.ts +1 -1
  50. package/src/__tests__/oauth-callback-registry.test.ts +11 -4
  51. package/src/__tests__/playbook-execution.test.ts +502 -0
  52. package/src/__tests__/playbook-tools.test.ts +4 -6
  53. package/src/__tests__/public-ingress-urls.test.ts +34 -0
  54. package/src/__tests__/qdrant-manager.test.ts +267 -0
  55. package/src/__tests__/recurrence-engine-rruleset.test.ts +97 -0
  56. package/src/__tests__/recurrence-engine.test.ts +9 -0
  57. package/src/__tests__/recurrence-types.test.ts +8 -0
  58. package/src/__tests__/registry.test.ts +1 -1
  59. package/src/__tests__/runtime-runs.test.ts +1 -25
  60. package/src/__tests__/schedule-store.test.ts +16 -14
  61. package/src/__tests__/schedule-tools.test.ts +83 -0
  62. package/src/__tests__/scheduler-recurrence.test.ts +111 -10
  63. package/src/__tests__/secret-allowlist.test.ts +18 -17
  64. package/src/__tests__/secret-ingress-handler.test.ts +11 -0
  65. package/src/__tests__/secret-scanner.test.ts +43 -0
  66. package/src/__tests__/session-conflict-gate.test.ts +442 -6
  67. package/src/__tests__/session-init.benchmark.test.ts +3 -0
  68. package/src/__tests__/session-process-bridge.test.ts +242 -0
  69. package/src/__tests__/session-skill-tools.test.ts +1 -1
  70. package/src/__tests__/shell-identity.test.ts +256 -0
  71. package/src/__tests__/skill-projection.benchmark.test.ts +11 -1
  72. package/src/__tests__/subagent-tools.test.ts +637 -54
  73. package/src/__tests__/task-management-tools.test.ts +936 -0
  74. package/src/__tests__/task-runner.test.ts +2 -2
  75. package/src/__tests__/terminal-tools.test.ts +840 -0
  76. package/src/__tests__/tool-executor-shell-integration.test.ts +301 -0
  77. package/src/__tests__/tool-executor.test.ts +85 -151
  78. package/src/__tests__/tool-permission-simulate-handler.test.ts +336 -0
  79. package/src/__tests__/trust-store.test.ts +28 -453
  80. package/src/__tests__/twilio-provider.test.ts +153 -3
  81. package/src/__tests__/twilio-routes-elevenlabs.test.ts +375 -0
  82. package/src/__tests__/twilio-routes-twiml.test.ts +127 -0
  83. package/src/__tests__/twilio-routes.test.ts +17 -262
  84. package/src/__tests__/twitter-auth-handler.test.ts +2 -1
  85. package/src/__tests__/twitter-cli-error-shaping.test.ts +208 -0
  86. package/src/__tests__/twitter-cli-routing.test.ts +252 -0
  87. package/src/__tests__/twitter-oauth-client.test.ts +209 -0
  88. package/src/__tests__/workspace-policy.test.ts +213 -0
  89. package/src/calls/call-bridge.ts +92 -19
  90. package/src/calls/call-domain.ts +157 -5
  91. package/src/calls/call-orchestrator.ts +96 -8
  92. package/src/calls/call-store.ts +6 -0
  93. package/src/calls/elevenlabs-client.ts +97 -0
  94. package/src/calls/elevenlabs-config.ts +31 -0
  95. package/src/calls/twilio-provider.ts +91 -0
  96. package/src/calls/twilio-routes.ts +50 -6
  97. package/src/calls/types.ts +3 -1
  98. package/src/calls/voice-quality.ts +114 -0
  99. package/src/cli/twitter.ts +200 -21
  100. package/src/cli.ts +1 -20
  101. package/src/config/bundled-skills/contacts/tools/contact-merge.ts +52 -4
  102. package/src/config/bundled-skills/contacts/tools/contact-search.ts +55 -4
  103. package/src/config/bundled-skills/contacts/tools/contact-upsert.ts +61 -4
  104. package/src/config/bundled-skills/messaging/SKILL.md +17 -2
  105. package/src/config/bundled-skills/messaging/tools/messaging-reply.ts +4 -1
  106. package/src/config/bundled-skills/messaging/tools/messaging-send.ts +5 -1
  107. package/src/config/bundled-skills/messaging/tools/shared.ts +5 -0
  108. package/src/config/bundled-skills/phone-calls/SKILL.md +207 -19
  109. package/src/config/bundled-skills/playbooks/tools/playbook-create.ts +95 -6
  110. package/src/config/bundled-skills/playbooks/tools/playbook-delete.ts +51 -6
  111. package/src/config/bundled-skills/playbooks/tools/playbook-list.ts +73 -6
  112. package/src/config/bundled-skills/playbooks/tools/playbook-update.ts +110 -6
  113. package/src/config/bundled-skills/public-ingress/SKILL.md +22 -5
  114. package/src/config/bundled-skills/twitter/SKILL.md +103 -17
  115. package/src/config/defaults.ts +26 -2
  116. package/src/config/schema.ts +178 -9
  117. package/src/config/types.ts +3 -0
  118. package/src/config/vellum-skills/telegram-setup/SKILL.md +56 -61
  119. package/src/daemon/assistant-attachments.ts +4 -2
  120. package/src/daemon/handlers/apps.ts +69 -0
  121. package/src/daemon/handlers/config.ts +543 -24
  122. package/src/daemon/handlers/index.ts +1 -0
  123. package/src/daemon/handlers/sessions.ts +22 -6
  124. package/src/daemon/handlers/shared.ts +2 -1
  125. package/src/daemon/handlers/skills.ts +5 -20
  126. package/src/daemon/ipc-contract-inventory.json +28 -0
  127. package/src/daemon/ipc-contract.ts +168 -10
  128. package/src/daemon/ipc-validate.ts +17 -0
  129. package/src/daemon/lifecycle.ts +2 -0
  130. package/src/daemon/server.ts +78 -72
  131. package/src/daemon/session-attachments.ts +1 -1
  132. package/src/daemon/session-conflict-gate.ts +62 -6
  133. package/src/daemon/session-notifiers.ts +1 -1
  134. package/src/daemon/session-process.ts +62 -3
  135. package/src/daemon/session-tool-setup.ts +1 -2
  136. package/src/daemon/tls-certs.ts +189 -0
  137. package/src/daemon/video-thumbnail.ts +5 -3
  138. package/src/hooks/manager.ts +5 -9
  139. package/src/memory/app-git-service.ts +295 -0
  140. package/src/memory/app-store.ts +21 -0
  141. package/src/memory/conflict-intent.ts +47 -4
  142. package/src/memory/conflict-policy.ts +73 -0
  143. package/src/memory/conflict-store.ts +9 -1
  144. package/src/memory/contradiction-checker.ts +28 -0
  145. package/src/memory/conversation-key-store.ts +15 -0
  146. package/src/memory/db.ts +81 -0
  147. package/src/memory/embedding-local.ts +3 -13
  148. package/src/memory/external-conversation-store.ts +234 -0
  149. package/src/memory/job-handlers/conflict.ts +22 -2
  150. package/src/memory/jobs-worker.ts +67 -28
  151. package/src/memory/runs-store.ts +54 -7
  152. package/src/memory/schema.ts +20 -0
  153. package/src/messaging/provider.ts +9 -0
  154. package/src/messaging/providers/telegram-bot/adapter.ts +162 -0
  155. package/src/messaging/providers/telegram-bot/client.ts +104 -0
  156. package/src/messaging/providers/telegram-bot/types.ts +15 -0
  157. package/src/messaging/registry.ts +1 -0
  158. package/src/permissions/checker.ts +48 -44
  159. package/src/permissions/defaults.ts +11 -0
  160. package/src/permissions/prompter.ts +0 -4
  161. package/src/permissions/shell-identity.ts +227 -0
  162. package/src/permissions/trust-store.ts +76 -53
  163. package/src/permissions/types.ts +0 -19
  164. package/src/permissions/workspace-policy.ts +114 -0
  165. package/src/providers/retry.ts +12 -37
  166. package/src/runtime/assistant-event-hub.ts +41 -4
  167. package/src/runtime/channel-approval-parser.ts +60 -0
  168. package/src/runtime/channel-approval-types.ts +71 -0
  169. package/src/runtime/channel-approvals.ts +145 -0
  170. package/src/runtime/gateway-client.ts +16 -0
  171. package/src/runtime/http-server.ts +29 -9
  172. package/src/runtime/routes/call-routes.ts +52 -2
  173. package/src/runtime/routes/channel-routes.ts +296 -16
  174. package/src/runtime/routes/conversation-routes.ts +12 -5
  175. package/src/runtime/routes/events-routes.ts +97 -28
  176. package/src/runtime/routes/run-routes.ts +2 -7
  177. package/src/runtime/run-orchestrator.ts +0 -3
  178. package/src/schedule/recurrence-engine.ts +26 -2
  179. package/src/schedule/recurrence-types.ts +1 -1
  180. package/src/schedule/schedule-store.ts +12 -3
  181. package/src/security/secret-scanner.ts +7 -0
  182. package/src/tasks/ephemeral-permissions.ts +0 -2
  183. package/src/tasks/task-scheduler.ts +2 -1
  184. package/src/tools/calls/call-start.ts +8 -0
  185. package/src/tools/execution-target.ts +21 -0
  186. package/src/tools/execution-timeout.ts +49 -0
  187. package/src/tools/executor.ts +6 -135
  188. package/src/tools/network/web-search.ts +9 -32
  189. package/src/tools/policy-context.ts +29 -0
  190. package/src/tools/schedule/update.ts +8 -1
  191. package/src/tools/terminal/parser.ts +16 -18
  192. package/src/tools/types.ts +4 -11
  193. package/src/twitter/oauth-client.ts +102 -0
  194. package/src/twitter/router.ts +101 -0
  195. package/src/util/debounce.ts +88 -0
  196. package/src/util/network-info.ts +47 -0
  197. package/src/util/platform.ts +29 -4
  198. package/src/util/promise-guard.ts +37 -0
  199. package/src/util/retry.ts +98 -0
  200. package/src/util/truncate.ts +1 -1
  201. package/src/workspace/git-service.ts +129 -112
  202. package/src/tools/contacts/contact-merge.ts +0 -55
  203. package/src/tools/contacts/contact-search.ts +0 -58
  204. package/src/tools/contacts/contact-upsert.ts +0 -64
  205. package/src/tools/playbooks/index.ts +0 -4
  206. package/src/tools/playbooks/playbook-create.ts +0 -96
  207. package/src/tools/playbooks/playbook-delete.ts +0 -52
  208. package/src/tools/playbooks/playbook-list.ts +0 -74
  209. package/src/tools/playbooks/playbook-update.ts +0 -111
@@ -44,6 +44,7 @@ import {
44
44
  } from './routes/channel-routes.js';
45
45
  import * as channelDeliveryStore from '../memory/channel-delivery-store.js';
46
46
  import * as conversationStore from '../memory/conversation-store.js';
47
+ import * as externalConversationStore from '../memory/external-conversation-store.js';
47
48
  import * as attachmentsStore from '../memory/attachments-store.js';
48
49
  import { renderHistoryContent } from '../daemon/handlers.js';
49
50
  import { deliverChannelReply } from './gateway-client.js';
@@ -60,6 +61,7 @@ import {
60
61
  handleGetCallStatus,
61
62
  handleCancelCall,
62
63
  handleAnswerCall,
64
+ handleInstructionCall,
63
65
  } from './routes/call-routes.js';
64
66
  import {
65
67
  handleVoiceWebhook,
@@ -616,13 +618,28 @@ export class RuntimeHttpServer {
616
618
  if (endpoint === 'conversations' && req.method === 'GET') {
617
619
  const limit = Number(url.searchParams.get('limit') ?? 50);
618
620
  const conversations = conversationStore.listConversations(limit);
621
+ const bindings = externalConversationStore.getBindingsForConversations(
622
+ conversations.map((c) => c.id),
623
+ );
619
624
  return Response.json({
620
- sessions: conversations.map((c) => ({
621
- id: c.id,
622
- title: c.title ?? 'Untitled',
623
- updatedAt: c.updatedAt,
624
- threadType: c.threadType === 'private' ? 'private' : 'standard',
625
- })),
625
+ sessions: conversations.map((c) => {
626
+ const binding = bindings.get(c.id);
627
+ return {
628
+ id: c.id,
629
+ title: c.title ?? 'Untitled',
630
+ updatedAt: c.updatedAt,
631
+ threadType: c.threadType === 'private' ? 'private' : 'standard',
632
+ ...(binding ? {
633
+ channelBinding: {
634
+ sourceChannel: binding.sourceChannel,
635
+ externalChatId: binding.externalChatId,
636
+ externalUserId: binding.externalUserId,
637
+ displayName: binding.displayName,
638
+ username: binding.username,
639
+ },
640
+ } : {}),
641
+ };
642
+ }),
626
643
  });
627
644
  }
628
645
 
@@ -700,7 +717,7 @@ export class RuntimeHttpServer {
700
717
  }
701
718
 
702
719
  if (endpoint === 'channels/inbound' && req.method === 'POST') {
703
- return await handleChannelInbound(req, this.processMessage, this.bearerToken);
720
+ return await handleChannelInbound(req, this.processMessage, this.bearerToken, this.runOrchestrator);
704
721
  }
705
722
 
706
723
  if (endpoint === 'channels/delivery-ack' && req.method === 'POST') {
@@ -720,8 +737,8 @@ export class RuntimeHttpServer {
720
737
  return await handleStartCall(req);
721
738
  }
722
739
 
723
- // Match calls/:callSessionId and calls/:callSessionId/cancel, calls/:callSessionId/answer
724
- const callsMatch = endpoint.match(/^calls\/([^/]+?)(\/cancel|\/answer)?$/);
740
+ // Match calls/:callSessionId and calls/:callSessionId/cancel, calls/:callSessionId/answer, calls/:callSessionId/instruction
741
+ const callsMatch = endpoint.match(/^calls\/([^/]+?)(\/cancel|\/answer|\/instruction)?$/);
725
742
  if (callsMatch) {
726
743
  const callSessionId = callsMatch[1];
727
744
  // Skip known sub-paths that are handled elsewhere (twilio, relay)
@@ -732,6 +749,9 @@ export class RuntimeHttpServer {
732
749
  if (callsMatch[2] === '/answer' && req.method === 'POST') {
733
750
  return await handleAnswerCall(req, callSessionId);
734
751
  }
752
+ if (callsMatch[2] === '/instruction' && req.method === 'POST') {
753
+ return await handleInstructionCall(req, callSessionId);
754
+ }
735
755
  if (!callsMatch[2] && req.method === 'GET') {
736
756
  return handleGetCallStatus(callSessionId);
737
757
  }
@@ -5,15 +5,17 @@
5
5
  * GET /v1/calls/:callSessionId — get call status
6
6
  * POST /v1/calls/:callSessionId/cancel — cancel a call
7
7
  * POST /v1/calls/:callSessionId/answer — answer a pending question
8
+ * POST /v1/calls/:callSessionId/instruction — relay an instruction to an active call
8
9
  */
9
10
 
10
- import { startCall, getCallStatus, cancelCall, answerCall } from '../../calls/call-domain.js';
11
+ import { startCall, getCallStatus, cancelCall, answerCall, relayInstruction } from '../../calls/call-domain.js';
11
12
  import { getConfig } from '../../config/loader.js';
13
+ import { VALID_CALLER_IDENTITY_MODES } from '../../config/schema.js';
12
14
 
13
15
  /**
14
16
  * POST /v1/calls/start
15
17
  *
16
- * Body: { phoneNumber: string; task: string; context?: string; conversationId: string }
18
+ * Body: { phoneNumber: string; task: string; context?: string; conversationId: string; callerIdentityMode?: 'assistant_number' | 'user_number' }
17
19
  */
18
20
  export async function handleStartCall(req: Request): Promise<Response> {
19
21
  if (!getConfig().calls.enabled) {
@@ -28,6 +30,7 @@ export async function handleStartCall(req: Request): Promise<Response> {
28
30
  task?: string;
29
31
  context?: string;
30
32
  conversationId?: string;
33
+ callerIdentityMode?: 'assistant_number' | 'user_number';
31
34
  };
32
35
  try {
33
36
  body = await req.json() as typeof body;
@@ -35,15 +38,28 @@ export async function handleStartCall(req: Request): Promise<Response> {
35
38
  return Response.json({ error: 'Invalid JSON in request body' }, { status: 400 });
36
39
  }
37
40
 
41
+ if (typeof body !== 'object' || body === null || Array.isArray(body)) {
42
+ return Response.json({ error: 'Request body must be a JSON object' }, { status: 400 });
43
+ }
44
+
38
45
  if (!body.conversationId) {
39
46
  return Response.json({ error: 'conversationId is required' }, { status: 400 });
40
47
  }
41
48
 
49
+ if (body.callerIdentityMode != null &&
50
+ !(VALID_CALLER_IDENTITY_MODES as readonly string[]).includes(body.callerIdentityMode as string)) {
51
+ return Response.json(
52
+ { error: `Invalid callerIdentityMode: "${body.callerIdentityMode}". Must be one of: ${VALID_CALLER_IDENTITY_MODES.join(', ')}` },
53
+ { status: 400 },
54
+ );
55
+ }
56
+
42
57
  const result = await startCall({
43
58
  phoneNumber: body.phoneNumber ?? '',
44
59
  task: body.task ?? '',
45
60
  context: body.context,
46
61
  conversationId: body.conversationId,
62
+ callerIdentityMode: body.callerIdentityMode,
47
63
  });
48
64
 
49
65
  if (!result.ok) {
@@ -56,6 +72,7 @@ export async function handleStartCall(req: Request): Promise<Response> {
56
72
  status: result.session.status,
57
73
  toNumber: result.session.toNumber,
58
74
  fromNumber: result.session.fromNumber,
75
+ callerIdentityMode: result.callerIdentityMode,
59
76
  }, { status: 201 });
60
77
  }
61
78
 
@@ -127,6 +144,10 @@ export async function handleAnswerCall(req: Request, callSessionId: string): Pro
127
144
  return Response.json({ error: 'Invalid JSON in request body' }, { status: 400 });
128
145
  }
129
146
 
147
+ if (typeof body !== 'object' || body === null || Array.isArray(body)) {
148
+ return Response.json({ error: 'Request body must be a JSON object' }, { status: 400 });
149
+ }
150
+
130
151
  const result = await answerCall({
131
152
  callSessionId,
132
153
  answer: body.answer ?? '',
@@ -138,3 +159,32 @@ export async function handleAnswerCall(req: Request, callSessionId: string): Pro
138
159
 
139
160
  return Response.json({ ok: true, questionId: result.questionId });
140
161
  }
162
+
163
+ /**
164
+ * POST /v1/calls/:callSessionId/instruction
165
+ *
166
+ * Body: { instruction: string }
167
+ */
168
+ export async function handleInstructionCall(req: Request, callSessionId: string): Promise<Response> {
169
+ let body: { instruction?: string };
170
+ try {
171
+ body = await req.json() as typeof body;
172
+ } catch {
173
+ return Response.json({ error: 'Invalid JSON in request body' }, { status: 400 });
174
+ }
175
+
176
+ if (typeof body !== 'object' || body === null || Array.isArray(body)) {
177
+ return Response.json({ error: 'Request body must be a JSON object' }, { status: 400 });
178
+ }
179
+
180
+ const result = await relayInstruction({
181
+ callSessionId,
182
+ instructionText: body.instruction ?? '',
183
+ });
184
+
185
+ if (!result.ok) {
186
+ return Response.json({ error: result.error }, { status: result.status ?? 500 });
187
+ }
188
+
189
+ return Response.json({ ok: true });
190
+ }
@@ -6,11 +6,22 @@ import { deleteConversationKey } from '../../memory/conversation-key-store.js';
6
6
  import * as conversationStore from '../../memory/conversation-store.js';
7
7
  import * as attachmentsStore from '../../memory/attachments-store.js';
8
8
  import * as channelDeliveryStore from '../../memory/channel-delivery-store.js';
9
+ import * as externalConversationStore from '../../memory/external-conversation-store.js';
10
+ import { getPendingConfirmationsByConversation } from '../../memory/runs-store.js';
9
11
  import { renderHistoryContent } from '../../daemon/handlers.js';
10
12
  import { checkIngressForSecrets } from '../../security/secret-ingress.js';
11
13
  import { IngressBlockedError } from '../../util/errors.js';
12
14
  import { getLogger } from '../../util/logger.js';
13
- import { deliverChannelReply } from '../gateway-client.js';
15
+ import { deliverChannelReply, deliverApprovalPrompt } from '../gateway-client.js';
16
+ import { parseApprovalDecision } from '../channel-approval-parser.js';
17
+ import {
18
+ getChannelApprovalPrompt,
19
+ buildApprovalUIMetadata,
20
+ handleChannelDecision,
21
+ buildReminderPrompt,
22
+ } from '../channel-approvals.js';
23
+ import type { ApprovalAction, ApprovalDecisionResult } from '../channel-approval-types.js';
24
+ import type { RunOrchestrator } from '../run-orchestrator.js';
14
25
  import type {
15
26
  MessageProcessor,
16
27
  RuntimeAttachmentMetadata,
@@ -18,6 +29,32 @@ import type {
18
29
 
19
30
  const log = getLogger('runtime-http');
20
31
 
32
+ // ---------------------------------------------------------------------------
33
+ // Feature flag
34
+ // ---------------------------------------------------------------------------
35
+
36
+ export function isChannelApprovalsEnabled(): boolean {
37
+ return process.env.CHANNEL_APPROVALS_ENABLED === 'true';
38
+ }
39
+
40
+ // ---------------------------------------------------------------------------
41
+ // Callback data parser — format: "apr:<runId>:<action>"
42
+ // ---------------------------------------------------------------------------
43
+
44
+ const VALID_ACTIONS: ReadonlySet<string> = new Set<string>([
45
+ 'approve_once',
46
+ 'approve_always',
47
+ 'reject',
48
+ ]);
49
+
50
+ function parseCallbackData(data: string): ApprovalDecisionResult | null {
51
+ const parts = data.split(':');
52
+ if (parts.length < 3 || parts[0] !== 'apr') return null;
53
+ const action = parts.slice(2).join(':');
54
+ if (!VALID_ACTIONS.has(action)) return null;
55
+ return { action: action as ApprovalAction, source: 'telegram_button' };
56
+ }
57
+
21
58
  export async function handleDeleteConversation(req: Request): Promise<Response> {
22
59
  const body = await req.json() as {
23
60
  sourceChannel?: string;
@@ -35,6 +72,7 @@ export async function handleDeleteConversation(req: Request): Promise<Response>
35
72
 
36
73
  const conversationKey = `${sourceChannel}:${externalChatId}`;
37
74
  deleteConversationKey(conversationKey);
75
+ externalConversationStore.deleteBindingByChannelChat(sourceChannel, externalChatId);
38
76
 
39
77
  return Response.json({ ok: true });
40
78
  }
@@ -43,6 +81,7 @@ export async function handleChannelInbound(
43
81
  req: Request,
44
82
  processMessage?: MessageProcessor,
45
83
  bearerToken?: string,
84
+ runOrchestrator?: RunOrchestrator,
46
85
  ): Promise<Response> {
47
86
  const body = await req.json() as {
48
87
  sourceChannel?: string;
@@ -56,6 +95,8 @@ export async function handleChannelInbound(
56
95
  senderUsername?: string;
57
96
  sourceMetadata?: Record<string, unknown>;
58
97
  replyCallbackUrl?: string;
98
+ callbackQueryId?: string;
99
+ callbackData?: string;
59
100
  };
60
101
 
61
102
  const {
@@ -179,6 +220,16 @@ export async function handleChannelInbound(
179
220
  { sourceMessageId },
180
221
  );
181
222
 
223
+ // Upsert external conversation binding with sender metadata
224
+ externalConversationStore.upsertBinding({
225
+ conversationId: result.conversationId,
226
+ sourceChannel,
227
+ externalChatId,
228
+ externalUserId: body.senderExternalUserId ?? null,
229
+ displayName: body.senderName ?? null,
230
+ username: body.senderUsername ?? null,
231
+ });
232
+
182
233
  const metadataHintsRaw = sourceMetadata?.hints;
183
234
  const metadataHints = Array.isArray(metadataHintsRaw)
184
235
  ? metadataHintsRaw.filter((hint): hint is string => typeof hint === 'string' && hint.trim().length > 0)
@@ -189,6 +240,33 @@ export async function handleChannelInbound(
189
240
 
190
241
  const replyCallbackUrl = body.replyCallbackUrl;
191
242
 
243
+ // ── Approval interception (gated behind feature flag) ──
244
+ if (
245
+ isChannelApprovalsEnabled() &&
246
+ runOrchestrator &&
247
+ replyCallbackUrl &&
248
+ !result.duplicate
249
+ ) {
250
+ const approvalResult = await handleApprovalInterception({
251
+ conversationId: result.conversationId,
252
+ callbackData: body.callbackData,
253
+ content: trimmedContent,
254
+ externalChatId,
255
+ replyCallbackUrl,
256
+ bearerToken,
257
+ orchestrator: runOrchestrator,
258
+ });
259
+
260
+ if (approvalResult.handled) {
261
+ return Response.json({
262
+ accepted: true,
263
+ duplicate: false,
264
+ eventId: result.eventId,
265
+ approval: approvalResult.type,
266
+ });
267
+ }
268
+ }
269
+
192
270
  // For new (non-duplicate) messages, run the secret ingress check
193
271
  // synchronously, then fire off the agent loop in the background.
194
272
  if (!result.duplicate && processMessage) {
@@ -217,21 +295,40 @@ export async function handleChannelInbound(
217
295
  throw new IngressBlockedError(ingressCheck.userNotice!, ingressCheck.detectedTypes);
218
296
  }
219
297
 
220
- // Fire-and-forget: process the message and deliver the reply in the background.
221
- // The HTTP response returns immediately so the gateway webhook is not blocked.
222
- processChannelMessageInBackground({
223
- processMessage,
224
- conversationId: result.conversationId,
225
- eventId: result.eventId,
226
- content: content ?? '',
227
- attachmentIds: hasAttachments ? attachmentIds : undefined,
228
- sourceChannel,
229
- externalChatId,
230
- metadataHints,
231
- metadataUxBrief,
232
- replyCallbackUrl,
233
- bearerToken,
234
- });
298
+ // When approval flow is enabled and we have an orchestrator, use the
299
+ // orchestrator-backed path which properly intercepts confirmation_request
300
+ // events and sends proactive approval prompts to the channel.
301
+ const useApprovalPath =
302
+ isChannelApprovalsEnabled() && runOrchestrator && replyCallbackUrl;
303
+
304
+ if (useApprovalPath) {
305
+ processChannelMessageWithApprovals({
306
+ orchestrator: runOrchestrator,
307
+ conversationId: result.conversationId,
308
+ eventId: result.eventId,
309
+ content: content ?? '',
310
+ attachmentIds: hasAttachments ? attachmentIds : undefined,
311
+ externalChatId,
312
+ replyCallbackUrl,
313
+ bearerToken,
314
+ });
315
+ } else {
316
+ // Fire-and-forget: process the message and deliver the reply in the background.
317
+ // The HTTP response returns immediately so the gateway webhook is not blocked.
318
+ processChannelMessageInBackground({
319
+ processMessage,
320
+ conversationId: result.conversationId,
321
+ eventId: result.eventId,
322
+ content: content ?? '',
323
+ attachmentIds: hasAttachments ? attachmentIds : undefined,
324
+ sourceChannel,
325
+ externalChatId,
326
+ metadataHints,
327
+ metadataUxBrief,
328
+ replyCallbackUrl,
329
+ bearerToken,
330
+ });
331
+ }
235
332
  }
236
333
 
237
334
  return Response.json({
@@ -298,6 +395,189 @@ function processChannelMessageInBackground(params: BackgroundProcessingParams):
298
395
  })();
299
396
  }
300
397
 
398
+ // ---------------------------------------------------------------------------
399
+ // Orchestrator-backed channel processing with approval prompt delivery
400
+ // ---------------------------------------------------------------------------
401
+
402
+ const RUN_POLL_INTERVAL_MS = 500;
403
+ const RUN_POLL_MAX_WAIT_MS = 300_000; // 5 minutes
404
+
405
+ interface ApprovalProcessingParams {
406
+ orchestrator: RunOrchestrator;
407
+ conversationId: string;
408
+ eventId: string;
409
+ content: string;
410
+ attachmentIds?: string[];
411
+ externalChatId: string;
412
+ replyCallbackUrl: string;
413
+ bearerToken?: string;
414
+ }
415
+
416
+ /**
417
+ * Process a channel message using the run orchestrator so that
418
+ * `confirmation_request` events are intercepted and written to the
419
+ * runs store. Polls the run until it reaches a terminal state,
420
+ * sending approval prompts when `needs_confirmation` is detected.
421
+ */
422
+ function processChannelMessageWithApprovals(params: ApprovalProcessingParams): void {
423
+ const {
424
+ orchestrator,
425
+ conversationId,
426
+ eventId,
427
+ content,
428
+ attachmentIds,
429
+ externalChatId,
430
+ replyCallbackUrl,
431
+ bearerToken,
432
+ } = params;
433
+
434
+ (async () => {
435
+ try {
436
+ const run = await orchestrator.startRun(conversationId, content, attachmentIds);
437
+
438
+ // Poll the run until it reaches a terminal state, delivering approval
439
+ // prompts when it transitions to needs_confirmation.
440
+ const startTime = Date.now();
441
+ let lastStatus = run.status;
442
+
443
+ while (Date.now() - startTime < RUN_POLL_MAX_WAIT_MS) {
444
+ await new Promise((resolve) => setTimeout(resolve, RUN_POLL_INTERVAL_MS));
445
+
446
+ const current = orchestrator.getRun(run.id);
447
+ if (!current) break;
448
+
449
+ if (current.status === 'needs_confirmation' && lastStatus !== 'needs_confirmation') {
450
+ // Run just transitioned to needs_confirmation — send approval prompt
451
+ const prompt = getChannelApprovalPrompt(conversationId);
452
+ if (prompt) {
453
+ const pending = getPendingConfirmationsByConversation(conversationId);
454
+ if (pending.length > 0) {
455
+ const uiMetadata = buildApprovalUIMetadata(prompt, pending[0]);
456
+ try {
457
+ await deliverApprovalPrompt(
458
+ replyCallbackUrl,
459
+ externalChatId,
460
+ prompt.promptText,
461
+ uiMetadata,
462
+ bearerToken,
463
+ );
464
+ } catch (err) {
465
+ log.error({ err, runId: run.id }, 'Failed to deliver approval prompt for channel run');
466
+ }
467
+ }
468
+ }
469
+ }
470
+
471
+ lastStatus = current.status;
472
+
473
+ if (current.status === 'completed' || current.status === 'failed') {
474
+ break;
475
+ }
476
+ }
477
+
478
+ channelDeliveryStore.markProcessed(eventId);
479
+
480
+ // Deliver the final assistant reply
481
+ await deliverReplyViaCallback(conversationId, externalChatId, replyCallbackUrl, bearerToken);
482
+ } catch (err) {
483
+ log.error({ err, conversationId }, 'Approval-aware channel message processing failed');
484
+ channelDeliveryStore.recordProcessingFailure(eventId, err);
485
+ }
486
+ })();
487
+ }
488
+
489
+ // ---------------------------------------------------------------------------
490
+ // Approval interception
491
+ // ---------------------------------------------------------------------------
492
+
493
+ interface ApprovalInterceptionParams {
494
+ conversationId: string;
495
+ callbackData?: string;
496
+ content: string;
497
+ externalChatId: string;
498
+ replyCallbackUrl: string;
499
+ bearerToken?: string;
500
+ orchestrator: RunOrchestrator;
501
+ }
502
+
503
+ interface ApprovalInterceptionResult {
504
+ handled: boolean;
505
+ type?: 'decision_applied' | 'reminder_sent';
506
+ }
507
+
508
+ /**
509
+ * Check for pending approvals and handle inbound messages accordingly.
510
+ *
511
+ * Returns `{ handled: true }` when the message was consumed by the approval
512
+ * flow (either as a decision or a reminder), so the caller should NOT proceed
513
+ * to normal message processing.
514
+ */
515
+ async function handleApprovalInterception(
516
+ params: ApprovalInterceptionParams,
517
+ ): Promise<ApprovalInterceptionResult> {
518
+ const {
519
+ conversationId,
520
+ callbackData,
521
+ content,
522
+ externalChatId,
523
+ replyCallbackUrl,
524
+ bearerToken,
525
+ orchestrator,
526
+ } = params;
527
+
528
+ const pendingPrompt = getChannelApprovalPrompt(conversationId);
529
+ if (!pendingPrompt) return { handled: false };
530
+
531
+ // Try to extract a decision from callback data (button press) first,
532
+ // then fall back to plain-text parsing.
533
+ let decision: ApprovalDecisionResult | null = null;
534
+
535
+ if (callbackData) {
536
+ decision = parseCallbackData(callbackData);
537
+ }
538
+
539
+ if (!decision && content) {
540
+ decision = parseApprovalDecision(content);
541
+ }
542
+
543
+ if (decision) {
544
+ const result = handleChannelDecision(conversationId, decision, orchestrator);
545
+
546
+ if (result.applied) {
547
+ // Deliver the run's result back to the channel once the decision is applied.
548
+ // The run will resume in the background; deliver whatever assistant reply
549
+ // is available now (there may not be one yet if the run is still processing).
550
+ try {
551
+ await deliverReplyViaCallback(conversationId, externalChatId, replyCallbackUrl, bearerToken);
552
+ } catch (err) {
553
+ log.error({ err, conversationId }, 'Failed to deliver post-decision reply');
554
+ }
555
+ }
556
+
557
+ return { handled: true, type: 'decision_applied' };
558
+ }
559
+
560
+ // The message is not a decision — send a reminder with the approval buttons.
561
+ const reminder = buildReminderPrompt(pendingPrompt);
562
+ const pending = getPendingConfirmationsByConversation(conversationId);
563
+ if (pending.length > 0) {
564
+ const uiMetadata = buildApprovalUIMetadata(reminder, pending[0]);
565
+ try {
566
+ await deliverApprovalPrompt(
567
+ replyCallbackUrl,
568
+ externalChatId,
569
+ reminder.promptText,
570
+ uiMetadata,
571
+ bearerToken,
572
+ );
573
+ } catch (err) {
574
+ log.error({ err, conversationId }, 'Failed to deliver approval reminder');
575
+ }
576
+ }
577
+
578
+ return { handled: true, type: 'reminder_sent' };
579
+ }
580
+
301
581
  async function deliverReplyViaCallback(
302
582
  conversationId: string,
303
583
  externalChatId: string,
@@ -46,19 +46,26 @@ export function handleListMessages(
46
46
  url: URL,
47
47
  interfacesDir: string | null,
48
48
  ): Response {
49
+ const conversationId = url.searchParams.get('conversationId');
49
50
  const conversationKey = url.searchParams.get('conversationKey');
50
- if (!conversationKey) {
51
+
52
+ let resolvedConversationId: string | undefined;
53
+ if (conversationId) {
54
+ resolvedConversationId = conversationId;
55
+ } else if (conversationKey) {
56
+ const mapping = getConversationByKey(conversationKey);
57
+ resolvedConversationId = mapping?.conversationId;
58
+ } else {
51
59
  return Response.json(
52
- { error: 'conversationKey query parameter is required' },
60
+ { error: 'conversationKey or conversationId query parameter is required' },
53
61
  { status: 400 },
54
62
  );
55
63
  }
56
64
 
57
- const mapping = getConversationByKey(conversationKey);
58
- if (!mapping) {
65
+ if (!resolvedConversationId) {
59
66
  return Response.json({ messages: [] });
60
67
  }
61
- const rawMessages = conversationStore.getMessages(mapping.conversationId);
68
+ const rawMessages = conversationStore.getMessages(resolvedConversationId);
62
69
 
63
70
  // Parse content blocks and extract text + tool calls
64
71
  const parsed = rawMessages.map((msg) => {