@vellumai/assistant 0.3.5 → 0.3.7

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 (487) hide show
  1. package/README.md +51 -0
  2. package/eslint.config.mjs +31 -0
  3. package/package.json +1 -1
  4. package/scripts/ipc/check-swift-decoder-drift.ts +4 -1
  5. package/scripts/ipc/generate-swift.ts +18 -2
  6. package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +338 -1
  7. package/src/__tests__/approval-conversation-turn.test.ts +214 -0
  8. package/src/__tests__/browser-manager.test.ts +1 -0
  9. package/src/__tests__/call-conversation-messages.test.ts +130 -0
  10. package/src/__tests__/call-orchestrator.test.ts +752 -271
  11. package/src/__tests__/call-pointer-messages.test.ts +148 -0
  12. package/src/__tests__/call-recovery.test.ts +3 -0
  13. package/src/__tests__/call-routes-http.test.ts +5 -0
  14. package/src/__tests__/call-store.test.ts +3 -0
  15. package/src/__tests__/channel-approval-routes.test.ts +1260 -85
  16. package/src/__tests__/channel-approval.test.ts +37 -0
  17. package/src/__tests__/channel-approvals.test.ts +4 -65
  18. package/src/__tests__/channel-guardian.test.ts +556 -0
  19. package/src/__tests__/channel-readiness-service.test.ts +74 -7
  20. package/src/__tests__/checker.test.ts +14 -7
  21. package/src/__tests__/clarification-resolver.test.ts +44 -24
  22. package/src/__tests__/commit-message-enrichment-service.test.ts +9 -4
  23. package/src/__tests__/computer-use-session-working-dir.test.ts +8 -0
  24. package/src/__tests__/config-schema.test.ts +12 -7
  25. package/src/__tests__/context-window-manager.test.ts +30 -2
  26. package/src/__tests__/contradiction-checker.test.ts +20 -5
  27. package/src/__tests__/credential-security-invariants.test.ts +6 -2
  28. package/src/__tests__/db-migration-rollback.test.ts +752 -0
  29. package/src/__tests__/dynamic-skill-workflow-prompt.test.ts +2 -0
  30. package/src/__tests__/fuzzy-match-property.test.ts +5 -5
  31. package/src/__tests__/guardian-action-store.test.ts +123 -0
  32. package/src/__tests__/guardian-action-sweep.test.ts +277 -0
  33. package/src/__tests__/guardian-dispatch.test.ts +389 -0
  34. package/src/__tests__/guardian-question-copy.test.ts +47 -0
  35. package/src/__tests__/handlers-telegram-config.test.ts +4 -2
  36. package/src/__tests__/handlers-twilio-config.test.ts +126 -0
  37. package/src/__tests__/intent-routing.test.ts +2 -0
  38. package/src/__tests__/ipc-snapshot.test.ts +228 -1
  39. package/src/__tests__/memory-upsert-concurrency.test.ts +828 -0
  40. package/src/__tests__/model-intents.test.ts +96 -0
  41. package/src/__tests__/no-direct-anthropic-sdk-imports.test.ts +42 -0
  42. package/src/__tests__/oauth2-gateway-transport.test.ts +130 -0
  43. package/src/__tests__/onboarding-starter-tasks.test.ts +2 -0
  44. package/src/__tests__/provider-commit-message-generator.test.ts +89 -13
  45. package/src/__tests__/provider-error-scenarios.test.ts +621 -0
  46. package/src/__tests__/provider-fail-open-selection.test.ts +119 -0
  47. package/src/__tests__/qdrant-manager.test.ts +27 -20
  48. package/src/__tests__/relay-server.test.ts +779 -40
  49. package/src/__tests__/run-orchestrator-assistant-events.test.ts +2 -0
  50. package/src/__tests__/run-orchestrator.test.ts +20 -4
  51. package/src/__tests__/runtime-runs-http.test.ts +17 -1
  52. package/src/__tests__/runtime-runs.test.ts +16 -0
  53. package/src/__tests__/schedule-store.test.ts +18 -4
  54. package/src/__tests__/scheduler-recurrence.test.ts +13 -4
  55. package/src/__tests__/session-abort-tool-results.test.ts +6 -0
  56. package/src/__tests__/session-agent-loop.test.ts +857 -0
  57. package/src/__tests__/session-conflict-gate.test.ts +6 -0
  58. package/src/__tests__/session-pre-run-repair.test.ts +6 -0
  59. package/src/__tests__/session-profile-injection.test.ts +6 -0
  60. package/src/__tests__/session-provider-retry-repair.test.ts +6 -0
  61. package/src/__tests__/session-queue.test.ts +6 -0
  62. package/src/__tests__/session-runtime-assembly.test.ts +237 -13
  63. package/src/__tests__/session-slash-known.test.ts +6 -0
  64. package/src/__tests__/session-slash-queue.test.ts +6 -0
  65. package/src/__tests__/session-slash-unknown.test.ts +6 -0
  66. package/src/__tests__/session-surfaces-task-progress.test.ts +2 -0
  67. package/src/__tests__/session-tool-setup-app-refresh.test.ts +1 -0
  68. package/src/__tests__/session-tool-setup-memory-scope.test.ts +1 -0
  69. package/src/__tests__/session-tool-setup-side-effect-flag.test.ts +1 -0
  70. package/src/__tests__/session-workspace-injection.test.ts +6 -0
  71. package/src/__tests__/session-workspace-tool-tracking.test.ts +6 -0
  72. package/src/__tests__/skills.test.ts +2 -0
  73. package/src/__tests__/sms-messaging-provider.test.ts +2 -1
  74. package/src/__tests__/starter-task-flow.test.ts +2 -0
  75. package/src/__tests__/swarm-dag-pathological.test.ts +535 -0
  76. package/src/__tests__/system-prompt.test.ts +2 -0
  77. package/src/__tests__/task-management-tools.test.ts +2 -2
  78. package/src/__tests__/task-runner.test.ts +14 -4
  79. package/src/__tests__/terminal-tools.test.ts +25 -19
  80. package/src/__tests__/tool-execution-abort-cleanup.test.ts +545 -0
  81. package/src/__tests__/tool-executor-shell-integration.test.ts +11 -11
  82. package/src/__tests__/tool-executor.test.ts +23 -24
  83. package/src/__tests__/trust-store.test.ts +3 -3
  84. package/src/__tests__/twilio-rest.test.ts +29 -0
  85. package/src/__tests__/twilio-routes-elevenlabs.test.ts +3 -0
  86. package/src/__tests__/twilio-routes-twiml.test.ts +11 -0
  87. package/src/__tests__/twilio-routes.test.ts +141 -21
  88. package/src/__tests__/user-reference.test.ts +2 -0
  89. package/src/__tests__/voice-quality.test.ts +222 -0
  90. package/src/__tests__/web-search.test.ts +45 -29
  91. package/src/agent/loop.ts +1 -1
  92. package/src/agent-heartbeat/agent-heartbeat-service.ts +2 -10
  93. package/src/amazon/client.ts +1418 -0
  94. package/src/amazon/request-extractor.ts +135 -0
  95. package/src/amazon/session.ts +109 -0
  96. package/src/autonomy/autonomy-store.ts +5 -5
  97. package/src/browser-extension-relay/client.ts +124 -0
  98. package/src/browser-extension-relay/protocol.ts +63 -0
  99. package/src/browser-extension-relay/server.ts +177 -0
  100. package/src/bundler/app-bundler.ts +3 -3
  101. package/src/bundler/bundle-signer.ts +1 -1
  102. package/src/bundler/signature-verifier.ts +1 -1
  103. package/src/calls/call-conversation-messages.ts +33 -0
  104. package/src/calls/call-domain.ts +106 -5
  105. package/src/calls/call-orchestrator.ts +252 -54
  106. package/src/calls/call-pointer-messages.ts +53 -0
  107. package/src/calls/call-recovery.ts +3 -8
  108. package/src/calls/call-store.ts +69 -87
  109. package/src/calls/elevenlabs-config.ts +3 -2
  110. package/src/calls/guardian-action-sweep.ts +105 -0
  111. package/src/calls/guardian-dispatch.ts +203 -0
  112. package/src/calls/guardian-question-copy.ts +133 -0
  113. package/src/calls/relay-server.ts +466 -8
  114. package/src/calls/speaker-identification.ts +1 -1
  115. package/src/calls/twilio-config.ts +7 -5
  116. package/src/calls/twilio-provider.ts +6 -4
  117. package/src/calls/twilio-rest.ts +40 -15
  118. package/src/calls/twilio-routes.ts +60 -45
  119. package/src/calls/types.ts +3 -1
  120. package/src/channels/types.ts +25 -0
  121. package/src/cli/amazon.ts +815 -0
  122. package/src/cli/config-commands.ts +2 -2
  123. package/src/cli/core-commands.ts +4 -3
  124. package/src/cli/influencer.ts +244 -0
  125. package/src/cli/map.ts +89 -6
  126. package/src/cli.ts +1 -1
  127. package/src/config/agent-schema.ts +171 -0
  128. package/src/config/bundled-skills/amazon/SKILL.md +127 -0
  129. package/src/config/bundled-skills/amazon/icon.svg +13 -0
  130. package/src/config/bundled-skills/api-mapping/SKILL.md +78 -0
  131. package/src/config/bundled-skills/browser/SKILL.md +1 -0
  132. package/src/config/bundled-skills/browser/TOOLS.json +17 -0
  133. package/src/config/bundled-skills/browser/tools/browser-wait-for-download.ts +25 -0
  134. package/src/config/bundled-skills/doordash/SKILL.md +51 -51
  135. package/src/config/bundled-skills/email-setup/SKILL.md +14 -5
  136. package/src/config/bundled-skills/google-oauth-setup/SKILL.md +183 -0
  137. package/src/config/bundled-skills/influencer/SKILL.md +144 -0
  138. package/src/config/bundled-skills/macos-automation/icon.svg +12 -0
  139. package/src/config/bundled-skills/media-processing/SKILL.md +72 -95
  140. package/src/config/bundled-skills/media-processing/TOOLS.json +57 -147
  141. package/src/config/bundled-skills/media-processing/__tests__/concurrency-pool.test.ts +77 -0
  142. package/src/config/bundled-skills/media-processing/__tests__/cost-tracker.test.ts +69 -0
  143. package/src/config/bundled-skills/media-processing/__tests__/preprocess.test.ts +303 -0
  144. package/src/config/bundled-skills/media-processing/services/concurrency-pool.ts +55 -0
  145. package/src/config/bundled-skills/media-processing/services/cost-tracker.ts +86 -0
  146. package/src/config/bundled-skills/media-processing/services/gemini-map.ts +339 -0
  147. package/src/config/bundled-skills/media-processing/services/preprocess.ts +551 -0
  148. package/src/config/bundled-skills/media-processing/services/processing-pipeline.ts +7 -9
  149. package/src/config/bundled-skills/media-processing/services/reduce.ts +197 -0
  150. package/src/config/bundled-skills/media-processing/tools/analyze-keyframes.ts +88 -253
  151. package/src/config/bundled-skills/media-processing/tools/extract-keyframes.ts +22 -153
  152. package/src/config/bundled-skills/media-processing/tools/generate-clip.ts +2 -2
  153. package/src/config/bundled-skills/media-processing/tools/media-diagnostics.ts +28 -51
  154. package/src/config/bundled-skills/media-processing/tools/query-media-events.ts +35 -270
  155. package/src/config/bundled-skills/messaging/SKILL.md +12 -2
  156. package/src/config/bundled-skills/messaging/tools/messaging-analyze-style.ts +4 -7
  157. package/src/config/bundled-skills/messaging/tools/messaging-reply.ts +2 -1
  158. package/src/config/bundled-skills/phone-calls/SKILL.md +86 -21
  159. package/src/config/bundled-skills/twitter/icon.svg +14 -0
  160. package/src/config/bundled-tool-registry.ts +310 -0
  161. package/src/config/calls-schema.ts +181 -0
  162. package/src/config/core-schema.ts +309 -0
  163. package/src/config/defaults.ts +27 -3
  164. package/src/config/env-registry.ts +169 -0
  165. package/src/config/env.ts +175 -0
  166. package/src/config/loader.ts +6 -6
  167. package/src/config/memory-schema.ts +528 -0
  168. package/src/config/sandbox-schema.ts +55 -0
  169. package/src/config/schema.ts +157 -1138
  170. package/src/config/skill-state.ts +1 -1
  171. package/src/config/skills-schema.ts +32 -0
  172. package/src/config/skills.ts +35 -24
  173. package/src/config/system-prompt.ts +107 -56
  174. package/src/config/templates/SOUL.md +1 -1
  175. package/src/config/types.ts +1 -0
  176. package/src/config/user-reference.ts +4 -9
  177. package/src/config/vellum-skills/catalog.json +0 -7
  178. package/src/config/vellum-skills/chatgpt-import/tools/chatgpt-import.ts +5 -1
  179. package/src/config/vellum-skills/slack-oauth-setup/SKILL.md +1 -0
  180. package/src/config/vellum-skills/sms-setup/SKILL.md +112 -14
  181. package/src/context/window-manager.ts +27 -7
  182. package/src/daemon/approval-generators.ts +186 -0
  183. package/src/daemon/approved-devices-store.ts +140 -0
  184. package/src/daemon/assistant-attachments.ts +1 -1
  185. package/src/daemon/classifier.ts +35 -32
  186. package/src/daemon/config-watcher.ts +1 -1
  187. package/src/daemon/daemon-control.ts +254 -0
  188. package/src/daemon/handlers/apps.ts +2 -3
  189. package/src/daemon/handlers/config-channels.ts +158 -0
  190. package/src/daemon/handlers/config-inbox.ts +540 -0
  191. package/src/daemon/handlers/config-ingress.ts +231 -0
  192. package/src/daemon/handlers/config-integrations.ts +258 -0
  193. package/src/daemon/handlers/config-model.ts +143 -0
  194. package/src/daemon/handlers/config-parental.ts +163 -0
  195. package/src/daemon/handlers/config-scheduling.ts +172 -0
  196. package/src/daemon/handlers/config-slack.ts +92 -0
  197. package/src/daemon/handlers/config-telegram.ts +301 -0
  198. package/src/daemon/handlers/config-tools.ts +177 -0
  199. package/src/daemon/handlers/config-trust.ts +104 -0
  200. package/src/daemon/handlers/config-twilio.ts +1080 -0
  201. package/src/daemon/handlers/config.ts +53 -2463
  202. package/src/daemon/handlers/diagnostics.ts +1 -1
  203. package/src/daemon/handlers/dictation.ts +4 -6
  204. package/src/daemon/handlers/documents.ts +18 -32
  205. package/src/daemon/handlers/index.ts +9 -0
  206. package/src/daemon/handlers/misc.ts +3 -5
  207. package/src/daemon/handlers/pairing.ts +98 -0
  208. package/src/daemon/handlers/sessions.ts +74 -5
  209. package/src/daemon/handlers/shared.ts +3 -1
  210. package/src/daemon/handlers/skills.ts +1 -1
  211. package/src/daemon/handlers/twitter-auth.ts +2 -0
  212. package/src/daemon/handlers/work-items.ts +2 -2
  213. package/src/daemon/handlers/workspace-files.ts +4 -3
  214. package/src/daemon/install-cli-launchers.ts +113 -0
  215. package/src/daemon/ipc-contract/apps.ts +356 -0
  216. package/src/daemon/ipc-contract/browser.ts +74 -0
  217. package/src/daemon/ipc-contract/computer-use.ts +151 -0
  218. package/src/daemon/ipc-contract/diagnostics.ts +56 -0
  219. package/src/daemon/ipc-contract/documents.ts +74 -0
  220. package/src/daemon/ipc-contract/inbox.ts +209 -0
  221. package/src/daemon/ipc-contract/integrations.ts +284 -0
  222. package/src/daemon/ipc-contract/memory.ts +48 -0
  223. package/src/daemon/ipc-contract/messages.ts +211 -0
  224. package/src/daemon/ipc-contract/pairing.ts +45 -0
  225. package/src/daemon/ipc-contract/parental-control.ts +95 -0
  226. package/src/daemon/ipc-contract/schedules.ts +97 -0
  227. package/src/daemon/ipc-contract/sessions.ts +321 -0
  228. package/src/daemon/ipc-contract/shared.ts +42 -0
  229. package/src/daemon/ipc-contract/skills.ts +120 -0
  230. package/src/daemon/ipc-contract/subagents.ts +58 -0
  231. package/src/daemon/ipc-contract/surfaces.ts +250 -0
  232. package/src/daemon/ipc-contract/trust.ts +60 -0
  233. package/src/daemon/ipc-contract/work-items.ts +225 -0
  234. package/src/daemon/ipc-contract/workspace.ts +113 -0
  235. package/src/daemon/ipc-contract-inventory.json +62 -0
  236. package/src/daemon/ipc-contract-inventory.ts +55 -29
  237. package/src/daemon/ipc-contract.ts +227 -2527
  238. package/src/daemon/ipc-protocol.ts +1 -1
  239. package/src/daemon/ipc-validate.ts +7 -0
  240. package/src/daemon/lifecycle.ts +97 -379
  241. package/src/daemon/pairing-store.ts +177 -0
  242. package/src/daemon/providers-setup.ts +43 -0
  243. package/src/daemon/ride-shotgun-handler.ts +67 -2
  244. package/src/daemon/server.ts +60 -44
  245. package/src/daemon/session-agent-loop-handlers.ts +421 -0
  246. package/src/daemon/session-agent-loop.ts +113 -275
  247. package/src/daemon/session-dynamic-profile.ts +1 -1
  248. package/src/daemon/session-history.ts +1 -1
  249. package/src/daemon/session-media-retry.ts +1 -1
  250. package/src/daemon/session-messaging.ts +37 -2
  251. package/src/daemon/session-notifiers.ts +5 -25
  252. package/src/daemon/session-process.ts +99 -59
  253. package/src/daemon/session-queue-manager.ts +98 -4
  254. package/src/daemon/session-runtime-assembly.ts +149 -15
  255. package/src/daemon/session-surfaces.ts +26 -4
  256. package/src/daemon/session-tool-setup.ts +28 -30
  257. package/src/daemon/session-workspace.ts +1 -1
  258. package/src/daemon/session.ts +24 -1
  259. package/src/daemon/shutdown-handlers.ts +122 -0
  260. package/src/daemon/trace-emitter.ts +1 -1
  261. package/src/daemon/watch-handler.ts +36 -33
  262. package/src/doordash/cart-queries.ts +787 -0
  263. package/src/doordash/client.ts +144 -127
  264. package/src/doordash/order-queries.ts +85 -0
  265. package/src/doordash/queries.ts +10 -1308
  266. package/src/doordash/search-queries.ts +203 -0
  267. package/src/doordash/session.ts +3 -2
  268. package/src/doordash/store-queries.ts +246 -0
  269. package/src/doordash/types.ts +367 -0
  270. package/src/email/providers/agentmail.ts +2 -1
  271. package/src/email/providers/index.ts +3 -2
  272. package/src/email/service.ts +3 -2
  273. package/src/errors.ts +43 -0
  274. package/src/home-base/prebuilt/seed.ts +1 -1
  275. package/src/hooks/cli.ts +6 -5
  276. package/src/hooks/config.ts +6 -8
  277. package/src/hooks/discovery.ts +6 -5
  278. package/src/hooks/manager.ts +4 -3
  279. package/src/hooks/runner.ts +2 -2
  280. package/src/hooks/templates.ts +5 -5
  281. package/src/inbound/public-ingress-urls.ts +3 -1
  282. package/src/index.ts +4 -2
  283. package/src/influencer/client.ts +1104 -0
  284. package/src/instrument.ts +4 -3
  285. package/src/logfire.ts +4 -3
  286. package/src/memory/admin.ts +25 -35
  287. package/src/memory/attachments-store.ts +4 -7
  288. package/src/memory/channel-delivery-store.ts +30 -1
  289. package/src/memory/channel-guardian-store.ts +200 -1
  290. package/src/memory/clarification-resolver.ts +37 -33
  291. package/src/memory/conflict-store.ts +67 -61
  292. package/src/memory/contradiction-checker.ts +141 -117
  293. package/src/memory/conversation-store.ts +335 -51
  294. package/src/memory/db-connection.ts +27 -4
  295. package/src/memory/db-init.ts +121 -4
  296. package/src/memory/db.ts +14 -1
  297. package/src/memory/embedding-backend.ts +27 -5
  298. package/src/memory/embedding-ollama.ts +2 -1
  299. package/src/memory/entity-extractor.ts +38 -35
  300. package/src/memory/guardian-action-store.ts +430 -0
  301. package/src/memory/inbox-escalation-projection.ts +59 -0
  302. package/src/memory/inbox-thread-store.ts +218 -0
  303. package/src/memory/ingress-invite-store.ts +338 -0
  304. package/src/memory/ingress-member-store.ts +350 -0
  305. package/src/memory/items-extractor.ts +91 -97
  306. package/src/memory/job-handlers/index-maintenance.ts +3 -3
  307. package/src/memory/job-handlers/media-processing.ts +11 -42
  308. package/src/memory/job-handlers/summarization.ts +32 -26
  309. package/src/memory/job-utils.ts +3 -10
  310. package/src/memory/jobs-store.ts +6 -9
  311. package/src/memory/jobs-worker.ts +51 -36
  312. package/src/memory/migrations/001-job-deferrals.ts +45 -0
  313. package/src/memory/migrations/002-tool-invocations-fk.ts +43 -0
  314. package/src/memory/migrations/003-memory-fts-backfill.ts +24 -0
  315. package/src/memory/migrations/004-entity-relation-dedup.ts +87 -0
  316. package/src/memory/migrations/005-fingerprint-scope-unique.ts +80 -0
  317. package/src/memory/migrations/006-scope-salted-fingerprints.ts +62 -0
  318. package/src/memory/migrations/007-assistant-id-to-self.ts +254 -0
  319. package/src/memory/migrations/008-remove-assistant-id-columns.ts +208 -0
  320. package/src/memory/migrations/009-llm-usage-events-drop-assistant-id.ts +83 -0
  321. package/src/memory/migrations/010-ext-conv-bindings-channel-chat-unique.ts +56 -0
  322. package/src/memory/migrations/011-call-sessions-provider-sid-dedup.ts +63 -0
  323. package/src/memory/migrations/012-call-sessions-add-initiated-from.ts +19 -0
  324. package/src/memory/migrations/013-guardian-action-tables.ts +68 -0
  325. package/src/memory/migrations/014-backfill-inbox-thread-state.ts +76 -0
  326. package/src/memory/migrations/015-drop-active-search-index.ts +27 -0
  327. package/src/memory/migrations/016-memory-segments-indexes.ts +11 -0
  328. package/src/memory/migrations/017-memory-items-indexes.ts +12 -0
  329. package/src/memory/migrations/018-remaining-table-indexes.ts +13 -0
  330. package/src/memory/migrations/index.ts +24 -0
  331. package/src/memory/migrations/registry.ts +79 -0
  332. package/src/memory/migrations/validate-migration-state.ts +69 -0
  333. package/src/memory/qdrant-manager.ts +49 -8
  334. package/src/memory/query-builder.ts +1 -1
  335. package/src/memory/raw-query.ts +119 -0
  336. package/src/memory/recall-cache.ts +4 -1
  337. package/src/memory/retriever.ts +163 -47
  338. package/src/memory/schema-migration.ts +25 -984
  339. package/src/memory/schema.ts +130 -7
  340. package/src/memory/search/entity.ts +10 -19
  341. package/src/memory/search/lexical.ts +81 -52
  342. package/src/memory/search/ranking.ts +21 -22
  343. package/src/memory/search/semantic.ts +157 -19
  344. package/src/memory/shared-app-links-store.ts +4 -5
  345. package/src/memory/validation.ts +19 -0
  346. package/src/messaging/draft-store.ts +5 -6
  347. package/src/messaging/providers/sms/adapter.ts +3 -6
  348. package/src/messaging/providers/telegram-bot/adapter.ts +2 -5
  349. package/src/messaging/providers/whatsapp/adapter.ts +136 -0
  350. package/src/messaging/providers/whatsapp/client.ts +67 -0
  351. package/src/messaging/style-analyzer.ts +5 -4
  352. package/src/messaging/thread-summarizer.ts +61 -69
  353. package/src/messaging/triage-engine.ts +62 -71
  354. package/src/migrations/config-merge.ts +53 -0
  355. package/src/migrations/data-layout.ts +68 -0
  356. package/src/migrations/data-merge.ts +33 -0
  357. package/src/migrations/hooks-merge.ts +90 -0
  358. package/src/migrations/index.ts +6 -0
  359. package/src/migrations/log.ts +23 -0
  360. package/src/migrations/skills-merge.ts +33 -0
  361. package/src/migrations/workspace-layout.ts +79 -0
  362. package/src/permissions/checker.ts +126 -11
  363. package/src/permissions/prompter.ts +14 -0
  364. package/src/permissions/shell-identity.ts +31 -1
  365. package/src/permissions/trust-store.ts +21 -1
  366. package/src/providers/anthropic/client.ts +4 -4
  367. package/src/providers/failover.ts +2 -2
  368. package/src/providers/model-intents.ts +70 -0
  369. package/src/providers/ollama/client.ts +2 -1
  370. package/src/providers/provider-send-message.ts +176 -0
  371. package/src/providers/registry.ts +71 -30
  372. package/src/providers/retry.ts +35 -1
  373. package/src/providers/types.ts +12 -1
  374. package/src/runtime/approval-conversation-turn.ts +97 -0
  375. package/src/runtime/approval-message-composer.ts +115 -5
  376. package/src/runtime/assistant-event-hub.ts +3 -1
  377. package/src/runtime/channel-approval-parser.ts +36 -2
  378. package/src/runtime/channel-approvals.ts +0 -21
  379. package/src/runtime/channel-guardian-service.ts +48 -7
  380. package/src/runtime/channel-readiness-service.ts +160 -34
  381. package/src/runtime/channel-readiness-types.ts +10 -4
  382. package/src/runtime/channel-retry-sweep.ts +184 -0
  383. package/src/runtime/guardian-context-resolver.ts +108 -0
  384. package/src/runtime/http-server.ts +289 -745
  385. package/src/runtime/http-types.ts +56 -3
  386. package/src/runtime/middleware/auth.ts +116 -0
  387. package/src/runtime/middleware/error-handler.ts +33 -0
  388. package/src/runtime/middleware/twilio-validation.ts +127 -0
  389. package/src/runtime/routes/app-routes.ts +1 -1
  390. package/src/runtime/routes/call-routes.ts +49 -6
  391. package/src/runtime/routes/channel-delivery-routes.ts +170 -0
  392. package/src/runtime/routes/channel-guardian-routes.ts +1191 -0
  393. package/src/runtime/routes/channel-inbound-routes.ts +1152 -0
  394. package/src/runtime/routes/channel-route-shared.ts +144 -0
  395. package/src/runtime/routes/channel-routes.ts +32 -1634
  396. package/src/runtime/routes/conversation-routes.ts +50 -7
  397. package/src/runtime/routes/events-routes.ts +2 -2
  398. package/src/runtime/routes/identity-routes.ts +126 -0
  399. package/src/runtime/routes/pairing-routes.ts +144 -0
  400. package/src/runtime/routes/run-routes.ts +15 -1
  401. package/src/runtime/run-orchestrator.ts +52 -34
  402. package/src/schedule/schedule-store.ts +36 -32
  403. package/src/schedule/scheduler.ts +3 -3
  404. package/src/security/encrypted-store.ts +5 -7
  405. package/src/security/oauth2.ts +45 -15
  406. package/src/security/parental-control-store.ts +183 -0
  407. package/src/security/secret-allowlist.ts +4 -3
  408. package/src/security/secret-scanner.ts +5 -5
  409. package/src/security/secure-keys.ts +1 -1
  410. package/src/security/token-manager.ts +3 -2
  411. package/src/services/vercel-deploy.ts +6 -2
  412. package/src/skills/tool-manifest.ts +3 -3
  413. package/src/skills/vellum-catalog-remote.ts +75 -16
  414. package/src/slack/slack-webhook.ts +2 -1
  415. package/src/swarm/orchestrator.ts +92 -1
  416. package/src/swarm/router-planner.ts +6 -9
  417. package/src/swarm/worker-prompts.ts +9 -12
  418. package/src/tasks/task-compiler.ts +19 -28
  419. package/src/tasks/task-runner.ts +1 -1
  420. package/src/tools/assets/search.ts +15 -14
  421. package/src/tools/browser/__tests__/auth-detector.test.ts +1 -0
  422. package/src/tools/browser/auto-navigate.ts +1 -0
  423. package/src/tools/browser/browser-execution.ts +13 -1
  424. package/src/tools/browser/browser-manager.ts +119 -4
  425. package/src/tools/browser/network-recorder.ts +5 -0
  426. package/src/tools/credentials/broker.ts +11 -2
  427. package/src/tools/credentials/metadata-store.ts +18 -14
  428. package/src/tools/credentials/post-connect-hooks.ts +61 -0
  429. package/src/tools/credentials/vault.ts +49 -23
  430. package/src/tools/executor.ts +80 -18
  431. package/src/tools/host-terminal/cli-discover.ts +1 -1
  432. package/src/tools/network/script-proxy/http-forwarder.ts +1 -1
  433. package/src/tools/network/script-proxy/mitm-handler.ts +1 -1
  434. package/src/tools/network/script-proxy/server.ts +1 -1
  435. package/src/tools/network/script-proxy/session-manager.ts +6 -5
  436. package/src/tools/network/web-fetch.ts +18 -2
  437. package/src/tools/network/web-search.ts +7 -3
  438. package/src/tools/reminder/reminder-store.ts +14 -15
  439. package/src/tools/schedule/create.ts +1 -0
  440. package/src/tools/schedule/list.ts +2 -1
  441. package/src/tools/shared/filesystem/file-ops-service.ts +5 -7
  442. package/src/tools/skills/skill-script-runner.ts +24 -9
  443. package/src/tools/skills/skill-tool-factory.ts +1 -0
  444. package/src/tools/tasks/work-item-enqueue.ts +2 -2
  445. package/src/tools/terminal/evaluate-typescript.ts +21 -12
  446. package/src/tools/terminal/parser.ts +50 -0
  447. package/src/tools/watcher/delete.ts +6 -0
  448. package/src/tools/weather/service.ts +1 -1
  449. package/src/twitter/client.ts +190 -24
  450. package/src/twitter/session.ts +4 -3
  451. package/src/util/clipboard.ts +1 -1
  452. package/src/util/errors.ts +65 -8
  453. package/src/util/fs.ts +40 -0
  454. package/src/util/json.ts +10 -0
  455. package/src/util/log-redact.ts +189 -0
  456. package/src/util/logger.ts +25 -18
  457. package/src/util/object.ts +3 -0
  458. package/src/util/platform.ts +72 -365
  459. package/src/util/pricing.ts +1 -1
  460. package/src/util/promise-guard.ts +1 -1
  461. package/src/util/retry.ts +19 -0
  462. package/src/util/row-mapper.ts +79 -0
  463. package/src/util/silently.ts +21 -0
  464. package/src/watcher/engine.ts +5 -1
  465. package/src/watcher/provider-types.ts +20 -0
  466. package/src/watcher/providers/github.ts +156 -0
  467. package/src/watcher/providers/gmail.ts +1 -0
  468. package/src/watcher/providers/google-calendar.ts +1 -0
  469. package/src/watcher/providers/linear.ts +460 -0
  470. package/src/watcher/providers/slack.ts +1 -0
  471. package/src/work-items/work-item-runner.ts +1 -1
  472. package/src/workspace/git-service.ts +1 -1
  473. package/src/workspace/provider-commit-message-generator.ts +51 -22
  474. package/src/__tests__/call-bridge.test.ts +0 -517
  475. package/src/__tests__/session-process-bridge.test.ts +0 -244
  476. package/src/calls/call-bridge.ts +0 -168
  477. package/src/config/bundled-skills/media-processing/services/capability-registry.ts +0 -137
  478. package/src/config/bundled-skills/media-processing/services/event-detection-service.ts +0 -280
  479. package/src/config/bundled-skills/media-processing/services/feedback-aggregation.ts +0 -144
  480. package/src/config/bundled-skills/media-processing/services/feedback-store.ts +0 -136
  481. package/src/config/bundled-skills/media-processing/services/retrieval-service.ts +0 -95
  482. package/src/config/bundled-skills/media-processing/services/timeline-service.ts +0 -267
  483. package/src/config/bundled-skills/media-processing/tools/detect-events.ts +0 -110
  484. package/src/config/bundled-skills/media-processing/tools/recalibrate.ts +0 -235
  485. package/src/config/bundled-skills/media-processing/tools/select-tracking-profile.ts +0 -142
  486. package/src/config/bundled-skills/media-processing/tools/submit-feedback.ts +0 -150
  487. package/src/config/vellum-skills/google-oauth-setup/SKILL.md +0 -199
@@ -0,0 +1,1152 @@
1
+ /**
2
+ * Channel inbound routes: message ingress, conversation deletion,
3
+ * and background message processing.
4
+ */
5
+ import { isChannelId, CHANNEL_IDS } from '../../channels/types.js';
6
+ import type { ChannelId, TurnChannelContext } from '../../channels/types.js';
7
+ import { deleteConversationKey } from '../../memory/conversation-key-store.js';
8
+ import * as conversationStore from '../../memory/conversation-store.js';
9
+ import * as attachmentsStore from '../../memory/attachments-store.js';
10
+ import * as channelDeliveryStore from '../../memory/channel-delivery-store.js';
11
+ import * as externalConversationStore from '../../memory/external-conversation-store.js';
12
+ import { getPendingConfirmationsByConversation } from '../../memory/runs-store.js';
13
+ import { checkIngressForSecrets } from '../../security/secret-ingress.js';
14
+ import { IngressBlockedError } from '../../util/errors.js';
15
+ import { getLogger } from '../../util/logger.js';
16
+ import { findMember, updateLastSeen } from '../../memory/ingress-member-store.js';
17
+ import {
18
+ getGuardianBinding,
19
+ validateAndConsumeChallenge,
20
+ } from '../channel-guardian-service.js';
21
+ import { resolveGuardianContext } from '../guardian-context-resolver.js';
22
+ import {
23
+ getPendingDeliveriesByDestination,
24
+ getGuardianActionRequest,
25
+ resolveGuardianActionRequest,
26
+ } from '../../memory/guardian-action-store.js';
27
+ import { answerCall } from '../../calls/call-domain.js';
28
+ import {
29
+ createApprovalRequest,
30
+ updateApprovalDecision,
31
+ getPendingApprovalForRun,
32
+ } from '../../memory/channel-guardian-store.js';
33
+ import { deliverChannelReply } from '../gateway-client.js';
34
+ import {
35
+ getChannelApprovalPrompt,
36
+ buildApprovalUIMetadata,
37
+ buildGuardianApprovalPrompt,
38
+ handleChannelDecision,
39
+ } from '../channel-approvals.js';
40
+ import type { RunOrchestrator } from '../run-orchestrator.js';
41
+ import type {
42
+ MessageProcessor,
43
+ ApprovalCopyGenerator,
44
+ ApprovalConversationGenerator,
45
+ } from '../http-types.js';
46
+ import { composeApprovalMessageGenerative } from '../approval-message-composer.js';
47
+ import { refreshThreadEscalation } from '../../memory/inbox-escalation-projection.js';
48
+ import {
49
+ type GuardianContext,
50
+ verifyGatewayOrigin,
51
+ toGuardianRuntimeContext,
52
+ GUARDIAN_APPROVAL_TTL_MS,
53
+ stripVerificationFailurePrefix,
54
+ buildGuardianDenyContext,
55
+ buildPromptDeliveryFailureContext,
56
+ RUN_POLL_INTERVAL_MS,
57
+ getEffectivePollMaxWait,
58
+ } from './channel-route-shared.js';
59
+ import { deliverReplyViaCallback } from './channel-delivery-routes.js';
60
+ import { handleApprovalInterception, deliverGeneratedApprovalPrompt } from './channel-guardian-routes.js';
61
+
62
+ const log = getLogger('runtime-http');
63
+
64
+ // ---------------------------------------------------------------------------
65
+ // Conversation deletion
66
+ // ---------------------------------------------------------------------------
67
+
68
+ export async function handleDeleteConversation(req: Request, assistantId: string = 'self'): Promise<Response> {
69
+ const body = await req.json() as {
70
+ sourceChannel?: string;
71
+ externalChatId?: string;
72
+ };
73
+
74
+ const { sourceChannel, externalChatId } = body;
75
+
76
+ if (!sourceChannel || typeof sourceChannel !== 'string') {
77
+ return Response.json({ error: 'sourceChannel is required' }, { status: 400 });
78
+ }
79
+ if (!externalChatId || typeof externalChatId !== 'string') {
80
+ return Response.json({ error: 'externalChatId is required' }, { status: 400 });
81
+ }
82
+
83
+ // Delete the assistant-scoped key unconditionally. The legacy key is
84
+ // canonical for the self assistant and must not be deleted from non-self
85
+ // routes, otherwise a non-self reset can accidentally reset self state.
86
+ const legacyKey = `${sourceChannel}:${externalChatId}`;
87
+ const scopedKey = `asst:${assistantId}:${sourceChannel}:${externalChatId}`;
88
+ deleteConversationKey(scopedKey);
89
+ if (assistantId === 'self') {
90
+ deleteConversationKey(legacyKey);
91
+ }
92
+ // external_conversation_bindings is currently assistant-agnostic
93
+ // (unique by sourceChannel + externalChatId). Restrict mutations to the
94
+ // canonical self-assistant route so multi-assistant legacy routes do not
95
+ // clobber each other's bindings.
96
+ if (assistantId === 'self') {
97
+ externalConversationStore.deleteBindingByChannelChat(sourceChannel, externalChatId);
98
+ }
99
+
100
+ return Response.json({ ok: true });
101
+ }
102
+
103
+ // ---------------------------------------------------------------------------
104
+ // Channel inbound message handler
105
+ // ---------------------------------------------------------------------------
106
+
107
+ export async function handleChannelInbound(
108
+ req: Request,
109
+ processMessage?: MessageProcessor,
110
+ bearerToken?: string,
111
+ runOrchestrator?: RunOrchestrator,
112
+ assistantId: string = 'self',
113
+ gatewayOriginSecret?: string,
114
+ approvalCopyGenerator?: ApprovalCopyGenerator,
115
+ approvalConversationGenerator?: ApprovalConversationGenerator,
116
+ ): Promise<Response> {
117
+ // Reject requests that lack valid gateway-origin proof. This ensures
118
+ // channel inbound messages can only arrive via the gateway (which
119
+ // performs webhook-level verification) and not via direct HTTP calls.
120
+ if (!verifyGatewayOrigin(req, bearerToken, gatewayOriginSecret)) {
121
+ log.warn('Rejected channel inbound request: missing or invalid gateway-origin proof');
122
+ return Response.json(
123
+ { error: 'Forbidden: missing gateway-origin proof', code: 'GATEWAY_ORIGIN_REQUIRED' },
124
+ { status: 403 },
125
+ );
126
+ }
127
+
128
+ const body = await req.json() as {
129
+ sourceChannel?: string;
130
+ externalChatId?: string;
131
+ externalMessageId?: string;
132
+ content?: string;
133
+ isEdit?: boolean;
134
+ senderName?: string;
135
+ attachmentIds?: string[];
136
+ senderExternalUserId?: string;
137
+ senderUsername?: string;
138
+ sourceMetadata?: Record<string, unknown>;
139
+ replyCallbackUrl?: string;
140
+ callbackQueryId?: string;
141
+ callbackData?: string;
142
+ };
143
+
144
+ const {
145
+ externalChatId,
146
+ externalMessageId,
147
+ content,
148
+ isEdit,
149
+ attachmentIds,
150
+ sourceMetadata,
151
+ } = body;
152
+
153
+ if (!body.sourceChannel || typeof body.sourceChannel !== 'string') {
154
+ return Response.json({ error: 'sourceChannel is required' }, { status: 400 });
155
+ }
156
+ // Validate and narrow to canonical ChannelId at the boundary — the gateway
157
+ // only sends well-known channel strings, so an unknown value is rejected.
158
+ if (!isChannelId(body.sourceChannel)) {
159
+ return Response.json(
160
+ { error: `Invalid sourceChannel: ${body.sourceChannel}. Valid values: ${CHANNEL_IDS.join(', ')}` },
161
+ { status: 400 },
162
+ );
163
+ }
164
+
165
+ const sourceChannel = body.sourceChannel;
166
+ if (!externalChatId || typeof externalChatId !== 'string') {
167
+ return Response.json({ error: 'externalChatId is required' }, { status: 400 });
168
+ }
169
+ if (!externalMessageId || typeof externalMessageId !== 'string') {
170
+ return Response.json({ error: 'externalMessageId is required' }, { status: 400 });
171
+ }
172
+
173
+ // Reject non-string content regardless of whether attachments are present.
174
+ if (content != null && typeof content !== 'string') {
175
+ return Response.json({ error: 'content must be a string' }, { status: 400 });
176
+ }
177
+
178
+ const trimmedContent = typeof content === 'string' ? content.trim() : '';
179
+ const hasAttachments = Array.isArray(attachmentIds) && attachmentIds.length > 0;
180
+
181
+ const hasCallbackData = typeof body.callbackData === 'string' && body.callbackData.length > 0;
182
+
183
+ if (trimmedContent.length === 0 && !hasAttachments && !isEdit && !hasCallbackData) {
184
+ return Response.json({ error: 'content or attachmentIds is required' }, { status: 400 });
185
+ }
186
+
187
+ // ── Ingress ACL enforcement ──
188
+ // Track the resolved member so the escalate branch can reference it after
189
+ // recordInbound (where we have a conversationId).
190
+ let resolvedMember: ReturnType<typeof findMember> = null;
191
+
192
+ if (body.senderExternalUserId) {
193
+ resolvedMember = findMember({
194
+ sourceChannel,
195
+ externalUserId: body.senderExternalUserId,
196
+ externalChatId,
197
+ });
198
+
199
+ if (!resolvedMember) {
200
+ log.info({ sourceChannel, externalUserId: body.senderExternalUserId }, 'Ingress ACL: no member record, denying');
201
+ return Response.json({ accepted: true, denied: true, reason: 'not_a_member' });
202
+ }
203
+
204
+ if (resolvedMember.status !== 'active') {
205
+ log.info({ sourceChannel, memberId: resolvedMember.id, status: resolvedMember.status }, 'Ingress ACL: member not active, denying');
206
+ return Response.json({ accepted: true, denied: true, reason: `member_${resolvedMember.status}` });
207
+ }
208
+
209
+ if (resolvedMember.policy === 'deny') {
210
+ log.info({ sourceChannel, memberId: resolvedMember.id }, 'Ingress ACL: member policy deny');
211
+ return Response.json({ accepted: true, denied: true, reason: 'policy_deny' });
212
+ }
213
+
214
+ // 'allow' or 'escalate' — update last seen and continue
215
+ updateLastSeen(resolvedMember.id);
216
+ }
217
+
218
+ if (hasAttachments) {
219
+ const resolved = attachmentsStore.getAttachmentsByIds(attachmentIds);
220
+ if (resolved.length !== attachmentIds.length) {
221
+ const resolvedIds = new Set(resolved.map((a) => a.id));
222
+ const missing = attachmentIds.filter((id) => !resolvedIds.has(id));
223
+ return Response.json(
224
+ { error: `Attachment IDs not found: ${missing.join(', ')}` },
225
+ { status: 400 },
226
+ );
227
+ }
228
+ }
229
+
230
+ const sourceMessageId = typeof sourceMetadata?.messageId === 'string'
231
+ ? sourceMetadata.messageId
232
+ : undefined;
233
+
234
+ if (isEdit && !sourceMessageId) {
235
+ return Response.json({ error: 'sourceMetadata.messageId is required for edits' }, { status: 400 });
236
+ }
237
+
238
+ // ── Edit path: update existing message content, no new agent loop ──
239
+ if (isEdit && sourceMessageId) {
240
+ // Dedup the edit event itself (retried edited_message webhooks)
241
+ const editResult = channelDeliveryStore.recordInbound(
242
+ sourceChannel,
243
+ externalChatId,
244
+ externalMessageId,
245
+ { sourceMessageId, assistantId },
246
+ );
247
+
248
+ if (editResult.duplicate) {
249
+ return Response.json({
250
+ accepted: true,
251
+ duplicate: true,
252
+ eventId: editResult.eventId,
253
+ });
254
+ }
255
+
256
+ // Retry lookup a few times — the original message may still be processing
257
+ // (linkMessage hasn't been called yet). Short backoff avoids losing edits
258
+ // that arrive while the original agent loop is in progress.
259
+ const EDIT_LOOKUP_RETRIES = 5;
260
+ const EDIT_LOOKUP_DELAY_MS = 2000;
261
+
262
+ let original: { messageId: string; conversationId: string } | null = null;
263
+ for (let attempt = 0; attempt <= EDIT_LOOKUP_RETRIES; attempt++) {
264
+ original = channelDeliveryStore.findMessageBySourceId(
265
+ sourceChannel,
266
+ externalChatId,
267
+ sourceMessageId,
268
+ );
269
+ if (original) break;
270
+ if (attempt < EDIT_LOOKUP_RETRIES) {
271
+ log.info(
272
+ { assistantId, sourceMessageId, attempt: attempt + 1, maxAttempts: EDIT_LOOKUP_RETRIES },
273
+ 'Original message not linked yet, retrying edit lookup',
274
+ );
275
+ await new Promise((resolve) => setTimeout(resolve, EDIT_LOOKUP_DELAY_MS));
276
+ }
277
+ }
278
+
279
+ if (original) {
280
+ conversationStore.updateMessageContent(original.messageId, content ?? '');
281
+ log.info(
282
+ { assistantId, sourceMessageId, messageId: original.messageId },
283
+ 'Updated message content from edited_message',
284
+ );
285
+ } else {
286
+ log.warn(
287
+ { assistantId, sourceChannel, externalChatId, sourceMessageId },
288
+ 'Could not find original message for edit after retries, ignoring',
289
+ );
290
+ }
291
+
292
+ return Response.json({
293
+ accepted: true,
294
+ duplicate: false,
295
+ eventId: editResult.eventId,
296
+ });
297
+ }
298
+
299
+ // ── New message path ──
300
+ const result = channelDeliveryStore.recordInbound(
301
+ sourceChannel,
302
+ externalChatId,
303
+ externalMessageId,
304
+ { sourceMessageId, assistantId },
305
+ );
306
+
307
+ // external_conversation_bindings is assistant-agnostic. Restrict writes to
308
+ // self so assistant-scoped legacy routes do not overwrite each other's
309
+ // channel binding metadata for the same chat.
310
+ if (assistantId === 'self') {
311
+ externalConversationStore.upsertBinding({
312
+ conversationId: result.conversationId,
313
+ sourceChannel,
314
+ externalChatId,
315
+ externalUserId: body.senderExternalUserId ?? null,
316
+ displayName: body.senderName ?? null,
317
+ username: body.senderUsername ?? null,
318
+ });
319
+ }
320
+
321
+ // ── Ingress escalation ──
322
+ // When the member's policy is 'escalate', create a pending guardian
323
+ // approval request and halt the run. This check runs after recordInbound
324
+ // so we have a conversationId for the approval record.
325
+ if (resolvedMember?.policy === 'escalate') {
326
+ const binding = getGuardianBinding(assistantId, sourceChannel);
327
+ if (!binding) {
328
+ // Fail-closed: can't escalate without a guardian to route to
329
+ log.info({ sourceChannel, memberId: resolvedMember.id }, 'Ingress ACL: escalate policy but no guardian binding, denying');
330
+ return Response.json({ accepted: true, denied: true, reason: 'escalate_no_guardian' });
331
+ }
332
+
333
+ // Persist the raw payload so the decide handler can recover the original
334
+ // message content when the escalation is approved.
335
+ channelDeliveryStore.storePayload(result.eventId, {
336
+ sourceChannel, externalChatId, externalMessageId, content,
337
+ attachmentIds, sourceMetadata: body.sourceMetadata,
338
+ senderName: body.senderName,
339
+ senderExternalUserId: body.senderExternalUserId,
340
+ senderUsername: body.senderUsername,
341
+ replyCallbackUrl: body.replyCallbackUrl,
342
+ assistantId,
343
+ });
344
+
345
+ createApprovalRequest({
346
+ runId: `ingress-escalation-${Date.now()}`,
347
+ conversationId: result.conversationId,
348
+ assistantId,
349
+ channel: sourceChannel,
350
+ requesterExternalUserId: body.senderExternalUserId ?? '',
351
+ requesterChatId: externalChatId,
352
+ guardianExternalUserId: binding.guardianExternalUserId,
353
+ guardianChatId: binding.guardianDeliveryChatId,
354
+ toolName: 'ingress_message',
355
+ riskLevel: 'escalated_ingress',
356
+ reason: 'Ingress policy requires guardian approval',
357
+ expiresAt: Date.now() + GUARDIAN_APPROVAL_TTL_MS,
358
+ });
359
+
360
+ // Update inbox thread escalation state so the desktop UI badge is accurate
361
+ refreshThreadEscalation(result.conversationId, assistantId);
362
+
363
+ // Notify the guardian about the pending escalation via channel delivery
364
+ const senderIdentifier = body.senderName || body.senderUsername || body.senderExternalUserId || 'Unknown sender';
365
+ if (body.replyCallbackUrl) {
366
+ try {
367
+ const notificationText = await composeApprovalMessageGenerative(
368
+ {
369
+ scenario: 'guardian_prompt',
370
+ channel: sourceChannel,
371
+ toolName: 'ingress_message',
372
+ requesterIdentifier: senderIdentifier,
373
+ },
374
+ {
375
+ fallbackText: `New message from ${senderIdentifier} requires your review. Reply with "approve" or "deny".`,
376
+ },
377
+ approvalCopyGenerator,
378
+ );
379
+
380
+ await deliverChannelReply(
381
+ body.replyCallbackUrl,
382
+ { chatId: binding.guardianDeliveryChatId, text: notificationText, assistantId },
383
+ bearerToken,
384
+ );
385
+ } catch (err) {
386
+ // Fail-closed: approval request is already created in the DB and
387
+ // thread escalation state is updated — the desktop UI will show
388
+ // the pending escalation even if channel notification failed.
389
+ log.error({ err, conversationId: result.conversationId, guardianChatId: binding.guardianDeliveryChatId }, 'Failed to notify guardian of ingress escalation');
390
+ }
391
+ } else {
392
+ log.warn({ conversationId: result.conversationId }, 'Ingress escalation created but no replyCallbackUrl to notify guardian');
393
+ }
394
+
395
+ return Response.json({ accepted: true, escalated: true, reason: 'policy_escalate' });
396
+ }
397
+
398
+ const metadataHintsRaw = sourceMetadata?.hints;
399
+ const metadataHints = Array.isArray(metadataHintsRaw)
400
+ ? metadataHintsRaw.filter((hint): hint is string => typeof hint === 'string' && hint.trim().length > 0)
401
+ : [];
402
+ const metadataUxBrief = typeof sourceMetadata?.uxBrief === 'string' && sourceMetadata.uxBrief.trim().length > 0
403
+ ? sourceMetadata.uxBrief.trim()
404
+ : undefined;
405
+
406
+ // Extract channel command intent (e.g. /start from Telegram)
407
+ const rawCommandIntent = sourceMetadata?.commandIntent;
408
+ const commandIntent = rawCommandIntent && typeof rawCommandIntent === 'object' && !Array.isArray(rawCommandIntent)
409
+ ? rawCommandIntent as Record<string, unknown>
410
+ : undefined;
411
+
412
+ // Preserve locale from sourceMetadata so the model can greet in the user's language
413
+ const sourceLanguageCode = typeof sourceMetadata?.languageCode === 'string' && sourceMetadata.languageCode.trim().length > 0
414
+ ? sourceMetadata.languageCode.trim()
415
+ : undefined;
416
+
417
+ const replyCallbackUrl = body.replyCallbackUrl;
418
+
419
+ // ── Guardian verification command intercept ──
420
+ // Handled before normal message processing so it never enters the agent loop.
421
+ if (
422
+ !result.duplicate &&
423
+ trimmedContent.startsWith('/guardian_verify ') &&
424
+ replyCallbackUrl &&
425
+ body.senderExternalUserId
426
+ ) {
427
+ const token = trimmedContent.slice('/guardian_verify '.length).trim();
428
+ if (token.length > 0) {
429
+ const verifyResult = validateAndConsumeChallenge(
430
+ assistantId,
431
+ sourceChannel,
432
+ token,
433
+ body.senderExternalUserId,
434
+ externalChatId,
435
+ body.senderUsername,
436
+ body.senderName,
437
+ );
438
+
439
+ const replyText = verifyResult.success
440
+ ? await composeApprovalMessageGenerative({
441
+ scenario: 'guardian_verify_success',
442
+ channel: sourceChannel,
443
+ }, {}, approvalCopyGenerator)
444
+ : await composeApprovalMessageGenerative({
445
+ scenario: 'guardian_verify_failed',
446
+ channel: sourceChannel,
447
+ failureReason: stripVerificationFailurePrefix(verifyResult.reason),
448
+ }, {}, approvalCopyGenerator);
449
+
450
+ try {
451
+ await deliverChannelReply(replyCallbackUrl, {
452
+ chatId: externalChatId,
453
+ text: replyText,
454
+ assistantId,
455
+ }, bearerToken);
456
+ } catch (err) {
457
+ log.error({ err, externalChatId }, 'Failed to deliver guardian verification reply');
458
+ }
459
+
460
+ return Response.json({
461
+ accepted: true,
462
+ duplicate: false,
463
+ eventId: result.eventId,
464
+ guardianVerification: verifyResult.success ? 'verified' : 'failed',
465
+ });
466
+ }
467
+ }
468
+
469
+ // ── Guardian action answer interception ──
470
+ // Check if this inbound message is a reply to a cross-channel guardian
471
+ // action request (from a voice call). Must run before approval interception
472
+ // so guardian answers are not mistakenly routed into the approval flow.
473
+ if (
474
+ !result.duplicate &&
475
+ trimmedContent.length > 0 &&
476
+ body.senderExternalUserId &&
477
+ replyCallbackUrl
478
+ ) {
479
+ const pendingDeliveries = getPendingDeliveriesByDestination(assistantId, sourceChannel, externalChatId);
480
+ if (pendingDeliveries.length > 0) {
481
+ // Identity check: only the designated guardian can answer
482
+ const validDeliveries = pendingDeliveries.filter(
483
+ (d) => d.destinationExternalUserId === body.senderExternalUserId,
484
+ );
485
+
486
+ if (validDeliveries.length > 0) {
487
+ let matchedDelivery = validDeliveries.length === 1 ? validDeliveries[0] : null;
488
+ let answerText = trimmedContent;
489
+
490
+ // Multiple pending deliveries: require request code prefix for disambiguation
491
+ if (validDeliveries.length > 1) {
492
+ for (const d of validDeliveries) {
493
+ const req = getGuardianActionRequest(d.requestId);
494
+ if (req && trimmedContent.toUpperCase().startsWith(req.requestCode)) {
495
+ matchedDelivery = d;
496
+ answerText = trimmedContent.slice(req.requestCode.length).trim();
497
+ break;
498
+ }
499
+ }
500
+
501
+ if (!matchedDelivery) {
502
+ // Send disambiguation message listing the request codes
503
+ const codes = validDeliveries
504
+ .map((d) => {
505
+ const req = getGuardianActionRequest(d.requestId);
506
+ return req ? req.requestCode : null;
507
+ })
508
+ .filter(Boolean);
509
+ try {
510
+ await deliverChannelReply(replyCallbackUrl, {
511
+ chatId: externalChatId,
512
+ text: `You have multiple pending guardian questions. Please prefix your reply with the reference code (${codes.join(', ')}) to indicate which question you are answering.`,
513
+ assistantId,
514
+ }, bearerToken);
515
+ } catch (err) {
516
+ log.error({ err, externalChatId }, 'Failed to deliver guardian action disambiguation message');
517
+ }
518
+ return Response.json({
519
+ accepted: true,
520
+ duplicate: false,
521
+ eventId: result.eventId,
522
+ guardianAnswer: 'disambiguation_sent',
523
+ });
524
+ }
525
+ }
526
+
527
+ if (matchedDelivery) {
528
+ const request = getGuardianActionRequest(matchedDelivery.requestId);
529
+ if (request) {
530
+ // Attempt to deliver the answer to the call first. Only resolve
531
+ // the guardian action request if answerCall succeeds, so that a
532
+ // failed delivery (e.g. pending question timed out) leaves the
533
+ // request pending for retry from another channel.
534
+ const answerResult = await answerCall({ callSessionId: request.callSessionId, answer: answerText });
535
+
536
+ if (!('ok' in answerResult) || !answerResult.ok) {
537
+ const errorMsg = 'error' in answerResult ? answerResult.error : 'Unknown error';
538
+ log.warn({ callSessionId: request.callSessionId, error: errorMsg }, 'answerCall failed for guardian answer');
539
+ try {
540
+ await deliverChannelReply(replyCallbackUrl, {
541
+ chatId: externalChatId,
542
+ text: 'Failed to deliver your answer to the call. Please try again.',
543
+ assistantId,
544
+ }, bearerToken);
545
+ } catch (deliverErr) {
546
+ log.error({ err: deliverErr, externalChatId }, 'Failed to deliver guardian answer failure notice');
547
+ }
548
+ return Response.json({
549
+ accepted: true,
550
+ duplicate: false,
551
+ eventId: result.eventId,
552
+ guardianAnswer: 'answer_failed',
553
+ });
554
+ }
555
+
556
+ const resolved = resolveGuardianActionRequest(
557
+ request.id,
558
+ answerText,
559
+ sourceChannel,
560
+ body.senderExternalUserId,
561
+ );
562
+
563
+ if (resolved) {
564
+ return Response.json({
565
+ accepted: true,
566
+ duplicate: false,
567
+ eventId: result.eventId,
568
+ guardianAnswer: 'resolved',
569
+ });
570
+ } else {
571
+ // Already answered from another channel
572
+ try {
573
+ await deliverChannelReply(replyCallbackUrl, {
574
+ chatId: externalChatId,
575
+ text: 'This question has already been answered from another channel.',
576
+ assistantId,
577
+ }, bearerToken);
578
+ } catch (err) {
579
+ log.error({ err, externalChatId }, 'Failed to deliver guardian action stale notice');
580
+ }
581
+ return Response.json({
582
+ accepted: true,
583
+ duplicate: false,
584
+ eventId: result.eventId,
585
+ guardianAnswer: 'stale',
586
+ });
587
+ }
588
+ }
589
+ }
590
+ }
591
+ }
592
+ }
593
+
594
+ // ── Actor role resolution ──
595
+ // Uses shared channel-agnostic resolution so all ingress paths classify
596
+ // guardian vs non-guardian actors the same way.
597
+ const guardianCtx: GuardianContext = resolveGuardianContext({
598
+ assistantId,
599
+ sourceChannel,
600
+ externalChatId,
601
+ senderExternalUserId: body.senderExternalUserId,
602
+ senderUsername: body.senderUsername,
603
+ });
604
+
605
+ // ── Approval interception ──
606
+ // Keep this active whenever orchestrator + callback context are available.
607
+ if (
608
+ runOrchestrator &&
609
+ replyCallbackUrl &&
610
+ !result.duplicate
611
+ ) {
612
+ const approvalResult = await handleApprovalInterception({
613
+ conversationId: result.conversationId,
614
+ callbackData: body.callbackData,
615
+ content: trimmedContent,
616
+ externalChatId,
617
+ sourceChannel,
618
+ senderExternalUserId: body.senderExternalUserId,
619
+ replyCallbackUrl,
620
+ bearerToken,
621
+ orchestrator: runOrchestrator,
622
+ guardianCtx,
623
+ assistantId,
624
+ approvalCopyGenerator,
625
+ approvalConversationGenerator,
626
+ });
627
+
628
+ if (approvalResult.handled) {
629
+ return Response.json({
630
+ accepted: true,
631
+ duplicate: false,
632
+ eventId: result.eventId,
633
+ approval: approvalResult.type,
634
+ });
635
+ }
636
+
637
+ // When a callback payload was not handled by approval interception, it's
638
+ // a stale button press with no pending approval. Return early regardless
639
+ // of whether content/attachments are present — callback payloads always
640
+ // have non-empty content (normalize.ts sets message.content to cbq.data),
641
+ // so checking for empty content alone would miss stale callbacks.
642
+ if (hasCallbackData) {
643
+ return Response.json({
644
+ accepted: true,
645
+ duplicate: false,
646
+ eventId: result.eventId,
647
+ approval: 'stale_ignored',
648
+ });
649
+ }
650
+ }
651
+
652
+ // For new (non-duplicate) messages, run the secret ingress check
653
+ // synchronously, then fire off the agent loop in the background.
654
+ if (!result.duplicate && processMessage) {
655
+ // Persist the raw payload first so dead-lettered events can always be
656
+ // replayed. If the ingress check later detects secrets we clear it
657
+ // before throwing, so secret-bearing content is never left on disk.
658
+ channelDeliveryStore.storePayload(result.eventId, {
659
+ sourceChannel, externalChatId, externalMessageId, content,
660
+ attachmentIds, sourceMetadata: body.sourceMetadata,
661
+ senderName: body.senderName,
662
+ senderExternalUserId: body.senderExternalUserId,
663
+ senderUsername: body.senderUsername,
664
+ guardianCtx: toGuardianRuntimeContext(sourceChannel, guardianCtx),
665
+ replyCallbackUrl,
666
+ assistantId,
667
+ });
668
+
669
+ const contentToCheck = content ?? '';
670
+ let ingressCheck: ReturnType<typeof checkIngressForSecrets>;
671
+ try {
672
+ ingressCheck = checkIngressForSecrets(contentToCheck);
673
+ } catch (checkErr) {
674
+ channelDeliveryStore.clearPayload(result.eventId);
675
+ throw checkErr;
676
+ }
677
+ if (ingressCheck.blocked) {
678
+ channelDeliveryStore.clearPayload(result.eventId);
679
+ throw new IngressBlockedError(ingressCheck.userNotice!, ingressCheck.detectedTypes);
680
+ }
681
+
682
+ // Use the approval-aware orchestrator path whenever orchestration and a
683
+ // callback delivery target are available. This keeps approval handling
684
+ // consistent across all channels and avoids silent prompt timeouts.
685
+ const useApprovalPath = Boolean(
686
+ runOrchestrator &&
687
+ replyCallbackUrl,
688
+ );
689
+
690
+ if (useApprovalPath && runOrchestrator && replyCallbackUrl) {
691
+ processChannelMessageWithApprovals({
692
+ orchestrator: runOrchestrator,
693
+ conversationId: result.conversationId,
694
+ eventId: result.eventId,
695
+ content: content ?? '',
696
+ attachmentIds: hasAttachments ? attachmentIds : undefined,
697
+ externalChatId,
698
+ sourceChannel,
699
+ replyCallbackUrl,
700
+ bearerToken,
701
+ guardianCtx,
702
+ assistantId,
703
+ metadataHints,
704
+ metadataUxBrief,
705
+ commandIntent,
706
+ sourceLanguageCode,
707
+ approvalCopyGenerator,
708
+ });
709
+ } else {
710
+ // Fire-and-forget: process the message and deliver the reply in the background.
711
+ // The HTTP response returns immediately so the gateway webhook is not blocked.
712
+ processChannelMessageInBackground({
713
+ processMessage,
714
+ conversationId: result.conversationId,
715
+ eventId: result.eventId,
716
+ content: content ?? '',
717
+ attachmentIds: hasAttachments ? attachmentIds : undefined,
718
+ sourceChannel,
719
+ externalChatId,
720
+ guardianCtx,
721
+ metadataHints,
722
+ metadataUxBrief,
723
+ commandIntent,
724
+ sourceLanguageCode,
725
+ replyCallbackUrl,
726
+ bearerToken,
727
+ assistantId,
728
+ });
729
+ }
730
+ }
731
+
732
+ return Response.json({
733
+ accepted: result.accepted,
734
+ duplicate: result.duplicate,
735
+ eventId: result.eventId,
736
+ });
737
+ }
738
+
739
+ // ---------------------------------------------------------------------------
740
+ // Background message processing
741
+ // ---------------------------------------------------------------------------
742
+
743
+ interface BackgroundProcessingParams {
744
+ processMessage: MessageProcessor;
745
+ conversationId: string;
746
+ eventId: string;
747
+ content: string;
748
+ attachmentIds?: string[];
749
+ sourceChannel: ChannelId;
750
+ externalChatId: string;
751
+ guardianCtx: GuardianContext;
752
+ metadataHints: string[];
753
+ metadataUxBrief?: string;
754
+ replyCallbackUrl?: string;
755
+ bearerToken?: string;
756
+ assistantId?: string;
757
+ commandIntent?: Record<string, unknown>;
758
+ sourceLanguageCode?: string;
759
+ }
760
+
761
+ function processChannelMessageInBackground(params: BackgroundProcessingParams): void {
762
+ const {
763
+ processMessage,
764
+ conversationId,
765
+ eventId,
766
+ content,
767
+ attachmentIds,
768
+ sourceChannel,
769
+ externalChatId,
770
+ guardianCtx,
771
+ metadataHints,
772
+ metadataUxBrief,
773
+ replyCallbackUrl,
774
+ bearerToken,
775
+ assistantId,
776
+ commandIntent,
777
+ sourceLanguageCode,
778
+ } = params;
779
+
780
+ (async () => {
781
+ try {
782
+ const cmdIntent = commandIntent && typeof commandIntent.type === 'string'
783
+ ? { type: commandIntent.type as string, ...(typeof commandIntent.payload === 'string' ? { payload: commandIntent.payload } : {}), ...(sourceLanguageCode ? { languageCode: sourceLanguageCode } : {}) }
784
+ : undefined;
785
+ const { messageId: userMessageId } = await processMessage(
786
+ conversationId,
787
+ content,
788
+ attachmentIds,
789
+ {
790
+ transport: {
791
+ channelId: sourceChannel,
792
+ hints: metadataHints.length > 0 ? metadataHints : undefined,
793
+ uxBrief: metadataUxBrief,
794
+ },
795
+ assistantId,
796
+ guardianContext: toGuardianRuntimeContext(sourceChannel, guardianCtx),
797
+ ...(cmdIntent ? { commandIntent: cmdIntent } : {}),
798
+ },
799
+ sourceChannel,
800
+ );
801
+ channelDeliveryStore.linkMessage(eventId, userMessageId);
802
+ channelDeliveryStore.markProcessed(eventId);
803
+
804
+ if (replyCallbackUrl) {
805
+ await deliverReplyViaCallback(
806
+ conversationId,
807
+ externalChatId,
808
+ replyCallbackUrl,
809
+ bearerToken,
810
+ assistantId,
811
+ );
812
+ }
813
+ } catch (err) {
814
+ log.error({ err, conversationId }, 'Background channel message processing failed');
815
+ channelDeliveryStore.recordProcessingFailure(eventId, err);
816
+ }
817
+ })();
818
+ }
819
+
820
+ // ---------------------------------------------------------------------------
821
+ // Orchestrator-backed channel processing with approval prompt delivery
822
+ // ---------------------------------------------------------------------------
823
+
824
+ interface ApprovalProcessingParams {
825
+ orchestrator: RunOrchestrator;
826
+ conversationId: string;
827
+ eventId: string;
828
+ content: string;
829
+ attachmentIds?: string[];
830
+ externalChatId: string;
831
+ sourceChannel: ChannelId;
832
+ replyCallbackUrl: string;
833
+ bearerToken?: string;
834
+ guardianCtx: GuardianContext;
835
+ assistantId: string;
836
+ metadataHints: string[];
837
+ metadataUxBrief?: string;
838
+ commandIntent?: Record<string, unknown>;
839
+ sourceLanguageCode?: string;
840
+ approvalCopyGenerator?: ApprovalCopyGenerator;
841
+ }
842
+
843
+ /**
844
+ * Process a channel message using the run orchestrator so that
845
+ * `confirmation_request` events are intercepted and written to the
846
+ * runs store. Polls the run until it reaches a terminal state,
847
+ * sending approval prompts when `needs_confirmation` is detected.
848
+ *
849
+ * When the actor is a non-guardian:
850
+ * - `forceStrictSideEffects` is set on the run so all side-effect tools
851
+ * trigger the confirmation flow
852
+ * - Approval prompts are routed to the guardian's chat
853
+ * - A `channelGuardianApprovalRequest` record is created
854
+ */
855
+ function processChannelMessageWithApprovals(params: ApprovalProcessingParams): void {
856
+ const {
857
+ orchestrator,
858
+ conversationId,
859
+ eventId,
860
+ content,
861
+ attachmentIds,
862
+ externalChatId,
863
+ sourceChannel,
864
+ replyCallbackUrl,
865
+ bearerToken,
866
+ guardianCtx,
867
+ assistantId,
868
+ metadataHints,
869
+ metadataUxBrief,
870
+ commandIntent,
871
+ sourceLanguageCode,
872
+ approvalCopyGenerator,
873
+ } = params;
874
+
875
+ const isNonGuardian = guardianCtx.actorRole === 'non-guardian';
876
+ const isUnverifiedChannel = guardianCtx.actorRole === 'unverified_channel';
877
+
878
+ const cmdIntent = commandIntent && typeof commandIntent.type === 'string'
879
+ ? { type: commandIntent.type as string, ...(typeof commandIntent.payload === 'string' ? { payload: commandIntent.payload } : {}), ...(sourceLanguageCode ? { languageCode: sourceLanguageCode } : {}) }
880
+ : undefined;
881
+
882
+ (async () => {
883
+ try {
884
+ const turnChannelContext: TurnChannelContext = {
885
+ userMessageChannel: sourceChannel,
886
+ assistantMessageChannel: sourceChannel,
887
+ };
888
+
889
+ const run = await orchestrator.startRun(
890
+ conversationId,
891
+ content,
892
+ attachmentIds,
893
+ {
894
+ ...((isNonGuardian || isUnverifiedChannel) ? { forceStrictSideEffects: true } : {}),
895
+ sourceChannel,
896
+ hints: metadataHints.length > 0 ? metadataHints : undefined,
897
+ uxBrief: metadataUxBrief,
898
+ assistantId,
899
+ guardianContext: toGuardianRuntimeContext(sourceChannel, guardianCtx),
900
+ ...(cmdIntent ? { commandIntent: cmdIntent } : {}),
901
+ turnChannelContext,
902
+ },
903
+ );
904
+
905
+ // Poll the run until it reaches a terminal state, delivering approval
906
+ // prompts when it transitions to needs_confirmation.
907
+ const startTime = Date.now();
908
+ const pollMaxWait = getEffectivePollMaxWait();
909
+ let lastStatus = run.status;
910
+ // Track whether a post-decision delivery path is guaranteed for this
911
+ // run. Set to true only when the approval prompt is successfully
912
+ // delivered (guardian or standard path), meaning
913
+ // handleApprovalInterception will schedule schedulePostDecisionDelivery
914
+ // when a decision arrives. Auto-deny paths (unverified channel, prompt
915
+ // delivery failures) do NOT set this flag because no post-decision
916
+ // delivery is scheduled in those cases.
917
+ let hasPostDecisionDelivery = false;
918
+
919
+ while (Date.now() - startTime < pollMaxWait) {
920
+ await new Promise((resolve) => setTimeout(resolve, RUN_POLL_INTERVAL_MS));
921
+
922
+ const current = orchestrator.getRun(run.id);
923
+ if (!current) break;
924
+
925
+ if (current.status === 'needs_confirmation' && lastStatus !== 'needs_confirmation') {
926
+ const pending = getPendingConfirmationsByConversation(conversationId);
927
+
928
+ if (isUnverifiedChannel && pending.length > 0) {
929
+ // Unverified channel — auto-deny the sensitive action (fail-closed).
930
+ handleChannelDecision(
931
+ conversationId,
932
+ { action: 'reject', source: 'plain_text' },
933
+ orchestrator,
934
+ buildGuardianDenyContext(
935
+ pending[0].toolName,
936
+ guardianCtx.denialReason ?? 'no_binding',
937
+ sourceChannel,
938
+ ),
939
+ );
940
+ } else if (isNonGuardian && guardianCtx.guardianChatId && pending.length > 0) {
941
+ // Non-guardian actor: route the approval prompt to the guardian's chat
942
+ const guardianPrompt = buildGuardianApprovalPrompt(
943
+ pending[0],
944
+ guardianCtx.requesterIdentifier ?? 'Unknown user',
945
+ );
946
+ const uiMetadata = buildApprovalUIMetadata(guardianPrompt, pending[0]);
947
+
948
+ // Persist the guardian approval request so we can look it up when
949
+ // the guardian responds from their chat.
950
+ const approvalReqRecord = createApprovalRequest({
951
+ runId: run.id,
952
+ conversationId,
953
+ assistantId,
954
+ channel: sourceChannel,
955
+ requesterExternalUserId: guardianCtx.requesterExternalUserId ?? '',
956
+ requesterChatId: guardianCtx.requesterChatId ?? externalChatId,
957
+ guardianExternalUserId: guardianCtx.guardianExternalUserId ?? '',
958
+ guardianChatId: guardianCtx.guardianChatId,
959
+ toolName: pending[0].toolName,
960
+ riskLevel: pending[0].riskLevel,
961
+ expiresAt: Date.now() + GUARDIAN_APPROVAL_TTL_MS,
962
+ });
963
+
964
+ const guardianNotified = await deliverGeneratedApprovalPrompt({
965
+ replyCallbackUrl,
966
+ chatId: guardianCtx.guardianChatId,
967
+ sourceChannel,
968
+ assistantId,
969
+ bearerToken,
970
+ prompt: guardianPrompt,
971
+ uiMetadata,
972
+ messageContext: {
973
+ scenario: 'guardian_prompt',
974
+ toolName: pending[0].toolName,
975
+ requesterIdentifier: guardianCtx.requesterIdentifier ?? 'Unknown user',
976
+ },
977
+ approvalCopyGenerator,
978
+ });
979
+
980
+ if (guardianNotified) {
981
+ hasPostDecisionDelivery = true;
982
+ } else {
983
+ // Deny the approval and the underlying run — fail-closed. If
984
+ // the prompt could not be delivered, the guardian will never see
985
+ // it, so the safest action is to deny rather than cancel (which
986
+ // would allow requester fallback).
987
+ updateApprovalDecision(approvalReqRecord.id, { status: 'denied' });
988
+ handleChannelDecision(
989
+ conversationId,
990
+ { action: 'reject', source: 'plain_text' },
991
+ orchestrator,
992
+ buildPromptDeliveryFailureContext(pending[0].toolName),
993
+ );
994
+ }
995
+
996
+ // Only notify the requester if the guardian prompt was actually delivered
997
+ if (guardianNotified) {
998
+ try {
999
+ const forwardedText = await composeApprovalMessageGenerative({
1000
+ scenario: 'guardian_request_forwarded',
1001
+ toolName: pending[0].toolName,
1002
+ channel: sourceChannel,
1003
+ }, {}, approvalCopyGenerator);
1004
+ await deliverChannelReply(replyCallbackUrl, {
1005
+ chatId: guardianCtx.requesterChatId ?? externalChatId,
1006
+ text: forwardedText,
1007
+ assistantId,
1008
+ }, bearerToken);
1009
+ } catch (err) {
1010
+ log.error({ err, runId: run.id }, 'Failed to notify requester of pending guardian approval');
1011
+ }
1012
+ }
1013
+ } else {
1014
+ // Guardian actor or no guardian binding: standard approval prompt
1015
+ // sent to the requester's own chat.
1016
+ const prompt = getChannelApprovalPrompt(conversationId);
1017
+ if (prompt && pending.length > 0) {
1018
+ const uiMetadata = buildApprovalUIMetadata(prompt, pending[0]);
1019
+ const delivered = await deliverGeneratedApprovalPrompt({
1020
+ replyCallbackUrl,
1021
+ chatId: externalChatId,
1022
+ sourceChannel,
1023
+ assistantId,
1024
+ bearerToken,
1025
+ prompt,
1026
+ uiMetadata,
1027
+ messageContext: {
1028
+ scenario: 'standard_prompt',
1029
+ toolName: pending[0].toolName,
1030
+ },
1031
+ approvalCopyGenerator,
1032
+ });
1033
+ if (delivered) {
1034
+ hasPostDecisionDelivery = true;
1035
+ } else {
1036
+ // Fail-closed: if we cannot deliver the approval prompt, the
1037
+ // user will never see it and the run would hang indefinitely
1038
+ // in needs_confirmation. Auto-deny to avoid silent wait states.
1039
+ log.error(
1040
+ { runId: run.id, conversationId },
1041
+ 'Failed to deliver standard approval prompt; auto-denying (fail-closed)',
1042
+ );
1043
+ handleChannelDecision(
1044
+ conversationId,
1045
+ { action: 'reject', source: 'plain_text' },
1046
+ orchestrator,
1047
+ buildPromptDeliveryFailureContext(pending[0].toolName),
1048
+ );
1049
+ }
1050
+ }
1051
+ }
1052
+ }
1053
+
1054
+ lastStatus = current.status;
1055
+
1056
+ if (current.status === 'completed' || current.status === 'failed') {
1057
+ break;
1058
+ }
1059
+ }
1060
+
1061
+ // Only mark processed and deliver the final reply when the run has
1062
+ // actually reached a terminal state.
1063
+ const finalRun = orchestrator.getRun(run.id);
1064
+ const isTerminal = finalRun?.status === 'completed' || finalRun?.status === 'failed';
1065
+
1066
+ if (isTerminal) {
1067
+ // Link the inbound event to the user message created by the run so
1068
+ // that edit lookups and dead letter replay work correctly.
1069
+ if (run.messageId) {
1070
+ channelDeliveryStore.linkMessage(eventId, run.messageId);
1071
+ }
1072
+
1073
+ channelDeliveryStore.markProcessed(eventId);
1074
+
1075
+ // Deliver the final assistant reply exactly once. The post-decision
1076
+ // poll in schedulePostDecisionDelivery races with this path; the
1077
+ // claimRunDelivery guard ensures only the winner sends the reply.
1078
+ // If delivery fails, release the claim so the other poller can retry
1079
+ // rather than permanently losing the reply.
1080
+ if (channelDeliveryStore.claimRunDelivery(run.id)) {
1081
+ try {
1082
+ await deliverReplyViaCallback(
1083
+ conversationId,
1084
+ externalChatId,
1085
+ replyCallbackUrl,
1086
+ bearerToken,
1087
+ assistantId,
1088
+ );
1089
+ } catch (deliveryErr) {
1090
+ channelDeliveryStore.resetRunDeliveryClaim(run.id);
1091
+ throw deliveryErr;
1092
+ }
1093
+ }
1094
+
1095
+ // If this was a non-guardian run that went through guardian approval,
1096
+ // also notify the guardian's chat about the outcome.
1097
+ if (isNonGuardian && guardianCtx.guardianChatId) {
1098
+ const approvalReq = getPendingApprovalForRun(run.id);
1099
+ if (approvalReq) {
1100
+ // The approval was resolved (run completed or failed) — mark it
1101
+ const outcomeStatus = finalRun?.status === 'completed' ? 'approved' as const : 'denied' as const;
1102
+ updateApprovalDecision(approvalReq.id, { status: outcomeStatus });
1103
+ }
1104
+ }
1105
+ } else if (
1106
+ finalRun?.status === 'needs_confirmation' ||
1107
+ (hasPostDecisionDelivery && finalRun?.status === 'running')
1108
+ ) {
1109
+ // The run is either still waiting for an approval decision or was
1110
+ // recently approved and has resumed execution. In both cases, mark
1111
+ // the event as processed rather than failed:
1112
+ //
1113
+ // - needs_confirmation: the run will resume when the user clicks
1114
+ // approve/reject, and `handleApprovalInterception` will deliver
1115
+ // the reply via `schedulePostDecisionDelivery`.
1116
+ //
1117
+ // - running (after successful prompt delivery): an approval was
1118
+ // applied near the poll deadline and the run resumed but hasn't
1119
+ // reached terminal state yet. `handleApprovalInterception` has
1120
+ // already scheduled post-decision delivery, so the final reply
1121
+ // will be delivered. This condition is only true when the approval
1122
+ // prompt was actually delivered (not in auto-deny paths), ensuring
1123
+ // we don't suppress retry/dead-letter for cases where no
1124
+ // post-decision delivery path exists.
1125
+ //
1126
+ // Marking either state as failed would cause the generic retry sweep
1127
+ // to replay through `processMessage`, which throws "Session is
1128
+ // already processing a message" and dead-letters a valid conversation.
1129
+ log.warn(
1130
+ { runId: run.id, status: finalRun.status, conversationId, hasPostDecisionDelivery },
1131
+ 'Approval-path poll loop timed out while run is in approval-related state; marking event as processed',
1132
+ );
1133
+ channelDeliveryStore.markProcessed(eventId);
1134
+ } else {
1135
+ // The run is in a non-terminal, non-approval state (e.g. running
1136
+ // without prior approval, needs_secret, or disappeared). Record a
1137
+ // processing failure so the retry/dead-letter machinery can handle it.
1138
+ const timeoutErr = new Error(
1139
+ `Approval poll timeout: run did not reach terminal state within ${pollMaxWait}ms (status: ${finalRun?.status ?? 'null'})`,
1140
+ );
1141
+ log.warn(
1142
+ { runId: run.id, status: finalRun?.status, conversationId },
1143
+ 'Approval-path poll loop timed out before run reached terminal state',
1144
+ );
1145
+ channelDeliveryStore.recordProcessingFailure(eventId, timeoutErr);
1146
+ }
1147
+ } catch (err) {
1148
+ log.error({ err, conversationId }, 'Approval-aware channel message processing failed');
1149
+ channelDeliveryStore.recordProcessingFailure(eventId, err);
1150
+ }
1151
+ })();
1152
+ }