@vellumai/assistant 0.10.1 → 0.10.2-dev.202606241651.2d2b40d

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 (367) hide show
  1. package/docs/workspace-tools.md +42 -33
  2. package/eslint-rules/cli-no-daemon-internals.js +6 -0
  3. package/node_modules/@vellumai/gateway-client/src/__tests__/guardian-delivery-contract.test.ts +91 -0
  4. package/node_modules/@vellumai/gateway-client/src/__tests__/trust-verdict-contract.test.ts +31 -0
  5. package/node_modules/@vellumai/gateway-client/src/guardian-delivery-contract.ts +48 -0
  6. package/node_modules/@vellumai/gateway-client/src/index.ts +14 -0
  7. package/node_modules/@vellumai/gateway-client/src/trust-verdict-contract.ts +17 -0
  8. package/openapi.yaml +74 -1
  9. package/package.json +1 -1
  10. package/scripts/test.sh +36 -15
  11. package/src/__tests__/actor-token-service.test.ts +36 -14
  12. package/src/__tests__/agent-loop-override-profile.test.ts +1 -0
  13. package/src/__tests__/agent-wake-disk-pressure-callsite.test.ts +2 -0
  14. package/src/__tests__/agent-wake-override-profile.test.ts +2 -0
  15. package/src/__tests__/annotate-activity-metadata.test.ts +2 -0
  16. package/src/__tests__/annotate-risk-options.test.ts +2 -0
  17. package/src/__tests__/approval-cascade.test.ts +2 -0
  18. package/src/__tests__/background-workers-disk-pressure.test.ts +2 -0
  19. package/src/__tests__/btw-routes.test.ts +2 -0
  20. package/src/__tests__/build-persisted-content.test.ts +2 -0
  21. package/src/__tests__/call-controller.test.ts +19 -0
  22. package/src/__tests__/channel-guardian.test.ts +94 -58
  23. package/src/__tests__/channel-reply-delivery.test.ts +2 -0
  24. package/src/__tests__/compaction-events.test.ts +2 -0
  25. package/src/__tests__/compaction.benchmark.test.ts +2 -0
  26. package/src/__tests__/compactor-call-site-logging.test.ts +2 -0
  27. package/src/__tests__/compactor-low-watermark-cut.test.ts +2 -0
  28. package/src/__tests__/compactor-preserved-tail-count.test.ts +2 -0
  29. package/src/__tests__/compactor-summary-call-truncation.test.ts +2 -0
  30. package/src/__tests__/compactor-web-search-strip.test.ts +2 -0
  31. package/src/__tests__/computer-use-tools.test.ts +13 -0
  32. package/src/__tests__/config-loader-backfill.test.ts +5 -1
  33. package/src/__tests__/config-schema.test.ts +1 -0
  34. package/src/__tests__/confirmation-request-guardian-bridge.test.ts +31 -29
  35. package/src/__tests__/contacts-relay-reads.test.ts +13 -15
  36. package/src/__tests__/conversation-abort-tool-results.test.ts +2 -0
  37. package/src/__tests__/conversation-agent-loop-disk-pressure.test.ts +2 -0
  38. package/src/__tests__/conversation-agent-loop-inference-profile.test.ts +2 -0
  39. package/src/__tests__/conversation-agent-loop-overflow.test.ts +2 -0
  40. package/src/__tests__/conversation-agent-loop.test.ts +7 -0
  41. package/src/__tests__/conversation-analysis-routes.test.ts +2 -0
  42. package/src/__tests__/conversation-app-control-lifecycle.test.ts +2 -0
  43. package/src/__tests__/conversation-confirmation-signals.test.ts +2 -0
  44. package/src/__tests__/conversation-history-web-search.test.ts +2 -0
  45. package/src/__tests__/conversation-load-history-repair.test.ts +2 -0
  46. package/src/__tests__/conversation-load-history-stripped.test.ts +2 -0
  47. package/src/__tests__/conversation-pairing.test.ts +2 -0
  48. package/src/__tests__/conversation-process-app-control-preactivation.test.ts +2 -0
  49. package/src/__tests__/conversation-process-callsite.test.ts +2 -0
  50. package/src/__tests__/conversation-provider-retry-repair.test.ts +2 -0
  51. package/src/__tests__/conversation-queue.test.ts +91 -0
  52. package/src/__tests__/conversation-routes-guardian-reply.test.ts +14 -0
  53. package/src/__tests__/conversation-routes-slash-commands.test.ts +14 -0
  54. package/src/__tests__/conversation-slash-queue.test.ts +2 -0
  55. package/src/__tests__/conversation-slash-unknown.test.ts +2 -0
  56. package/src/__tests__/conversation-speed-override.test.ts +2 -0
  57. package/src/__tests__/conversation-surfaces-action-delivery.test.ts +65 -0
  58. package/src/__tests__/conversation-title-service.test.ts +2 -0
  59. package/src/__tests__/conversation-tool-setup-attribution.test.ts +47 -0
  60. package/src/__tests__/conversation-usage.test.ts +2 -0
  61. package/src/__tests__/conversation-workspace-cache-state.test.ts +2 -0
  62. package/src/__tests__/conversation-workspace-injection.test.ts +2 -0
  63. package/src/__tests__/conversation-workspace-tool-tracking.test.ts +2 -0
  64. package/src/__tests__/credential-security-invariants.test.ts +0 -1
  65. package/src/__tests__/db-migration-rollback.test.ts +205 -171
  66. package/src/__tests__/db-test-helpers.ts +5 -4
  67. package/src/__tests__/deterministic-verification-control-plane.test.ts +4 -2
  68. package/src/__tests__/disk-pressure-guard.test.ts +41 -0
  69. package/src/__tests__/dm-persistence.test.ts +2 -0
  70. package/src/__tests__/emit-signal-routing-intent.test.ts +10 -5
  71. package/src/__tests__/events-dev-bypass-actor.test.ts +7 -1
  72. package/src/__tests__/filing-service.test.ts +2 -0
  73. package/src/__tests__/guardian-binding-drift-heal.test.ts +75 -10
  74. package/src/__tests__/guardian-dispatch.test.ts +95 -1
  75. package/src/__tests__/guardian-outbound-http.test.ts +13 -0
  76. package/src/__tests__/heartbeat-disk-pressure.test.ts +2 -0
  77. package/src/__tests__/heartbeat-service.test.ts +2 -0
  78. package/src/__tests__/helpers/channel-test-adapter.ts +1 -7
  79. package/src/__tests__/host-app-control-routes.test.ts +24 -30
  80. package/src/__tests__/host-bash-routes.test.ts +31 -41
  81. package/src/__tests__/host-browser-routes.test.ts +26 -32
  82. package/src/__tests__/host-cu-proxy.test.ts +299 -0
  83. package/src/__tests__/host-cu-routes-targeted.test.ts +25 -33
  84. package/src/__tests__/host-file-routes-targeted.test.ts +40 -52
  85. package/src/__tests__/host-transfer-routes-targeted.test.ts +31 -43
  86. package/src/__tests__/http-user-message-parity.test.ts +167 -8
  87. package/src/__tests__/inbound-slack-persistence.test.ts +2 -0
  88. package/src/__tests__/invite-redemption-service.test.ts +43 -0
  89. package/src/__tests__/llm-context-normalization.test.ts +105 -0
  90. package/src/__tests__/llm-usage-store.test.ts +25 -0
  91. package/src/__tests__/media-stream-server-integration.test.ts +127 -0
  92. package/src/__tests__/memory-retrieval-hook.test.ts +2 -0
  93. package/src/__tests__/messaging-send-tool.test.ts +2 -0
  94. package/src/__tests__/migration-import-from-url.test.ts +2 -2
  95. package/src/__tests__/native-web-search.test.ts +2 -0
  96. package/src/__tests__/non-member-access-request.test.ts +189 -17
  97. package/src/__tests__/notification-broadcaster.test.ts +4 -0
  98. package/src/__tests__/notification-decision-recipient-context.test.ts +33 -32
  99. package/src/__tests__/notification-deep-link.test.ts +6 -0
  100. package/src/__tests__/notification-guardian-path.test.ts +19 -0
  101. package/src/__tests__/outbound-slack-persistence.test.ts +2 -0
  102. package/src/__tests__/pending-interactions-resolved-event.test.ts +7 -4
  103. package/src/__tests__/persistence-secret-redaction.test.ts +2 -0
  104. package/src/__tests__/plugin-bootstrap.test.ts +3 -73
  105. package/src/__tests__/plugin-route-contribution.test.ts +4 -17
  106. package/src/__tests__/plugin-tool-contribution.test.ts +3 -18
  107. package/src/__tests__/plugin-types.test.ts +0 -2
  108. package/src/__tests__/process-message-background-slack.test.ts +2 -0
  109. package/src/__tests__/process-message-display-content.test.ts +2 -0
  110. package/src/__tests__/provider-usage-tracking.test.ts +39 -0
  111. package/src/__tests__/regenerate-fire-and-forget-trace.test.ts +2 -0
  112. package/src/__tests__/registry.test.ts +3 -0
  113. package/src/__tests__/relay-server.test.ts +694 -25
  114. package/src/__tests__/runtime-attachment-metadata.test.ts +0 -1
  115. package/src/__tests__/secret-ingress-http.test.ts +14 -0
  116. package/src/__tests__/send-endpoint-busy.test.ts +30 -8
  117. package/src/__tests__/skills.test.ts +44 -0
  118. package/src/__tests__/slack-inbound-verification.test.ts +47 -2
  119. package/src/__tests__/sse-actor-principal-guardian-source.test.ts +102 -0
  120. package/src/__tests__/steer-on-enqueue-question.test.ts +181 -0
  121. package/src/__tests__/stt-hints.test.ts +44 -13
  122. package/src/__tests__/subagent-detail.test.ts +27 -0
  123. package/src/__tests__/subagent-disposal.test.ts +65 -0
  124. package/src/__tests__/subagent-notify-parent.test.ts +2 -0
  125. package/src/__tests__/subagent-spawn-tool-fork.test.ts +2 -0
  126. package/src/__tests__/subagent-tools.test.ts +2 -0
  127. package/src/__tests__/suggestion-routes.test.ts +2 -0
  128. package/src/__tests__/title-generate-hook.test.ts +2 -0
  129. package/src/__tests__/tool-executor-lifecycle-events.test.ts +2 -0
  130. package/src/__tests__/tool-executor.test.ts +16 -11
  131. package/src/__tests__/tool-preview-lifecycle.test.ts +2 -0
  132. package/src/__tests__/tool-result-metadata-plumbing.test.ts +2 -0
  133. package/src/__tests__/tool-start-timestamp.test.ts +2 -0
  134. package/src/__tests__/trusted-contact-inline-approval-integration.test.ts +10 -10
  135. package/src/__tests__/twilio-routes.test.ts +96 -0
  136. package/src/__tests__/verification-control-plane-policy.test.ts +2 -0
  137. package/src/__tests__/web-search-backend-failure.test.ts +2 -0
  138. package/src/__tests__/workspace-tool-loader.test.ts +195 -2
  139. package/src/agent/loop-exclusive-tool.test.ts +150 -0
  140. package/src/agent/loop.ts +56 -0
  141. package/src/api/constants/sse-replay.ts +41 -0
  142. package/src/api/index.ts +6 -0
  143. package/src/api/responses/llm-request-log-entry.ts +25 -0
  144. package/src/api/responses/subagent-detail.ts +17 -0
  145. package/src/calls/__tests__/relay-setup-router.test.ts +262 -4
  146. package/src/calls/call-domain.ts +3 -3
  147. package/src/calls/guardian-dispatch.ts +10 -8
  148. package/src/calls/inbound-trust-reader.ts +17 -1
  149. package/src/calls/media-stream-server.ts +21 -0
  150. package/src/calls/relay-server.ts +167 -50
  151. package/src/calls/relay-setup-router.ts +37 -7
  152. package/src/calls/relay-verification.ts +4 -4
  153. package/src/calls/stt-hints.ts +9 -12
  154. package/src/calls/twilio-routes.ts +14 -4
  155. package/src/cli/commands/__tests__/cache.test.ts +8 -1
  156. package/src/cli/commands/cache.ts +194 -181
  157. package/src/cli/commands/db/__tests__/repair.test.ts +6 -5
  158. package/src/cli/commands/db/status.ts +37 -1
  159. package/src/cli/commands/mcp.ts +252 -218
  160. package/src/cli/commands/memory/__tests__/worker.test.ts +302 -0
  161. package/src/cli/commands/memory/index.ts +2 -0
  162. package/src/cli/commands/memory/worker.ts +175 -0
  163. package/src/cli/commands/plugins.ts +75 -3
  164. package/src/cli/lib/__tests__/install-from-github.test.ts +102 -0
  165. package/src/cli/lib/__tests__/list-installed-plugins.test.ts +160 -1
  166. package/src/cli/lib/list-installed-plugins.ts +179 -1
  167. package/src/config/__tests__/loader-callsite-strip-fallback.test.ts +143 -0
  168. package/src/config/bundled-skills/computer-use/TOOLS.json +6 -1
  169. package/src/config/bundled-skills/contacts/tools/contact-merge.ts +27 -17
  170. package/src/config/bundled-skills/contacts/tools/contact-search.ts +13 -3
  171. package/src/config/feature-flag-registry.json +0 -8
  172. package/src/config/loader.ts +36 -5
  173. package/src/config/schemas/__tests__/memory-v3.test.ts +1 -0
  174. package/src/config/schemas/memory-lifecycle.ts +12 -0
  175. package/src/config/schemas/memory-v3.ts +7 -0
  176. package/src/config/schemas/memory.ts +4 -0
  177. package/src/config/schemas/timeouts.ts +8 -0
  178. package/src/config/seed-inference-profiles.ts +14 -5
  179. package/src/config/skills.ts +27 -5
  180. package/src/contacts/__tests__/guardian-delivery-reader.test.ts +312 -0
  181. package/src/contacts/contacts-write.ts +3 -0
  182. package/src/contacts/guardian-delivery-reader.ts +223 -0
  183. package/src/daemon/conversation-agent-loop.ts +9 -0
  184. package/src/daemon/conversation-process.ts +39 -17
  185. package/src/daemon/conversation-surfaces.ts +8 -0
  186. package/src/daemon/conversation-tool-setup.ts +49 -16
  187. package/src/daemon/conversation.ts +21 -2
  188. package/src/daemon/disk-pressure-guard.ts +12 -2
  189. package/src/daemon/event-loop-watchdog.ts +28 -1
  190. package/src/daemon/external-plugins-bootstrap.ts +4 -34
  191. package/src/daemon/handlers/__tests__/config-a2a-redeem.test.ts +25 -0
  192. package/src/daemon/handlers/__tests__/config-channels.test.ts +225 -0
  193. package/src/daemon/handlers/config-a2a.ts +6 -14
  194. package/src/daemon/handlers/config-channels.ts +78 -22
  195. package/src/daemon/handlers/conversations.ts +77 -0
  196. package/src/daemon/host-cu-proxy.ts +102 -11
  197. package/src/daemon/lifecycle.ts +4 -0
  198. package/src/daemon/memory-v2-startup.test.ts +72 -0
  199. package/src/daemon/memory-v2-startup.ts +87 -19
  200. package/src/daemon/server.ts +0 -4
  201. package/src/daemon/shutdown-handlers.ts +20 -0
  202. package/src/daemon/tool-setup-types.ts +9 -0
  203. package/src/ipc/__tests__/clients-list-ipc.test.ts +1 -1
  204. package/src/ipc/assistant-server.ts +2 -2
  205. package/src/memory/__tests__/301-create-watchdog-events.test.ts +110 -0
  206. package/src/memory/__tests__/memory-retrospective-job.test.ts +8 -0
  207. package/src/memory/__tests__/prompt-override.test.ts +192 -0
  208. package/src/memory/__tests__/watchdog-events-store.test.ts +161 -0
  209. package/src/memory/conversation-crud.ts +38 -0
  210. package/src/memory/db-connection.ts +22 -3
  211. package/src/memory/db-init.ts +36 -502
  212. package/src/memory/db-singleton.ts +6 -4
  213. package/src/memory/jobs-worker.ts +58 -0
  214. package/src/memory/llm-usage-store.ts +48 -20
  215. package/src/memory/memory-retrospective-job.ts +9 -8
  216. package/src/memory/migrations/014-backfill-inbox-thread-state.ts +13 -3
  217. package/src/memory/migrations/136-drop-assistant-id-columns.ts +52 -27
  218. package/src/memory/migrations/209-strip-thinking-from-consolidated.ts +130 -56
  219. package/src/memory/migrations/300-add-processing-started-at.ts +30 -0
  220. package/src/memory/migrations/301-create-watchdog-events.ts +45 -0
  221. package/src/memory/migrations/__tests__/014-backfill-inbox-thread-state.test.ts +108 -0
  222. package/src/memory/migrations/__tests__/136-drop-assistant-id-columns.test.ts +82 -0
  223. package/src/memory/migrations/__tests__/209-strip-thinking-from-consolidated.test.ts +224 -0
  224. package/src/memory/migrations/__tests__/run-migrations.test.ts +2 -2
  225. package/src/memory/migrations/run-migrations.ts +90 -6
  226. package/src/memory/migrations/schema-introspection.ts +14 -0
  227. package/src/memory/migrations/validate-migration-state.ts +101 -66
  228. package/src/memory/prompt-override.ts +129 -0
  229. package/src/memory/schema/conversations.ts +9 -0
  230. package/src/memory/schema/infrastructure.ts +20 -0
  231. package/src/memory/steps.ts +573 -0
  232. package/src/memory/v2/__tests__/cli-command-store.test.ts +25 -0
  233. package/src/memory/v2/__tests__/skill-store.test.ts +80 -0
  234. package/src/memory/v2/cli-command-store.ts +75 -38
  235. package/src/memory/v2/prompts/consolidation.ts +13 -82
  236. package/src/memory/v2/prompts/router.ts +21 -93
  237. package/src/memory/v2/skill-store.ts +68 -31
  238. package/src/memory/watchdog-events-store.ts +87 -0
  239. package/src/memory/worker-control.ts +118 -0
  240. package/src/memory/worker-process.ts +72 -0
  241. package/src/notifications/__tests__/broadcaster.test.ts +16 -8
  242. package/src/notifications/__tests__/connected-channels.test.ts +114 -0
  243. package/src/notifications/__tests__/decision-engine.test.ts +78 -9
  244. package/src/notifications/__tests__/destination-resolver.test.ts +256 -0
  245. package/src/notifications/broadcaster.ts +8 -1
  246. package/src/notifications/decision-engine.ts +15 -7
  247. package/src/notifications/destination-resolver.ts +68 -24
  248. package/src/notifications/emit-signal.ts +39 -14
  249. package/src/onboarding/checkin-event.test.ts +220 -0
  250. package/src/onboarding/checkin-event.ts +321 -0
  251. package/src/onboarding/schedule-checkin.ts +190 -0
  252. package/src/permissions/question-prompter.test.ts +1 -1
  253. package/src/permissions/question-prompter.ts +7 -4
  254. package/src/plugin-api/index.ts +6 -6
  255. package/src/plugin-api/types.ts +3 -5
  256. package/src/plugin-api/vision-support.test.ts +28 -4
  257. package/src/plugin-api/vision-support.ts +66 -31
  258. package/src/plugins/defaults/advisor/__tests__/consult.test.ts +161 -0
  259. package/src/plugins/defaults/advisor/__tests__/context-pack-gating.test.ts +106 -0
  260. package/src/plugins/defaults/advisor/__tests__/context-pack.test.ts +60 -0
  261. package/src/plugins/defaults/advisor/consult.ts +110 -6
  262. package/src/plugins/defaults/advisor/context-pack.ts +288 -0
  263. package/src/plugins/defaults/advisor/steering.ts +14 -2
  264. package/src/plugins/defaults/advisor/tools/advisor.ts +32 -5
  265. package/src/plugins/defaults/image-fallback/__tests__/image-fallback.test.ts +47 -7
  266. package/src/plugins/defaults/image-fallback/hooks/post-tool-use.ts +10 -11
  267. package/src/plugins/defaults/image-fallback/hooks/user-prompt-submit.ts +12 -20
  268. package/src/plugins/defaults/image-fallback/src/caption-blocks.ts +42 -11
  269. package/src/plugins/defaults/memory-v3-shadow/orchestrate.ts +11 -2
  270. package/src/plugins/defaults/memory-v3-shadow/pool-select.test.ts +146 -0
  271. package/src/plugins/defaults/memory-v3-shadow/pool-select.ts +29 -1
  272. package/src/plugins/defaults/memory-v3-shadow/shadow-plugin.ts +8 -1
  273. package/src/plugins/mtime-cache.ts +7 -2
  274. package/src/plugins/types.ts +0 -2
  275. package/src/providers/anthropic/client.ts +5 -0
  276. package/src/providers/call-site-routing.ts +4 -0
  277. package/src/providers/model-catalog.ts +16 -0
  278. package/src/providers/openai/responses-provider.ts +5 -0
  279. package/src/providers/openrouter/client.ts +5 -0
  280. package/src/providers/provider-send-message.ts +4 -0
  281. package/src/providers/ratelimit.ts +4 -0
  282. package/src/providers/retry.ts +4 -0
  283. package/src/providers/types.ts +9 -0
  284. package/src/providers/usage-tracking.ts +4 -0
  285. package/src/runtime/__tests__/channel-verification-service.test.ts +133 -0
  286. package/src/runtime/__tests__/guardian-vellum-migration.test.ts +181 -0
  287. package/src/runtime/__tests__/is-guardian-bound-for-channel.test.ts +66 -0
  288. package/src/runtime/__tests__/local-principal-trust.test.ts +164 -0
  289. package/src/runtime/__tests__/trust-verdict-consumer.test.ts +335 -3
  290. package/src/runtime/access-request-helper.ts +19 -39
  291. package/src/runtime/actor-trust-resolver.ts +2 -2
  292. package/src/runtime/anchored-guardian.test.ts +156 -0
  293. package/src/runtime/anchored-guardian.ts +135 -0
  294. package/src/runtime/assistant-event-hub.ts +1 -1
  295. package/src/runtime/assistant-stream-state.ts +9 -2
  296. package/src/runtime/auth/__tests__/require-bound-guardian.test.ts +99 -0
  297. package/src/runtime/auth/require-bound-guardian.ts +21 -11
  298. package/src/runtime/channel-verification-service.ts +56 -31
  299. package/src/runtime/confirmation-request-guardian-bridge.ts +3 -3
  300. package/src/runtime/guardian-vellum-migration.ts +66 -7
  301. package/src/runtime/invite-redemption-service.ts +50 -18
  302. package/src/runtime/local-actor-identity.ts +76 -11
  303. package/src/runtime/local-principal-trust.ts +52 -0
  304. package/src/runtime/pending-interactions.ts +11 -1
  305. package/src/runtime/routes/__tests__/channel-verification-revoke.test.ts +56 -5
  306. package/src/runtime/routes/__tests__/channel-verification-routes.test.ts +1 -1
  307. package/src/runtime/routes/__tests__/contact-routes.test.ts +212 -0
  308. package/src/runtime/routes/__tests__/global-search-routes.test.ts +93 -0
  309. package/src/runtime/routes/__tests__/surface-action-routes.test.ts +215 -1
  310. package/src/runtime/routes/browser-routes.ts +1 -1
  311. package/src/runtime/routes/channel-verification-routes.ts +3 -3
  312. package/src/runtime/routes/contact-routes.ts +8 -32
  313. package/src/runtime/routes/conversation-cli-routes.ts +4 -5
  314. package/src/runtime/routes/conversation-list-routes.ts +4 -7
  315. package/src/runtime/routes/conversation-routes.ts +74 -81
  316. package/src/runtime/routes/events-routes.ts +2 -2
  317. package/src/runtime/routes/global-search-routes.ts +3 -1
  318. package/src/runtime/routes/guardian-action-routes.ts +4 -5
  319. package/src/runtime/routes/host-app-control-routes.ts +5 -4
  320. package/src/runtime/routes/host-bash-routes.ts +5 -4
  321. package/src/runtime/routes/host-browser-routes.ts +9 -11
  322. package/src/runtime/routes/host-cu-routes.ts +5 -4
  323. package/src/runtime/routes/host-file-routes.ts +5 -4
  324. package/src/runtime/routes/host-transfer-routes.ts +6 -6
  325. package/src/runtime/routes/http-adapter.ts +1 -1
  326. package/src/runtime/routes/identity-routes.ts +3 -2
  327. package/src/runtime/routes/inbound-message-handler.ts +5 -5
  328. package/src/runtime/routes/inbound-stages/acl-enforcement.test.ts +97 -5
  329. package/src/runtime/routes/inbound-stages/acl-enforcement.ts +61 -49
  330. package/src/runtime/routes/inbound-stages/background-dispatch.ts +16 -4
  331. package/src/runtime/routes/inbound-stages/escalation-intercept.ts +7 -7
  332. package/src/runtime/routes/inbound-stages/guardian-activation-intercept.test.ts +21 -8
  333. package/src/runtime/routes/inbound-stages/guardian-activation-intercept.ts +14 -3
  334. package/src/runtime/routes/index.ts +2 -0
  335. package/src/runtime/routes/llm-context-normalization.ts +71 -0
  336. package/src/runtime/routes/mcp-auth-routes.ts +38 -15
  337. package/src/runtime/routes/migration-rollback-routes.ts +4 -3
  338. package/src/runtime/routes/migration-routes.ts +4 -1
  339. package/src/runtime/routes/onboarding-checkin-routes.ts +86 -0
  340. package/src/runtime/routes/subagents-routes.ts +5 -0
  341. package/src/runtime/routes/surface-action-routes.ts +51 -55
  342. package/src/runtime/services/__tests__/conversation-serializer.test.ts +1 -0
  343. package/src/runtime/services/conversation-serializer.ts +7 -9
  344. package/src/runtime/tool-grant-request-helper.ts +3 -3
  345. package/src/runtime/trust-verdict-consumer.ts +85 -9
  346. package/src/runtime/verification-outbound-actions.ts +18 -18
  347. package/src/signals/user-message.ts +16 -0
  348. package/src/subagent/manager.ts +9 -0
  349. package/src/telemetry/types.ts +34 -1
  350. package/src/telemetry/usage-telemetry-reporter.test.ts +3 -2
  351. package/src/telemetry/usage-telemetry-reporter.ts +87 -3
  352. package/src/tools/ask-question/ask-question-tool.test.ts +29 -0
  353. package/src/tools/ask-question/ask-question-tool.ts +13 -0
  354. package/src/tools/computer-use/definitions.ts +8 -2
  355. package/src/tools/executor.ts +4 -4
  356. package/src/tools/registry.ts +18 -0
  357. package/src/tools/tool-approval-handler.ts +1 -1
  358. package/src/tools/tool-defaults.ts +9 -2
  359. package/src/tools/types.ts +17 -2
  360. package/src/tools/workspace-tools/loader.ts +348 -244
  361. package/src/util/platform.ts +5 -0
  362. package/src/util/telemetry-db-path.ts +24 -0
  363. package/src/workspace/migrations/017-seed-persona-dirs.ts +3 -34
  364. package/src/workspace/migrations/019-scope-journal-to-guardian.ts +3 -24
  365. package/src/__tests__/workspace-tools-watcher-flag.test.ts +0 -70
  366. package/src/daemon/workspace-tools-watcher.ts +0 -328
  367. package/src/memory/migrations/registry.ts +0 -573
@@ -30,13 +30,30 @@
30
30
  * → loadWorkspaceTools() ← this module (first scan)
31
31
  * → loadUserPlugins()
32
32
  * → bootstrapPlugins()
33
- * → start file watcher ← hot register/unregister (no restart)
34
33
  *
35
34
  * Plugins load *after* the initial workspace-tool scan so the registry
36
35
  * hands them a stable view of which workspace tools exist before any
37
- * plugin code runs. The file watcher then runs for the lifetime of the
38
- * assistant, picking up add/change/delete events to keep the registry
39
- * in sync with disk.
36
+ * plugin code runs.
37
+ *
38
+ * ## Reconcile on read, not on a watcher
39
+ *
40
+ * {@link loadWorkspaceTools} is idempotent and safe to call repeatedly:
41
+ * after the initial scan it reconciles the registry against on-disk
42
+ * state. Each call re-derives "given what's on disk right now under
43
+ * `tools/`, what registry state should the assistant be in?" and applies
44
+ * the delta — registering newly added tools, re-importing changed tools
45
+ * (mtime-gated, cache-busting via the per-import URL query string),
46
+ * unregistering deleted tools, stripping core tools when a `.removed`
47
+ * sentinel appears, and restoring them when it disappears.
48
+ *
49
+ * Instead of a long-lived filesystem watcher, the per-turn tool resolver
50
+ * (`createResolveToolsCallback` in `conversation-tool-setup.ts`) kicks this
51
+ * reconcile and then re-reads workspace tools from the registry — the same
52
+ * way it re-reads MCP tools — so a conversation picks up on-disk edits
53
+ * without a restart and without recreating the conversation. The "edit a
54
+ * file, see the change" loop closes on the next turn. Unchanged files are
55
+ * skipped via the mtime cache, so a no-op reconcile costs one `readdir`
56
+ * plus a `stat` per file and never re-imports.
40
57
  *
41
58
  * Per-tool isolation:
42
59
  *
@@ -62,12 +79,19 @@
62
79
  import { existsSync, readdirSync, statSync } from "node:fs";
63
80
  import { readFile } from "node:fs/promises";
64
81
  import { extname, join } from "node:path";
65
- import { pathToFileURL } from "node:url";
66
82
 
67
83
  import { getLogger } from "../../util/logger.js";
68
84
  import { getWorkspaceToolsDir } from "../../util/platform.js";
69
85
  import { isProviderSafeToolName } from "../provider-tool-name.js";
70
- import { registerWorkspaceTools, removeCoreToolViaWorkspace } from "../registry.js";
86
+ import {
87
+ getCoreToolOverride,
88
+ getTool,
89
+ getToolOwner,
90
+ registerWorkspaceTools,
91
+ removeCoreToolViaWorkspace,
92
+ restoreStrippedCoreTool,
93
+ unregisterWorkspaceTool,
94
+ } from "../registry.js";
71
95
  import { finalizeTool } from "../tool-defaults.js";
72
96
  import type {
73
97
  RiskLevel,
@@ -139,14 +163,21 @@ function isValidToolFilenameStem(stem: string): boolean {
139
163
  */
140
164
  function classifyEntry(
141
165
  entry: string,
142
- ): { kind: "live"; stem: string; ext: LiveToolExtension } | { kind: "removed"; stem: string } | undefined {
166
+ ):
167
+ | { kind: "live"; stem: string; ext: LiveToolExtension }
168
+ | { kind: "removed"; stem: string }
169
+ | undefined {
143
170
  const ext = extname(entry);
144
171
  if (ext === REMOVED_EXTENSION) {
145
172
  return { kind: "removed", stem: entry.slice(0, -REMOVED_EXTENSION.length) };
146
173
  }
147
174
  for (const candidate of LIVE_TOOL_EXTENSIONS) {
148
175
  if (ext === candidate) {
149
- return { kind: "live", stem: entry.slice(0, -candidate.length), ext: candidate };
176
+ return {
177
+ kind: "live",
178
+ stem: entry.slice(0, -candidate.length),
179
+ ext: candidate,
180
+ };
150
181
  }
151
182
  }
152
183
  return undefined;
@@ -162,7 +193,9 @@ interface LiveSelection {
162
193
  shadowed: LiveToolExtension[];
163
194
  }
164
195
 
165
- function selectLiveExtension(extensions: Set<LiveToolExtension>): LiveSelection {
196
+ function selectLiveExtension(
197
+ extensions: Set<LiveToolExtension>,
198
+ ): LiveSelection {
166
199
  for (const candidate of LIVE_TOOL_EXTENSIONS) {
167
200
  if (extensions.has(candidate)) {
168
201
  const shadowed: LiveToolExtension[] = [];
@@ -192,14 +225,19 @@ function selectLiveExtension(extensions: Set<LiveToolExtension>): LiveSelection
192
225
  * The tool still loads cleanly with these defaults — a broken tool must
193
226
  * never block daemon boot. Always sets `category: "workspace"` so the
194
227
  * registry can distinguish workspace overrides from other origins.
228
+ *
229
+ * The registered name is pinned to the filename stem (`name`), overriding
230
+ * any `name` field on the file's own export. This is the documented
231
+ * "filename stem is the tool name verbatim" contract — `finalizeTool`
232
+ * would otherwise prefer `tool.name` — and it keeps the registered name in
233
+ * lockstep with the stem the reconcile keys its mtime cache by, so a later
234
+ * delete of the file unregisters the right tool.
195
235
  */
196
- function applyWorkspaceToolDefaults(
197
- tool: ToolDefinition,
198
- name: string,
199
- ): Tool {
236
+ function applyWorkspaceToolDefaults(tool: ToolDefinition, name: string): Tool {
200
237
  const finalized = finalizeTool(
201
238
  {
202
239
  ...tool,
240
+ name,
203
241
  defaultRiskLevel:
204
242
  tool.defaultRiskLevel ?? WORKSPACE_TOOL_DEFAULTS.defaultRiskLevel,
205
243
  category: tool.category ?? "workspace",
@@ -221,10 +259,18 @@ function applyWorkspaceToolDefaults(
221
259
  * module's default export, or `undefined` if the import times out, has
222
260
  * no default export, or throws.
223
261
  *
224
- * A cache-busting `?v=<counter>` query string is appended so the loader's
225
- * later re-imports (driven by the file watcher) pick up disk changes
226
- * instead of node's cached module. The counter is per-call, so every
227
- * import gets a fresh module identity.
262
+ * A cache-busting `?v=<counter>` query string is appended so a reconcile
263
+ * that re-imports a changed file picks up the new contents instead of the
264
+ * module bun already transpiled. The counter is per-call, so every import
265
+ * gets a fresh module identity.
266
+ *
267
+ * The specifier is the raw absolute path (not a `file://` URL): bun honors
268
+ * the `?v=` query for cache-busting on a bare absolute path but collapses
269
+ * it to the same cached module for a `file://` URL, which would silently
270
+ * serve stale source on re-import. Absolute paths (and embedded spaces)
271
+ * import cleanly; only `?`/`#` in the path would confuse the query, and
272
+ * tool stems are provider-safe so the directory prefix is the only place
273
+ * those could appear.
228
274
  *
229
275
  * All failure paths log with file attribution so operators can find the
230
276
  * broken tool quickly.
@@ -235,7 +281,7 @@ async function importToolDefaultBounded(
235
281
  entryPath: string,
236
282
  timeoutMs: number,
237
283
  ): Promise<unknown> {
238
- const url = `${pathToFileURL(entryPath).href}?v=${++importCounter}`;
284
+ const url = `${entryPath}?v=${++importCounter}`;
239
285
  let timeoutHandle: ReturnType<typeof setTimeout> | undefined;
240
286
  try {
241
287
  const timeoutSentinel = Symbol("workspace-tool-import-timeout");
@@ -285,7 +331,9 @@ async function importToolDefaultBounded(
285
331
  * exist for declarative use cases — schema-only tool stubs, override
286
332
  * placeholders, etc.
287
333
  */
288
- async function readJsonToolSpec(entryPath: string): Promise<ToolDefinition | undefined> {
334
+ async function readJsonToolSpec(
335
+ entryPath: string,
336
+ ): Promise<ToolDefinition | undefined> {
289
337
  let raw: string;
290
338
  try {
291
339
  raw = await readFile(entryPath, "utf8");
@@ -326,53 +374,62 @@ export interface LoadWorkspaceToolsOptions {
326
374
  }
327
375
 
328
376
  /**
329
- * Result of a {@link loadWorkspaceTools} call — exposes which tool names
330
- * were registered and which were stripped so callers (notably the file
331
- * watcher) can compute deltas against subsequent re-scans.
377
+ * Result of a {@link loadWorkspaceTools} call — the names workspace tools
378
+ * currently own and the core-tool names currently stripped via
379
+ * `<name>.removed` sentinels, reflecting the registry state after the
380
+ * reconcile applied its delta.
332
381
  */
333
382
  export interface LoadWorkspaceToolsResult {
334
- /** Tool names successfully registered as workspace tools. */
383
+ /** Tool names currently registered as workspace tools. */
335
384
  readonly registered: string[];
336
- /** Tool names stripped from the registry via `<name>.removed` sentinels. */
385
+ /** Core-tool names currently stripped via `<name>.removed` sentinels. */
337
386
  readonly removed: string[];
338
387
  }
339
388
 
340
389
  /**
341
- * Scan `<workspaceDir>/tools/` and register every well-formed
342
- * `<name>.{ts,js,json}` as a workspace tool. Files matching
343
- * `<name>.removed` strip the core tool of that name from the registry
344
- * via {@link removeCoreToolViaWorkspace}.
345
- *
346
- * Invariants:
347
- *
348
- * - No-ops silently when the tools directory does not exist. A clean
349
- * install with zero workspace tools must produce no log noise beyond
350
- * the eventual "0 workspace tools registered" debug line.
351
- * - Per-tool isolation: any single broken tool is logged and skipped
352
- * without aborting the scan. The function resolves normally even when
353
- * every candidate fails.
354
- * - Idempotency is the registry's job: a second call without a
355
- * preceding unregister will throw on the duplicate-workspace-tool
356
- * check. Callers (daemon startup) are expected to call once; the file
357
- * watcher uses the per-event entry points instead.
358
- *
359
- * Caller responsibilities:
360
- *
361
- * - Must be invoked between {@link initializeTools} and
362
- * {@link loadUserPlugins}. Calling earlier risks racing core
363
- * registrations; calling later means plugins see an incomplete
364
- * registry and may register over a name a workspace tool will later
365
- * try to own.
390
+ * What the loader last established on disk for a given stem. The mtime
391
+ * cache lets a repeat {@link loadWorkspaceTools} call skip re-importing a
392
+ * file that hasn't changed since the previous reconcile a no-op
393
+ * reconcile costs one `readdir` plus a `stat` per file and never touches
394
+ * the registry.
366
395
  */
367
- export async function loadWorkspaceTools(
368
- options: LoadWorkspaceToolsOptions = {},
369
- ): Promise<LoadWorkspaceToolsResult> {
370
- const importTimeoutMs = options.importTimeoutMs ?? IMPORT_TIMEOUT_MS;
371
- const toolsDir = getWorkspaceToolsDir();
396
+ type ManagedEntry =
397
+ | { kind: "live"; ext: LiveToolExtension; mtimeMs: number }
398
+ | { kind: "removed" };
399
+
400
+ /**
401
+ * Per-stem record of the workspace-tool state this module installed on the
402
+ * last reconcile. Module-level (process-wide) because the registry it
403
+ * mirrors is also process-wide. Reset between tests via
404
+ * {@link __resetWorkspaceToolCacheForTesting}.
405
+ */
406
+ const managed = new Map<string, ManagedEntry>();
407
+
408
+ /**
409
+ * The winning live file for a stem, resolved from the on-disk scan.
410
+ */
411
+ interface DesiredLiveEntry {
412
+ readonly ext: LiveToolExtension;
413
+ readonly mtimeMs: number;
414
+ readonly path: string;
415
+ }
416
+
417
+ /**
418
+ * Pure (no registry mutation) scan of `<workspaceDir>/tools/`. Resolves
419
+ * each stem to its winning live file (with mtime) and the set of stems
420
+ * carrying a `.removed` sentinel, applying the same validation and
421
+ * shadow/ambiguity rules the reconcile relies on. Returns empty maps when
422
+ * the directory is missing or unreadable.
423
+ */
424
+ function scanWorkspaceToolsDir(toolsDir: string): {
425
+ desiredLive: Map<string, DesiredLiveEntry>;
426
+ removedStems: Set<string>;
427
+ } {
428
+ const desiredLive = new Map<string, DesiredLiveEntry>();
429
+ const removedStems = new Set<string>();
372
430
 
373
431
  if (!existsSync(toolsDir)) {
374
- log.debug({ toolsDir }, "Workspace tools directory does not exist — skipping");
375
- return { registered: [], removed: [] };
432
+ return { desiredLive, removedStems };
376
433
  }
377
434
 
378
435
  let entries: string[];
@@ -383,16 +440,15 @@ export async function loadWorkspaceTools(
383
440
  { err, toolsDir },
384
441
  "loadWorkspaceTools: failed to read tools directory — continuing with no workspace tools",
385
442
  );
386
- return { registered: [], removed: [] };
443
+ return { desiredLive, removedStems };
387
444
  }
388
445
 
389
446
  // Group entries by stem so we can detect multi-extension shadowing
390
447
  // (e.g. `foo.ts` + `foo.js` claiming the same name) before we kick off
391
- // any imports. Each stem maps to a Set of extensions; .removed sentinels
392
- // are tracked in a separate set since they're mutually exclusive with
393
- // live tool files (you don't strip AND register a name at once).
394
- const liveByStem = new Map<string, Set<LiveToolExtension>>();
395
- const removedStems = new Set<string>();
448
+ // any imports. Each stem maps to its live extensions (with mtimes);
449
+ // .removed sentinels are tracked separately since they're mutually
450
+ // exclusive with live tool files (you don't strip AND register at once).
451
+ const liveByStem = new Map<string, Map<LiveToolExtension, number>>();
396
452
 
397
453
  for (const entry of entries) {
398
454
  const fullPath = join(toolsDir, entry);
@@ -429,10 +485,10 @@ export async function loadWorkspaceTools(
429
485
  }
430
486
  let extensions = liveByStem.get(classified.stem);
431
487
  if (!extensions) {
432
- extensions = new Set<LiveToolExtension>();
488
+ extensions = new Map<LiveToolExtension, number>();
433
489
  liveByStem.set(classified.stem, extensions);
434
490
  }
435
- extensions.add(classified.ext);
491
+ extensions.set(classified.ext, stats.mtimeMs);
436
492
  }
437
493
 
438
494
  // A stem cannot both be live AND removed. Operator intent is ambiguous;
@@ -448,226 +504,274 @@ export async function loadWorkspaceTools(
448
504
  }
449
505
  }
450
506
 
451
- // Apply removals before registrations so the batch validation in
452
- // registerWorkspaceTools sees the post-removal registry state.
453
- const removed: string[] = [];
454
- for (const stem of removedStems) {
455
- try {
456
- removeCoreToolViaWorkspace(stem);
457
- removed.push(stem);
458
- } catch (err) {
459
- const message = err instanceof Error ? err.message : String(err);
460
- log.error(
461
- { err, stem },
462
- `loadWorkspaceTools: failed to strip core tool "${stem}": ${message}`,
463
- );
464
- }
465
- }
466
-
467
- // Resolve each live stem to its winning entry, import it, and add to
468
- // the registration batch. Multi-extension shadowing warns once per
469
- // ignored sibling so the operator can clean up the redundant file.
470
- const batch: Array<{ tool: Tool; workspacePath: string }> = [];
471
-
507
+ // Resolve each live stem to its winning extension. Multi-extension
508
+ // shadowing warns once per ignored sibling so the operator can clean up
509
+ // the redundant file.
472
510
  for (const [stem, extensions] of liveByStem) {
473
- const { ext: winningExt, shadowed } = selectLiveExtension(extensions);
511
+ const { ext: winningExt, shadowed } = selectLiveExtension(
512
+ new Set(extensions.keys()),
513
+ );
474
514
  if (shadowed.length > 0) {
475
515
  log.warn(
476
516
  { stem, winningExt, shadowed, toolsDir },
477
517
  `loadWorkspaceTools: "${stem}" has multiple files (${[winningExt, ...shadowed].join(", ")}) — using ${winningExt} and ignoring the rest`,
478
518
  );
479
519
  }
480
- const entryPath = join(toolsDir, `${stem}${winningExt}`);
481
-
482
- let toolSpec: ToolDefinition | undefined;
483
- if (winningExt === ".json") {
484
- toolSpec = await readJsonToolSpec(entryPath);
485
- } else {
486
- const defaultExport = await importToolDefaultBounded(entryPath, importTimeoutMs);
487
- if (defaultExport === undefined) continue; // Failure already logged.
488
- if (defaultExport === null || typeof defaultExport !== "object") {
489
- log.error(
490
- { entryPath, type: typeof defaultExport },
491
- `Workspace tool at ${entryPath} default export must be an object — skipping`,
492
- );
493
- continue;
494
- }
495
- toolSpec = defaultExport as ToolDefinition;
496
- }
497
- if (!toolSpec) continue;
498
-
499
- const loaded = applyWorkspaceToolDefaults(toolSpec, stem);
500
- batch.push({ tool: loaded, workspacePath: entryPath });
501
- }
502
-
503
- if (batch.length === 0) {
504
- if (removed.length === 0) {
505
- log.debug(
506
- { toolsDir },
507
- "loadWorkspaceTools: no workspace tools to register or strip",
508
- );
509
- } else {
510
- log.info(
511
- { toolsDir, removedCount: removed.length, removed },
512
- `Stripped ${removed.length} core tool${removed.length === 1 ? "" : "s"} via workspace .removed sentinels`,
513
- );
514
- }
515
- return { registered: [], removed };
520
+ desiredLive.set(stem, {
521
+ ext: winningExt,
522
+ mtimeMs: extensions.get(winningExt) ?? 0,
523
+ path: join(toolsDir, `${stem}${winningExt}`),
524
+ });
516
525
  }
517
526
 
518
- try {
519
- const accepted = registerWorkspaceTools(batch);
520
- log.info(
521
- { count: accepted.length, toolsDir, removedCount: removed.length },
522
- `Registered ${accepted.length} workspace tool${accepted.length === 1 ? "" : "s"}${removed.length > 0 ? ` (and stripped ${removed.length} core tool${removed.length === 1 ? "" : "s"})` : ""}`,
523
- );
524
- return { registered: accepted.map((t) => t.name), removed };
525
- } catch (err) {
526
- // A throw from registerWorkspaceTools means a hard conflict (e.g.
527
- // duplicate name in batch, lifecycle-order regression). The batch
528
- // validation phase guarantees no partial application landed, so
529
- // every workspace tool from this load attempt is absent from the
530
- // registry. Surface the error loudly but do NOT rethrow — assistant
531
- // startup must still complete.
532
- const message = err instanceof Error ? err.message : String(err);
533
- log.error(
534
- { err, toolsDir, batchSize: batch.length },
535
- `loadWorkspaceTools: registry rejected batch — ${message}`,
536
- );
537
- return { registered: [], removed };
538
- }
527
+ return { desiredLive, removedStems };
539
528
  }
540
529
 
541
- // ─── Single-entry helpers for the file watcher ───────────────────────────────
542
- //
543
- // The watcher calls these on each fs event. The initial-scan path
544
- // ({@link loadWorkspaceTools}) batches for transactional registration;
545
- // the per-event path takes the simpler one-tool-at-a-time route since
546
- // fs events arrive serially and the registry handles each as an
547
- // atomic operation.
548
-
549
530
  /**
550
- * Load and register a single workspace tool file. Returns the registered
551
- * tool name on success or `undefined` if the file failed to load (errors
552
- * are logged with file attribution and never thrown to the caller).
553
- *
554
- * Used by the file watcher's `add` event. The caller is expected to
555
- * have already unregistered any prior workspace tool for the same name.
531
+ * Tear down any workspace-tool state this module owns for `stem`:
532
+ * unregister a live workspace tool (restoring a stashed core tool if the
533
+ * workspace tool overrode one), and restore a core tool previously
534
+ * stripped via a `.removed` sentinel. Both are no-ops when there is
535
+ * nothing to undo, so this is safe to call for any stem.
556
536
  */
557
- export async function loadSingleWorkspaceTool(
558
- entryPath: string,
559
- options: LoadWorkspaceToolsOptions = {},
560
- ): Promise<string | undefined> {
561
- const importTimeoutMs = options.importTimeoutMs ?? IMPORT_TIMEOUT_MS;
562
- const filename = entryPath.split("/").pop() ?? "";
563
- const classified = classifyEntry(filename);
564
- if (!classified || classified.kind !== "live") {
565
- log.debug(
566
- { entryPath },
567
- "loadSingleWorkspaceTool: file is not a live tool entry — skipping",
568
- );
569
- return undefined;
537
+ function teardownStem(stem: string): void {
538
+ if (getToolOwner(stem)?.kind === "workspace") {
539
+ unregisterWorkspaceTool(stem);
570
540
  }
571
- if (!isValidToolFilenameStem(classified.stem)) {
572
- log.error(
573
- { entryPath, stem: classified.stem },
574
- `loadSingleWorkspaceTool: filename stem "${classified.stem}" is not a provider-safe tool name — skipping`,
575
- );
576
- return undefined;
541
+ if (getCoreToolOverride(stem) && !getTool(stem)) {
542
+ restoreStrippedCoreTool(stem);
577
543
  }
544
+ }
578
545
 
546
+ /**
547
+ * Import and finalize the winning live file for `stem`, returning the
548
+ * registry-ready {@link Tool} or `undefined` when the file fails to load
549
+ * (every failure is logged with file attribution and never thrown).
550
+ */
551
+ async function loadDesiredLiveTool(
552
+ stem: string,
553
+ entry: DesiredLiveEntry,
554
+ importTimeoutMs: number,
555
+ ): Promise<Tool | undefined> {
579
556
  let toolSpec: ToolDefinition | undefined;
580
- if (classified.ext === ".json") {
581
- toolSpec = await readJsonToolSpec(entryPath);
557
+ if (entry.ext === ".json") {
558
+ toolSpec = await readJsonToolSpec(entry.path);
582
559
  } else {
583
- const defaultExport = await importToolDefaultBounded(entryPath, importTimeoutMs);
584
- if (defaultExport === undefined) return undefined;
560
+ const defaultExport = await importToolDefaultBounded(
561
+ entry.path,
562
+ importTimeoutMs,
563
+ );
564
+ if (defaultExport === undefined) return undefined; // Failure already logged.
585
565
  if (defaultExport === null || typeof defaultExport !== "object") {
586
566
  log.error(
587
- { entryPath, type: typeof defaultExport },
588
- `Workspace tool at ${entryPath} default export must be an object — skipping`,
567
+ { entryPath: entry.path, type: typeof defaultExport },
568
+ `Workspace tool at ${entry.path} default export must be an object — skipping`,
589
569
  );
590
570
  return undefined;
591
571
  }
592
572
  toolSpec = defaultExport as ToolDefinition;
593
573
  }
594
574
  if (!toolSpec) return undefined;
595
-
596
- const loaded = applyWorkspaceToolDefaults(toolSpec, classified.stem);
597
- try {
598
- registerWorkspaceTools([{ tool: loaded, workspacePath: entryPath }]);
599
- return classified.stem;
600
- } catch (err) {
601
- const message = err instanceof Error ? err.message : String(err);
602
- log.error(
603
- { err, entryPath },
604
- `loadSingleWorkspaceTool: registry rejected "${classified.stem}": ${message}`,
605
- );
606
- return undefined;
607
- }
575
+ return applyWorkspaceToolDefaults(toolSpec, stem);
608
576
  }
609
577
 
610
578
  /**
611
- * Classify a single filesystem entry. Exposed for the file watcher so
612
- * it can route events without re-implementing the extension logic.
579
+ * The currently-running reconcile, if any. Concurrent callers coalesce onto
580
+ * it so the per-turn fire-and-forget kicks from many conversations can't
581
+ * pile up or interleave their unregister/register sequences against the
582
+ * shared registry. Once it settles this is cleared, so a later caller (the
583
+ * next turn, or a sequential awaiter like boot/tests) starts a fresh scan.
613
584
  */
614
- export function classifyWorkspaceToolEntry(
615
- filename: string,
616
- ):
617
- | { kind: "live"; stem: string; ext: LiveToolExtension }
618
- | { kind: "removed"; stem: string }
619
- | undefined {
620
- return classifyEntry(filename);
621
- }
585
+ let inflightReconcile: Promise<LoadWorkspaceToolsResult> | null = null;
622
586
 
623
587
  /**
624
- * Scan `toolsDir` for all entries matching `stem` and return the winning
625
- * live file's absolute path (if any) plus whether a `.removed` sentinel
626
- * exists for the same stem.
588
+ * Reconcile the registry's workspace-tool layer against
589
+ * `<workspaceDir>/tools/`.
590
+ *
591
+ * Idempotent and safe to call repeatedly: the first call registers every
592
+ * well-formed `<name>.{ts,js,json}` as a workspace tool and strips core
593
+ * tools named by `<name>.removed` sentinels; subsequent calls apply only
594
+ * the delta since the previous reconcile — registering added files,
595
+ * re-importing changed files (detected by mtime), unregistering deleted
596
+ * files, and restoring core tools whose sentinel was removed.
597
+ *
598
+ * Invariants:
599
+ *
600
+ * - No-ops to an empty registry footprint when the tools directory does
601
+ * not exist, tearing down anything a previous reconcile installed.
602
+ * - Per-tool isolation: any single broken tool is logged and skipped
603
+ * without aborting the reconcile. The function resolves normally even
604
+ * when every candidate fails.
605
+ * - Concurrency-safe: concurrent callers coalesce onto a single in-flight
606
+ * reconcile, so the unregister/register sequence for a changed tool never
607
+ * races another reconcile.
627
608
  *
628
- * Multi-extension precedence: `.js` > `.ts` > `.json`. Shadowed siblings
629
- * are not reported here — the caller decides whether to warn (the full
630
- * scan path does; the per-stem watcher does not, because shadow events
631
- * are noisy in editor save flows).
609
+ * Caller responsibilities:
610
+ *
611
+ * - The first call must run between {@link initializeTools} and
612
+ * {@link loadUserPlugins}. Calling earlier risks racing core
613
+ * registrations; calling later means plugins see an incomplete
614
+ * registry and may register over a name a workspace tool will later
615
+ * try to own. Later calls (driven by the per-turn tool resolver) are
616
+ * free to run any time — the reconcile only ever touches
617
+ * workspace-owned and core-stashed names.
632
618
  */
633
- export function findWinningWorkspaceToolPath(
634
- toolsDir: string,
635
- stem: string,
636
- ): { livePath: string | null; liveExt: LiveToolExtension | null; hasRemovedSentinel: boolean } {
637
- if (!existsSync(toolsDir)) {
638
- return { livePath: null, liveExt: null, hasRemovedSentinel: false };
619
+ export function loadWorkspaceTools(
620
+ options: LoadWorkspaceToolsOptions = {},
621
+ ): Promise<LoadWorkspaceToolsResult> {
622
+ if (inflightReconcile) return inflightReconcile;
623
+ // `reconcileWorkspaceTools` never rejects (all failures are caught and
624
+ // logged); `.finally` clears the slot either way so the next caller scans
625
+ // fresh.
626
+ inflightReconcile = reconcileWorkspaceTools(options).finally(() => {
627
+ inflightReconcile = null;
628
+ });
629
+ return inflightReconcile;
630
+ }
631
+
632
+ async function reconcileWorkspaceTools(
633
+ options: LoadWorkspaceToolsOptions,
634
+ ): Promise<LoadWorkspaceToolsResult> {
635
+ const importTimeoutMs = options.importTimeoutMs ?? IMPORT_TIMEOUT_MS;
636
+ const toolsDir = getWorkspaceToolsDir();
637
+
638
+ const { desiredLive, removedStems } = scanWorkspaceToolsDir(toolsDir);
639
+
640
+ // Snapshot what we managed before so we can detect stems that vanished
641
+ // from disk entirely (present last time, absent now) and tear them down.
642
+ const prevManaged = new Map(managed);
643
+
644
+ // 1. Tear down stems we previously managed that disk no longer mentions
645
+ // (neither a live file nor a .removed sentinel). Stems still present
646
+ // are handled by the live/removed passes below.
647
+ for (const stem of prevManaged.keys()) {
648
+ if (!desiredLive.has(stem) && !removedStems.has(stem)) {
649
+ teardownStem(stem);
650
+ managed.delete(stem);
651
+ }
639
652
  }
640
- let entries: string[];
641
- try {
642
- entries = readdirSync(toolsDir);
643
- } catch (err) {
644
- log.warn(
645
- { err, toolsDir, stem },
646
- "findWinningWorkspaceToolPath: failed to read tools directory",
647
- );
648
- return { livePath: null, liveExt: null, hasRemovedSentinel: false };
653
+
654
+ // 2. `.removed` sentinels — strip the named core tool. Unregister any
655
+ // prior workspace registration for the stem first so the strip path
656
+ // sees a core (or empty) baseline rather than a workspace override.
657
+ for (const stem of removedStems) {
658
+ if (getToolOwner(stem)?.kind === "workspace") {
659
+ unregisterWorkspaceTool(stem);
660
+ }
661
+ try {
662
+ removeCoreToolViaWorkspace(stem);
663
+ managed.set(stem, { kind: "removed" });
664
+ } catch (err) {
665
+ const message = err instanceof Error ? err.message : String(err);
666
+ log.error(
667
+ { err, stem },
668
+ `loadWorkspaceTools: failed to strip core tool "${stem}": ${message}`,
669
+ );
670
+ managed.delete(stem);
671
+ }
649
672
  }
650
673
 
651
- const liveExtensions = new Set<LiveToolExtension>();
652
- let hasRemovedSentinel = false;
674
+ // 3. Live tools — register new files, re-import changed files (mtime
675
+ // differs), skip unchanged ones. Each entry is imported and registered
676
+ // on its own so one broken or conflicting file cannot drop the others
677
+ // (per-tool isolation): the import is bounded/caught, and registration
678
+ // goes through registerWorkspaceTools one tool at a time. Stems are the
679
+ // map keys here, so there are no intra-reconcile duplicate names that a
680
+ // batch would otherwise need to validate.
681
+ for (const [stem, entry] of desiredLive) {
682
+ const prev = prevManaged.get(stem);
683
+ const unchanged =
684
+ prev?.kind === "live" &&
685
+ prev.ext === entry.ext &&
686
+ prev.mtimeMs === entry.mtimeMs &&
687
+ getToolOwner(stem)?.kind === "workspace";
688
+ if (unchanged) {
689
+ managed.set(stem, {
690
+ kind: "live",
691
+ ext: entry.ext,
692
+ mtimeMs: entry.mtimeMs,
693
+ });
694
+ continue;
695
+ }
696
+
697
+ // Changed, or a fresh import is needed. Import FIRST and only mutate the
698
+ // registry once we hold a valid tool, so a failed re-import leaves the
699
+ // previously-registered version in place rather than tearing it down.
700
+ const tool = await loadDesiredLiveTool(stem, entry, importTimeoutMs);
701
+ if (!tool) {
702
+ // Import failed (already logged). Leave any prior registration intact
703
+ // and keep the managed entry so a later fix re-imports cleanly.
704
+ continue;
705
+ }
653
706
 
654
- for (const entry of entries) {
655
- const classified = classifyEntry(entry);
656
- if (!classified || classified.stem !== stem) continue;
657
- if (classified.kind === "removed") {
658
- hasRemovedSentinel = true;
659
- } else {
660
- liveExtensions.add(classified.ext);
707
+ // Drop any prior workspace registration so the loader re-registers
708
+ // cleanly, and restore a previously-stripped core tool so the override
709
+ // path sees the expected baseline (core present stash + replace).
710
+ if (getToolOwner(stem)?.kind === "workspace") {
711
+ unregisterWorkspaceTool(stem);
712
+ }
713
+ if (getCoreToolOverride(stem) && !getTool(stem)) {
714
+ restoreStrippedCoreTool(stem);
715
+ }
716
+
717
+ try {
718
+ registerWorkspaceTools([{ tool, workspacePath: entry.path }]);
719
+ managed.set(stem, {
720
+ kind: "live",
721
+ ext: entry.ext,
722
+ mtimeMs: entry.mtimeMs,
723
+ });
724
+ } catch (err) {
725
+ // A throw means a hard conflict for this name (e.g. a plugin/MCP tool
726
+ // already owns it — a lifecycle-order regression). Surface it loudly,
727
+ // but do NOT rethrow and do NOT abort the other tools — startup /
728
+ // conversation reads must still complete.
729
+ const message = err instanceof Error ? err.message : String(err);
730
+ log.error(
731
+ { err, stem, toolsDir },
732
+ `loadWorkspaceTools: registry rejected "${stem}" — ${message}`,
733
+ );
734
+ managed.delete(stem);
661
735
  }
662
736
  }
663
737
 
664
- if (liveExtensions.size === 0) {
665
- return { livePath: null, liveExt: null, hasRemovedSentinel };
738
+ // Derive the result from the final registry state so it reflects what
739
+ // actually landed rather than what we attempted.
740
+ const registered: string[] = [];
741
+ const removed: string[] = [];
742
+ for (const [stem, entry] of managed) {
743
+ if (entry.kind === "live" && getToolOwner(stem)?.kind === "workspace") {
744
+ registered.push(stem);
745
+ } else if (
746
+ entry.kind === "removed" &&
747
+ getCoreToolOverride(stem) &&
748
+ !getTool(stem)
749
+ ) {
750
+ removed.push(stem);
751
+ }
666
752
  }
667
- const { ext } = selectLiveExtension(liveExtensions);
668
- return {
669
- livePath: join(toolsDir, `${stem}${ext}`),
670
- liveExt: ext,
671
- hasRemovedSentinel,
672
- };
753
+
754
+ if (registered.length === 0 && removed.length === 0) {
755
+ log.debug(
756
+ { toolsDir },
757
+ "loadWorkspaceTools: no workspace tools registered or stripped",
758
+ );
759
+ } else {
760
+ log.info(
761
+ { count: registered.length, toolsDir, removedCount: removed.length },
762
+ `Workspace tools reconciled: ${registered.length} registered${removed.length > 0 ? `, ${removed.length} core tool${removed.length === 1 ? "" : "s"} stripped` : ""}`,
763
+ );
764
+ }
765
+
766
+ return { registered, removed };
767
+ }
768
+
769
+ /**
770
+ * Test-only — drop the mtime cache and serialization chain so a fresh
771
+ * test starts from a clean reconcile baseline. The registry itself is
772
+ * reset separately via `__clearRegistryForTesting`.
773
+ */
774
+ export function __resetWorkspaceToolCacheForTesting(): void {
775
+ managed.clear();
776
+ inflightReconcile = null;
673
777
  }