@vellumai/assistant 0.3.5 → 0.3.6

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 (486) 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 +26 -2
  164. package/src/config/env-registry.ts +162 -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 +156 -1137
  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 +217 -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 +54 -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 +315 -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 +60 -0
  236. package/src/daemon/ipc-contract-inventory.ts +55 -29
  237. package/src/daemon/ipc-contract.ts +226 -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 +96 -4
  254. package/src/daemon/session-runtime-assembly.ts +149 -15
  255. package/src/daemon/session-surfaces.ts +19 -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 +10 -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 +160 -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 +119 -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/channel-approval-parser.ts +36 -2
  377. package/src/runtime/channel-approvals.ts +0 -21
  378. package/src/runtime/channel-guardian-service.ts +48 -7
  379. package/src/runtime/channel-readiness-service.ts +160 -34
  380. package/src/runtime/channel-readiness-types.ts +10 -4
  381. package/src/runtime/channel-retry-sweep.ts +184 -0
  382. package/src/runtime/guardian-context-resolver.ts +108 -0
  383. package/src/runtime/http-server.ts +275 -743
  384. package/src/runtime/http-types.ts +56 -3
  385. package/src/runtime/middleware/auth.ts +116 -0
  386. package/src/runtime/middleware/error-handler.ts +33 -0
  387. package/src/runtime/middleware/twilio-validation.ts +127 -0
  388. package/src/runtime/routes/app-routes.ts +1 -1
  389. package/src/runtime/routes/call-routes.ts +49 -6
  390. package/src/runtime/routes/channel-delivery-routes.ts +170 -0
  391. package/src/runtime/routes/channel-guardian-routes.ts +1191 -0
  392. package/src/runtime/routes/channel-inbound-routes.ts +1152 -0
  393. package/src/runtime/routes/channel-route-shared.ts +144 -0
  394. package/src/runtime/routes/channel-routes.ts +32 -1634
  395. package/src/runtime/routes/conversation-routes.ts +50 -7
  396. package/src/runtime/routes/events-routes.ts +2 -2
  397. package/src/runtime/routes/identity-routes.ts +126 -0
  398. package/src/runtime/routes/pairing-routes.ts +143 -0
  399. package/src/runtime/routes/run-routes.ts +15 -1
  400. package/src/runtime/run-orchestrator.ts +52 -34
  401. package/src/schedule/schedule-store.ts +36 -32
  402. package/src/schedule/scheduler.ts +3 -3
  403. package/src/security/encrypted-store.ts +5 -7
  404. package/src/security/oauth2.ts +45 -15
  405. package/src/security/parental-control-store.ts +183 -0
  406. package/src/security/secret-allowlist.ts +4 -3
  407. package/src/security/secret-scanner.ts +5 -5
  408. package/src/security/secure-keys.ts +1 -1
  409. package/src/security/token-manager.ts +3 -2
  410. package/src/services/vercel-deploy.ts +6 -2
  411. package/src/skills/tool-manifest.ts +3 -3
  412. package/src/skills/vellum-catalog-remote.ts +75 -16
  413. package/src/slack/slack-webhook.ts +2 -1
  414. package/src/swarm/orchestrator.ts +92 -1
  415. package/src/swarm/router-planner.ts +6 -9
  416. package/src/swarm/worker-prompts.ts +9 -12
  417. package/src/tasks/task-compiler.ts +19 -28
  418. package/src/tasks/task-runner.ts +1 -1
  419. package/src/tools/assets/search.ts +15 -14
  420. package/src/tools/browser/__tests__/auth-detector.test.ts +1 -0
  421. package/src/tools/browser/auto-navigate.ts +1 -0
  422. package/src/tools/browser/browser-execution.ts +10 -1
  423. package/src/tools/browser/browser-manager.ts +119 -4
  424. package/src/tools/browser/network-recorder.ts +5 -0
  425. package/src/tools/credentials/broker.ts +11 -2
  426. package/src/tools/credentials/metadata-store.ts +18 -14
  427. package/src/tools/credentials/post-connect-hooks.ts +61 -0
  428. package/src/tools/credentials/vault.ts +49 -23
  429. package/src/tools/executor.ts +68 -9
  430. package/src/tools/host-terminal/cli-discover.ts +1 -1
  431. package/src/tools/network/script-proxy/http-forwarder.ts +1 -1
  432. package/src/tools/network/script-proxy/mitm-handler.ts +1 -1
  433. package/src/tools/network/script-proxy/server.ts +1 -1
  434. package/src/tools/network/script-proxy/session-manager.ts +6 -5
  435. package/src/tools/network/web-fetch.ts +18 -2
  436. package/src/tools/network/web-search.ts +7 -3
  437. package/src/tools/reminder/reminder-store.ts +14 -15
  438. package/src/tools/schedule/create.ts +1 -0
  439. package/src/tools/schedule/list.ts +2 -1
  440. package/src/tools/shared/filesystem/file-ops-service.ts +5 -7
  441. package/src/tools/skills/skill-script-runner.ts +24 -9
  442. package/src/tools/skills/skill-tool-factory.ts +1 -0
  443. package/src/tools/tasks/work-item-enqueue.ts +2 -2
  444. package/src/tools/terminal/evaluate-typescript.ts +21 -12
  445. package/src/tools/terminal/parser.ts +50 -0
  446. package/src/tools/watcher/delete.ts +6 -0
  447. package/src/tools/weather/service.ts +1 -1
  448. package/src/twitter/client.ts +190 -24
  449. package/src/twitter/session.ts +4 -3
  450. package/src/util/clipboard.ts +1 -1
  451. package/src/util/errors.ts +65 -8
  452. package/src/util/fs.ts +40 -0
  453. package/src/util/json.ts +10 -0
  454. package/src/util/log-redact.ts +189 -0
  455. package/src/util/logger.ts +19 -17
  456. package/src/util/object.ts +3 -0
  457. package/src/util/platform.ts +72 -365
  458. package/src/util/pricing.ts +1 -1
  459. package/src/util/promise-guard.ts +1 -1
  460. package/src/util/retry.ts +19 -0
  461. package/src/util/row-mapper.ts +79 -0
  462. package/src/util/silently.ts +21 -0
  463. package/src/watcher/engine.ts +5 -1
  464. package/src/watcher/provider-types.ts +20 -0
  465. package/src/watcher/providers/github.ts +156 -0
  466. package/src/watcher/providers/gmail.ts +1 -0
  467. package/src/watcher/providers/google-calendar.ts +1 -0
  468. package/src/watcher/providers/linear.ts +460 -0
  469. package/src/watcher/providers/slack.ts +1 -0
  470. package/src/work-items/work-item-runner.ts +1 -1
  471. package/src/workspace/git-service.ts +1 -1
  472. package/src/workspace/provider-commit-message-generator.ts +51 -22
  473. package/src/__tests__/call-bridge.test.ts +0 -517
  474. package/src/__tests__/session-process-bridge.test.ts +0 -244
  475. package/src/calls/call-bridge.ts +0 -168
  476. package/src/config/bundled-skills/media-processing/services/capability-registry.ts +0 -137
  477. package/src/config/bundled-skills/media-processing/services/event-detection-service.ts +0 -280
  478. package/src/config/bundled-skills/media-processing/services/feedback-aggregation.ts +0 -144
  479. package/src/config/bundled-skills/media-processing/services/feedback-store.ts +0 -136
  480. package/src/config/bundled-skills/media-processing/services/retrieval-service.ts +0 -95
  481. package/src/config/bundled-skills/media-processing/services/timeline-service.ts +0 -267
  482. package/src/config/bundled-skills/media-processing/tools/detect-events.ts +0 -110
  483. package/src/config/bundled-skills/media-processing/tools/recalibrate.ts +0 -235
  484. package/src/config/bundled-skills/media-processing/tools/select-tracking-profile.ts +0 -142
  485. package/src/config/bundled-skills/media-processing/tools/submit-feedback.ts +0 -150
  486. package/src/config/vellum-skills/google-oauth-setup/SKILL.md +0 -199
@@ -0,0 +1,1191 @@
1
+ /**
2
+ * Guardian/approval routes: approval interception, approval prompt delivery,
3
+ * and guardian expiry sweep.
4
+ */
5
+ import type { ChannelId } from '../../channels/types.js';
6
+ import { getPendingConfirmationsByConversation } from '../../memory/runs-store.js';
7
+ import { getLogger } from '../../util/logger.js';
8
+ import {
9
+ getPendingApprovalByRunAndGuardianChat,
10
+ getAllPendingApprovalsByGuardianChat,
11
+ getPendingApprovalForRun,
12
+ getUnresolvedApprovalForRun,
13
+ getExpiredPendingApprovals,
14
+ updateApprovalDecision,
15
+ } from '../../memory/channel-guardian-store.js';
16
+ import { deliverChannelReply, deliverApprovalPrompt } from '../gateway-client.js';
17
+ import { parseApprovalDecision } from '../channel-approval-parser.js';
18
+ import {
19
+ getChannelApprovalPrompt,
20
+ handleChannelDecision,
21
+ channelSupportsRichApprovalUI,
22
+ } from '../channel-approvals.js';
23
+ import { runApprovalConversationTurn } from '../approval-conversation-turn.js';
24
+ import type {
25
+ ApprovalDecisionResult,
26
+ ApprovalUIMetadata,
27
+ ChannelApprovalPrompt,
28
+ } from '../channel-approval-types.js';
29
+ import type { RunOrchestrator } from '../run-orchestrator.js';
30
+ import type {
31
+ ApprovalCopyGenerator,
32
+ ApprovalConversationGenerator,
33
+ ApprovalConversationContext,
34
+ } from '../http-types.js';
35
+ import { composeApprovalMessageGenerative } from '../approval-message-composer.js';
36
+ import type { ApprovalMessageContext } from '../approval-message-composer.js';
37
+ import {
38
+ type GuardianContext,
39
+ parseCallbackData,
40
+ requiredDecisionKeywords,
41
+ buildGuardianDenyContext,
42
+ } from './channel-route-shared.js';
43
+ import { schedulePostDecisionDelivery } from './channel-delivery-routes.js';
44
+
45
+ const log = getLogger('runtime-http');
46
+
47
+ // ---------------------------------------------------------------------------
48
+ // Approval prompt delivery
49
+ // ---------------------------------------------------------------------------
50
+
51
+ interface DeliverGeneratedApprovalPromptParams {
52
+ replyCallbackUrl: string;
53
+ chatId: string;
54
+ sourceChannel: ChannelId;
55
+ assistantId: string;
56
+ bearerToken?: string;
57
+ prompt: ChannelApprovalPrompt;
58
+ uiMetadata: ApprovalUIMetadata;
59
+ messageContext: ApprovalMessageContext;
60
+ approvalCopyGenerator?: ApprovalCopyGenerator;
61
+ }
62
+
63
+ /**
64
+ * Deliver approval prompts with best-available UX:
65
+ * 1) Rich UI (buttons) when supported
66
+ * 2) Plain-text fallback if rich delivery fails
67
+ * 3) Plain-text path for channels without rich UI
68
+ */
69
+ export async function deliverGeneratedApprovalPrompt(params: DeliverGeneratedApprovalPromptParams): Promise<boolean> {
70
+ const {
71
+ replyCallbackUrl,
72
+ chatId,
73
+ sourceChannel,
74
+ assistantId,
75
+ bearerToken,
76
+ prompt,
77
+ uiMetadata,
78
+ messageContext,
79
+ approvalCopyGenerator,
80
+ } = params;
81
+ const keywords = requiredDecisionKeywords(uiMetadata.actions);
82
+
83
+ if (channelSupportsRichApprovalUI(sourceChannel)) {
84
+ const richText = await composeApprovalMessageGenerative(
85
+ { ...messageContext, channel: sourceChannel, richUi: true },
86
+ { fallbackText: prompt.promptText },
87
+ approvalCopyGenerator,
88
+ );
89
+
90
+ try {
91
+ await deliverApprovalPrompt(
92
+ replyCallbackUrl,
93
+ chatId,
94
+ richText,
95
+ uiMetadata,
96
+ assistantId,
97
+ bearerToken,
98
+ );
99
+ return true;
100
+ } catch (err) {
101
+ log.error(
102
+ { err, chatId, sourceChannel },
103
+ 'Failed to deliver rich approval prompt, attempting plain-text fallback',
104
+ );
105
+ }
106
+
107
+ const plainTextFallback = await composeApprovalMessageGenerative(
108
+ { ...messageContext, channel: sourceChannel, richUi: false },
109
+ { fallbackText: prompt.plainTextFallback, requiredKeywords: keywords },
110
+ approvalCopyGenerator,
111
+ );
112
+
113
+ // Embed the run reference so plain-text replies can disambiguate when
114
+ // multiple approvals are pending for the same guardian chat.
115
+ const taggedFallback = `${plainTextFallback}\n[ref:${uiMetadata.runId}]`;
116
+
117
+ try {
118
+ await deliverChannelReply(replyCallbackUrl, {
119
+ chatId,
120
+ text: taggedFallback,
121
+ assistantId,
122
+ }, bearerToken);
123
+ return true;
124
+ } catch (err) {
125
+ log.error(
126
+ { err, chatId, sourceChannel },
127
+ 'Failed to deliver plain-text fallback approval prompt',
128
+ );
129
+ return false;
130
+ }
131
+ }
132
+
133
+ const plainText = await composeApprovalMessageGenerative(
134
+ { ...messageContext, channel: sourceChannel, richUi: false },
135
+ { fallbackText: prompt.plainTextFallback, requiredKeywords: keywords },
136
+ approvalCopyGenerator,
137
+ );
138
+
139
+ // Embed the run reference for disambiguation in multi-pending scenarios.
140
+ const taggedPlainText = `${plainText}\n[ref:${uiMetadata.runId}]`;
141
+
142
+ try {
143
+ await deliverChannelReply(replyCallbackUrl, {
144
+ chatId,
145
+ text: taggedPlainText,
146
+ assistantId,
147
+ }, bearerToken);
148
+ return true;
149
+ } catch (err) {
150
+ log.error({ err, chatId, sourceChannel }, 'Failed to deliver plain-text approval prompt');
151
+ return false;
152
+ }
153
+ }
154
+
155
+ // ---------------------------------------------------------------------------
156
+ // Approval interception
157
+ // ---------------------------------------------------------------------------
158
+
159
+ export interface ApprovalInterceptionParams {
160
+ conversationId: string;
161
+ callbackData?: string;
162
+ content: string;
163
+ externalChatId: string;
164
+ sourceChannel: ChannelId;
165
+ senderExternalUserId?: string;
166
+ replyCallbackUrl: string;
167
+ bearerToken?: string;
168
+ orchestrator: RunOrchestrator;
169
+ guardianCtx: GuardianContext;
170
+ assistantId: string;
171
+ approvalCopyGenerator?: ApprovalCopyGenerator;
172
+ approvalConversationGenerator?: ApprovalConversationGenerator;
173
+ }
174
+
175
+ export interface ApprovalInterceptionResult {
176
+ handled: boolean;
177
+ type?: 'decision_applied' | 'assistant_turn' | 'guardian_decision_applied' | 'stale_ignored';
178
+ }
179
+
180
+ /**
181
+ * Check for pending approvals and handle inbound messages accordingly.
182
+ *
183
+ * Returns `{ handled: true }` when the message was consumed by the approval
184
+ * flow (either as a decision or a reminder), so the caller should NOT proceed
185
+ * to normal message processing.
186
+ *
187
+ * When the sender is a guardian responding from their chat, also checks for
188
+ * pending guardian approval requests and routes the decision accordingly.
189
+ */
190
+ export async function handleApprovalInterception(
191
+ params: ApprovalInterceptionParams,
192
+ ): Promise<ApprovalInterceptionResult> {
193
+ const {
194
+ conversationId,
195
+ callbackData,
196
+ content,
197
+ externalChatId,
198
+ sourceChannel,
199
+ senderExternalUserId,
200
+ replyCallbackUrl,
201
+ bearerToken,
202
+ orchestrator,
203
+ guardianCtx,
204
+ assistantId,
205
+ approvalCopyGenerator,
206
+ approvalConversationGenerator,
207
+ } = params;
208
+
209
+ // ── Guardian approval decision path ──
210
+ // When the sender is the guardian and there's a pending guardian approval
211
+ // request targeting this chat, the message might be a decision on behalf
212
+ // of a non-guardian requester.
213
+ if (
214
+ guardianCtx.actorRole === 'guardian' &&
215
+ senderExternalUserId
216
+ ) {
217
+ // Callback/button path: deterministic and takes priority.
218
+ let callbackDecision: ApprovalDecisionResult | null = null;
219
+ if (callbackData) {
220
+ callbackDecision = parseCallbackData(callbackData);
221
+ }
222
+
223
+ // When a callback button provides a run ID, use the scoped lookup so
224
+ // the decision resolves to exactly the right approval even when
225
+ // multiple approvals target the same guardian chat.
226
+ let guardianApproval = callbackDecision?.runId
227
+ ? getPendingApprovalByRunAndGuardianChat(callbackDecision.runId, sourceChannel, externalChatId, assistantId)
228
+ : null;
229
+
230
+ // When the scoped lookup didn't resolve an approval (either because
231
+ // there was no callback or the runId pointed to a stale/expired run),
232
+ // fall back to checking all pending approvals for this guardian chat.
233
+ if (!guardianApproval && callbackDecision) {
234
+ const allPending = getAllPendingApprovalsByGuardianChat(sourceChannel, externalChatId, assistantId);
235
+ if (allPending.length === 1) {
236
+ guardianApproval = allPending[0];
237
+ } else if (allPending.length > 1) {
238
+ // The callback targeted a stale/expired run but the guardian has other
239
+ // pending approvals. Inform them the clicked approval is no longer valid.
240
+ try {
241
+ const staleText = await composeApprovalMessageGenerative({
242
+ scenario: 'guardian_disambiguation',
243
+ pendingCount: allPending.length,
244
+ channel: sourceChannel,
245
+ }, {}, approvalCopyGenerator);
246
+ await deliverChannelReply(replyCallbackUrl, {
247
+ chatId: externalChatId,
248
+ text: staleText,
249
+ assistantId,
250
+ }, bearerToken);
251
+ } catch (err) {
252
+ log.error({ err, externalChatId }, 'Failed to deliver stale callback disambiguation notice');
253
+ }
254
+ return { handled: true, type: 'stale_ignored' };
255
+ }
256
+ }
257
+
258
+ // For plain-text messages (no callback), check if there are any pending
259
+ // approvals for this guardian chat to route through the conversation engine.
260
+ if (!guardianApproval && !callbackDecision) {
261
+ const allPending = getAllPendingApprovalsByGuardianChat(sourceChannel, externalChatId, assistantId);
262
+ if (allPending.length === 1) {
263
+ guardianApproval = allPending[0];
264
+ } else if (allPending.length > 1) {
265
+ // Multiple pending — pick the first approval matching this sender as
266
+ // primary context. The conversation engine sees all matching approvals
267
+ // via pendingApprovals and can disambiguate.
268
+ guardianApproval = allPending.find(a => a.guardianExternalUserId === senderExternalUserId) ?? allPending[0];
269
+ }
270
+ }
271
+
272
+ if (guardianApproval) {
273
+ // Validate that the sender is the specific guardian who was assigned
274
+ // this approval request. This is a defense-in-depth check — the
275
+ // actorRole check above already verifies the sender is a guardian,
276
+ // but this catches edge cases like binding rotation between request
277
+ // creation and decision.
278
+ if (senderExternalUserId !== guardianApproval.guardianExternalUserId) {
279
+ log.warn(
280
+ { externalChatId, senderExternalUserId, expectedGuardian: guardianApproval.guardianExternalUserId },
281
+ 'Non-guardian sender attempted to act on guardian approval request',
282
+ );
283
+ try {
284
+ const mismatchText = await composeApprovalMessageGenerative({
285
+ scenario: 'guardian_identity_mismatch',
286
+ channel: sourceChannel,
287
+ }, {}, approvalCopyGenerator);
288
+ await deliverChannelReply(replyCallbackUrl, {
289
+ chatId: externalChatId,
290
+ text: mismatchText,
291
+ assistantId,
292
+ }, bearerToken);
293
+ } catch (err) {
294
+ log.error({ err, externalChatId }, 'Failed to deliver guardian identity rejection notice');
295
+ }
296
+ return { handled: true, type: 'guardian_decision_applied' };
297
+ }
298
+
299
+ if (callbackDecision) {
300
+ // approve_always is not available for guardian approvals — guardians
301
+ // should not be able to permanently allowlist tools on behalf of the
302
+ // requester. Downgrade to approve_once.
303
+ if (callbackDecision.action === 'approve_always') {
304
+ callbackDecision = { ...callbackDecision, action: 'approve_once' };
305
+ }
306
+
307
+ // Apply the decision to the underlying run using the requester's
308
+ // conversation context
309
+ const result = handleChannelDecision(
310
+ guardianApproval.conversationId,
311
+ callbackDecision,
312
+ orchestrator,
313
+ );
314
+
315
+ if (result.applied) {
316
+ // Update the guardian approval request record only when the decision
317
+ // was actually applied. If the run was already resolved (race with
318
+ // expiry sweep or concurrent callback), skip to avoid inconsistency.
319
+ const approvalStatus = callbackDecision.action === 'reject' ? 'denied' as const : 'approved' as const;
320
+ updateApprovalDecision(guardianApproval.id, {
321
+ status: approvalStatus,
322
+ decidedByExternalUserId: senderExternalUserId,
323
+ });
324
+
325
+ // Notify the requester's chat about the outcome with the tool name
326
+ const outcomeText = await composeApprovalMessageGenerative({
327
+ scenario: 'guardian_decision_outcome',
328
+ decision: callbackDecision.action === 'reject' ? 'denied' : 'approved',
329
+ toolName: guardianApproval.toolName,
330
+ channel: sourceChannel,
331
+ }, {}, approvalCopyGenerator);
332
+ try {
333
+ await deliverChannelReply(replyCallbackUrl, {
334
+ chatId: guardianApproval.requesterChatId,
335
+ text: outcomeText,
336
+ assistantId,
337
+ }, bearerToken);
338
+ } catch (err) {
339
+ log.error({ err, conversationId: guardianApproval.conversationId }, 'Failed to notify requester of guardian decision');
340
+ }
341
+
342
+ // Schedule post-decision delivery to the requester's chat in case
343
+ // the original poll has already exited.
344
+ if (result.runId) {
345
+ schedulePostDecisionDelivery(
346
+ orchestrator,
347
+ result.runId,
348
+ guardianApproval.conversationId,
349
+ guardianApproval.requesterChatId,
350
+ replyCallbackUrl,
351
+ bearerToken,
352
+ assistantId,
353
+ );
354
+ }
355
+ return { handled: true, type: 'guardian_decision_applied' };
356
+ }
357
+
358
+ // Race condition: callback arrived after run was already resolved.
359
+ return { handled: true, type: 'stale_ignored' };
360
+ }
361
+
362
+ // ── Conversational engine for guardian plain-text messages ──
363
+ // Gather all pending guardian approvals for this chat so the engine
364
+ // can handle disambiguation when multiple are pending.
365
+ const allGuardianPending = getAllPendingApprovalsByGuardianChat(sourceChannel, externalChatId, assistantId);
366
+ // Only present approvals that belong to this sender so the engine
367
+ // does not offer disambiguation for requests assigned to a rotated
368
+ // guardian the sender cannot act on.
369
+ const senderPending = allGuardianPending.filter(a => a.guardianExternalUserId === senderExternalUserId);
370
+ const effectivePending = senderPending.length > 0 ? senderPending : allGuardianPending;
371
+ if (effectivePending.length > 0 && approvalConversationGenerator && content) {
372
+ const guardianAllowedActions = ['approve_once', 'reject'];
373
+ const engineContext: ApprovalConversationContext = {
374
+ toolName: guardianApproval.toolName,
375
+ allowedActions: guardianAllowedActions,
376
+ role: 'guardian',
377
+ pendingApprovals: effectivePending.map((a) => ({ runId: a.runId, toolName: a.toolName })),
378
+ userMessage: content,
379
+ };
380
+
381
+ const engineResult = await runApprovalConversationTurn(engineContext, approvalConversationGenerator);
382
+
383
+ if (engineResult.disposition === 'keep_pending') {
384
+ // Non-decision follow-up (clarification, disambiguation, etc.)
385
+ try {
386
+ await deliverChannelReply(replyCallbackUrl, {
387
+ chatId: externalChatId,
388
+ text: engineResult.replyText,
389
+ assistantId,
390
+ }, bearerToken);
391
+ } catch (err) {
392
+ log.error({ err, conversationId: guardianApproval.conversationId }, 'Failed to deliver guardian conversation reply');
393
+ }
394
+ return { handled: true, type: 'assistant_turn' };
395
+ }
396
+
397
+ // Decision-bearing disposition from the engine
398
+ let decisionAction = engineResult.disposition as 'approve_once' | 'approve_always' | 'reject';
399
+
400
+ // Belt-and-suspenders: guardians cannot approve_always even if the
401
+ // engine returns it (the engine's allowedActions validation should
402
+ // already prevent this, but enforce it here too).
403
+ if (decisionAction === 'approve_always') {
404
+ decisionAction = 'approve_once';
405
+ }
406
+
407
+ // Resolve the target approval: use targetRunId from the engine if
408
+ // provided, otherwise use the single guardian approval.
409
+ const targetApproval = engineResult.targetRunId
410
+ ? allGuardianPending.find((a) => a.runId === engineResult.targetRunId) ?? guardianApproval
411
+ : guardianApproval;
412
+
413
+ // Re-validate guardian identity against the resolved target. The
414
+ // engine may select a different pending approval (via targetRunId)
415
+ // that was assigned to a different guardian. Without this check a
416
+ // currently bound guardian could act on a request assigned to a
417
+ // previous guardian after a binding rotation.
418
+ if (senderExternalUserId !== targetApproval.guardianExternalUserId) {
419
+ log.warn(
420
+ { externalChatId, senderExternalUserId, expectedGuardian: targetApproval.guardianExternalUserId, targetRunId: engineResult.targetRunId },
421
+ 'Guardian identity mismatch on engine-selected target approval',
422
+ );
423
+ try {
424
+ const mismatchText = await composeApprovalMessageGenerative({
425
+ scenario: 'guardian_identity_mismatch',
426
+ channel: sourceChannel,
427
+ }, {}, approvalCopyGenerator);
428
+ await deliverChannelReply(replyCallbackUrl, {
429
+ chatId: externalChatId,
430
+ text: mismatchText,
431
+ assistantId,
432
+ }, bearerToken);
433
+ } catch (err) {
434
+ log.error({ err, externalChatId }, 'Failed to deliver guardian identity mismatch notice for engine target');
435
+ }
436
+ return { handled: true, type: 'guardian_decision_applied' };
437
+ }
438
+
439
+ const engineDecision: ApprovalDecisionResult = {
440
+ action: decisionAction,
441
+ source: 'plain_text',
442
+ ...(engineResult.targetRunId ? { runId: engineResult.targetRunId } : {}),
443
+ };
444
+
445
+ const result = handleChannelDecision(
446
+ targetApproval.conversationId,
447
+ engineDecision,
448
+ orchestrator,
449
+ );
450
+
451
+ if (result.applied) {
452
+ // Update the guardian approval request record only when the decision
453
+ // was actually applied. If the run was already resolved (race with
454
+ // expiry sweep or concurrent callback), skip to avoid inconsistency.
455
+ const approvalStatus = decisionAction === 'reject' ? 'denied' as const : 'approved' as const;
456
+ updateApprovalDecision(targetApproval.id, {
457
+ status: approvalStatus,
458
+ decidedByExternalUserId: senderExternalUserId,
459
+ });
460
+
461
+ // Notify the requester's chat about the outcome
462
+ const outcomeText = await composeApprovalMessageGenerative({
463
+ scenario: 'guardian_decision_outcome',
464
+ decision: decisionAction === 'reject' ? 'denied' : 'approved',
465
+ toolName: targetApproval.toolName,
466
+ channel: sourceChannel,
467
+ }, {}, approvalCopyGenerator);
468
+ try {
469
+ await deliverChannelReply(replyCallbackUrl, {
470
+ chatId: targetApproval.requesterChatId,
471
+ text: outcomeText,
472
+ assistantId,
473
+ }, bearerToken);
474
+ } catch (err) {
475
+ log.error({ err, conversationId: targetApproval.conversationId }, 'Failed to notify requester of guardian decision');
476
+ }
477
+
478
+ // Schedule post-decision delivery to the requester's chat
479
+ if (result.runId) {
480
+ schedulePostDecisionDelivery(
481
+ orchestrator,
482
+ result.runId,
483
+ targetApproval.conversationId,
484
+ targetApproval.requesterChatId,
485
+ replyCallbackUrl,
486
+ bearerToken,
487
+ assistantId,
488
+ );
489
+ }
490
+
491
+ // Deliver the engine's reply to the guardian
492
+ try {
493
+ await deliverChannelReply(replyCallbackUrl, {
494
+ chatId: externalChatId,
495
+ text: engineResult.replyText,
496
+ assistantId,
497
+ }, bearerToken);
498
+ } catch (err) {
499
+ log.error({ err, conversationId: targetApproval.conversationId }, 'Failed to deliver guardian decision reply');
500
+ }
501
+
502
+ return { handled: true, type: 'guardian_decision_applied' };
503
+ }
504
+
505
+ // Race condition: run was already resolved. Deliver a stale notice
506
+ // instead of the engine's optimistic reply.
507
+ try {
508
+ const staleText = await composeApprovalMessageGenerative({
509
+ scenario: 'approval_already_resolved',
510
+ channel: sourceChannel,
511
+ }, {}, approvalCopyGenerator);
512
+ await deliverChannelReply(replyCallbackUrl, {
513
+ chatId: externalChatId,
514
+ text: staleText,
515
+ assistantId,
516
+ }, bearerToken);
517
+ } catch (err) {
518
+ log.error({ err, conversationId: targetApproval.conversationId }, 'Failed to deliver stale guardian approval notice');
519
+ }
520
+
521
+ return { handled: true, type: 'stale_ignored' };
522
+ }
523
+
524
+ // ── Legacy fallback when no conversational engine is available ──
525
+ // Use the deterministic parser to handle guardian plain-text so that
526
+ // simple yes/no replies still work when the engine is not injected.
527
+ if (content && !approvalConversationGenerator) {
528
+ const legacyGuardianDecision = parseApprovalDecision(content);
529
+ if (legacyGuardianDecision) {
530
+ // Guardians cannot approve_always — downgrade to approve_once.
531
+ if (legacyGuardianDecision.action === 'approve_always') {
532
+ legacyGuardianDecision.action = 'approve_once';
533
+ }
534
+
535
+ // Resolve the target approval: when a [ref:<runId>] tag is
536
+ // present, look up the specific pending approval by that runId
537
+ // so the decision applies to the correct conversation even when
538
+ // multiple guardian approvals are pending.
539
+ let targetLegacyApproval = guardianApproval;
540
+ if (legacyGuardianDecision.runId) {
541
+ const resolvedByRun = getPendingApprovalByRunAndGuardianChat(
542
+ legacyGuardianDecision.runId,
543
+ sourceChannel,
544
+ externalChatId,
545
+ assistantId,
546
+ );
547
+ if (!resolvedByRun) {
548
+ // The referenced run doesn't match any pending guardian
549
+ // approval — it may have expired or already been resolved.
550
+ try {
551
+ const staleText = await composeApprovalMessageGenerative({
552
+ scenario: 'guardian_disambiguation',
553
+ channel: sourceChannel,
554
+ }, {}, approvalCopyGenerator);
555
+ await deliverChannelReply(replyCallbackUrl, {
556
+ chatId: externalChatId,
557
+ text: staleText,
558
+ assistantId,
559
+ }, bearerToken);
560
+ } catch (err) {
561
+ log.error({ err, externalChatId }, 'Failed to deliver stale approval notice (legacy path)');
562
+ }
563
+ return { handled: true, type: 'stale_ignored' };
564
+ }
565
+ targetLegacyApproval = resolvedByRun;
566
+ }
567
+
568
+ // Re-validate guardian identity against the resolved target.
569
+ // The default guardianApproval was already checked, but a
570
+ // runId-resolved approval may belong to a different guardian.
571
+ if (senderExternalUserId !== targetLegacyApproval.guardianExternalUserId) {
572
+ log.warn(
573
+ { externalChatId, senderExternalUserId, expectedGuardian: targetLegacyApproval.guardianExternalUserId, runId: legacyGuardianDecision.runId },
574
+ 'Guardian identity mismatch on legacy run-ref resolved target approval',
575
+ );
576
+ try {
577
+ const mismatchText = await composeApprovalMessageGenerative({
578
+ scenario: 'guardian_identity_mismatch',
579
+ channel: sourceChannel,
580
+ }, {}, approvalCopyGenerator);
581
+ await deliverChannelReply(replyCallbackUrl, {
582
+ chatId: externalChatId,
583
+ text: mismatchText,
584
+ assistantId,
585
+ }, bearerToken);
586
+ } catch (err) {
587
+ log.error({ err, externalChatId }, 'Failed to deliver guardian identity mismatch notice (legacy path)');
588
+ }
589
+ return { handled: true, type: 'guardian_decision_applied' };
590
+ }
591
+
592
+ const result = handleChannelDecision(
593
+ targetLegacyApproval.conversationId,
594
+ legacyGuardianDecision,
595
+ orchestrator,
596
+ );
597
+
598
+ if (result.applied) {
599
+ const approvalStatus = legacyGuardianDecision.action === 'reject' ? 'denied' as const : 'approved' as const;
600
+ updateApprovalDecision(targetLegacyApproval.id, {
601
+ status: approvalStatus,
602
+ decidedByExternalUserId: senderExternalUserId,
603
+ });
604
+
605
+ // Notify the requester's chat about the outcome
606
+ const outcomeText = await composeApprovalMessageGenerative({
607
+ scenario: 'guardian_decision_outcome',
608
+ decision: legacyGuardianDecision.action === 'reject' ? 'denied' : 'approved',
609
+ toolName: targetLegacyApproval.toolName,
610
+ channel: sourceChannel,
611
+ }, {}, approvalCopyGenerator);
612
+ try {
613
+ await deliverChannelReply(replyCallbackUrl, {
614
+ chatId: targetLegacyApproval.requesterChatId,
615
+ text: outcomeText,
616
+ assistantId,
617
+ }, bearerToken);
618
+ } catch (err) {
619
+ log.error({ err, conversationId: targetLegacyApproval.conversationId }, 'Failed to notify requester of guardian decision (legacy path)');
620
+ }
621
+
622
+ if (result.runId) {
623
+ schedulePostDecisionDelivery(
624
+ orchestrator,
625
+ result.runId,
626
+ targetLegacyApproval.conversationId,
627
+ targetLegacyApproval.requesterChatId,
628
+ replyCallbackUrl,
629
+ bearerToken,
630
+ assistantId,
631
+ );
632
+ }
633
+
634
+ return { handled: true, type: 'guardian_decision_applied' };
635
+ }
636
+
637
+ // Race condition: run was already resolved. Deliver stale notice.
638
+ try {
639
+ const staleText = await composeApprovalMessageGenerative({
640
+ scenario: 'approval_already_resolved',
641
+ channel: sourceChannel,
642
+ }, {}, approvalCopyGenerator);
643
+ await deliverChannelReply(replyCallbackUrl, {
644
+ chatId: externalChatId,
645
+ text: staleText,
646
+ assistantId,
647
+ }, bearerToken);
648
+ } catch (err) {
649
+ log.error({ err, conversationId: targetLegacyApproval.conversationId }, 'Failed to deliver stale guardian legacy fallback notice');
650
+ }
651
+ return { handled: true, type: 'stale_ignored' };
652
+ }
653
+
654
+ // No decision could be parsed — send a generic reminder to the guardian
655
+ try {
656
+ const reminderText = await composeApprovalMessageGenerative({
657
+ scenario: 'reminder_prompt',
658
+ toolName: guardianApproval.toolName,
659
+ channel: sourceChannel,
660
+ }, {}, approvalCopyGenerator);
661
+ await deliverChannelReply(replyCallbackUrl, {
662
+ chatId: externalChatId,
663
+ text: reminderText,
664
+ assistantId,
665
+ }, bearerToken);
666
+ } catch (err) {
667
+ log.error({ err, conversationId: guardianApproval.conversationId }, 'Failed to deliver guardian reminder (legacy path)');
668
+ }
669
+ return { handled: true, type: 'assistant_turn' };
670
+ }
671
+
672
+ // No content and no engine — nothing to do, fall through to standard
673
+ // approval interception below.
674
+ }
675
+ }
676
+
677
+ // ── Standard approval interception (existing flow) ──
678
+ const pendingPrompt = getChannelApprovalPrompt(conversationId);
679
+ if (!pendingPrompt) return { handled: false };
680
+
681
+ // When the sender is from an unverified channel, auto-deny any pending
682
+ // confirmation and block self-approval.
683
+ if (guardianCtx.actorRole === 'unverified_channel') {
684
+ const pending = getPendingConfirmationsByConversation(conversationId);
685
+ if (pending.length > 0) {
686
+ const denyResult = handleChannelDecision(
687
+ conversationId,
688
+ { action: 'reject', source: 'plain_text' },
689
+ orchestrator,
690
+ buildGuardianDenyContext(
691
+ pending[0].toolName,
692
+ guardianCtx.denialReason ?? 'no_binding',
693
+ sourceChannel,
694
+ ),
695
+ );
696
+ if (denyResult.applied && denyResult.runId) {
697
+ schedulePostDecisionDelivery(
698
+ orchestrator,
699
+ denyResult.runId,
700
+ conversationId,
701
+ externalChatId,
702
+ replyCallbackUrl,
703
+ bearerToken,
704
+ assistantId,
705
+ );
706
+ }
707
+ return { handled: true, type: 'decision_applied' };
708
+ }
709
+ }
710
+
711
+ // When the sender is a non-guardian and there's a pending guardian approval
712
+ // for this conversation's run, block self-approval. The non-guardian must
713
+ // wait for the guardian to decide.
714
+ if (guardianCtx.actorRole === 'non-guardian') {
715
+ const pending = getPendingConfirmationsByConversation(conversationId);
716
+ if (pending.length > 0) {
717
+ const guardianApprovalForRun = getPendingApprovalForRun(pending[0].runId);
718
+ if (guardianApprovalForRun) {
719
+ // Allow the requester to cancel their own pending guardian request.
720
+ // Only reject/cancel is permitted — self-approval is still blocked.
721
+ if (content) {
722
+ let requesterCancelIntent = false;
723
+ let cancelReplyText: string | undefined;
724
+ let requesterFollowupReplyText: string | undefined;
725
+
726
+ // Interpret requester follow-ups through the conversation engine so
727
+ // "nevermind/cancel" resolves naturally while clarifying questions
728
+ // remain conversational turns.
729
+ if (approvalConversationGenerator) {
730
+ const cancelContext: ApprovalConversationContext = {
731
+ toolName: pending[0].toolName,
732
+ allowedActions: ['reject'],
733
+ role: 'requester',
734
+ pendingApprovals: pending.map(p => ({ runId: p.runId, toolName: p.toolName })),
735
+ userMessage: content,
736
+ };
737
+ const cancelResult = await runApprovalConversationTurn(cancelContext, approvalConversationGenerator);
738
+ if (cancelResult.disposition === 'reject') {
739
+ requesterCancelIntent = true;
740
+ cancelReplyText = cancelResult.replyText;
741
+ } else if (cancelResult.disposition === 'keep_pending') {
742
+ requesterFollowupReplyText = cancelResult.replyText;
743
+ }
744
+ }
745
+
746
+ if (requesterCancelIntent) {
747
+ const rejectDecision: ApprovalDecisionResult = {
748
+ action: 'reject',
749
+ source: 'plain_text',
750
+ };
751
+ const cancelApplyResult = handleChannelDecision(conversationId, rejectDecision, orchestrator);
752
+ if (cancelApplyResult.applied) {
753
+ updateApprovalDecision(guardianApprovalForRun.id, {
754
+ status: 'denied',
755
+ decidedByExternalUserId: senderExternalUserId,
756
+ });
757
+
758
+ // Notify requester
759
+ const replyText = cancelReplyText ?? await composeApprovalMessageGenerative({
760
+ scenario: 'requester_cancel',
761
+ toolName: pending[0].toolName,
762
+ channel: sourceChannel,
763
+ }, {}, approvalCopyGenerator);
764
+ try {
765
+ await deliverChannelReply(replyCallbackUrl, {
766
+ chatId: externalChatId,
767
+ text: replyText,
768
+ assistantId,
769
+ }, bearerToken);
770
+ } catch (err) {
771
+ log.error({ err, conversationId }, 'Failed to deliver requester cancel notice');
772
+ }
773
+
774
+ // Notify guardian that the request was cancelled
775
+ try {
776
+ const guardianNotice = await composeApprovalMessageGenerative({
777
+ scenario: 'guardian_decision_outcome',
778
+ decision: 'denied',
779
+ toolName: pending[0].toolName,
780
+ channel: sourceChannel,
781
+ }, {}, approvalCopyGenerator);
782
+ await deliverChannelReply(replyCallbackUrl, {
783
+ chatId: guardianApprovalForRun.guardianChatId,
784
+ text: guardianNotice,
785
+ assistantId,
786
+ }, bearerToken);
787
+ } catch (err) {
788
+ log.error({ err, conversationId }, 'Failed to notify guardian of requester cancellation');
789
+ }
790
+
791
+ if (cancelApplyResult.runId) {
792
+ schedulePostDecisionDelivery(
793
+ orchestrator, cancelApplyResult.runId, conversationId, externalChatId,
794
+ replyCallbackUrl, bearerToken, assistantId,
795
+ );
796
+ }
797
+ return { handled: true, type: 'decision_applied' };
798
+ }
799
+
800
+ // Race condition: approval was already resolved elsewhere.
801
+ try {
802
+ const staleText = await composeApprovalMessageGenerative({
803
+ scenario: 'approval_already_resolved',
804
+ channel: sourceChannel,
805
+ }, {}, approvalCopyGenerator);
806
+ await deliverChannelReply(replyCallbackUrl, {
807
+ chatId: externalChatId,
808
+ text: staleText,
809
+ assistantId,
810
+ }, bearerToken);
811
+ } catch (err) {
812
+ log.error({ err, conversationId }, 'Failed to deliver stale requester-cancel notice');
813
+ }
814
+ return { handled: true, type: 'stale_ignored' };
815
+ }
816
+
817
+ if (requesterFollowupReplyText) {
818
+ try {
819
+ await deliverChannelReply(replyCallbackUrl, {
820
+ chatId: externalChatId,
821
+ text: requesterFollowupReplyText,
822
+ assistantId,
823
+ }, bearerToken);
824
+ } catch (err) {
825
+ log.error({ err, conversationId }, 'Failed to deliver requester follow-up reply while awaiting guardian');
826
+ }
827
+ return { handled: true, type: 'assistant_turn' };
828
+ }
829
+ }
830
+
831
+ // Not a cancel intent — tell the requester their request is pending
832
+ try {
833
+ const pendingText = await composeApprovalMessageGenerative({
834
+ scenario: 'request_pending_guardian',
835
+ channel: sourceChannel,
836
+ }, {}, approvalCopyGenerator);
837
+ await deliverChannelReply(replyCallbackUrl, {
838
+ chatId: externalChatId,
839
+ text: pendingText,
840
+ assistantId,
841
+ }, bearerToken);
842
+ } catch (err) {
843
+ log.error({ err, conversationId }, 'Failed to deliver guardian-pending notice to requester');
844
+ }
845
+ return { handled: true, type: 'assistant_turn' };
846
+ }
847
+
848
+ // Check for an expired-but-unresolved guardian approval. If the approval
849
+ // expired without a guardian decision, auto-deny the run and transition
850
+ // the approval to 'expired'. Without this, the requester could bypass
851
+ // guardian-only controls by simply waiting for the TTL to elapse.
852
+ const unresolvedApproval = getUnresolvedApprovalForRun(pending[0].runId);
853
+ if (unresolvedApproval) {
854
+ updateApprovalDecision(unresolvedApproval.id, { status: 'expired' });
855
+
856
+ // Auto-deny the underlying run so it does not remain actionable
857
+ const expiredDecision: ApprovalDecisionResult = {
858
+ action: 'reject',
859
+ source: 'plain_text',
860
+ };
861
+ handleChannelDecision(conversationId, expiredDecision, orchestrator);
862
+
863
+ try {
864
+ const expiredText = await composeApprovalMessageGenerative({
865
+ scenario: 'guardian_expired_requester',
866
+ toolName: pending[0].toolName,
867
+ channel: sourceChannel,
868
+ }, {}, approvalCopyGenerator);
869
+ await deliverChannelReply(replyCallbackUrl, {
870
+ chatId: externalChatId,
871
+ text: expiredText,
872
+ assistantId,
873
+ }, bearerToken);
874
+ } catch (err) {
875
+ log.error({ err, conversationId }, 'Failed to deliver guardian-expiry notice to requester');
876
+ }
877
+ return { handled: true, type: 'decision_applied' };
878
+ }
879
+ }
880
+ }
881
+
882
+ // Try to extract a decision from callback data (button press) first.
883
+ // Callback/button path remains deterministic and takes priority.
884
+ if (callbackData) {
885
+ const cbDecision = parseCallbackData(callbackData);
886
+ if (cbDecision) {
887
+ // When the decision came from a callback button, validate that the embedded
888
+ // run ID matches the currently pending run. A stale button (from a previous
889
+ // approval prompt) must not apply to a different pending run.
890
+ if (cbDecision.runId) {
891
+ const pending = getPendingConfirmationsByConversation(conversationId);
892
+ if (pending.length === 0 || pending[0].runId !== cbDecision.runId) {
893
+ log.warn(
894
+ { conversationId, callbackRunId: cbDecision.runId, pendingRunId: pending[0]?.runId },
895
+ 'Callback run ID does not match pending run, ignoring stale button press',
896
+ );
897
+ return { handled: true, type: 'stale_ignored' };
898
+ }
899
+ }
900
+
901
+ const result = handleChannelDecision(conversationId, cbDecision, orchestrator);
902
+
903
+ if (result.applied) {
904
+ // Schedule a background poll for run terminal state and deliver the reply.
905
+ // This handles the case where the original poll in
906
+ // processChannelMessageWithApprovals has already exited due to timeout.
907
+ // The claimRunDelivery guard ensures at-most-once delivery when both
908
+ // pollers race to terminal state.
909
+ if (result.runId) {
910
+ schedulePostDecisionDelivery(
911
+ orchestrator,
912
+ result.runId,
913
+ conversationId,
914
+ externalChatId,
915
+ replyCallbackUrl,
916
+ bearerToken,
917
+ assistantId,
918
+ );
919
+ }
920
+ return { handled: true, type: 'decision_applied' };
921
+ }
922
+
923
+ // Race condition: run was already resolved between the stale check
924
+ // above and the decision attempt.
925
+ return { handled: true, type: 'stale_ignored' };
926
+ }
927
+ }
928
+
929
+ // ── Conversational approval engine for plain-text messages ──
930
+ // Instead of deterministic keyword matching and reminder prompts, delegate
931
+ // to the conversational approval engine which can classify natural language
932
+ // and respond conversationally.
933
+ const pending = getPendingConfirmationsByConversation(conversationId);
934
+ if (pending.length > 0 && approvalConversationGenerator && content) {
935
+ const allowedActions = pendingPrompt.actions.map((a) => a.id);
936
+ const engineContext: ApprovalConversationContext = {
937
+ toolName: pending[0].toolName,
938
+ allowedActions,
939
+ role: 'requester',
940
+ pendingApprovals: pending.map((p) => ({ runId: p.runId, toolName: p.toolName })),
941
+ userMessage: content,
942
+ };
943
+
944
+ const engineResult = await runApprovalConversationTurn(engineContext, approvalConversationGenerator);
945
+
946
+ if (engineResult.disposition === 'keep_pending') {
947
+ // Non-decision follow-up — deliver the engine's reply and keep the run pending
948
+ try {
949
+ await deliverChannelReply(replyCallbackUrl, {
950
+ chatId: externalChatId,
951
+ text: engineResult.replyText,
952
+ assistantId,
953
+ }, bearerToken);
954
+ } catch (err) {
955
+ log.error({ err, conversationId }, 'Failed to deliver approval conversation reply');
956
+ }
957
+ return { handled: true, type: 'assistant_turn' };
958
+ }
959
+
960
+ // Decision-bearing disposition — map to ApprovalDecisionResult and apply
961
+ const decisionAction = engineResult.disposition as 'approve_once' | 'approve_always' | 'reject';
962
+ const engineDecision: ApprovalDecisionResult = {
963
+ action: decisionAction,
964
+ source: 'plain_text',
965
+ ...(engineResult.targetRunId ? { runId: engineResult.targetRunId } : {}),
966
+ };
967
+
968
+ const result = handleChannelDecision(conversationId, engineDecision, orchestrator);
969
+
970
+ if (result.applied) {
971
+ if (result.runId) {
972
+ schedulePostDecisionDelivery(
973
+ orchestrator,
974
+ result.runId,
975
+ conversationId,
976
+ externalChatId,
977
+ replyCallbackUrl,
978
+ bearerToken,
979
+ assistantId,
980
+ );
981
+ }
982
+
983
+ // Deliver the engine's reply text to the user
984
+ try {
985
+ await deliverChannelReply(replyCallbackUrl, {
986
+ chatId: externalChatId,
987
+ text: engineResult.replyText,
988
+ assistantId,
989
+ }, bearerToken);
990
+ } catch (err) {
991
+ log.error({ err, conversationId }, 'Failed to deliver approval decision reply');
992
+ }
993
+
994
+ return { handled: true, type: 'decision_applied' };
995
+ }
996
+
997
+ // Race condition: run was already resolved by expiry sweep or
998
+ // concurrent callback. Deliver a stale notice instead of the
999
+ // engine's optimistic reply.
1000
+ try {
1001
+ const staleText = await composeApprovalMessageGenerative({
1002
+ scenario: 'approval_already_resolved',
1003
+ channel: sourceChannel,
1004
+ }, {}, approvalCopyGenerator);
1005
+ await deliverChannelReply(replyCallbackUrl, {
1006
+ chatId: externalChatId,
1007
+ text: staleText,
1008
+ assistantId,
1009
+ }, bearerToken);
1010
+ } catch (err) {
1011
+ log.error({ err, conversationId }, 'Failed to deliver stale approval notice');
1012
+ }
1013
+
1014
+ return { handled: true, type: 'stale_ignored' };
1015
+ }
1016
+
1017
+ // Fallback: no conversational generator available or no content — use
1018
+ // the legacy deterministic path as a safety net. This preserves backward
1019
+ // compatibility when the generator is not injected.
1020
+ if (content) {
1021
+ const legacyDecision = parseApprovalDecision(content);
1022
+ if (legacyDecision) {
1023
+ if (legacyDecision.runId) {
1024
+ if (pending.length === 0 || pending[0].runId !== legacyDecision.runId) {
1025
+ return { handled: true, type: 'stale_ignored' };
1026
+ }
1027
+ }
1028
+ const result = handleChannelDecision(conversationId, legacyDecision, orchestrator);
1029
+ if (result.applied) {
1030
+ if (result.runId) {
1031
+ schedulePostDecisionDelivery(
1032
+ orchestrator,
1033
+ result.runId,
1034
+ conversationId,
1035
+ externalChatId,
1036
+ replyCallbackUrl,
1037
+ bearerToken,
1038
+ assistantId,
1039
+ );
1040
+ }
1041
+ return { handled: true, type: 'decision_applied' };
1042
+ }
1043
+
1044
+ // Race condition: run was already resolved.
1045
+ try {
1046
+ const staleText = await composeApprovalMessageGenerative({
1047
+ scenario: 'approval_already_resolved',
1048
+ channel: sourceChannel,
1049
+ }, {}, approvalCopyGenerator);
1050
+ await deliverChannelReply(replyCallbackUrl, {
1051
+ chatId: externalChatId,
1052
+ text: staleText,
1053
+ assistantId,
1054
+ }, bearerToken);
1055
+ } catch (err) {
1056
+ log.error({ err, conversationId }, 'Failed to deliver stale approval notice (legacy path)');
1057
+ }
1058
+ return { handled: true, type: 'stale_ignored' };
1059
+ }
1060
+ }
1061
+
1062
+ // No decision could be extracted and no conversational engine is available —
1063
+ // deliver a simple status reply rather than a reminder prompt.
1064
+ try {
1065
+ const statusText = await composeApprovalMessageGenerative({
1066
+ scenario: 'reminder_prompt',
1067
+ channel: sourceChannel,
1068
+ toolName: pending.length > 0 ? pending[0].toolName : undefined,
1069
+ }, {}, approvalCopyGenerator);
1070
+ await deliverChannelReply(replyCallbackUrl, {
1071
+ chatId: externalChatId,
1072
+ text: statusText,
1073
+ assistantId,
1074
+ }, bearerToken);
1075
+ } catch (err) {
1076
+ log.error({ err, conversationId }, 'Failed to deliver approval status reply');
1077
+ }
1078
+
1079
+ return { handled: true, type: 'assistant_turn' };
1080
+ }
1081
+
1082
+ // ---------------------------------------------------------------------------
1083
+ // Proactive guardian approval expiry sweep
1084
+ // ---------------------------------------------------------------------------
1085
+
1086
+ /** Interval at which the expiry sweep runs (60 seconds). */
1087
+ const GUARDIAN_EXPIRY_SWEEP_INTERVAL_MS = 60_000;
1088
+
1089
+ /** Timer handle for the expiry sweep so it can be stopped in tests. */
1090
+ let expirySweepTimer: ReturnType<typeof setInterval> | null = null;
1091
+
1092
+ /**
1093
+ * Sweep expired guardian approval requests, auto-deny the underlying runs,
1094
+ * and notify both the requester and guardian. This runs proactively on a
1095
+ * timer so expired approvals are closed without waiting for follow-up
1096
+ * traffic from either party.
1097
+ *
1098
+ * Accepts a `gatewayBaseUrl` rather than a fixed delivery URL so that
1099
+ * each approval's notification is routed to the correct channel-specific
1100
+ * endpoint (e.g. `/deliver/telegram`, `/deliver/sms`).
1101
+ */
1102
+ export function sweepExpiredGuardianApprovals(
1103
+ orchestrator: RunOrchestrator,
1104
+ gatewayBaseUrl: string,
1105
+ bearerToken?: string,
1106
+ approvalCopyGenerator?: ApprovalCopyGenerator,
1107
+ ): void {
1108
+ const expired = getExpiredPendingApprovals();
1109
+ for (const approval of expired) {
1110
+ // Mark the approval as expired
1111
+ updateApprovalDecision(approval.id, { status: 'expired' });
1112
+
1113
+ // Auto-deny the underlying run
1114
+ const expiredDecision: ApprovalDecisionResult = {
1115
+ action: 'reject',
1116
+ source: 'plain_text',
1117
+ };
1118
+ handleChannelDecision(approval.conversationId, expiredDecision, orchestrator);
1119
+
1120
+ // Construct the per-channel delivery URL from the approval's channel
1121
+ const deliverUrl = `${gatewayBaseUrl}/deliver/${approval.channel}`;
1122
+
1123
+ // Notify the requester that the approval expired
1124
+ void (async () => {
1125
+ const requesterText = await composeApprovalMessageGenerative({
1126
+ scenario: 'guardian_expired_requester',
1127
+ toolName: approval.toolName,
1128
+ channel: approval.channel,
1129
+ }, {}, approvalCopyGenerator);
1130
+ await deliverChannelReply(deliverUrl, {
1131
+ chatId: approval.requesterChatId,
1132
+ text: requesterText,
1133
+ assistantId: approval.assistantId,
1134
+ }, bearerToken);
1135
+ })().catch((err) => {
1136
+ log.error({ err, runId: approval.runId }, 'Failed to notify requester of guardian approval expiry');
1137
+ });
1138
+
1139
+ // Notify the guardian that the approval expired
1140
+ void (async () => {
1141
+ const guardianText = await composeApprovalMessageGenerative({
1142
+ scenario: 'guardian_expired_guardian',
1143
+ toolName: approval.toolName,
1144
+ requesterIdentifier: approval.requesterExternalUserId,
1145
+ channel: approval.channel,
1146
+ }, {}, approvalCopyGenerator);
1147
+ await deliverChannelReply(deliverUrl, {
1148
+ chatId: approval.guardianChatId,
1149
+ text: guardianText,
1150
+ assistantId: approval.assistantId,
1151
+ }, bearerToken);
1152
+ })().catch((err) => {
1153
+ log.error({ err, runId: approval.runId }, 'Failed to notify guardian of approval expiry');
1154
+ });
1155
+
1156
+ log.info(
1157
+ { runId: approval.runId, approvalId: approval.id },
1158
+ 'Auto-denied expired guardian approval request',
1159
+ );
1160
+ }
1161
+ }
1162
+
1163
+ /**
1164
+ * Start the periodic expiry sweep. Idempotent — calling it multiple times
1165
+ * re-uses the same timer.
1166
+ */
1167
+ export function startGuardianExpirySweep(
1168
+ orchestrator: RunOrchestrator,
1169
+ gatewayBaseUrl: string,
1170
+ bearerToken?: string,
1171
+ approvalCopyGenerator?: ApprovalCopyGenerator,
1172
+ ): void {
1173
+ if (expirySweepTimer) return;
1174
+ expirySweepTimer = setInterval(() => {
1175
+ try {
1176
+ sweepExpiredGuardianApprovals(orchestrator, gatewayBaseUrl, bearerToken, approvalCopyGenerator);
1177
+ } catch (err) {
1178
+ log.error({ err }, 'Guardian expiry sweep failed');
1179
+ }
1180
+ }, GUARDIAN_EXPIRY_SWEEP_INTERVAL_MS);
1181
+ }
1182
+
1183
+ /**
1184
+ * Stop the periodic expiry sweep. Used in tests and shutdown.
1185
+ */
1186
+ export function stopGuardianExpirySweep(): void {
1187
+ if (expirySweepTimer) {
1188
+ clearInterval(expirySweepTimer);
1189
+ expirySweepTimer = null;
1190
+ }
1191
+ }