vellum 0.2.1 → 0.2.2

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 (349) hide show
  1. package/README.md +15 -2
  2. package/bun.lock +5 -2
  3. package/package.json +4 -2
  4. package/scripts/capture-x-graphql.ts +562 -0
  5. package/scripts/ipc/check-swift-decoder-drift.ts +2 -1
  6. package/scripts/test.sh +5 -0
  7. package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +133 -34
  8. package/src/__tests__/account-registry.test.ts +2 -1
  9. package/src/__tests__/agent-heartbeat-service.test.ts +250 -0
  10. package/src/__tests__/asset-materialize-tool.test.ts +16 -15
  11. package/src/__tests__/asset-search-tool.test.ts +23 -22
  12. package/src/__tests__/attachments-store.test.ts +56 -127
  13. package/src/__tests__/browser-skill-baseline-tool-payload.test.ts +5 -4
  14. package/src/__tests__/browser-skill-endstate.test.ts +4 -3
  15. package/src/__tests__/call-bridge.test.ts +385 -0
  16. package/src/__tests__/call-constants.test.ts +40 -0
  17. package/src/__tests__/call-orchestrator.test.ts +130 -4
  18. package/src/__tests__/call-recovery.test.ts +518 -0
  19. package/src/__tests__/call-routes-http.test.ts +459 -0
  20. package/src/__tests__/call-state-machine.test.ts +143 -0
  21. package/src/__tests__/call-store.test.ts +216 -1
  22. package/src/__tests__/cli-discover.test.ts +1 -1
  23. package/src/__tests__/commit-message-enrichment-service.test.ts +148 -7
  24. package/src/__tests__/compaction.benchmark.test.ts +176 -0
  25. package/src/__tests__/computer-use-tools.test.ts +250 -0
  26. package/src/__tests__/config-schema.test.ts +299 -3
  27. package/src/__tests__/conflict-store.test.ts +2 -1
  28. package/src/__tests__/contacts-tools.test.ts +331 -0
  29. package/src/__tests__/conversation-store.test.ts +30 -32
  30. package/src/__tests__/credential-security-invariants.test.ts +4 -0
  31. package/src/__tests__/date-context.test.ts +373 -0
  32. package/src/__tests__/db-schedule-syntax-migration.test.ts +129 -0
  33. package/src/__tests__/fixtures/media-reuse-fixtures.ts +3 -3
  34. package/src/__tests__/followup-tools.test.ts +303 -0
  35. package/src/__tests__/handlers-twitter-config.test.ts +718 -0
  36. package/src/__tests__/intent-routing.test.ts +64 -57
  37. package/src/__tests__/ipc-roundtrip.benchmark.test.ts +237 -0
  38. package/src/__tests__/ipc-snapshot.test.ts +62 -28
  39. package/src/__tests__/llm-usage-store.test.ts +3 -8
  40. package/src/__tests__/media-generate-image.test.ts +1 -1
  41. package/src/__tests__/media-reuse-story.e2e.test.ts +7 -7
  42. package/src/__tests__/memory-retrieval.benchmark.test.ts +430 -0
  43. package/src/__tests__/parallel-tool.benchmark.test.ts +294 -0
  44. package/src/__tests__/playbook-tools.test.ts +342 -0
  45. package/src/__tests__/profile-compiler.test.ts +2 -1
  46. package/src/__tests__/provider-streaming.benchmark.test.ts +773 -0
  47. package/src/__tests__/recurrence-engine-rruleset.test.ts +78 -0
  48. package/src/__tests__/recurrence-engine.test.ts +69 -0
  49. package/src/__tests__/recurrence-types.test.ts +71 -0
  50. package/src/__tests__/registry.test.ts +5 -3
  51. package/src/__tests__/relay-server.test.ts +633 -0
  52. package/src/__tests__/reminder-store.test.ts +6 -3
  53. package/src/__tests__/reminder.test.ts +43 -77
  54. package/src/__tests__/run-orchestrator-assistant-events.test.ts +8 -4
  55. package/src/__tests__/run-orchestrator.test.ts +4 -4
  56. package/src/__tests__/runtime-attachment-metadata.test.ts +7 -6
  57. package/src/__tests__/runtime-runs-http.test.ts +4 -4
  58. package/src/__tests__/runtime-runs.test.ts +4 -4
  59. package/src/__tests__/schedule-store.test.ts +482 -0
  60. package/src/__tests__/schedule-tools.test.ts +700 -0
  61. package/src/__tests__/scheduler-recurrence.test.ts +329 -0
  62. package/src/__tests__/server-history-render.test.ts +14 -13
  63. package/src/__tests__/session-error.test.ts +28 -0
  64. package/src/__tests__/session-init.benchmark.test.ts +462 -0
  65. package/src/__tests__/session-queue.test.ts +71 -48
  66. package/src/__tests__/session-runtime-assembly.test.ts +161 -0
  67. package/src/__tests__/session-surfaces-task-progress.test.ts +104 -0
  68. package/src/__tests__/signup-e2e.test.ts +2 -1
  69. package/src/__tests__/skill-projection.benchmark.test.ts +328 -0
  70. package/src/__tests__/skill-script-runner.test.ts +159 -0
  71. package/src/__tests__/speaker-identification.test.ts +52 -0
  72. package/src/__tests__/subagent-manager-notify.test.ts +42 -10
  73. package/src/__tests__/subagent-tools.test.ts +141 -41
  74. package/src/__tests__/task-compiler.test.ts +2 -1
  75. package/src/__tests__/task-runner.test.ts +2 -1
  76. package/src/__tests__/task-scheduler.test.ts +2 -1
  77. package/src/__tests__/task-tools.test.ts +49 -56
  78. package/src/__tests__/tool-audit-listener.test.ts +1 -0
  79. package/src/__tests__/tool-domain-event-publisher.test.ts +2 -0
  80. package/src/__tests__/tool-execution-pipeline.benchmark.test.ts +500 -0
  81. package/src/__tests__/tool-executor.test.ts +13 -17
  82. package/src/__tests__/turn-commit.test.ts +218 -3
  83. package/src/__tests__/twilio-provider.test.ts +143 -0
  84. package/src/__tests__/twilio-routes.test.ts +789 -0
  85. package/src/__tests__/twitter-auth-handler.test.ts +581 -0
  86. package/src/__tests__/view-image-tool.test.ts +217 -0
  87. package/src/__tests__/workspace-git-service.test.ts +186 -0
  88. package/src/__tests__/workspace-heartbeat-service.test.ts +13 -3
  89. package/src/agent-heartbeat/agent-heartbeat-service.ts +155 -0
  90. package/src/bundler/app-bundler.ts +12 -8
  91. package/src/calls/call-bridge.ts +95 -0
  92. package/src/calls/call-constants.ts +43 -5
  93. package/src/calls/call-domain.ts +276 -0
  94. package/src/calls/call-orchestrator.ts +43 -17
  95. package/src/calls/call-recovery.ts +207 -0
  96. package/src/calls/call-state-machine.ts +68 -0
  97. package/src/calls/call-store.ts +192 -5
  98. package/src/calls/relay-server.ts +41 -4
  99. package/src/calls/speaker-identification.ts +213 -0
  100. package/src/calls/twilio-provider.ts +10 -6
  101. package/src/calls/twilio-routes.ts +90 -76
  102. package/src/calls/types.ts +1 -1
  103. package/src/cli/config-commands.ts +334 -0
  104. package/src/cli/core-commands.ts +776 -0
  105. package/src/cli/doordash.ts +251 -1
  106. package/src/cli/ipc-client.ts +82 -0
  107. package/src/cli/map.ts +246 -0
  108. package/src/cli/twitter.ts +575 -0
  109. package/src/cli.ts +7 -5
  110. package/src/commands/__tests__/cc-command-registry.test.ts +319 -0
  111. package/src/commands/cc-command-registry.ts +209 -0
  112. package/src/config/bundled-skills/contacts/SKILL.md +39 -0
  113. package/src/config/bundled-skills/contacts/TOOLS.json +122 -0
  114. package/src/config/bundled-skills/contacts/tools/contact-merge.ts +9 -0
  115. package/src/config/bundled-skills/contacts/tools/contact-search.ts +9 -0
  116. package/src/config/bundled-skills/contacts/tools/contact-upsert.ts +9 -0
  117. package/src/config/bundled-skills/document/SKILL.md +18 -0
  118. package/src/config/bundled-skills/document/TOOLS.json +53 -0
  119. package/src/config/bundled-skills/document/tools/document-create.ts +9 -0
  120. package/src/config/bundled-skills/document/tools/document-update.ts +9 -0
  121. package/src/config/bundled-skills/doordash/SKILL.md +82 -23
  122. package/src/config/bundled-skills/followups/SKILL.md +32 -0
  123. package/src/config/bundled-skills/followups/TOOLS.json +100 -0
  124. package/src/config/bundled-skills/followups/tools/followup-create.ts +9 -0
  125. package/src/config/bundled-skills/followups/tools/followup-list.ts +9 -0
  126. package/src/config/bundled-skills/followups/tools/followup-resolve.ts +9 -0
  127. package/src/config/bundled-skills/image-studio/tools/media-generate-image.ts +1 -23
  128. package/src/config/bundled-skills/messaging/tools/messaging-analyze-style.ts +2 -1
  129. package/src/config/bundled-skills/playbooks/SKILL.md +31 -0
  130. package/src/config/bundled-skills/playbooks/TOOLS.json +126 -0
  131. package/src/config/bundled-skills/playbooks/tools/playbook-create.ts +9 -0
  132. package/src/config/bundled-skills/playbooks/tools/playbook-delete.ts +9 -0
  133. package/src/config/bundled-skills/playbooks/tools/playbook-list.ts +9 -0
  134. package/src/config/bundled-skills/playbooks/tools/playbook-update.ts +9 -0
  135. package/src/config/bundled-skills/reminder/SKILL.md +20 -0
  136. package/src/config/bundled-skills/reminder/TOOLS.json +67 -0
  137. package/src/config/bundled-skills/reminder/tools/reminder-cancel.ts +9 -0
  138. package/src/config/bundled-skills/reminder/tools/reminder-create.ts +9 -0
  139. package/src/config/bundled-skills/reminder/tools/reminder-list.ts +9 -0
  140. package/src/config/bundled-skills/schedule/SKILL.md +74 -0
  141. package/src/config/bundled-skills/schedule/TOOLS.json +135 -0
  142. package/src/config/bundled-skills/schedule/tools/schedule-create.ts +9 -0
  143. package/src/config/bundled-skills/schedule/tools/schedule-delete.ts +9 -0
  144. package/src/config/bundled-skills/schedule/tools/schedule-list.ts +9 -0
  145. package/src/config/bundled-skills/schedule/tools/schedule-update.ts +9 -0
  146. package/src/config/bundled-skills/subagent/SKILL.md +25 -0
  147. package/src/config/bundled-skills/subagent/TOOLS.json +107 -0
  148. package/src/config/bundled-skills/subagent/tools/subagent-abort.ts +9 -0
  149. package/src/config/bundled-skills/subagent/tools/subagent-message.ts +9 -0
  150. package/src/config/bundled-skills/subagent/tools/subagent-read.ts +9 -0
  151. package/src/config/bundled-skills/subagent/tools/subagent-spawn.ts +9 -0
  152. package/src/config/bundled-skills/subagent/tools/subagent-status.ts +9 -0
  153. package/src/config/bundled-skills/tasks/SKILL.md +28 -0
  154. package/src/config/bundled-skills/tasks/TOOLS.json +256 -0
  155. package/src/config/bundled-skills/tasks/tools/task-delete.ts +9 -0
  156. package/src/config/bundled-skills/tasks/tools/task-list-add.ts +9 -0
  157. package/src/config/bundled-skills/tasks/tools/task-list-remove.ts +9 -0
  158. package/src/config/bundled-skills/tasks/tools/task-list-show.ts +9 -0
  159. package/src/config/bundled-skills/tasks/tools/task-list-update.ts +9 -0
  160. package/src/config/bundled-skills/tasks/tools/task-list.ts +9 -0
  161. package/src/config/bundled-skills/tasks/tools/task-run.ts +9 -0
  162. package/src/config/bundled-skills/tasks/tools/task-save.ts +9 -0
  163. package/src/config/bundled-skills/twitter/SKILL.md +134 -0
  164. package/src/config/bundled-skills/watcher/SKILL.md +27 -0
  165. package/src/config/bundled-skills/watcher/TOOLS.json +147 -0
  166. package/src/config/bundled-skills/watcher/tools/watcher-create.ts +9 -0
  167. package/src/config/bundled-skills/watcher/tools/watcher-delete.ts +9 -0
  168. package/src/config/bundled-skills/watcher/tools/watcher-digest.ts +9 -0
  169. package/src/config/bundled-skills/watcher/tools/watcher-list.ts +9 -0
  170. package/src/config/bundled-skills/watcher/tools/watcher-update.ts +9 -0
  171. package/src/config/defaults.ts +33 -0
  172. package/src/config/loader.ts +4 -1
  173. package/src/config/schema.ts +161 -1
  174. package/src/config/system-prompt.ts +61 -16
  175. package/src/config/templates/IDENTITY.md +7 -0
  176. package/src/config/types.ts +4 -0
  177. package/src/contacts/contact-store.ts +4 -4
  178. package/src/daemon/assistant-attachments.ts +10 -0
  179. package/src/daemon/classifier.ts +3 -1
  180. package/src/daemon/computer-use-session.ts +3 -1
  181. package/src/daemon/date-context.ts +136 -0
  182. package/src/daemon/handlers/apps.ts +16 -1
  183. package/src/daemon/handlers/browser.ts +54 -0
  184. package/src/daemon/handlers/computer-use.ts +7 -1
  185. package/src/daemon/handlers/config.ts +163 -5
  186. package/src/daemon/handlers/diagnostics.ts +5 -1
  187. package/src/daemon/handlers/documents.ts +18 -29
  188. package/src/daemon/handlers/home-base.ts +5 -1
  189. package/src/daemon/handlers/index.ts +40 -277
  190. package/src/daemon/handlers/misc.ts +9 -1
  191. package/src/daemon/handlers/publish.ts +6 -1
  192. package/src/daemon/handlers/sessions.ts +65 -12
  193. package/src/daemon/handlers/shared.ts +36 -1
  194. package/src/daemon/handlers/signing.ts +37 -0
  195. package/src/daemon/handlers/skills.ts +20 -6
  196. package/src/daemon/handlers/subagents.ts +8 -3
  197. package/src/daemon/handlers/twitter-auth.ts +169 -0
  198. package/src/daemon/handlers/work-items.ts +384 -68
  199. package/src/daemon/ipc-contract-inventory.json +28 -4
  200. package/src/daemon/ipc-contract.ts +133 -37
  201. package/src/daemon/ipc-protocol.ts +7 -2
  202. package/src/daemon/lifecycle.ts +21 -0
  203. package/src/daemon/main.ts +10 -4
  204. package/src/daemon/ride-shotgun-handler.ts +74 -10
  205. package/src/daemon/server.ts +143 -26
  206. package/src/daemon/session-agent-loop.ts +887 -0
  207. package/src/daemon/session-attachments.ts +28 -5
  208. package/src/daemon/session-error.ts +24 -3
  209. package/src/daemon/session-lifecycle.ts +147 -0
  210. package/src/daemon/session-media-retry.ts +147 -0
  211. package/src/daemon/session-messaging.ts +145 -0
  212. package/src/daemon/session-notifiers.ts +164 -0
  213. package/src/daemon/session-process.ts +2 -2
  214. package/src/daemon/session-queue-manager.ts +1 -0
  215. package/src/daemon/session-runtime-assembly.ts +52 -0
  216. package/src/daemon/session-skill-tools.ts +124 -5
  217. package/src/daemon/session-slash.ts +3 -0
  218. package/src/daemon/session-surfaces.ts +77 -2
  219. package/src/daemon/session-tool-setup.ts +216 -2
  220. package/src/daemon/session-usage.ts +0 -2
  221. package/src/daemon/session.ts +114 -1404
  222. package/src/daemon/video-thumbnail.ts +60 -0
  223. package/src/doordash/client.ts +121 -27
  224. package/src/doordash/queries.ts +1 -2
  225. package/src/export/formatter.ts +3 -1
  226. package/src/followups/followup-store.ts +4 -2
  227. package/src/followups/types.ts +6 -0
  228. package/src/hooks/templates.ts +1 -1
  229. package/src/index.ts +32 -1153
  230. package/src/memory/attachments-store.ts +28 -83
  231. package/src/memory/channel-delivery-store.ts +7 -21
  232. package/src/memory/clarification-resolver.ts +6 -5
  233. package/src/memory/contradiction-checker.ts +3 -2
  234. package/src/memory/conversation-key-store.ts +10 -29
  235. package/src/memory/conversation-store.ts +2 -1
  236. package/src/memory/db.ts +96 -2
  237. package/src/memory/entity-extractor.ts +6 -3
  238. package/src/memory/items-extractor.ts +5 -4
  239. package/src/memory/jobs-store.ts +3 -2
  240. package/src/memory/llm-usage-store.ts +1 -2
  241. package/src/memory/runs-store.ts +1 -2
  242. package/src/memory/schema.ts +23 -2
  243. package/src/messaging/style-analyzer.ts +3 -2
  244. package/src/messaging/thread-summarizer.ts +8 -12
  245. package/src/messaging/triage-engine.ts +4 -2
  246. package/src/providers/openrouter/client.ts +20 -0
  247. package/src/providers/registry.ts +8 -0
  248. package/src/runtime/http-server.ts +108 -20
  249. package/src/runtime/routes/attachment-routes.ts +2 -3
  250. package/src/runtime/routes/call-routes.ts +140 -0
  251. package/src/runtime/routes/channel-routes.ts +5 -10
  252. package/src/runtime/routes/conversation-routes.ts +5 -5
  253. package/src/runtime/routes/run-routes.ts +2 -2
  254. package/src/runtime/run-orchestrator.ts +9 -3
  255. package/src/schedule/recurrence-engine.ts +138 -0
  256. package/src/schedule/recurrence-types.ts +67 -0
  257. package/src/schedule/schedule-store.ts +102 -57
  258. package/src/schedule/scheduler.ts +9 -6
  259. package/src/security/oauth2.ts +29 -4
  260. package/src/security/secret-allowlist.ts +46 -0
  261. package/src/skills/clawhub.ts +1 -1
  262. package/src/subagent/manager.ts +40 -8
  263. package/src/swarm/backend-claude-code.ts +64 -9
  264. package/src/swarm/worker-prompts.ts +2 -1
  265. package/src/tasks/SPEC.md +34 -28
  266. package/src/tasks/ephemeral-permissions.ts +16 -7
  267. package/src/tasks/task-compiler.ts +5 -4
  268. package/src/tasks/task-runner.ts +10 -5
  269. package/src/tasks/task-scheduler.ts +1 -1
  270. package/src/tasks/tool-sanitizer.ts +36 -0
  271. package/src/tools/assets/search.ts +4 -4
  272. package/src/tools/browser/api-map.ts +220 -0
  273. package/src/tools/browser/auto-navigate.ts +270 -0
  274. package/src/tools/browser/browser-execution.ts +2 -1
  275. package/src/tools/browser/browser-manager.ts +2 -2
  276. package/src/tools/browser/network-recorder.ts +5 -4
  277. package/src/tools/browser/x-auto-navigate.ts +207 -0
  278. package/src/tools/calls/call-end.ts +17 -67
  279. package/src/tools/calls/call-start.ts +24 -85
  280. package/src/tools/calls/call-status.ts +35 -51
  281. package/src/tools/claude-code/claude-code.ts +77 -11
  282. package/src/tools/contacts/contact-merge.ts +46 -78
  283. package/src/tools/contacts/contact-search.ts +35 -79
  284. package/src/tools/contacts/contact-upsert.ts +35 -108
  285. package/src/tools/credentials/vault.ts +20 -4
  286. package/src/tools/document/document-tool.ts +71 -144
  287. package/src/tools/executor.ts +129 -10
  288. package/src/tools/followups/followup_create.ts +46 -88
  289. package/src/tools/followups/followup_list.ts +34 -74
  290. package/src/tools/followups/followup_resolve.ts +31 -66
  291. package/src/tools/host-terminal/cli-discover.ts +2 -1
  292. package/src/tools/host-terminal/host-shell.ts +10 -0
  293. package/src/tools/memory/handlers.ts +5 -4
  294. package/src/tools/network/__tests__/web-search.test.ts +427 -0
  295. package/src/tools/network/script-proxy/__tests__/logging.test.ts +248 -0
  296. package/src/tools/network/script-proxy/__tests__/policy.test.ts +234 -0
  297. package/src/tools/network/script-proxy/__tests__/router.test.ts +76 -0
  298. package/src/tools/network/web-fetch.ts +18 -6
  299. package/src/tools/playbooks/index.ts +4 -5
  300. package/src/tools/playbooks/playbook-create.ts +3 -47
  301. package/src/tools/playbooks/playbook-delete.ts +1 -25
  302. package/src/tools/playbooks/playbook-list.ts +1 -28
  303. package/src/tools/playbooks/playbook-update.ts +3 -51
  304. package/src/tools/reminder/reminder.ts +5 -78
  305. package/src/tools/schedule/create.ts +69 -74
  306. package/src/tools/schedule/delete.ts +21 -47
  307. package/src/tools/schedule/list.ts +55 -74
  308. package/src/tools/schedule/update.ts +77 -84
  309. package/src/tools/subagent/abort.ts +29 -58
  310. package/src/tools/subagent/message.ts +30 -63
  311. package/src/tools/subagent/read.ts +53 -84
  312. package/src/tools/subagent/spawn.ts +43 -82
  313. package/src/tools/subagent/status.ts +42 -71
  314. package/src/tools/swarm/delegate.ts +2 -1
  315. package/src/tools/tasks/index.ts +8 -8
  316. package/src/tools/tasks/task-delete.ts +60 -88
  317. package/src/tools/tasks/task-list.ts +31 -52
  318. package/src/tools/tasks/task-run.ts +72 -108
  319. package/src/tools/tasks/task-save.ts +33 -65
  320. package/src/tools/tasks/work-item-enqueue.ts +183 -215
  321. package/src/tools/tasks/work-item-list.ts +33 -63
  322. package/src/tools/tasks/work-item-remove.ts +45 -97
  323. package/src/tools/tasks/work-item-update.ts +91 -163
  324. package/src/tools/terminal/backends/native.ts +3 -1
  325. package/src/tools/tool-manifest.ts +0 -62
  326. package/src/tools/types.ts +6 -0
  327. package/src/tools/ui-surface/definitions.ts +3 -1
  328. package/src/tools/watch/screen-watch.ts +3 -1
  329. package/src/tools/watcher/create.ts +52 -98
  330. package/src/tools/watcher/delete.ts +20 -46
  331. package/src/tools/watcher/digest.ts +36 -70
  332. package/src/tools/watcher/list.ts +49 -79
  333. package/src/tools/watcher/update.ts +45 -91
  334. package/src/twitter/client.ts +690 -0
  335. package/src/twitter/session.ts +91 -0
  336. package/src/usage/types.ts +0 -1
  337. package/src/util/truncate.ts +6 -0
  338. package/src/watcher/providers/slack.ts +2 -1
  339. package/src/watcher/watcher-store.ts +3 -2
  340. package/src/work-items/work-item-store.ts +27 -2
  341. package/src/workspace/commit-message-enrichment-service.ts +31 -7
  342. package/src/workspace/git-service.ts +87 -22
  343. package/src/workspace/provider-commit-message-generator.ts +242 -0
  344. package/src/workspace/turn-commit.ts +62 -3
  345. package/src/tools/contacts/index.ts +0 -4
  346. package/src/tools/document/index.ts +0 -5
  347. package/src/tools/followups/index.ts +0 -3
  348. package/src/tools/subagent/index.ts +0 -5
  349. /package/src/__tests__/{memory-context-benchmark.test.ts → memory-context-benchmark.benchmark.test.ts} +0 -0
@@ -10,7 +10,6 @@ import { resolve } from 'node:path';
10
10
  import { timingSafeEqual } from 'node:crypto';
11
11
  import { ConfigError, IngressBlockedError } from '../util/errors.js';
12
12
  import { getLogger } from '../util/logger.js';
13
- import { getSecureKey } from '../security/secure-keys.js';
14
13
  import { TwilioConversationRelayProvider } from '../calls/twilio-provider.js';
15
14
  import type { RunOrchestrator } from './run-orchestrator.js';
16
15
 
@@ -47,11 +46,16 @@ import {
47
46
  handleDeleteSharedApp,
48
47
  } from './routes/app-routes.js';
49
48
  import { handleAddSecret } from './routes/secret-routes.js';
49
+ import {
50
+ handleStartCall,
51
+ handleGetCallStatus,
52
+ handleCancelCall,
53
+ handleAnswerCall,
54
+ } from './routes/call-routes.js';
50
55
  import {
51
56
  handleVoiceWebhook,
52
57
  handleStatusCallback,
53
58
  handleConnectAction,
54
- handleCallAnswer,
55
59
  } from '../calls/twilio-routes.js';
56
60
  import { RelayConnection, activeRelayConnections } from '../calls/relay-server.js';
57
61
  import type { RelayWebSocketData } from '../calls/relay-server.js';
@@ -113,25 +117,50 @@ function getDiskSpaceInfo(): DiskSpaceInfo | null {
113
117
  */
114
118
  const TWILIO_WEBHOOK_RE = /^\/v1\/(?:assistants\/[^/]+\/)?calls\/twilio\/(.+)$/;
115
119
 
120
+ /**
121
+ * Gateway-compatible Twilio webhook paths:
122
+ * /webhooks/twilio/<subpath>
123
+ *
124
+ * Maps gateway path segments to the internal subpath names used by the
125
+ * dispatcher below (e.g. "voice" -> "voice-webhook").
126
+ */
127
+ const TWILIO_GATEWAY_WEBHOOK_RE = /^\/webhooks\/twilio\/(.+)$/;
128
+ const GATEWAY_SUBPATH_MAP: Record<string, string> = {
129
+ voice: 'voice-webhook',
130
+ status: 'status',
131
+ 'connect-action': 'connect-action',
132
+ };
133
+
116
134
  /**
117
135
  * Validate a Twilio webhook request's X-Twilio-Signature header.
118
136
  *
119
137
  * Returns the raw body text on success so callers can reconstruct the Request
120
138
  * for downstream handlers (which also need to read the body).
121
139
  * Returns a 403 Response if signature validation fails.
122
- * If the Twilio auth token is not configured, skips validation with a warning.
140
+ *
141
+ * Fail-closed: if the auth token is not configured, the request is rejected
142
+ * with 403 rather than silently skipping validation. An explicit local-dev
143
+ * bypass is available via TWILIO_WEBHOOK_VALIDATION_DISABLED=true.
123
144
  */
124
145
  async function validateTwilioWebhook(
125
146
  req: Request,
126
147
  ): Promise<{ body: string } | Response> {
127
148
  const rawBody = await req.text();
128
- const authToken = getSecureKey('twilio_auth_token');
129
149
 
130
- if (!authToken) {
131
- log.warn('Twilio auth token not configured — skipping webhook signature validation');
150
+ // Allow explicit local-dev bypass — must be exactly "true"
151
+ if (process.env.TWILIO_WEBHOOK_VALIDATION_DISABLED === 'true') {
152
+ log.warn('Twilio webhook signature validation explicitly disabled via TWILIO_WEBHOOK_VALIDATION_DISABLED');
132
153
  return { body: rawBody };
133
154
  }
134
155
 
156
+ const authToken = TwilioConversationRelayProvider.getAuthToken();
157
+
158
+ // Fail-closed: reject if no auth token is configured
159
+ if (!authToken) {
160
+ log.error('Twilio auth token not configured — rejecting webhook request (fail-closed)');
161
+ return Response.json({ error: 'Forbidden' }, { status: 403 });
162
+ }
163
+
135
164
  const signature = req.headers.get('x-twilio-signature');
136
165
  if (!signature) {
137
166
  log.warn('Twilio webhook request missing X-Twilio-Signature header');
@@ -159,6 +188,7 @@ async function validateTwilioWebhook(
159
188
  publicUrl,
160
189
  params,
161
190
  signature,
191
+ authToken,
162
192
  );
163
193
 
164
194
  if (!isValid) {
@@ -302,11 +332,18 @@ export class RuntimeHttpServer {
302
332
 
303
333
  // ── Twilio webhook endpoints — before auth check because Twilio
304
334
  // webhook POSTs don't include bearer tokens.
305
- // Supports both /v1/calls/twilio/* and /v1/assistants/:id/calls/twilio/*
335
+ // Supports /v1/calls/twilio/*, /v1/assistants/:id/calls/twilio/*,
336
+ // and gateway-compatible /webhooks/twilio/* paths.
306
337
  // Validates X-Twilio-Signature to prevent unauthorized access. ──
307
338
  const twilioMatch = path.match(TWILIO_WEBHOOK_RE);
308
- if (twilioMatch && req.method === 'POST') {
309
- const twilioSubpath = twilioMatch[1];
339
+ const gatewayTwilioMatch = !twilioMatch ? path.match(TWILIO_GATEWAY_WEBHOOK_RE) : null;
340
+ const resolvedTwilioSubpath = twilioMatch
341
+ ? twilioMatch[1]
342
+ : gatewayTwilioMatch
343
+ ? GATEWAY_SUBPATH_MAP[gatewayTwilioMatch[1]]
344
+ : null;
345
+ if (resolvedTwilioSubpath && req.method === 'POST') {
346
+ const twilioSubpath = resolvedTwilioSubpath;
310
347
 
311
348
  // Validate Twilio request signature before dispatching
312
349
  const validation = await validateTwilioWebhook(req);
@@ -335,17 +372,6 @@ export class RuntimeHttpServer {
335
372
  }
336
373
  }
337
374
 
338
- // ── Call answer endpoint — behind auth gate ──────────────────────
339
- const callAnswerMatch = path.match(/^\/v1\/calls\/([^/]+)\/answer$/);
340
- if (callAnswerMatch && req.method === 'POST') {
341
- try {
342
- return await handleCallAnswer(req, callAnswerMatch[1]);
343
- } catch (err) {
344
- log.error({ err, callSessionId: callAnswerMatch[1] }, 'Runtime HTTP handler error answering call');
345
- return Response.json({ error: 'Internal server error' }, { status: 500 });
346
- }
347
- }
348
-
349
375
  // Serve shareable app pages
350
376
  const pagesMatch = path.match(/^\/pages\/([^/]+)$/);
351
377
  if (pagesMatch && req.method === 'GET') {
@@ -529,6 +555,68 @@ export class RuntimeHttpServer {
529
555
  return await handleReplayDeadLetters(req);
530
556
  }
531
557
 
558
+ // ── Call API routes ───────────────────────────────────────────
559
+ if (endpoint === 'calls/start' && req.method === 'POST') {
560
+ return await handleStartCall(req);
561
+ }
562
+
563
+ // Match calls/:callSessionId and calls/:callSessionId/cancel, calls/:callSessionId/answer
564
+ const callsMatch = endpoint.match(/^calls\/([^/]+?)(\/cancel|\/answer)?$/);
565
+ if (callsMatch) {
566
+ const callSessionId = callsMatch[1];
567
+ // Skip known sub-paths that are handled elsewhere (twilio, relay)
568
+ if (callSessionId !== 'twilio' && callSessionId !== 'relay' && callSessionId !== 'start') {
569
+ if (callsMatch[2] === '/cancel' && req.method === 'POST') {
570
+ return await handleCancelCall(req, callSessionId);
571
+ }
572
+ if (callsMatch[2] === '/answer' && req.method === 'POST') {
573
+ return await handleAnswerCall(req, callSessionId);
574
+ }
575
+ if (!callsMatch[2] && req.method === 'GET') {
576
+ return handleGetCallStatus(callSessionId);
577
+ }
578
+ }
579
+ }
580
+
581
+ // ── Internal Twilio forwarding endpoints (gateway → runtime) ──
582
+ // These accept JSON payloads from the gateway (which already validated
583
+ // the Twilio signature) and reconstruct requests for the existing
584
+ // Twilio route handlers.
585
+ if (endpoint === 'internal/twilio/voice-webhook' && req.method === 'POST') {
586
+ const json = await req.json() as { params: Record<string, string>; originalUrl?: string };
587
+ const formBody = new URLSearchParams(json.params).toString();
588
+ // Reconstruct request URL: keep the original URL query string (callSessionId)
589
+ const reconstructedUrl = json.originalUrl ?? req.url;
590
+ const fakeReq = new Request(reconstructedUrl, {
591
+ method: 'POST',
592
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
593
+ body: formBody,
594
+ });
595
+ return await handleVoiceWebhook(fakeReq);
596
+ }
597
+
598
+ if (endpoint === 'internal/twilio/status' && req.method === 'POST') {
599
+ const json = await req.json() as { params: Record<string, string> };
600
+ const formBody = new URLSearchParams(json.params).toString();
601
+ const fakeReq = new Request(req.url, {
602
+ method: 'POST',
603
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
604
+ body: formBody,
605
+ });
606
+ return await handleStatusCallback(fakeReq);
607
+ }
608
+
609
+ if (endpoint === 'internal/twilio/connect-action' && req.method === 'POST') {
610
+ const json = await req.json() as { params: Record<string, string> };
611
+ const formBody = new URLSearchParams(json.params).toString();
612
+ const fakeReq = new Request(req.url, {
613
+ method: 'POST',
614
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
615
+ body: formBody,
616
+ });
617
+ return await handleConnectAction(fakeReq);
618
+ }
619
+
532
620
  return Response.json({ error: 'Not found', source: 'runtime' }, { status: 404 });
533
621
  } catch (err) {
534
622
  if (err instanceof IngressBlockedError) {
@@ -56,7 +56,6 @@ export async function handleUploadAttachment(req: Request): Promise<Response> {
56
56
  let attachment: attachmentsStore.StoredAttachment;
57
57
  try {
58
58
  attachment = attachmentsStore.uploadAttachment(
59
- "self",
60
59
  filename,
61
60
  mimeType,
62
61
  data,
@@ -98,7 +97,7 @@ export async function handleDeleteAttachment(req: Request): Promise<Response> {
98
97
  );
99
98
  }
100
99
 
101
- const result = attachmentsStore.deleteAttachment("self", attachmentId);
100
+ const result = attachmentsStore.deleteAttachment(attachmentId);
102
101
 
103
102
  if (result === 'not_found') {
104
103
  return Response.json(
@@ -118,7 +117,7 @@ export async function handleDeleteAttachment(req: Request): Promise<Response> {
118
117
  }
119
118
 
120
119
  export function handleGetAttachment(attachmentId: string): Response {
121
- const attachment = attachmentsStore.getAttachmentById("self", attachmentId);
120
+ const attachment = attachmentsStore.getAttachmentById(attachmentId);
122
121
  if (!attachment) {
123
122
  return Response.json({ error: 'Attachment not found' }, { status: 404 });
124
123
  }
@@ -0,0 +1,140 @@
1
+ /**
2
+ * Runtime HTTP route handlers for the call API.
3
+ *
4
+ * POST /v1/calls/start — initiate a new call
5
+ * GET /v1/calls/:callSessionId — get call status
6
+ * POST /v1/calls/:callSessionId/cancel — cancel a call
7
+ * POST /v1/calls/:callSessionId/answer — answer a pending question
8
+ */
9
+
10
+ import { startCall, getCallStatus, cancelCall, answerCall } from '../../calls/call-domain.js';
11
+ import { getConfig } from '../../config/loader.js';
12
+
13
+ /**
14
+ * POST /v1/calls/start
15
+ *
16
+ * Body: { phoneNumber: string; task: string; context?: string; conversationId: string }
17
+ */
18
+ export async function handleStartCall(req: Request): Promise<Response> {
19
+ if (!getConfig().calls.enabled) {
20
+ return Response.json(
21
+ { error: 'Calls feature is disabled via configuration. Set calls.enabled to true to use this feature.' },
22
+ { status: 403 },
23
+ );
24
+ }
25
+
26
+ let body: {
27
+ phoneNumber?: string;
28
+ task?: string;
29
+ context?: string;
30
+ conversationId?: string;
31
+ };
32
+ try {
33
+ body = await req.json() as typeof body;
34
+ } catch {
35
+ return Response.json({ error: 'Invalid JSON in request body' }, { status: 400 });
36
+ }
37
+
38
+ if (!body.conversationId) {
39
+ return Response.json({ error: 'conversationId is required' }, { status: 400 });
40
+ }
41
+
42
+ const result = await startCall({
43
+ phoneNumber: body.phoneNumber ?? '',
44
+ task: body.task ?? '',
45
+ context: body.context,
46
+ conversationId: body.conversationId,
47
+ });
48
+
49
+ if (!result.ok) {
50
+ return Response.json({ error: result.error }, { status: result.status ?? 500 });
51
+ }
52
+
53
+ return Response.json({
54
+ callSessionId: result.session.id,
55
+ callSid: result.callSid,
56
+ status: result.session.status,
57
+ toNumber: result.session.toNumber,
58
+ fromNumber: result.session.fromNumber,
59
+ }, { status: 201 });
60
+ }
61
+
62
+ /**
63
+ * GET /v1/calls/:callSessionId
64
+ */
65
+ export function handleGetCallStatus(callSessionId: string): Response {
66
+ const result = getCallStatus(callSessionId);
67
+
68
+ if (!result.ok) {
69
+ return Response.json({ error: result.error }, { status: result.status ?? 500 });
70
+ }
71
+
72
+ const { session } = result;
73
+ return Response.json({
74
+ callSessionId: session.id,
75
+ conversationId: session.conversationId,
76
+ status: session.status,
77
+ toNumber: session.toNumber,
78
+ fromNumber: session.fromNumber,
79
+ provider: session.provider,
80
+ providerCallSid: session.providerCallSid,
81
+ task: session.task,
82
+ startedAt: session.startedAt ? new Date(session.startedAt).toISOString() : null,
83
+ endedAt: session.endedAt ? new Date(session.endedAt).toISOString() : null,
84
+ lastError: session.lastError,
85
+ pendingQuestion: result.pendingQuestion ?? null,
86
+ createdAt: new Date(session.createdAt).toISOString(),
87
+ updatedAt: new Date(session.updatedAt).toISOString(),
88
+ });
89
+ }
90
+
91
+ /**
92
+ * POST /v1/calls/:callSessionId/cancel
93
+ *
94
+ * Body: { reason?: string }
95
+ */
96
+ export async function handleCancelCall(req: Request, callSessionId: string): Promise<Response> {
97
+ let reason: string | undefined;
98
+ try {
99
+ const body = await req.json() as { reason?: string };
100
+ reason = body.reason;
101
+ } catch {
102
+ // Empty body is fine
103
+ }
104
+
105
+ const result = await cancelCall({ callSessionId, reason });
106
+
107
+ if (!result.ok) {
108
+ return Response.json({ error: result.error }, { status: result.status ?? 500 });
109
+ }
110
+
111
+ return Response.json({
112
+ callSessionId: result.session.id,
113
+ status: result.session.status,
114
+ });
115
+ }
116
+
117
+ /**
118
+ * POST /v1/calls/:callSessionId/answer
119
+ *
120
+ * Body: { answer: string }
121
+ */
122
+ export async function handleAnswerCall(req: Request, callSessionId: string): Promise<Response> {
123
+ let body: { answer?: string };
124
+ try {
125
+ body = await req.json() as typeof body;
126
+ } catch {
127
+ return Response.json({ error: 'Invalid JSON in request body' }, { status: 400 });
128
+ }
129
+
130
+ const result = await answerCall({
131
+ callSessionId,
132
+ answer: body.answer ?? '',
133
+ });
134
+
135
+ if (!result.ok) {
136
+ return Response.json({ error: result.error }, { status: result.status ?? 500 });
137
+ }
138
+
139
+ return Response.json({ ok: true, questionId: result.questionId });
140
+ }
@@ -34,7 +34,7 @@ export async function handleDeleteConversation(req: Request): Promise<Response>
34
34
  }
35
35
 
36
36
  const conversationKey = `${sourceChannel}:${externalChatId}`;
37
- deleteConversationKey("self", conversationKey);
37
+ deleteConversationKey(conversationKey);
38
38
 
39
39
  return Response.json({ ok: true });
40
40
  }
@@ -89,7 +89,7 @@ export async function handleChannelInbound(
89
89
  }
90
90
 
91
91
  if (hasAttachments) {
92
- const resolved = attachmentsStore.getAttachmentsByIds("self", attachmentIds);
92
+ const resolved = attachmentsStore.getAttachmentsByIds(attachmentIds);
93
93
  if (resolved.length !== attachmentIds.length) {
94
94
  const resolvedIds = new Set(resolved.map((a) => a.id));
95
95
  const missing = attachmentIds.filter((id) => !resolvedIds.has(id));
@@ -112,7 +112,6 @@ export async function handleChannelInbound(
112
112
  if (isEdit && sourceMessageId) {
113
113
  // Dedup the edit event itself (retried edited_message webhooks)
114
114
  const editResult = channelDeliveryStore.recordInbound(
115
- "self",
116
115
  sourceChannel,
117
116
  externalChatId,
118
117
  externalMessageId,
@@ -136,7 +135,6 @@ export async function handleChannelInbound(
136
135
  let original: { messageId: string; conversationId: string } | null = null;
137
136
  for (let attempt = 0; attempt <= EDIT_LOOKUP_RETRIES; attempt++) {
138
137
  original = channelDeliveryStore.findMessageBySourceId(
139
- "self",
140
138
  sourceChannel,
141
139
  externalChatId,
142
140
  sourceMessageId,
@@ -173,7 +171,6 @@ export async function handleChannelInbound(
173
171
 
174
172
  // ── New message path ──
175
173
  const result = channelDeliveryStore.recordInbound(
176
- "self",
177
174
  sourceChannel,
178
175
  externalChatId,
179
176
  externalMessageId,
@@ -239,7 +236,6 @@ export async function handleChannelInbound(
239
236
  } catch (err) {
240
237
  // Secret ingress blocks are not retryable — let the top-level handler return 422
241
238
  if (err instanceof IngressBlockedError) throw err;
242
- console.error(`[runtime-http] Processing failed`, err);
243
239
  log.error({ err, conversationId: result.conversationId }, 'Failed to process channel inbound message');
244
240
  channelDeliveryStore.recordProcessingFailure(result.eventId, err);
245
241
  }
@@ -257,7 +253,7 @@ export async function handleChannelInbound(
257
253
  try { parsed = JSON.parse(msgs[i].content); } catch { parsed = msgs[i].content; }
258
254
  const rendered = renderHistoryContent(parsed);
259
255
 
260
- const linked = attachmentsStore.getAttachmentMetadataForMessage(msgs[i].id, "self");
256
+ const linked = attachmentsStore.getAttachmentMetadataForMessage(msgs[i].id);
261
257
  const replyAttachments: RuntimeAttachmentMetadata[] = linked.map((a) => ({
262
258
  id: a.id,
263
259
  filename: a.originalFilename,
@@ -290,7 +286,7 @@ export async function handleChannelInbound(
290
286
  }
291
287
 
292
288
  export function handleListDeadLetters(): Response {
293
- const events = channelDeliveryStore.getDeadLetterEvents("self");
289
+ const events = channelDeliveryStore.getDeadLetterEvents();
294
290
  return Response.json({ events });
295
291
  }
296
292
 
@@ -302,7 +298,7 @@ export async function handleReplayDeadLetters(req: Request): Promise<Response> {
302
298
  return Response.json({ error: 'eventIds array is required' }, { status: 400 });
303
299
  }
304
300
 
305
- const replayed = channelDeliveryStore.replayDeadLetters("self", eventIds);
301
+ const replayed = channelDeliveryStore.replayDeadLetters(eventIds);
306
302
  return Response.json({ replayed });
307
303
  }
308
304
 
@@ -326,7 +322,6 @@ export async function handleChannelDeliveryAck(req: Request): Promise<Response>
326
322
  }
327
323
 
328
324
  const acked = channelDeliveryStore.acknowledgeDelivery(
329
- "self",
330
325
  sourceChannel,
331
326
  externalChatId,
332
327
  externalMessageId,
@@ -54,7 +54,7 @@ export function handleListMessages(
54
54
  );
55
55
  }
56
56
 
57
- const mapping = getConversationByKey("self", conversationKey);
57
+ const mapping = getConversationByKey(conversationKey);
58
58
  if (!mapping) {
59
59
  return Response.json({ messages: [] });
60
60
  }
@@ -89,7 +89,7 @@ export function handleListMessages(
89
89
  const messages: RuntimeMessagePayload[] = merged.map((m) => {
90
90
  let msgAttachments: RuntimeAttachmentMetadata[] = [];
91
91
  if (m.role === 'assistant' && m.id) {
92
- const linked = attachmentsStore.getAttachmentMetadataForMessage(m.id, "self");
92
+ const linked = attachmentsStore.getAttachmentMetadataForMessage(m.id);
93
93
  if (linked.length > 0) {
94
94
  msgAttachments = linked.map((a) => ({
95
95
  id: a.id,
@@ -170,7 +170,7 @@ export async function handleSendMessage(
170
170
 
171
171
  // Validate that all attachment IDs resolve
172
172
  if (hasAttachments) {
173
- const resolved = attachmentsStore.getAttachmentsByIds("self", attachmentIds);
173
+ const resolved = attachmentsStore.getAttachmentsByIds(attachmentIds);
174
174
  if (resolved.length !== attachmentIds.length) {
175
175
  const resolvedIds = new Set(resolved.map((a) => a.id));
176
176
  const missing = attachmentIds.filter((id) => !resolvedIds.has(id));
@@ -181,7 +181,7 @@ export async function handleSendMessage(
181
181
  }
182
182
  }
183
183
 
184
- const mapping = getOrCreateConversation("self", conversationKey);
184
+ const mapping = getOrCreateConversation(conversationKey);
185
185
 
186
186
  const processor = deps.persistAndProcessMessage ?? deps.processMessage;
187
187
  if (!processor) {
@@ -247,7 +247,7 @@ export async function handleGetSuggestion(
247
247
  );
248
248
  }
249
249
 
250
- const mapping = getConversationByKey("self", conversationKey);
250
+ const mapping = getConversationByKey(conversationKey);
251
251
  if (!mapping) {
252
252
  return Response.json({ suggestion: null, messageId: null, source: 'none' as const });
253
253
  }
@@ -38,7 +38,7 @@ export async function handleCreateRun(
38
38
  }
39
39
 
40
40
  if (hasAttachments) {
41
- const resolved = attachmentsStore.getAttachmentsByIds("self", attachmentIds);
41
+ const resolved = attachmentsStore.getAttachmentsByIds(attachmentIds);
42
42
  if (resolved.length !== attachmentIds.length) {
43
43
  const resolvedIds = new Set(resolved.map((a) => a.id));
44
44
  const missing = attachmentIds.filter((id) => !resolvedIds.has(id));
@@ -49,7 +49,7 @@ export async function handleCreateRun(
49
49
  }
50
50
  }
51
51
 
52
- const mapping = getOrCreateConversation("self", conversationKey);
52
+ const mapping = getOrCreateConversation(conversationKey);
53
53
 
54
54
  try {
55
55
  const run = await runOrchestrator.startRun(
@@ -14,6 +14,7 @@ import * as runsStore from '../memory/runs-store.js';
14
14
  import type { Run } from '../memory/runs-store.js';
15
15
  import type { Session } from '../daemon/session.js';
16
16
  import type { ServerMessage } from '../daemon/ipc-protocol.js';
17
+ import { resolveChannelCapabilities } from '../daemon/session-runtime-assembly.js';
17
18
  import type { UserDecision } from '../permissions/types.js';
18
19
  import { checkIngressForSecrets } from '../security/secret-ingress.js';
19
20
  import { IngressBlockedError } from '../util/errors.js';
@@ -88,10 +89,11 @@ export class RunOrchestrator {
88
89
 
89
90
  const requestId = crypto.randomUUID();
90
91
  const messageId = session.persistUserMessage(content, attachments, requestId);
91
- const run = runsStore.createRun('self', conversationId, messageId);
92
+ const run = runsStore.createRun(conversationId, messageId);
92
93
 
93
- // Set the assistant ID so attachments are scoped correctly.
94
- session.setAssistantId('self');
94
+ // Runs are always HTTP-originated; set channel capabilities so the attachment
95
+ // scope heuristic resolves to 'self' rather than 'local-assistant'.
96
+ session.setChannelCapabilities(resolveChannelCapabilities('http-api'));
95
97
 
96
98
  // Serialized publish chain so hub subscribers observe events in order.
97
99
  let hubChain: Promise<void> = Promise.resolve();
@@ -110,6 +112,7 @@ export class RunOrchestrator {
110
112
  });
111
113
  };
112
114
 
115
+
113
116
  // Hook into session to intercept confirmation_request events.
114
117
  // When the prompter sends a confirmation_request, we record it in the
115
118
  // run store so the web UI can poll and submit a decision.
@@ -144,6 +147,9 @@ export class RunOrchestrator {
144
147
  // Fire-and-forget the agent loop
145
148
  const cleanup = () => {
146
149
  this.pending.delete(run.id);
150
+ // Reset channel capabilities so a subsequent IPC/desktop session on the
151
+ // same conversation is not incorrectly treated as an HTTP-API client.
152
+ session.setChannelCapabilities(null);
147
153
  // Reset the session's client callback to a no-op so the stale
148
154
  // closure doesn't intercept events from future runs on the same session.
149
155
  // Set hasNoClient=true here since the run is done and no HTTP caller
@@ -0,0 +1,138 @@
1
+ import { Cron } from 'croner';
2
+ import { rrulestr, RRuleSet } from 'rrule';
3
+ import type { ScheduleSyntax } from './recurrence-types.js';
4
+
5
+ export interface ScheduleSpec {
6
+ syntax: ScheduleSyntax;
7
+ expression: string;
8
+ timezone?: string | null;
9
+ }
10
+
11
+ const SUPPORTED_RRULE_PREFIXES = ['DTSTART', 'RRULE:', 'RDATE', 'EXDATE', 'EXRULE'];
12
+
13
+ function normalizeRruleExpression(expression: string): string {
14
+ // Handle escaped newlines from JSON transport
15
+ return expression.replace(/\\n/g, '\n').trim();
16
+ }
17
+
18
+ function parseRruleLines(expression: string): string[] {
19
+ return normalizeRruleExpression(expression)
20
+ .split(/\r?\n/)
21
+ .map(l => l.trim())
22
+ .filter(Boolean);
23
+ }
24
+
25
+ function validateRruleLines(lines: string[]): string | null {
26
+ let hasInclusion = false;
27
+ let hasDtstart = false;
28
+
29
+ for (const line of lines) {
30
+ const upper = line.toUpperCase();
31
+ if (!SUPPORTED_RRULE_PREFIXES.some(p => upper.startsWith(p))) {
32
+ return `Unsupported recurrence line: ${line}`;
33
+ }
34
+ if (upper.startsWith('DTSTART')) hasDtstart = true;
35
+ if (upper.startsWith('RRULE:') || upper.startsWith('RDATE')) hasInclusion = true;
36
+ }
37
+
38
+ if (!hasDtstart) return 'RRULE expression must include DTSTART for deterministic scheduling';
39
+ if (!hasInclusion) return 'RRULE expression must include at least one RRULE or RDATE';
40
+ return null;
41
+ }
42
+
43
+ /**
44
+ * Detect whether an RRULE expression contains set constructs (RDATE, EXDATE,
45
+ * EXRULE, or multiple RRULE lines) that require RRuleSet parsing.
46
+ */
47
+ export function hasSetConstructs(expression: string): boolean {
48
+ const lines = parseRruleLines(expression);
49
+ let rruleCount = 0;
50
+ for (const line of lines) {
51
+ const upper = line.toUpperCase();
52
+ if (upper.startsWith('RDATE') || upper.startsWith('EXDATE') || upper.startsWith('EXRULE')) return true;
53
+ if (upper.startsWith('RRULE:')) rruleCount++;
54
+ }
55
+ return rruleCount > 1;
56
+ }
57
+
58
+ /**
59
+ * Validate RRULE set lines in an expression. Returns null if valid, or an
60
+ * actionable error string describing the problem. This is intended for tool
61
+ * layers that want to surface a specific error message before calling the
62
+ * store.
63
+ */
64
+ export function validateRruleSetLines(expression: string): string | null {
65
+ const lines = parseRruleLines(expression);
66
+ return validateRruleLines(lines);
67
+ }
68
+
69
+ /**
70
+ * Validate a schedule expression. Returns true if the expression is valid
71
+ * for the given syntax, false otherwise.
72
+ */
73
+ export function isValidScheduleExpression(spec: ScheduleSpec): boolean {
74
+ try {
75
+ if (spec.syntax === 'cron') {
76
+ new Cron(spec.expression, { maxRuns: 0 });
77
+ return true;
78
+ }
79
+
80
+ if (spec.syntax === 'rrule') {
81
+ const lines = parseRruleLines(spec.expression);
82
+ const error = validateRruleLines(lines);
83
+ if (error) return false;
84
+
85
+ const normalized = normalizeRruleExpression(spec.expression);
86
+ const tzid = spec.timezone ?? undefined;
87
+ if (hasSetConstructs(normalized)) {
88
+ rrulestr(normalized, { forceset: true, tzid });
89
+ } else {
90
+ rrulestr(normalized, { tzid });
91
+ }
92
+ return true;
93
+ }
94
+
95
+ return false;
96
+ } catch {
97
+ return false;
98
+ }
99
+ }
100
+
101
+ /**
102
+ * Compute the next run timestamp (epoch ms) for a schedule expression.
103
+ * Throws if no future runs exist.
104
+ */
105
+ export function computeNextRunAt(spec: ScheduleSpec, nowMs?: number): number {
106
+ const now = nowMs ?? Date.now();
107
+
108
+ if (spec.syntax === 'cron') {
109
+ const cron = new Cron(spec.expression, {
110
+ timezone: spec.timezone ?? undefined,
111
+ });
112
+ const next = cron.nextRun(new Date(now));
113
+ if (!next) {
114
+ throw new Error(`Cron expression "${spec.expression}" has no upcoming runs`);
115
+ }
116
+ return next.getTime();
117
+ }
118
+
119
+ if (spec.syntax === 'rrule') {
120
+ const normalized = normalizeRruleExpression(spec.expression);
121
+ const lines = parseRruleLines(normalized);
122
+ const error = validateRruleLines(lines);
123
+ if (error) throw new Error(error);
124
+
125
+ const useSet = hasSetConstructs(normalized);
126
+ const tzid = spec.timezone ?? undefined;
127
+ const parsed = useSet
128
+ ? (rrulestr(normalized, { forceset: true, tzid }) as RRuleSet)
129
+ : rrulestr(normalized, { tzid });
130
+ const next = parsed.after(new Date(now));
131
+ if (!next) {
132
+ throw new Error(`RRULE expression has no upcoming runs after ${new Date(now).toISOString()}`);
133
+ }
134
+ return next.getTime();
135
+ }
136
+
137
+ throw new Error(`Unsupported schedule syntax: ${spec.syntax}`);
138
+ }