vellum 0.2.1 → 0.2.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (361) hide show
  1. package/README.md +15 -2
  2. package/bun.lock +71 -100
  3. package/package.json +5 -3
  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 +305 -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-twilio-config.test.ts +221 -0
  36. package/src/__tests__/handlers-twitter-config.test.ts +718 -0
  37. package/src/__tests__/intent-routing.test.ts +64 -57
  38. package/src/__tests__/ipc-roundtrip.benchmark.test.ts +237 -0
  39. package/src/__tests__/ipc-snapshot.test.ts +71 -28
  40. package/src/__tests__/llm-usage-store.test.ts +3 -8
  41. package/src/__tests__/media-generate-image.test.ts +1 -1
  42. package/src/__tests__/media-reuse-story.e2e.test.ts +7 -7
  43. package/src/__tests__/memory-regressions.test.ts +100 -2
  44. package/src/__tests__/memory-retrieval.benchmark.test.ts +430 -0
  45. package/src/__tests__/parallel-tool.benchmark.test.ts +294 -0
  46. package/src/__tests__/playbook-tools.test.ts +342 -0
  47. package/src/__tests__/profile-compiler.test.ts +2 -1
  48. package/src/__tests__/provider-commit-message-generator.test.ts +303 -0
  49. package/src/__tests__/provider-streaming.benchmark.test.ts +773 -0
  50. package/src/__tests__/recurrence-engine-rruleset.test.ts +78 -0
  51. package/src/__tests__/recurrence-engine.test.ts +69 -0
  52. package/src/__tests__/recurrence-types.test.ts +71 -0
  53. package/src/__tests__/registry.test.ts +5 -3
  54. package/src/__tests__/relay-server.test.ts +633 -0
  55. package/src/__tests__/reminder-store.test.ts +6 -3
  56. package/src/__tests__/reminder.test.ts +43 -77
  57. package/src/__tests__/run-orchestrator-assistant-events.test.ts +8 -4
  58. package/src/__tests__/run-orchestrator.test.ts +4 -4
  59. package/src/__tests__/runtime-attachment-metadata.test.ts +7 -6
  60. package/src/__tests__/runtime-runs-http.test.ts +4 -4
  61. package/src/__tests__/runtime-runs.test.ts +4 -4
  62. package/src/__tests__/schedule-store.test.ts +482 -0
  63. package/src/__tests__/schedule-tools.test.ts +700 -0
  64. package/src/__tests__/scheduler-recurrence.test.ts +329 -0
  65. package/src/__tests__/server-history-render.test.ts +14 -13
  66. package/src/__tests__/session-conflict-gate.test.ts +28 -25
  67. package/src/__tests__/session-error.test.ts +28 -0
  68. package/src/__tests__/session-init.benchmark.test.ts +462 -0
  69. package/src/__tests__/session-queue.test.ts +71 -48
  70. package/src/__tests__/session-runtime-assembly.test.ts +161 -0
  71. package/src/__tests__/session-surfaces-task-progress.test.ts +104 -0
  72. package/src/__tests__/signup-e2e.test.ts +2 -1
  73. package/src/__tests__/skill-projection.benchmark.test.ts +328 -0
  74. package/src/__tests__/skill-script-runner.test.ts +159 -0
  75. package/src/__tests__/speaker-identification.test.ts +52 -0
  76. package/src/__tests__/subagent-manager-notify.test.ts +42 -10
  77. package/src/__tests__/subagent-tools.test.ts +141 -41
  78. package/src/__tests__/task-compiler.test.ts +2 -1
  79. package/src/__tests__/task-runner.test.ts +2 -1
  80. package/src/__tests__/task-scheduler.test.ts +2 -1
  81. package/src/__tests__/task-tools.test.ts +49 -56
  82. package/src/__tests__/tool-audit-listener.test.ts +1 -0
  83. package/src/__tests__/tool-domain-event-publisher.test.ts +2 -0
  84. package/src/__tests__/tool-execution-pipeline.benchmark.test.ts +500 -0
  85. package/src/__tests__/tool-executor.test.ts +13 -17
  86. package/src/__tests__/turn-commit.test.ts +218 -3
  87. package/src/__tests__/twilio-provider.test.ts +143 -0
  88. package/src/__tests__/twilio-routes.test.ts +789 -0
  89. package/src/__tests__/twitter-auth-handler.test.ts +581 -0
  90. package/src/__tests__/view-image-tool.test.ts +217 -0
  91. package/src/__tests__/workspace-git-service.test.ts +186 -0
  92. package/src/__tests__/workspace-heartbeat-service.test.ts +13 -3
  93. package/src/agent-heartbeat/agent-heartbeat-service.ts +155 -0
  94. package/src/bundler/app-bundler.ts +12 -8
  95. package/src/calls/__tests__/twilio-webhook-urls.test.ts +162 -0
  96. package/src/calls/call-bridge.ts +95 -0
  97. package/src/calls/call-constants.ts +43 -5
  98. package/src/calls/call-domain.ts +276 -0
  99. package/src/calls/call-orchestrator.ts +43 -17
  100. package/src/calls/call-recovery.ts +207 -0
  101. package/src/calls/call-state-machine.ts +68 -0
  102. package/src/calls/call-store.ts +192 -5
  103. package/src/calls/relay-server.ts +41 -4
  104. package/src/calls/speaker-identification.ts +213 -0
  105. package/src/calls/twilio-config.ts +8 -8
  106. package/src/calls/twilio-provider.ts +13 -9
  107. package/src/calls/twilio-routes.ts +90 -76
  108. package/src/calls/twilio-webhook-urls.ts +50 -0
  109. package/src/calls/types.ts +1 -1
  110. package/src/cli/config-commands.ts +334 -0
  111. package/src/cli/core-commands.ts +776 -0
  112. package/src/cli/doordash.ts +251 -1
  113. package/src/cli/ipc-client.ts +82 -0
  114. package/src/cli/map.ts +270 -0
  115. package/src/cli/twitter.ts +575 -0
  116. package/src/cli.ts +7 -5
  117. package/src/commands/__tests__/cc-command-registry.test.ts +319 -0
  118. package/src/commands/cc-command-registry.ts +209 -0
  119. package/src/config/bundled-skills/contacts/SKILL.md +39 -0
  120. package/src/config/bundled-skills/contacts/TOOLS.json +122 -0
  121. package/src/config/bundled-skills/contacts/tools/contact-merge.ts +9 -0
  122. package/src/config/bundled-skills/contacts/tools/contact-search.ts +9 -0
  123. package/src/config/bundled-skills/contacts/tools/contact-upsert.ts +9 -0
  124. package/src/config/bundled-skills/document/SKILL.md +18 -0
  125. package/src/config/bundled-skills/document/TOOLS.json +53 -0
  126. package/src/config/bundled-skills/document/tools/document-create.ts +9 -0
  127. package/src/config/bundled-skills/document/tools/document-update.ts +9 -0
  128. package/src/config/bundled-skills/doordash/SKILL.md +82 -23
  129. package/src/config/bundled-skills/followups/SKILL.md +32 -0
  130. package/src/config/bundled-skills/followups/TOOLS.json +100 -0
  131. package/src/config/bundled-skills/followups/tools/followup-create.ts +9 -0
  132. package/src/config/bundled-skills/followups/tools/followup-list.ts +9 -0
  133. package/src/config/bundled-skills/followups/tools/followup-resolve.ts +9 -0
  134. package/src/config/bundled-skills/image-studio/tools/media-generate-image.ts +1 -23
  135. package/src/config/bundled-skills/messaging/tools/messaging-analyze-style.ts +2 -1
  136. package/src/config/bundled-skills/playbooks/SKILL.md +31 -0
  137. package/src/config/bundled-skills/playbooks/TOOLS.json +126 -0
  138. package/src/config/bundled-skills/playbooks/tools/playbook-create.ts +9 -0
  139. package/src/config/bundled-skills/playbooks/tools/playbook-delete.ts +9 -0
  140. package/src/config/bundled-skills/playbooks/tools/playbook-list.ts +9 -0
  141. package/src/config/bundled-skills/playbooks/tools/playbook-update.ts +9 -0
  142. package/src/config/bundled-skills/reminder/SKILL.md +20 -0
  143. package/src/config/bundled-skills/reminder/TOOLS.json +67 -0
  144. package/src/config/bundled-skills/reminder/tools/reminder-cancel.ts +9 -0
  145. package/src/config/bundled-skills/reminder/tools/reminder-create.ts +9 -0
  146. package/src/config/bundled-skills/reminder/tools/reminder-list.ts +9 -0
  147. package/src/config/bundled-skills/schedule/SKILL.md +74 -0
  148. package/src/config/bundled-skills/schedule/TOOLS.json +135 -0
  149. package/src/config/bundled-skills/schedule/tools/schedule-create.ts +9 -0
  150. package/src/config/bundled-skills/schedule/tools/schedule-delete.ts +9 -0
  151. package/src/config/bundled-skills/schedule/tools/schedule-list.ts +9 -0
  152. package/src/config/bundled-skills/schedule/tools/schedule-update.ts +9 -0
  153. package/src/config/bundled-skills/subagent/SKILL.md +25 -0
  154. package/src/config/bundled-skills/subagent/TOOLS.json +107 -0
  155. package/src/config/bundled-skills/subagent/tools/subagent-abort.ts +9 -0
  156. package/src/config/bundled-skills/subagent/tools/subagent-message.ts +9 -0
  157. package/src/config/bundled-skills/subagent/tools/subagent-read.ts +9 -0
  158. package/src/config/bundled-skills/subagent/tools/subagent-spawn.ts +9 -0
  159. package/src/config/bundled-skills/subagent/tools/subagent-status.ts +9 -0
  160. package/src/config/bundled-skills/tasks/SKILL.md +28 -0
  161. package/src/config/bundled-skills/tasks/TOOLS.json +256 -0
  162. package/src/config/bundled-skills/tasks/tools/task-delete.ts +9 -0
  163. package/src/config/bundled-skills/tasks/tools/task-list-add.ts +9 -0
  164. package/src/config/bundled-skills/tasks/tools/task-list-remove.ts +9 -0
  165. package/src/config/bundled-skills/tasks/tools/task-list-show.ts +9 -0
  166. package/src/config/bundled-skills/tasks/tools/task-list-update.ts +9 -0
  167. package/src/config/bundled-skills/tasks/tools/task-list.ts +9 -0
  168. package/src/config/bundled-skills/tasks/tools/task-run.ts +9 -0
  169. package/src/config/bundled-skills/tasks/tools/task-save.ts +9 -0
  170. package/src/config/bundled-skills/twitter/SKILL.md +134 -0
  171. package/src/config/bundled-skills/watcher/SKILL.md +27 -0
  172. package/src/config/bundled-skills/watcher/TOOLS.json +147 -0
  173. package/src/config/bundled-skills/watcher/tools/watcher-create.ts +9 -0
  174. package/src/config/bundled-skills/watcher/tools/watcher-delete.ts +9 -0
  175. package/src/config/bundled-skills/watcher/tools/watcher-digest.ts +9 -0
  176. package/src/config/bundled-skills/watcher/tools/watcher-list.ts +9 -0
  177. package/src/config/bundled-skills/watcher/tools/watcher-update.ts +9 -0
  178. package/src/config/defaults.ts +34 -0
  179. package/src/config/loader.ts +4 -1
  180. package/src/config/schema.ts +165 -1
  181. package/src/config/system-prompt.ts +61 -16
  182. package/src/config/templates/IDENTITY.md +7 -0
  183. package/src/config/types.ts +4 -0
  184. package/src/config/vellum-skills/telegram-setup/SKILL.md +1 -5
  185. package/src/contacts/contact-store.ts +4 -4
  186. package/src/daemon/assistant-attachments.ts +10 -0
  187. package/src/daemon/classifier.ts +3 -1
  188. package/src/daemon/computer-use-session.ts +3 -1
  189. package/src/daemon/date-context.ts +136 -0
  190. package/src/daemon/handlers/apps.ts +16 -1
  191. package/src/daemon/handlers/browser.ts +54 -0
  192. package/src/daemon/handlers/computer-use.ts +7 -1
  193. package/src/daemon/handlers/config.ts +205 -5
  194. package/src/daemon/handlers/diagnostics.ts +5 -1
  195. package/src/daemon/handlers/documents.ts +18 -29
  196. package/src/daemon/handlers/home-base.ts +5 -1
  197. package/src/daemon/handlers/index.ts +40 -277
  198. package/src/daemon/handlers/misc.ts +9 -1
  199. package/src/daemon/handlers/publish.ts +6 -1
  200. package/src/daemon/handlers/sessions.ts +65 -12
  201. package/src/daemon/handlers/shared.ts +36 -1
  202. package/src/daemon/handlers/signing.ts +37 -0
  203. package/src/daemon/handlers/skills.ts +20 -6
  204. package/src/daemon/handlers/subagents.ts +8 -3
  205. package/src/daemon/handlers/twitter-auth.ts +169 -0
  206. package/src/daemon/handlers/work-items.ts +384 -68
  207. package/src/daemon/ipc-contract-inventory.json +32 -4
  208. package/src/daemon/ipc-contract.ts +156 -37
  209. package/src/daemon/ipc-protocol.ts +7 -2
  210. package/src/daemon/lifecycle.ts +21 -0
  211. package/src/daemon/main.ts +10 -4
  212. package/src/daemon/ride-shotgun-handler.ts +75 -10
  213. package/src/daemon/server.ts +143 -26
  214. package/src/daemon/session-agent-loop.ts +922 -0
  215. package/src/daemon/session-attachments.ts +28 -5
  216. package/src/daemon/session-conflict-gate.ts +18 -109
  217. package/src/daemon/session-error.ts +24 -3
  218. package/src/daemon/session-lifecycle.ts +147 -0
  219. package/src/daemon/session-media-retry.ts +147 -0
  220. package/src/daemon/session-messaging.ts +145 -0
  221. package/src/daemon/session-notifiers.ts +164 -0
  222. package/src/daemon/session-process.ts +2 -2
  223. package/src/daemon/session-queue-manager.ts +1 -0
  224. package/src/daemon/session-runtime-assembly.ts +52 -0
  225. package/src/daemon/session-skill-tools.ts +124 -5
  226. package/src/daemon/session-slash.ts +3 -0
  227. package/src/daemon/session-surfaces.ts +77 -2
  228. package/src/daemon/session-tool-setup.ts +216 -2
  229. package/src/daemon/session-usage.ts +0 -2
  230. package/src/daemon/session.ts +114 -1404
  231. package/src/daemon/video-thumbnail.ts +60 -0
  232. package/src/doordash/client.ts +121 -27
  233. package/src/doordash/queries.ts +1 -2
  234. package/src/export/formatter.ts +3 -1
  235. package/src/followups/followup-store.ts +4 -2
  236. package/src/followups/types.ts +6 -0
  237. package/src/hooks/templates.ts +1 -1
  238. package/src/index.ts +32 -1153
  239. package/src/memory/attachments-store.ts +28 -83
  240. package/src/memory/channel-delivery-store.ts +7 -21
  241. package/src/memory/clarification-resolver.ts +6 -5
  242. package/src/memory/conflict-intent.ts +114 -0
  243. package/src/memory/contradiction-checker.ts +3 -2
  244. package/src/memory/conversation-key-store.ts +10 -29
  245. package/src/memory/conversation-store.ts +2 -1
  246. package/src/memory/db.ts +96 -2
  247. package/src/memory/entity-extractor.ts +6 -3
  248. package/src/memory/items-extractor.ts +5 -4
  249. package/src/memory/job-handlers/conflict.ts +23 -1
  250. package/src/memory/jobs-store.ts +3 -2
  251. package/src/memory/llm-usage-store.ts +1 -2
  252. package/src/memory/runs-store.ts +1 -2
  253. package/src/memory/schema.ts +23 -2
  254. package/src/messaging/style-analyzer.ts +3 -2
  255. package/src/messaging/thread-summarizer.ts +8 -12
  256. package/src/messaging/triage-engine.ts +4 -2
  257. package/src/providers/openrouter/client.ts +20 -0
  258. package/src/providers/registry.ts +8 -0
  259. package/src/runtime/gateway-client.ts +36 -0
  260. package/src/runtime/http-server.ts +166 -22
  261. package/src/runtime/routes/attachment-routes.ts +2 -3
  262. package/src/runtime/routes/call-routes.ts +140 -0
  263. package/src/runtime/routes/channel-routes.ts +125 -88
  264. package/src/runtime/routes/conversation-routes.ts +5 -5
  265. package/src/runtime/routes/run-routes.ts +2 -2
  266. package/src/runtime/run-orchestrator.ts +9 -3
  267. package/src/schedule/recurrence-engine.ts +138 -0
  268. package/src/schedule/recurrence-types.ts +67 -0
  269. package/src/schedule/schedule-store.ts +102 -57
  270. package/src/schedule/scheduler.ts +9 -6
  271. package/src/security/oauth2.ts +29 -4
  272. package/src/security/secret-allowlist.ts +46 -0
  273. package/src/skills/clawhub.ts +1 -1
  274. package/src/subagent/manager.ts +40 -8
  275. package/src/swarm/backend-claude-code.ts +64 -9
  276. package/src/swarm/worker-prompts.ts +2 -1
  277. package/src/tasks/SPEC.md +34 -28
  278. package/src/tasks/ephemeral-permissions.ts +16 -7
  279. package/src/tasks/task-compiler.ts +5 -4
  280. package/src/tasks/task-runner.ts +10 -5
  281. package/src/tasks/task-scheduler.ts +1 -1
  282. package/src/tasks/tool-sanitizer.ts +36 -0
  283. package/src/tools/assets/search.ts +4 -4
  284. package/src/tools/browser/api-map.ts +293 -0
  285. package/src/tools/browser/auto-navigate.ts +270 -0
  286. package/src/tools/browser/browser-execution.ts +2 -1
  287. package/src/tools/browser/browser-manager.ts +2 -2
  288. package/src/tools/browser/network-recorder.ts +5 -4
  289. package/src/tools/browser/x-auto-navigate.ts +207 -0
  290. package/src/tools/calls/call-end.ts +17 -67
  291. package/src/tools/calls/call-start.ts +24 -85
  292. package/src/tools/calls/call-status.ts +35 -51
  293. package/src/tools/claude-code/claude-code.ts +207 -11
  294. package/src/tools/contacts/contact-merge.ts +46 -78
  295. package/src/tools/contacts/contact-search.ts +35 -79
  296. package/src/tools/contacts/contact-upsert.ts +35 -108
  297. package/src/tools/credentials/vault.ts +20 -4
  298. package/src/tools/document/document-tool.ts +71 -144
  299. package/src/tools/executor.ts +129 -10
  300. package/src/tools/followups/followup_create.ts +46 -88
  301. package/src/tools/followups/followup_list.ts +34 -74
  302. package/src/tools/followups/followup_resolve.ts +31 -66
  303. package/src/tools/host-terminal/cli-discover.ts +2 -1
  304. package/src/tools/host-terminal/host-shell.ts +10 -0
  305. package/src/tools/memory/handlers.ts +5 -4
  306. package/src/tools/network/__tests__/web-search.test.ts +427 -0
  307. package/src/tools/network/script-proxy/__tests__/logging.test.ts +248 -0
  308. package/src/tools/network/script-proxy/__tests__/policy.test.ts +234 -0
  309. package/src/tools/network/script-proxy/__tests__/router.test.ts +76 -0
  310. package/src/tools/network/web-fetch.ts +18 -6
  311. package/src/tools/playbooks/index.ts +4 -5
  312. package/src/tools/playbooks/playbook-create.ts +3 -47
  313. package/src/tools/playbooks/playbook-delete.ts +1 -25
  314. package/src/tools/playbooks/playbook-list.ts +1 -28
  315. package/src/tools/playbooks/playbook-update.ts +3 -51
  316. package/src/tools/reminder/reminder.ts +5 -78
  317. package/src/tools/schedule/create.ts +69 -74
  318. package/src/tools/schedule/delete.ts +21 -47
  319. package/src/tools/schedule/list.ts +55 -74
  320. package/src/tools/schedule/update.ts +77 -84
  321. package/src/tools/subagent/abort.ts +29 -58
  322. package/src/tools/subagent/message.ts +30 -63
  323. package/src/tools/subagent/read.ts +53 -84
  324. package/src/tools/subagent/spawn.ts +43 -82
  325. package/src/tools/subagent/status.ts +42 -71
  326. package/src/tools/swarm/delegate.ts +2 -1
  327. package/src/tools/tasks/index.ts +8 -8
  328. package/src/tools/tasks/task-delete.ts +60 -88
  329. package/src/tools/tasks/task-list.ts +31 -52
  330. package/src/tools/tasks/task-run.ts +72 -108
  331. package/src/tools/tasks/task-save.ts +33 -65
  332. package/src/tools/tasks/work-item-enqueue.ts +183 -215
  333. package/src/tools/tasks/work-item-list.ts +33 -63
  334. package/src/tools/tasks/work-item-remove.ts +45 -97
  335. package/src/tools/tasks/work-item-update.ts +91 -163
  336. package/src/tools/terminal/backends/native.ts +3 -1
  337. package/src/tools/tool-manifest.ts +0 -62
  338. package/src/tools/types.ts +6 -0
  339. package/src/tools/ui-surface/definitions.ts +3 -1
  340. package/src/tools/watch/screen-watch.ts +3 -1
  341. package/src/tools/watcher/create.ts +52 -98
  342. package/src/tools/watcher/delete.ts +20 -46
  343. package/src/tools/watcher/digest.ts +36 -70
  344. package/src/tools/watcher/list.ts +49 -79
  345. package/src/tools/watcher/update.ts +45 -91
  346. package/src/twitter/client.ts +690 -0
  347. package/src/twitter/session.ts +91 -0
  348. package/src/usage/types.ts +0 -1
  349. package/src/util/truncate.ts +6 -0
  350. package/src/watcher/providers/slack.ts +2 -1
  351. package/src/watcher/watcher-store.ts +3 -2
  352. package/src/work-items/work-item-store.ts +27 -2
  353. package/src/workspace/commit-message-enrichment-service.ts +31 -7
  354. package/src/workspace/git-service.ts +87 -22
  355. package/src/workspace/provider-commit-message-generator.ts +269 -0
  356. package/src/workspace/turn-commit.ts +62 -3
  357. package/src/tools/contacts/index.ts +0 -4
  358. package/src/tools/document/index.ts +0 -5
  359. package/src/tools/followups/index.ts +0 -3
  360. package/src/tools/subagent/index.ts +0 -5
  361. /package/src/__tests__/{memory-context-benchmark.test.ts → memory-context-benchmark.benchmark.test.ts} +0 -0
@@ -2,12 +2,16 @@ import { and, asc, desc, eq, lte } from 'drizzle-orm';
2
2
  import { v4 as uuid } from 'uuid';
3
3
  import { Cron } from 'croner';
4
4
  import { getDb } from '../memory/db.js';
5
- import { cronJobs, cronRuns } from '../memory/schema.js';
5
+ import { scheduleJobs, scheduleRuns } from '../memory/schema.js';
6
+ import { computeNextRunAt as computeNextRunAtEngine, isValidScheduleExpression } from './recurrence-engine.js';
7
+ import type { ScheduleSyntax } from './recurrence-types.js';
6
8
 
7
9
  export interface ScheduleJob {
8
10
  id: string;
9
11
  name: string;
10
12
  enabled: boolean;
13
+ syntax: ScheduleSyntax;
14
+ expression: string;
11
15
  cronExpression: string;
12
16
  timezone: string | null;
13
17
  message: string;
@@ -48,7 +52,7 @@ export function computeNextRunAt(cronExpression: string, timezone?: string | nul
48
52
  });
49
53
  const next = cron.nextRun();
50
54
  if (!next) {
51
- throw new Error(`Cron expression "${cronExpression}" has no upcoming runs`);
55
+ throw new Error(`Schedule expression "${cronExpression}" has no upcoming runs`);
52
56
  }
53
57
  return next.getTime();
54
58
  }
@@ -60,9 +64,16 @@ export function createSchedule(params: {
60
64
  message: string;
61
65
  enabled?: boolean;
62
66
  createdBy?: string;
67
+ syntax?: ScheduleSyntax;
68
+ expression?: string;
63
69
  }): ScheduleJob {
64
- if (!isValidCronExpression(params.cronExpression)) {
65
- throw new Error(`Invalid cron expression: "${params.cronExpression}"`);
70
+ // Resolve syntax and expression: prefer explicit values, fall back to cron default
71
+ const syntax: ScheduleSyntax = params.syntax ?? 'cron';
72
+ const expression = params.expression ?? params.cronExpression;
73
+
74
+ const spec = { syntax, expression, timezone: params.timezone };
75
+ if (!isValidScheduleExpression(spec)) {
76
+ throw new Error(`Invalid ${syntax} expression: "${expression}"`);
66
77
  }
67
78
 
68
79
  const db = getDb();
@@ -70,13 +81,14 @@ export function createSchedule(params: {
70
81
  const now = Date.now();
71
82
  const enabled = params.enabled ?? true;
72
83
  const timezone = params.timezone ?? null;
73
- const nextRunAt = enabled ? computeNextRunAt(params.cronExpression, timezone) : 0;
84
+ const nextRunAt = enabled ? computeNextRunAtEngine(spec) : 0;
74
85
 
75
86
  const row = {
76
87
  id,
77
88
  name: params.name,
78
89
  enabled,
79
- cronExpression: params.cronExpression,
90
+ cronExpression: expression,
91
+ scheduleSyntax: syntax,
80
92
  timezone,
81
93
  message: params.message,
82
94
  nextRunAt,
@@ -88,16 +100,16 @@ export function createSchedule(params: {
88
100
  updatedAt: now,
89
101
  };
90
102
 
91
- db.insert(cronJobs).values(row).run();
92
- return row;
103
+ db.insert(scheduleJobs).values(row).run();
104
+ return parseJobRow(row);
93
105
  }
94
106
 
95
107
  export function getSchedule(id: string): ScheduleJob | null {
96
108
  const db = getDb();
97
109
  const row = db
98
110
  .select()
99
- .from(cronJobs)
100
- .where(eq(cronJobs.id, id))
111
+ .from(scheduleJobs)
112
+ .where(eq(scheduleJobs.id, id))
101
113
  .get();
102
114
  if (!row) return null;
103
115
  return parseJobRow(row);
@@ -105,12 +117,12 @@ export function getSchedule(id: string): ScheduleJob | null {
105
117
 
106
118
  export function listSchedules(options?: { enabledOnly?: boolean }): ScheduleJob[] {
107
119
  const db = getDb();
108
- const conditions = options?.enabledOnly ? eq(cronJobs.enabled, true) : undefined;
120
+ const conditions = options?.enabledOnly ? eq(scheduleJobs.enabled, true) : undefined;
109
121
  const rows = db
110
122
  .select()
111
- .from(cronJobs)
123
+ .from(scheduleJobs)
112
124
  .where(conditions)
113
- .orderBy(asc(cronJobs.nextRunAt))
125
+ .orderBy(asc(scheduleJobs.nextRunAt))
114
126
  .all();
115
127
  return rows.map(parseJobRow);
116
128
  }
@@ -123,90 +135,120 @@ export function updateSchedule(
123
135
  timezone?: string | null;
124
136
  message?: string;
125
137
  enabled?: boolean;
138
+ syntax?: ScheduleSyntax;
139
+ expression?: string;
126
140
  },
127
141
  ): ScheduleJob | null {
128
142
  const db = getDb();
129
- const existing = db.select().from(cronJobs).where(eq(cronJobs.id, id)).get();
143
+ const existing = db.select().from(scheduleJobs).where(eq(scheduleJobs.id, id)).get();
130
144
  if (!existing) return null;
131
145
 
132
- if (updates.cronExpression && !isValidCronExpression(updates.cronExpression)) {
133
- throw new Error(`Invalid cron expression: "${updates.cronExpression}"`);
146
+ // Resolve the effective syntax and expression after this update
147
+ const newSyntax = updates.syntax ?? (existing.scheduleSyntax as ScheduleSyntax) ?? 'cron';
148
+ const newExpr = updates.expression ?? updates.cronExpression ?? existing.cronExpression;
149
+ const newTimezone = updates.timezone !== undefined ? updates.timezone : existing.timezone;
150
+ const newEnabled = updates.enabled !== undefined ? updates.enabled : existing.enabled;
151
+
152
+ // Validate if expression or syntax changed
153
+ if (updates.expression !== undefined || updates.cronExpression !== undefined || updates.syntax !== undefined) {
154
+ const spec = { syntax: newSyntax, expression: newExpr, timezone: newTimezone };
155
+ if (!isValidScheduleExpression(spec)) {
156
+ throw new Error(`Invalid ${newSyntax} expression: "${newExpr}"`);
157
+ }
134
158
  }
135
159
 
136
160
  const now = Date.now();
137
161
  const set: Record<string, unknown> = { updatedAt: now };
138
162
 
139
163
  if (updates.name !== undefined) set.name = updates.name;
140
- if (updates.cronExpression !== undefined) set.cronExpression = updates.cronExpression;
164
+ if (updates.cronExpression !== undefined || updates.expression !== undefined) set.cronExpression = newExpr;
165
+ if (updates.syntax !== undefined) set.scheduleSyntax = newSyntax;
141
166
  if (updates.timezone !== undefined) set.timezone = updates.timezone;
142
167
  if (updates.message !== undefined) set.message = updates.message;
143
168
  if (updates.enabled !== undefined) set.enabled = updates.enabled;
144
169
 
145
- const newExpr = updates.cronExpression ?? existing.cronExpression;
146
- const newTimezone = updates.timezone !== undefined ? updates.timezone : existing.timezone;
147
- const newEnabled = updates.enabled !== undefined ? updates.enabled : existing.enabled;
148
-
170
+ // Recompute nextRunAt if schedule timing may have changed
149
171
  if (
150
172
  updates.cronExpression !== undefined ||
173
+ updates.expression !== undefined ||
174
+ updates.syntax !== undefined ||
151
175
  updates.timezone !== undefined ||
152
176
  updates.enabled !== undefined
153
177
  ) {
154
- set.nextRunAt = newEnabled ? computeNextRunAt(newExpr, newTimezone) : 0;
178
+ const spec = { syntax: newSyntax, expression: newExpr, timezone: newTimezone };
179
+ set.nextRunAt = newEnabled ? computeNextRunAtEngine(spec) : 0;
155
180
  }
156
181
 
157
- db.update(cronJobs).set(set).where(eq(cronJobs.id, id)).run();
182
+ db.update(scheduleJobs).set(set).where(eq(scheduleJobs.id, id)).run();
158
183
 
159
184
  return getSchedule(id);
160
185
  }
161
186
 
162
187
  export function deleteSchedule(id: string): boolean {
163
188
  const db = getDb();
164
- const result = db.delete(cronJobs).where(eq(cronJobs.id, id)).run() as unknown as { changes?: number };
189
+ const result = db.delete(scheduleJobs).where(eq(scheduleJobs.id, id)).run() as unknown as { changes?: number };
165
190
  return (result.changes ?? 0) > 0;
166
191
  }
167
192
 
168
193
  /**
169
- * Claim due schedules atomically. For each candidate where enabled=true and
170
- * next_run_at <= now, we advance next_run_at using optimistic locking on the
171
- * old value to prevent double-claiming by concurrent ticks.
194
+ * Claim due recurrence schedules atomically. For each candidate where
195
+ * enabled=true and next_run_at <= now, we advance next_run_at using
196
+ * optimistic locking on the old value to prevent double-claiming by
197
+ * concurrent ticks. Works for both cron and RRULE syntax.
172
198
  */
173
199
  export function claimDueSchedules(now: number): ScheduleJob[] {
174
200
  const db = getDb();
175
201
  const candidates = db
176
202
  .select()
177
- .from(cronJobs)
178
- .where(and(eq(cronJobs.enabled, true), lte(cronJobs.nextRunAt, now)))
179
- .orderBy(asc(cronJobs.nextRunAt))
203
+ .from(scheduleJobs)
204
+ .where(and(eq(scheduleJobs.enabled, true), lte(scheduleJobs.nextRunAt, now)))
205
+ .orderBy(asc(scheduleJobs.nextRunAt))
180
206
  .all();
181
207
 
182
208
  const claimed: ScheduleJob[] = [];
183
209
  for (const row of candidates) {
184
- let newNextRunAt: number;
210
+ let newNextRunAt: number | null;
211
+ let exhausted = false;
185
212
  try {
186
- newNextRunAt = computeNextRunAt(row.cronExpression, row.timezone);
213
+ const syntax = (row.scheduleSyntax as ScheduleSyntax) ?? 'cron';
214
+ newNextRunAt = computeNextRunAtEngine({
215
+ syntax,
216
+ expression: row.cronExpression,
217
+ timezone: row.timezone,
218
+ });
187
219
  } catch {
188
- // Expression has no future runs — skip
189
- continue;
220
+ // Finite schedule with no future runs — still claim the current due
221
+ // run but disable the schedule so it doesn't fire again.
222
+ newNextRunAt = null;
223
+ exhausted = true;
190
224
  }
191
225
 
192
226
  // Optimistic lock: only update if nextRunAt hasn't changed
227
+ const updates: Record<string, unknown> = {
228
+ lastRunAt: now,
229
+ updatedAt: now,
230
+ };
231
+ if (exhausted) {
232
+ updates.nextRunAt = 0;
233
+ updates.enabled = false;
234
+ } else {
235
+ updates.nextRunAt = newNextRunAt!;
236
+ }
237
+
193
238
  const result = db
194
- .update(cronJobs)
195
- .set({
196
- nextRunAt: newNextRunAt,
197
- lastRunAt: now,
198
- updatedAt: now,
199
- })
200
- .where(and(eq(cronJobs.id, row.id), eq(cronJobs.nextRunAt, row.nextRunAt)))
239
+ .update(scheduleJobs)
240
+ .set(updates)
241
+ .where(and(eq(scheduleJobs.id, row.id), eq(scheduleJobs.nextRunAt, row.nextRunAt)))
201
242
  .run() as unknown as { changes?: number };
202
243
 
203
244
  if ((result.changes ?? 0) === 0) continue;
204
245
 
205
246
  claimed.push(parseJobRow({
206
247
  ...row,
207
- nextRunAt: newNextRunAt,
248
+ nextRunAt: exhausted ? 0 : newNextRunAt!,
208
249
  lastRunAt: now,
209
250
  updatedAt: now,
251
+ enabled: exhausted ? false : row.enabled,
210
252
  }));
211
253
  }
212
254
  return claimed;
@@ -216,7 +258,7 @@ export function createScheduleRun(jobId: string, conversationId: string): string
216
258
  const db = getDb();
217
259
  const id = uuid();
218
260
  const now = Date.now();
219
- db.insert(cronRuns).values({
261
+ db.insert(scheduleRuns).values({
220
262
  id,
221
263
  jobId,
222
264
  status: 'running',
@@ -238,12 +280,12 @@ export function completeScheduleRun(
238
280
  const db = getDb();
239
281
  const now = Date.now();
240
282
 
241
- const run = db.select().from(cronRuns).where(eq(cronRuns.id, runId)).get();
283
+ const run = db.select().from(scheduleRuns).where(eq(scheduleRuns.id, runId)).get();
242
284
  if (!run) return;
243
285
 
244
286
  const durationMs = now - run.startedAt;
245
287
 
246
- db.update(cronRuns)
288
+ db.update(scheduleRuns)
247
289
  .set({
248
290
  status: result.status,
249
291
  finishedAt: now,
@@ -251,23 +293,23 @@ export function completeScheduleRun(
251
293
  output: result.output?.slice(0, 10_000) ?? null,
252
294
  error: result.error?.slice(0, 2000) ?? null,
253
295
  })
254
- .where(eq(cronRuns.id, runId))
296
+ .where(eq(scheduleRuns.id, runId))
255
297
  .run();
256
298
 
257
299
  // Update the parent job's lastStatus and retryCount
258
300
  if (result.status === 'error') {
259
301
  // Increment retry count
260
- const job = db.select().from(cronJobs).where(eq(cronJobs.id, run.jobId)).get();
302
+ const job = db.select().from(scheduleJobs).where(eq(scheduleJobs.id, run.jobId)).get();
261
303
  if (job) {
262
- db.update(cronJobs)
304
+ db.update(scheduleJobs)
263
305
  .set({ lastStatus: 'error', retryCount: job.retryCount + 1, updatedAt: now })
264
- .where(eq(cronJobs.id, run.jobId))
306
+ .where(eq(scheduleJobs.id, run.jobId))
265
307
  .run();
266
308
  }
267
309
  } else {
268
- db.update(cronJobs)
310
+ db.update(scheduleJobs)
269
311
  .set({ lastStatus: 'ok', retryCount: 0, updatedAt: now })
270
- .where(eq(cronJobs.id, run.jobId))
312
+ .where(eq(scheduleJobs.id, run.jobId))
271
313
  .run();
272
314
  }
273
315
  }
@@ -276,9 +318,9 @@ export function getScheduleRuns(jobId: string, limit?: number): ScheduleRun[] {
276
318
  const db = getDb();
277
319
  const rows = db
278
320
  .select()
279
- .from(cronRuns)
280
- .where(eq(cronRuns.jobId, jobId))
281
- .orderBy(desc(cronRuns.createdAt))
321
+ .from(scheduleRuns)
322
+ .where(eq(scheduleRuns.jobId, jobId))
323
+ .orderBy(desc(scheduleRuns.createdAt))
282
324
  .limit(limit ?? 10)
283
325
  .all();
284
326
  return rows.map(parseRunRow);
@@ -296,7 +338,8 @@ export function formatLocalDate(timestamp: number): string {
296
338
  }
297
339
 
298
340
  // Convert a cron expression to a human-readable description.
299
- // Uses the croner library to parse the expression and inspect its pattern fields.
341
+ // Only applicable to cron syntax; RRULE schedules should display the
342
+ // raw expression text instead.
300
343
  //
301
344
  // Examples:
302
345
  // "* * * * *" -> "Every minute"
@@ -418,11 +461,13 @@ export function describeCronExpression(expr: string): string {
418
461
  }
419
462
  }
420
463
 
421
- function parseJobRow(row: typeof cronJobs.$inferSelect): ScheduleJob {
464
+ function parseJobRow(row: typeof scheduleJobs.$inferSelect): ScheduleJob {
422
465
  return {
423
466
  id: row.id,
424
467
  name: row.name,
425
468
  enabled: row.enabled,
469
+ syntax: (row.scheduleSyntax as ScheduleSyntax) ?? 'cron',
470
+ expression: row.cronExpression,
426
471
  cronExpression: row.cronExpression,
427
472
  timezone: row.timezone,
428
473
  message: row.message,
@@ -436,7 +481,7 @@ function parseJobRow(row: typeof cronJobs.$inferSelect): ScheduleJob {
436
481
  };
437
482
  }
438
483
 
439
- function parseRunRow(row: typeof cronRuns.$inferSelect): ScheduleRun {
484
+ function parseRunRow(row: typeof scheduleRuns.$inferSelect): ScheduleRun {
440
485
  return {
441
486
  id: row.id,
442
487
  jobId: row.jobId,
@@ -5,6 +5,7 @@ import {
5
5
  createScheduleRun,
6
6
  completeScheduleRun,
7
7
  } from './schedule-store.js';
8
+ import { hasSetConstructs } from './recurrence-engine.js';
8
9
  import { claimDueReminders, completeReminder, failReminder, setReminderConversationId } from '../tools/reminder/reminder-store.js';
9
10
  import { runWatchersOnce, type WatcherNotifier, type WatcherEscalator } from '../watcher/engine.js';
10
11
 
@@ -73,19 +74,20 @@ async function runScheduleOnce(
73
74
  const now = Date.now();
74
75
  let processed = 0;
75
76
 
76
- // ── Cron jobs ───────────────────────────────────────────────────────
77
+ // ── Recurrence schedules (cron + RRULE) ─────────────────────────────
77
78
  const jobs = claimDueSchedules(now);
78
79
  for (const job of jobs) {
79
80
  // Check if message is a task invocation (run_task:<task_id>)
80
81
  const taskMatch = job.message.match(/^run_task:(\S+)$/);
81
82
  if (taskMatch) {
82
83
  const taskId = taskMatch[1];
84
+ const isRruleSet = job.syntax === 'rrule' && hasSetConstructs(job.expression);
83
85
  try {
84
- log.info({ jobId: job.id, name: job.name, taskId }, 'Executing scheduled task');
86
+ log.info({ jobId: job.id, name: job.name, taskId, syntax: job.syntax, expression: job.expression, isRruleSet }, 'Executing scheduled task');
85
87
  const { runTask } = await import('../tasks/task-runner.js');
86
88
  const result = await runTask(
87
89
  { taskId, workingDir: process.cwd() },
88
- processMessage as (conversationId: string, message: string) => Promise<void>,
90
+ processMessage as (conversationId: string, message: string, taskRunId: string) => Promise<void>,
89
91
  );
90
92
 
91
93
  // Track the schedule run using the task's conversation
@@ -99,7 +101,7 @@ async function runScheduleOnce(
99
101
  processed += 1;
100
102
  } catch (err) {
101
103
  const message = err instanceof Error ? err.message : String(err);
102
- log.warn({ err, jobId: job.id, name: job.name, taskId }, 'Scheduled task execution failed');
104
+ log.warn({ err, jobId: job.id, name: job.name, taskId, syntax: job.syntax, expression: job.expression, isRruleSet }, 'Scheduled task execution failed');
103
105
  // Create a fallback conversation for the schedule run record
104
106
  const fallbackConversation = createConversation(`Schedule: ${job.name}`);
105
107
  const runId = createScheduleRun(job.id, fallbackConversation.id);
@@ -110,16 +112,17 @@ async function runScheduleOnce(
110
112
 
111
113
  const conversation = createConversation(`Schedule: ${job.name}`);
112
114
  const runId = createScheduleRun(job.id, conversation.id);
115
+ const isRruleSetMsg = job.syntax === 'rrule' && hasSetConstructs(job.expression);
113
116
 
114
117
  try {
115
- log.info({ jobId: job.id, name: job.name, conversationId: conversation.id }, 'Executing schedule');
118
+ log.info({ jobId: job.id, name: job.name, syntax: job.syntax, expression: job.expression, isRruleSet: isRruleSetMsg, conversationId: conversation.id }, 'Executing schedule');
116
119
  await processMessage(conversation.id, job.message);
117
120
  completeScheduleRun(runId, { status: 'ok' });
118
121
  notifySchedule({ id: job.id, name: job.name });
119
122
  processed += 1;
120
123
  } catch (err) {
121
124
  const message = err instanceof Error ? err.message : String(err);
122
- log.warn({ err, jobId: job.id, name: job.name }, 'Schedule execution failed');
125
+ log.warn({ err, jobId: job.id, name: job.name, syntax: job.syntax, expression: job.expression, isRruleSet: isRruleSetMsg }, 'Schedule execution failed');
123
126
  completeScheduleRun(runId, { status: 'error', error: message });
124
127
  }
125
128
  }
@@ -6,6 +6,9 @@
6
6
  */
7
7
 
8
8
  import { randomBytes, createHash } from 'node:crypto';
9
+ import { getLogger } from '../util/logger.js';
10
+
11
+ const log = getLogger('oauth2');
9
12
 
10
13
  // ---------------------------------------------------------------------------
11
14
  // Types
@@ -170,8 +173,19 @@ export async function startOAuth2Flow(
170
173
  });
171
174
 
172
175
  if (!tokenResp.ok) {
173
- const body = await tokenResp.text().catch(() => '');
174
- throw new Error(`OAuth2 token exchange failed (${tokenResp.status}): ${body}`);
176
+ const rawBody = await tokenResp.text().catch(() => '');
177
+ let safeDetail: Record<string, unknown> = {};
178
+ let errorCode = '';
179
+ try {
180
+ const parsed = JSON.parse(rawBody) as Record<string, unknown>;
181
+ if (parsed.error) { safeDetail.error = String(parsed.error); errorCode = String(parsed.error); }
182
+ if (parsed.error_description) safeDetail.error_description = String(parsed.error_description);
183
+ } catch {
184
+ safeDetail.error = '[non-JSON response]';
185
+ }
186
+ log.error({ status: tokenResp.status, ...safeDetail }, 'OAuth2 token exchange failed');
187
+ const detail = errorCode ? `HTTP ${tokenResp.status}: ${errorCode}` : `HTTP ${tokenResp.status}`;
188
+ throw new Error(`OAuth2 token exchange failed (${detail})`);
175
189
  }
176
190
 
177
191
  const tokenData = await tokenResp.json() as Record<string, unknown>;
@@ -225,8 +239,19 @@ export async function refreshOAuth2Token(
225
239
  });
226
240
 
227
241
  if (!resp.ok) {
228
- const body = await resp.text().catch(() => '');
229
- throw new Error(`OAuth2 token refresh failed (${resp.status}): ${body}`);
242
+ const rawBody = await resp.text().catch(() => '');
243
+ let safeDetail: Record<string, unknown> = {};
244
+ let errorCode = '';
245
+ try {
246
+ const parsed = JSON.parse(rawBody) as Record<string, unknown>;
247
+ if (parsed.error) { safeDetail.error = String(parsed.error); errorCode = String(parsed.error); }
248
+ if (parsed.error_description) safeDetail.error_description = String(parsed.error_description);
249
+ } catch {
250
+ safeDetail.error = '[non-JSON response]';
251
+ }
252
+ log.error({ status: resp.status, ...safeDetail }, 'OAuth2 token refresh failed');
253
+ const detail = errorCode ? `HTTP ${resp.status}: ${errorCode}` : `HTTP ${resp.status}`;
254
+ throw new Error(`OAuth2 token refresh failed (${detail})`);
230
255
  }
231
256
 
232
257
  const data = await resp.json() as Record<string, unknown>;
@@ -104,6 +104,52 @@ export function isAllowlisted(value: string): boolean {
104
104
  return false;
105
105
  }
106
106
 
107
+ export interface AllowlistValidationError {
108
+ index: number;
109
+ pattern: string;
110
+ message: string;
111
+ }
112
+
113
+ /**
114
+ * Validate all regex patterns in an allowlist config without loading them.
115
+ * Returns an array of validation errors (empty = all valid).
116
+ */
117
+ export function validateAllowlist(config: AllowlistConfig): AllowlistValidationError[] {
118
+ const errors: AllowlistValidationError[] = [];
119
+ if (!config.patterns) return errors;
120
+ if (!Array.isArray(config.patterns)) {
121
+ errors.push({ index: -1, pattern: String(config.patterns), message: '"patterns" must be an array' });
122
+ return errors;
123
+ }
124
+
125
+ for (let i = 0; i < config.patterns.length; i++) {
126
+ const p = config.patterns[i];
127
+ if (typeof p !== 'string') {
128
+ errors.push({ index: i, pattern: String(p), message: 'Pattern is not a string' });
129
+ continue;
130
+ }
131
+ try {
132
+ new RegExp(p);
133
+ } catch (err) {
134
+ errors.push({ index: i, pattern: p, message: (err as Error).message });
135
+ }
136
+ }
137
+ return errors;
138
+ }
139
+
140
+ /**
141
+ * Read secret-allowlist.json from disk and validate it.
142
+ * Returns validation errors, or null if the file doesn't exist.
143
+ */
144
+ export function validateAllowlistFile(): AllowlistValidationError[] | null {
145
+ const filePath = join(getRootDir(), 'protected', 'secret-allowlist.json');
146
+ if (!existsSync(filePath)) return null;
147
+
148
+ const raw = readFileSync(filePath, 'utf-8');
149
+ const config: AllowlistConfig = JSON.parse(raw);
150
+ return validateAllowlist(config);
151
+ }
152
+
107
153
  /**
108
154
  * Reset cached state so the allowlist is reloaded on next check.
109
155
  * Called by the daemon file watcher when secret-allowlist.json changes,
@@ -395,7 +395,7 @@ export async function clawhubInspect(slug: string): Promise<{ data?: ClawhubInsp
395
395
  size: (f.size as number) ?? 0,
396
396
  contentType: (f.contentType as string) ?? undefined,
397
397
  })) : null,
398
- skillMdContent: parsed.skillMdContent ?? parsed.fileContents?.['SKILL.md'] ?? null,
398
+ skillMdContent: parsed.skillMdContent ?? parsed.fileContents?.['SKILL.md'] ?? parsed.file?.content ?? null,
399
399
  };
400
400
  return { data };
401
401
  } catch {
@@ -52,10 +52,18 @@ interface ManagedSubagent {
52
52
  parentSendToClient: (msg: ServerMessage) => void;
53
53
  }
54
54
 
55
+ export interface SubagentNotificationInfo {
56
+ subagentId: string;
57
+ label: string;
58
+ status: 'completed' | 'failed' | 'aborted';
59
+ error?: string;
60
+ }
61
+
55
62
  export type ParentNotifyCallback = (
56
63
  parentSessionId: string,
57
64
  message: string,
58
65
  sendToClient: (msg: ServerMessage) => void,
66
+ notification: SubagentNotificationInfo,
59
67
  ) => void;
60
68
 
61
69
  export class SubagentManager {
@@ -221,6 +229,8 @@ export class SubagentManager {
221
229
  await managed.session.runAgentLoop(objective, messageId, onEvent);
222
230
 
223
231
  // Agent loop completed successfully.
232
+ // Copy usage stats from the session before sending status (which includes usage).
233
+ managed.state.usage = { ...managed.session.usageStats };
224
234
  // Only update state + notify if still non-terminal (guards against abort race).
225
235
  if (!TERMINAL_STATUSES.has(managed.state.status)) {
226
236
  managed.state.completedAt = Date.now();
@@ -235,6 +245,7 @@ export class SubagentManager {
235
245
  const errorMsg = err instanceof Error ? err.message : String(err);
236
246
  managed.state.error = errorMsg;
237
247
  managed.state.completedAt = Date.now();
248
+ managed.state.usage = { ...managed.session.usageStats };
238
249
 
239
250
  // Only update status if not already terminal (e.g. aborted).
240
251
  if (!TERMINAL_STATUSES.has(managed.state.status)) {
@@ -267,16 +278,28 @@ export class SubagentManager {
267
278
  managed.session.abort();
268
279
  managed.state.completedAt = Date.now();
269
280
  if (parentSendToClient) {
270
- this.setStatus(subagentId, 'aborted', parentSendToClient);
271
- // Notify parent about the abort skip when the parent already has the
272
- // tool result (e.g. subagent_abort tool) to avoid duplicate turns.
281
+ // Route the status update through the stored parent sender so the
282
+ // owning session's UI chip updates, even when the abort comes from a
283
+ // different socket (e.g. after thread switching). Fall back to the
284
+ // caller-provided sender if no stored sender exists.
285
+ const statusSender = managed.parentSendToClient ?? parentSendToClient;
286
+ this.setStatus(subagentId, 'aborted', statusSender);
287
+ // Notify parent that the subagent was explicitly aborted — tell it NOT to re-spawn.
288
+ // Skip when the parent LLM itself called subagent_abort (it already has the tool result).
273
289
  if (this.onSubagentFinished && !options?.suppressNotification) {
274
290
  const label = managed.state.config.label;
291
+ const message =
292
+ `[Subagent "${label}" was explicitly aborted]\n\n` +
293
+ `This subagent was cancelled on purpose. Do NOT re-spawn or retry it.`;
275
294
  try {
295
+ // Use the managed subagent's stored parentSendToClient so the
296
+ // notification routes to the parent session's socket, not the
297
+ // aborting socket (which may be a different thread after switching).
276
298
  this.onSubagentFinished(
277
299
  managed.state.config.parentSessionId,
278
- `[Subagent "${label}" was aborted]`,
279
- parentSendToClient,
300
+ message,
301
+ managed.parentSendToClient,
302
+ { subagentId, label, status: 'aborted' },
280
303
  );
281
304
  } catch (err) {
282
305
  log.error({ subagentId, err }, 'Failed to notify parent about abort');
@@ -460,16 +483,25 @@ export class SubagentManager {
460
483
  if (outcome === 'completed') {
461
484
  message =
462
485
  `[Subagent "${config.label}" completed]\n\n` +
463
- `Use subagent_read with subagent_id "${config.id}" to retrieve the full output.`;
486
+ `Use subagent_read with subagent_id "${config.id}" to retrieve the full output.\n` +
487
+ `Do NOT re-spawn this subagent — just read and share the results.`;
464
488
  } else {
465
489
  const error = managed.state.error ?? 'Unknown error';
466
490
  message =
467
491
  `[Subagent "${config.label}" failed]\n\n` +
468
- `Error: ${error}`;
492
+ `Error: ${error}\n` +
493
+ `Do NOT re-spawn or retry this subagent unless the user explicitly asks.`;
469
494
  }
470
495
 
496
+ const notification: SubagentNotificationInfo = {
497
+ subagentId: config.id,
498
+ label: config.label,
499
+ status: outcome,
500
+ ...(outcome === 'failed' ? { error: managed.state.error ?? 'Unknown error' } : {}),
501
+ };
502
+
471
503
  try {
472
- this.onSubagentFinished(config.parentSessionId, message, parentSendToClient);
504
+ this.onSubagentFinished(config.parentSessionId, message, parentSendToClient, notification);
473
505
  } catch (err) {
474
506
  log.error({ subagentId: config.id, err }, 'Failed to notify parent session');
475
507
  }