@vellumai/assistant 0.10.2-dev.202606250318.5e7cfb0 → 0.10.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (430) hide show
  1. package/bun.lock +0 -20
  2. package/docs/workspace-tools.md +33 -42
  3. package/eslint-rules/cli-no-daemon-internals.js +0 -6
  4. package/node_modules/@vellumai/gateway-client/src/__tests__/trust-verdict-contract.test.ts +0 -31
  5. package/node_modules/@vellumai/gateway-client/src/gateway-ipc-contracts.ts +0 -44
  6. package/node_modules/@vellumai/gateway-client/src/index.ts +0 -14
  7. package/node_modules/@vellumai/gateway-client/src/trust-verdict-contract.ts +0 -17
  8. package/node_modules/@vellumai/service-contracts/package.json +0 -1
  9. package/node_modules/@vellumai/service-contracts/src/index.ts +0 -1
  10. package/openapi.yaml +0 -155
  11. package/package.json +1 -4
  12. package/scripts/test.sh +15 -36
  13. package/src/__tests__/actor-token-service.test.ts +14 -36
  14. package/src/__tests__/agent-loop-override-profile.test.ts +0 -1
  15. package/src/__tests__/agent-wake-disk-pressure-callsite.test.ts +0 -2
  16. package/src/__tests__/agent-wake-override-profile.test.ts +0 -2
  17. package/src/__tests__/annotate-activity-metadata.test.ts +0 -2
  18. package/src/__tests__/annotate-risk-options.test.ts +0 -2
  19. package/src/__tests__/approval-cascade.test.ts +0 -2
  20. package/src/__tests__/assistant-attachments.test.ts +0 -42
  21. package/src/__tests__/background-workers-disk-pressure.test.ts +0 -2
  22. package/src/__tests__/btw-routes.test.ts +0 -2
  23. package/src/__tests__/build-persisted-content.test.ts +0 -2
  24. package/src/__tests__/call-controller.test.ts +0 -19
  25. package/src/__tests__/channel-guardian.test.ts +58 -94
  26. package/src/__tests__/channel-reply-delivery.test.ts +0 -2
  27. package/src/__tests__/compaction-events.test.ts +0 -2
  28. package/src/__tests__/compaction.benchmark.test.ts +0 -2
  29. package/src/__tests__/compactor-call-site-logging.test.ts +0 -2
  30. package/src/__tests__/compactor-low-watermark-cut.test.ts +0 -2
  31. package/src/__tests__/compactor-preserved-tail-count.test.ts +0 -2
  32. package/src/__tests__/compactor-summary-call-truncation.test.ts +0 -2
  33. package/src/__tests__/compactor-web-search-strip.test.ts +0 -2
  34. package/src/__tests__/config-loader-backfill.test.ts +10 -123
  35. package/src/__tests__/config-schema.test.ts +0 -1
  36. package/src/__tests__/confirmation-request-guardian-bridge.test.ts +29 -31
  37. package/src/__tests__/contacts-relay-reads.test.ts +15 -13
  38. package/src/__tests__/conversation-abort-tool-results.test.ts +0 -2
  39. package/src/__tests__/conversation-agent-loop-disk-pressure.test.ts +0 -2
  40. package/src/__tests__/conversation-agent-loop-inference-profile.test.ts +0 -2
  41. package/src/__tests__/conversation-agent-loop-overflow.test.ts +0 -2
  42. package/src/__tests__/conversation-agent-loop.test.ts +0 -134
  43. package/src/__tests__/conversation-analysis-routes.test.ts +0 -2
  44. package/src/__tests__/conversation-app-control-lifecycle.test.ts +0 -2
  45. package/src/__tests__/conversation-confirmation-signals.test.ts +0 -2
  46. package/src/__tests__/conversation-history-web-search.test.ts +0 -2
  47. package/src/__tests__/conversation-load-history-repair.test.ts +0 -2
  48. package/src/__tests__/conversation-load-history-stripped.test.ts +0 -2
  49. package/src/__tests__/conversation-pairing.test.ts +0 -2
  50. package/src/__tests__/conversation-process-app-control-preactivation.test.ts +0 -2
  51. package/src/__tests__/conversation-process-callsite.test.ts +0 -2
  52. package/src/__tests__/conversation-provider-retry-repair.test.ts +0 -2
  53. package/src/__tests__/conversation-queue.test.ts +0 -91
  54. package/src/__tests__/conversation-routes-guardian-reply.test.ts +0 -14
  55. package/src/__tests__/conversation-routes-slash-commands.test.ts +0 -14
  56. package/src/__tests__/conversation-slash-queue.test.ts +0 -2
  57. package/src/__tests__/conversation-slash-unknown.test.ts +0 -2
  58. package/src/__tests__/conversation-speed-override.test.ts +0 -2
  59. package/src/__tests__/conversation-surfaces-task-progress.test.ts +0 -29
  60. package/src/__tests__/conversation-title-service.test.ts +0 -2
  61. package/src/__tests__/conversation-tool-setup-attribution.test.ts +0 -47
  62. package/src/__tests__/conversation-usage.test.ts +0 -2
  63. package/src/__tests__/conversation-workspace-cache-state.test.ts +0 -2
  64. package/src/__tests__/conversation-workspace-injection.test.ts +0 -2
  65. package/src/__tests__/conversation-workspace-tool-tracking.test.ts +0 -2
  66. package/src/__tests__/credential-security-invariants.test.ts +1 -1
  67. package/src/__tests__/db-migration-rollback.test.ts +171 -205
  68. package/src/__tests__/db-test-helpers.ts +4 -5
  69. package/src/__tests__/deterministic-verification-control-plane.test.ts +2 -4
  70. package/src/__tests__/disk-pressure-guard.test.ts +0 -41
  71. package/src/__tests__/dm-persistence.test.ts +0 -2
  72. package/src/__tests__/emit-signal-routing-intent.test.ts +5 -10
  73. package/src/__tests__/events-dev-bypass-actor.test.ts +1 -7
  74. package/src/__tests__/exploration-drift-hook.test.ts +2 -3
  75. package/src/__tests__/filing-service.test.ts +0 -2
  76. package/src/__tests__/guardian-binding-drift-heal.test.ts +10 -75
  77. package/src/__tests__/guardian-dispatch.test.ts +1 -95
  78. package/src/__tests__/guardian-outbound-http.test.ts +0 -13
  79. package/src/__tests__/heartbeat-disk-pressure.test.ts +0 -2
  80. package/src/__tests__/heartbeat-service.test.ts +0 -2
  81. package/src/__tests__/helpers/channel-test-adapter.ts +7 -1
  82. package/src/__tests__/host-app-control-routes.test.ts +30 -24
  83. package/src/__tests__/host-bash-routes.test.ts +41 -31
  84. package/src/__tests__/host-browser-routes.test.ts +32 -26
  85. package/src/__tests__/host-cu-routes-targeted.test.ts +33 -25
  86. package/src/__tests__/host-file-routes-targeted.test.ts +52 -40
  87. package/src/__tests__/host-transfer-routes-targeted.test.ts +43 -31
  88. package/src/__tests__/http-user-message-parity.test.ts +8 -290
  89. package/src/__tests__/inbound-invite-redemption.test.ts +0 -28
  90. package/src/__tests__/inbound-slack-persistence.test.ts +0 -2
  91. package/src/__tests__/invite-redemption-service.test.ts +0 -198
  92. package/src/__tests__/llm-context-normalization.test.ts +0 -105
  93. package/src/__tests__/llm-request-log-error-payload.test.ts +9 -71
  94. package/src/__tests__/llm-usage-store.test.ts +0 -25
  95. package/src/__tests__/mcp-health-check.test.ts +1 -2
  96. package/src/__tests__/media-stream-server-integration.test.ts +0 -127
  97. package/src/__tests__/memory-retrieval-hook.test.ts +0 -2
  98. package/src/__tests__/messaging-send-tool.test.ts +0 -2
  99. package/src/__tests__/migration-import-from-url.test.ts +2 -2
  100. package/src/__tests__/mtime-cache.test.ts +5 -146
  101. package/src/__tests__/native-web-search.test.ts +0 -2
  102. package/src/__tests__/non-member-access-request.test.ts +17 -189
  103. package/src/__tests__/notification-broadcaster.test.ts +0 -4
  104. package/src/__tests__/notification-decision-recipient-context.test.ts +32 -33
  105. package/src/__tests__/notification-deep-link.test.ts +0 -6
  106. package/src/__tests__/notification-guardian-path.test.ts +0 -19
  107. package/src/__tests__/openai-provider.test.ts +12 -22
  108. package/src/__tests__/openai-responses-provider.test.ts +2 -12
  109. package/src/__tests__/outbound-slack-persistence.test.ts +0 -2
  110. package/src/__tests__/pending-interactions-resolved-event.test.ts +4 -7
  111. package/src/__tests__/persistence-secret-redaction.test.ts +0 -2
  112. package/src/__tests__/plugin-bootstrap.test.ts +73 -3
  113. package/src/__tests__/plugin-route-contribution.test.ts +17 -4
  114. package/src/__tests__/plugin-tool-contribution.test.ts +18 -3
  115. package/src/__tests__/plugin-types.test.ts +2 -0
  116. package/src/__tests__/process-message-background-slack.test.ts +0 -2
  117. package/src/__tests__/process-message-display-content.test.ts +0 -2
  118. package/src/__tests__/provider-error-scenarios.test.ts +4 -5
  119. package/src/__tests__/provider-usage-tracking.test.ts +0 -39
  120. package/src/__tests__/regenerate-fire-and-forget-trace.test.ts +0 -2
  121. package/src/__tests__/registry.test.ts +1 -4
  122. package/src/__tests__/relay-server.test.ts +25 -694
  123. package/src/__tests__/runtime-attachment-metadata.test.ts +1 -0
  124. package/src/__tests__/secret-ingress-http.test.ts +0 -14
  125. package/src/__tests__/send-endpoint-busy.test.ts +8 -30
  126. package/src/__tests__/skills.test.ts +0 -44
  127. package/src/__tests__/slack-inbound-verification.test.ts +2 -47
  128. package/src/__tests__/stt-hints.test.ts +13 -44
  129. package/src/__tests__/subagent-detail.test.ts +0 -27
  130. package/src/__tests__/subagent-disposal.test.ts +0 -65
  131. package/src/__tests__/subagent-notify-parent.test.ts +0 -2
  132. package/src/__tests__/subagent-role-registry.test.ts +2 -7
  133. package/src/__tests__/subagent-spawn-tool-fork.test.ts +0 -2
  134. package/src/__tests__/subagent-tools.test.ts +0 -2
  135. package/src/__tests__/suggestion-routes.test.ts +0 -2
  136. package/src/__tests__/title-generate-hook.test.ts +0 -2
  137. package/src/__tests__/tool-executor-lifecycle-events.test.ts +0 -2
  138. package/src/__tests__/tool-executor.test.ts +11 -16
  139. package/src/__tests__/tool-preview-lifecycle.test.ts +0 -2
  140. package/src/__tests__/tool-result-metadata-plumbing.test.ts +0 -2
  141. package/src/__tests__/tool-start-timestamp.test.ts +0 -2
  142. package/src/__tests__/trusted-contact-inline-approval-integration.test.ts +10 -10
  143. package/src/__tests__/twilio-routes.test.ts +0 -96
  144. package/src/__tests__/ui-file-upload-surface.test.ts +0 -86
  145. package/src/__tests__/verification-control-plane-policy.test.ts +0 -2
  146. package/src/__tests__/voice-invite-redemption.test.ts +0 -33
  147. package/src/__tests__/web-search-backend-failure.test.ts +0 -2
  148. package/src/__tests__/workspace-migration-remove-hooks.test.ts +35 -14
  149. package/src/__tests__/workspace-tool-loader.test.ts +2 -195
  150. package/src/__tests__/workspace-tools-watcher-flag.test.ts +70 -0
  151. package/src/agent/loop.ts +0 -56
  152. package/src/api/index.ts +1 -19
  153. package/src/api/responses/llm-request-log-entry.ts +0 -29
  154. package/src/api/responses/subagent-detail.ts +0 -17
  155. package/src/api/surfaces.ts +3 -39
  156. package/src/approvals/guardian-request-resolvers.ts +11 -1
  157. package/src/calls/__tests__/relay-setup-router.test.ts +4 -262
  158. package/src/calls/call-domain.ts +3 -3
  159. package/src/calls/guardian-dispatch.ts +8 -10
  160. package/src/calls/inbound-trust-reader.ts +1 -17
  161. package/src/calls/media-stream-server.ts +0 -21
  162. package/src/calls/relay-server.ts +50 -167
  163. package/src/calls/relay-setup-router.ts +7 -37
  164. package/src/calls/relay-verification.ts +4 -4
  165. package/src/calls/stt-hints.ts +12 -9
  166. package/src/calls/twilio-routes.ts +4 -14
  167. package/src/channels/types.ts +20 -10
  168. package/src/cli/commands/__tests__/cache.test.ts +1 -8
  169. package/src/cli/commands/cache.ts +181 -194
  170. package/src/cli/commands/db/__tests__/repair.test.ts +5 -6
  171. package/src/cli/commands/db/status.ts +1 -37
  172. package/src/cli/commands/mcp.ts +218 -252
  173. package/src/cli/commands/memory/index.ts +0 -2
  174. package/src/cli/commands/plugins.ts +3 -75
  175. package/src/cli/lib/__tests__/install-from-github.test.ts +0 -102
  176. package/src/cli/lib/__tests__/list-installed-plugins.test.ts +1 -160
  177. package/src/cli/lib/list-installed-plugins.ts +1 -179
  178. package/src/config/__tests__/sync-gated-profiles.test.ts +3 -11
  179. package/src/config/bundled-skills/contacts/tools/contact-merge.ts +17 -27
  180. package/src/config/bundled-skills/contacts/tools/contact-search.ts +3 -13
  181. package/src/config/bundled-skills/subagent/SKILL.md +1 -1
  182. package/src/config/bundled-skills/subagent/TOOLS.json +1 -1
  183. package/src/config/feature-flag-registry.json +13 -5
  184. package/src/config/loader.ts +5 -38
  185. package/src/config/schemas/__tests__/memory-v3.test.ts +0 -1
  186. package/src/config/schemas/memory-lifecycle.ts +0 -12
  187. package/src/config/schemas/memory-v3.ts +0 -7
  188. package/src/config/schemas/memory.ts +0 -4
  189. package/src/config/schemas/timeouts.ts +0 -8
  190. package/src/config/seed-inference-profiles.ts +11 -21
  191. package/src/config/skills.ts +5 -27
  192. package/src/config/sync-gated-profiles.ts +13 -12
  193. package/src/contacts/contacts-write.ts +0 -3
  194. package/src/daemon/assistant-attachments.ts +4 -27
  195. package/src/daemon/conversation-agent-loop.ts +0 -28
  196. package/src/daemon/conversation-process.ts +16 -35
  197. package/src/daemon/conversation-surfaces.ts +38 -111
  198. package/src/daemon/conversation-tool-setup.ts +16 -50
  199. package/src/daemon/conversation.ts +1 -13
  200. package/src/daemon/disk-pressure-guard.ts +2 -12
  201. package/src/daemon/event-loop-watchdog.ts +1 -28
  202. package/src/daemon/external-plugins-bootstrap.ts +34 -4
  203. package/src/daemon/handlers/__tests__/config-a2a-redeem.test.ts +0 -25
  204. package/src/daemon/handlers/config-a2a.ts +14 -6
  205. package/src/daemon/handlers/config-channels.ts +22 -78
  206. package/src/daemon/handlers/conversations.ts +0 -77
  207. package/src/daemon/lifecycle.ts +0 -4
  208. package/src/daemon/mcp-reload-service.ts +0 -10
  209. package/src/daemon/memory-v2-startup.test.ts +0 -72
  210. package/src/daemon/memory-v2-startup.ts +19 -87
  211. package/src/daemon/message-types/conversations.ts +0 -2
  212. package/src/daemon/message-types/surfaces.ts +12 -12
  213. package/src/daemon/server.ts +4 -0
  214. package/src/daemon/shutdown-handlers.ts +0 -20
  215. package/src/daemon/tool-setup-types.ts +0 -9
  216. package/src/daemon/workspace-tools-watcher.ts +328 -0
  217. package/src/ipc/__tests__/clients-list-ipc.test.ts +1 -1
  218. package/src/ipc/assistant-server.ts +2 -2
  219. package/src/mcp/__tests__/mcp-auth-orchestrator.test.ts +0 -1
  220. package/src/mcp/client.ts +1 -15
  221. package/src/mcp/mcp-auth-orchestrator.ts +1 -6
  222. package/src/mcp/mcp-oauth-provider.ts +8 -19
  223. package/src/memory/__tests__/memory-retrospective-job.test.ts +0 -8
  224. package/src/memory/conversation-crud.ts +0 -38
  225. package/src/memory/db-connection.ts +3 -22
  226. package/src/memory/db-init.ts +502 -36
  227. package/src/memory/db-singleton.ts +4 -6
  228. package/src/memory/jobs-worker.ts +0 -58
  229. package/src/memory/llm-request-log-store.ts +1 -26
  230. package/src/memory/llm-usage-store.ts +20 -48
  231. package/src/memory/memory-retrospective-job.ts +8 -9
  232. package/src/memory/migrations/209-strip-thinking-from-consolidated.ts +56 -130
  233. package/src/memory/migrations/__tests__/run-migrations.test.ts +2 -2
  234. package/src/memory/migrations/registry.ts +573 -0
  235. package/src/memory/migrations/run-migrations.ts +6 -90
  236. package/src/memory/migrations/validate-migration-state.ts +66 -101
  237. package/src/memory/schema/conversations.ts +0 -9
  238. package/src/memory/schema/infrastructure.ts +0 -20
  239. package/src/memory/v2/__tests__/cli-command-store.test.ts +0 -25
  240. package/src/memory/v2/__tests__/skill-store.test.ts +0 -80
  241. package/src/memory/v2/cli-command-store.ts +38 -75
  242. package/src/memory/v2/prompts/consolidation.ts +82 -13
  243. package/src/memory/v2/prompts/router.ts +93 -21
  244. package/src/memory/v2/skill-store.ts +31 -68
  245. package/src/notifications/__tests__/broadcaster.test.ts +8 -16
  246. package/src/notifications/__tests__/decision-engine.test.ts +9 -78
  247. package/src/notifications/broadcaster.ts +1 -8
  248. package/src/notifications/decision-engine.ts +7 -15
  249. package/src/notifications/destination-resolver.ts +24 -68
  250. package/src/notifications/emit-signal.ts +14 -39
  251. package/src/permissions/question-prompter.test.ts +1 -1
  252. package/src/permissions/question-prompter.ts +4 -7
  253. package/src/plugin-api/index.ts +6 -6
  254. package/src/plugin-api/types.ts +5 -3
  255. package/src/plugin-api/vision-support.test.ts +4 -28
  256. package/src/plugin-api/vision-support.ts +31 -66
  257. package/src/plugins/defaults/advisor/__tests__/consult.test.ts +0 -161
  258. package/src/plugins/defaults/advisor/consult.ts +6 -110
  259. package/src/plugins/defaults/advisor/steering.ts +2 -14
  260. package/src/plugins/defaults/advisor/tools/advisor.ts +5 -32
  261. package/src/plugins/defaults/exploration-drift/hooks/post-tool-use.ts +1 -2
  262. package/src/plugins/defaults/image-fallback/__tests__/image-fallback.test.ts +7 -47
  263. package/src/plugins/defaults/image-fallback/hooks/post-tool-use.ts +11 -10
  264. package/src/plugins/defaults/image-fallback/hooks/user-prompt-submit.ts +20 -12
  265. package/src/plugins/defaults/image-fallback/src/caption-blocks.ts +11 -42
  266. package/src/plugins/defaults/memory-v3-shadow/__tests__/injection.test.ts +3 -33
  267. package/src/plugins/defaults/memory-v3-shadow/__tests__/pool-select.test.ts +4 -48
  268. package/src/plugins/defaults/memory-v3-shadow/__tests__/shadow-plugin.test.ts +8 -4
  269. package/src/plugins/defaults/memory-v3-shadow/injector.ts +15 -43
  270. package/src/plugins/defaults/memory-v3-shadow/orchestrate.ts +2 -11
  271. package/src/plugins/defaults/memory-v3-shadow/pool-select.ts +13 -77
  272. package/src/plugins/defaults/memory-v3-shadow/shadow-plugin.ts +11 -12
  273. package/src/plugins/mtime-cache.ts +291 -76
  274. package/src/plugins/pipeline.ts +13 -111
  275. package/src/plugins/types.ts +2 -0
  276. package/src/providers/anthropic/client.ts +0 -5
  277. package/src/providers/call-site-routing.ts +0 -4
  278. package/src/providers/model-catalog.ts +0 -16
  279. package/src/providers/openai/__tests__/api-error-detail.test.ts +120 -0
  280. package/src/providers/openai/chat-completions-provider.ts +83 -37
  281. package/src/providers/openai/responses-provider.ts +46 -50
  282. package/src/providers/openrouter/client.ts +0 -5
  283. package/src/providers/provider-send-message.ts +0 -4
  284. package/src/providers/ratelimit.ts +0 -4
  285. package/src/providers/retry.ts +0 -4
  286. package/src/providers/types.ts +0 -9
  287. package/src/providers/usage-tracking.ts +0 -4
  288. package/src/runtime/__tests__/trust-verdict-consumer.test.ts +3 -335
  289. package/src/runtime/access-request-helper.ts +39 -19
  290. package/src/runtime/actor-trust-resolver.ts +2 -2
  291. package/src/runtime/assistant-event-hub.ts +1 -1
  292. package/src/runtime/assistant-stream-state.ts +2 -9
  293. package/src/runtime/auth/require-bound-guardian.ts +11 -21
  294. package/src/runtime/channel-verification-service.ts +31 -56
  295. package/src/runtime/confirmation-request-guardian-bridge.ts +3 -3
  296. package/src/runtime/guardian-vellum-migration.ts +7 -66
  297. package/src/runtime/invite-redemption-service.ts +187 -198
  298. package/src/runtime/local-actor-identity.ts +11 -76
  299. package/src/runtime/pending-interactions.ts +1 -11
  300. package/src/runtime/routes/__tests__/channel-verification-revoke.test.ts +5 -56
  301. package/src/runtime/routes/__tests__/channel-verification-routes.test.ts +1 -1
  302. package/src/runtime/routes/__tests__/surface-action-routes.test.ts +0 -187
  303. package/src/runtime/routes/browser-routes.ts +1 -1
  304. package/src/runtime/routes/canonical-guardian-expiry-sweep.ts +5 -13
  305. package/src/runtime/routes/channel-verification-routes.ts +3 -3
  306. package/src/runtime/routes/contact-routes.ts +32 -8
  307. package/src/runtime/routes/conversation-cli-routes.ts +5 -4
  308. package/src/runtime/routes/conversation-list-routes.ts +7 -4
  309. package/src/runtime/routes/conversation-query-routes.ts +0 -72
  310. package/src/runtime/routes/conversation-routes.ts +85 -84
  311. package/src/runtime/routes/events-routes.ts +2 -2
  312. package/src/runtime/routes/global-search-routes.ts +1 -3
  313. package/src/runtime/routes/guardian-action-routes.ts +5 -4
  314. package/src/runtime/routes/host-app-control-routes.ts +4 -5
  315. package/src/runtime/routes/host-bash-routes.ts +4 -5
  316. package/src/runtime/routes/host-browser-routes.ts +11 -9
  317. package/src/runtime/routes/host-cu-routes.ts +4 -5
  318. package/src/runtime/routes/host-file-routes.ts +4 -5
  319. package/src/runtime/routes/host-transfer-routes.ts +6 -6
  320. package/src/runtime/routes/http-adapter.ts +1 -1
  321. package/src/runtime/routes/identity-routes.ts +2 -3
  322. package/src/runtime/routes/inbound-message-handler.ts +5 -5
  323. package/src/runtime/routes/inbound-stages/acl-enforcement.test.ts +5 -97
  324. package/src/runtime/routes/inbound-stages/acl-enforcement.ts +49 -61
  325. package/src/runtime/routes/inbound-stages/background-dispatch.ts +4 -16
  326. package/src/runtime/routes/inbound-stages/escalation-intercept.ts +7 -7
  327. package/src/runtime/routes/inbound-stages/guardian-activation-intercept.test.ts +8 -21
  328. package/src/runtime/routes/inbound-stages/guardian-activation-intercept.ts +3 -14
  329. package/src/runtime/routes/index.ts +0 -2
  330. package/src/runtime/routes/llm-context-normalization.ts +0 -83
  331. package/src/runtime/routes/mcp-auth-routes.ts +19 -171
  332. package/src/runtime/routes/migration-rollback-routes.ts +3 -4
  333. package/src/runtime/routes/migration-routes.ts +1 -4
  334. package/src/runtime/routes/subagents-routes.ts +0 -5
  335. package/src/runtime/routes/surface-action-routes.ts +56 -42
  336. package/src/runtime/services/__tests__/conversation-serializer.test.ts +0 -1
  337. package/src/runtime/services/conversation-serializer.ts +9 -7
  338. package/src/runtime/tool-grant-request-helper.ts +3 -3
  339. package/src/runtime/trust-verdict-consumer.ts +9 -85
  340. package/src/runtime/verification-outbound-actions.ts +18 -18
  341. package/src/signals/user-message.ts +0 -16
  342. package/src/subagent/manager.ts +0 -9
  343. package/src/subagent/types.ts +3 -3
  344. package/src/telemetry/types.ts +1 -34
  345. package/src/telemetry/usage-telemetry-reporter.test.ts +2 -3
  346. package/src/telemetry/usage-telemetry-reporter.ts +3 -87
  347. package/src/tools/ask-question/ask-question-tool.test.ts +0 -29
  348. package/src/tools/ask-question/ask-question-tool.ts +0 -13
  349. package/src/tools/executor.ts +4 -4
  350. package/src/tools/registry.ts +0 -18
  351. package/src/tools/shared/filesystem/path-policy.ts +5 -12
  352. package/src/tools/tool-approval-handler.ts +1 -1
  353. package/src/tools/tool-defaults.ts +2 -9
  354. package/src/tools/tool-manifest.ts +0 -3
  355. package/src/tools/types.ts +2 -17
  356. package/src/tools/workspace-tools/loader.ts +244 -348
  357. package/src/util/errors.ts +1 -26
  358. package/src/util/platform.ts +0 -5
  359. package/src/workflows/library.test.ts +0 -140
  360. package/src/workflows/library.ts +28 -82
  361. package/src/workspace/migrations/017-seed-persona-dirs.ts +34 -3
  362. package/src/workspace/migrations/019-scope-journal-to-guardian.ts +24 -3
  363. package/src/workspace/migrations/048-remove-workspace-hooks.ts +66 -14
  364. package/src/workspace/migrations/registry.ts +0 -2
  365. package/node_modules/@vellumai/gateway-client/src/__tests__/guardian-delivery-contract.test.ts +0 -91
  366. package/node_modules/@vellumai/gateway-client/src/guardian-delivery-contract.ts +0 -48
  367. package/node_modules/@vellumai/service-contracts/src/__tests__/channels.test.ts +0 -28
  368. package/node_modules/@vellumai/service-contracts/src/channels.ts +0 -41
  369. package/src/__tests__/code-search-tool.test.ts +0 -585
  370. package/src/__tests__/guardian-expiry-notifier.test.ts +0 -282
  371. package/src/__tests__/mcp-config-secret-boundary.test.ts +0 -390
  372. package/src/__tests__/plugin-pipeline.test.ts +0 -96
  373. package/src/__tests__/sse-actor-principal-guardian-source.test.ts +0 -102
  374. package/src/__tests__/steer-on-enqueue-question.test.ts +0 -181
  375. package/src/__tests__/workspace-migration-111-prune-seeded-callsite-defaults.test.ts +0 -208
  376. package/src/agent/loop-exclusive-tool.test.ts +0 -150
  377. package/src/api/constants/sse-replay.ts +0 -41
  378. package/src/api/events/conversation-notice.ts +0 -26
  379. package/src/approvals/guardian-channel-delivery.ts +0 -30
  380. package/src/approvals/guardian-expiry-notifier.ts +0 -148
  381. package/src/cli/commands/memory/__tests__/worker.test.ts +0 -302
  382. package/src/cli/commands/memory/worker.ts +0 -175
  383. package/src/config/__tests__/loader-callsite-strip-fallback.test.ts +0 -143
  384. package/src/config/prune-seeded-callsite-defaults.ts +0 -110
  385. package/src/contacts/__tests__/contacts-write-revoke-relay.test.ts +0 -129
  386. package/src/contacts/__tests__/guardian-delivery-reader.test.ts +0 -312
  387. package/src/contacts/__tests__/member-write-relay.test.ts +0 -202
  388. package/src/contacts/guardian-delivery-reader.ts +0 -223
  389. package/src/contacts/member-write-relay.ts +0 -189
  390. package/src/daemon/conversation-notices.ts +0 -60
  391. package/src/daemon/handlers/__tests__/config-channels.test.ts +0 -225
  392. package/src/hooks/hook-loader.ts +0 -341
  393. package/src/mcp/mcp-header-store.ts +0 -134
  394. package/src/memory/__tests__/301-create-watchdog-events.test.ts +0 -110
  395. package/src/memory/__tests__/prompt-override.test.ts +0 -192
  396. package/src/memory/__tests__/watchdog-events-store.test.ts +0 -161
  397. package/src/memory/migrations/300-add-processing-started-at.ts +0 -30
  398. package/src/memory/migrations/301-create-watchdog-events.ts +0 -45
  399. package/src/memory/migrations/__tests__/209-strip-thinking-from-consolidated.test.ts +0 -224
  400. package/src/memory/prompt-override.ts +0 -129
  401. package/src/memory/steps.ts +0 -573
  402. package/src/memory/watchdog-events-store.ts +0 -87
  403. package/src/memory/worker-control.ts +0 -118
  404. package/src/memory/worker-process.ts +0 -72
  405. package/src/notifications/__tests__/connected-channels.test.ts +0 -114
  406. package/src/notifications/__tests__/destination-resolver.test.ts +0 -256
  407. package/src/onboarding/checkin-event.test.ts +0 -222
  408. package/src/onboarding/checkin-event.ts +0 -321
  409. package/src/onboarding/schedule-checkin.ts +0 -190
  410. package/src/plugins/defaults/advisor/__tests__/context-pack-gating.test.ts +0 -106
  411. package/src/plugins/defaults/advisor/__tests__/context-pack.test.ts +0 -60
  412. package/src/plugins/defaults/advisor/context-pack.ts +0 -288
  413. package/src/plugins/defaults/memory-v3-shadow/pool-select.test.ts +0 -146
  414. package/src/plugins/surface-import.ts +0 -121
  415. package/src/providers/openai/__tests__/api-error-normalization.test.ts +0 -321
  416. package/src/providers/openai/api-error-normalization.ts +0 -270
  417. package/src/runtime/__tests__/channel-verification-service.test.ts +0 -133
  418. package/src/runtime/__tests__/guardian-vellum-migration.test.ts +0 -181
  419. package/src/runtime/__tests__/is-guardian-bound-for-channel.test.ts +0 -66
  420. package/src/runtime/__tests__/local-principal-trust.test.ts +0 -164
  421. package/src/runtime/anchored-guardian.test.ts +0 -156
  422. package/src/runtime/anchored-guardian.ts +0 -135
  423. package/src/runtime/auth/__tests__/require-bound-guardian.test.ts +0 -99
  424. package/src/runtime/local-principal-trust.ts +0 -52
  425. package/src/runtime/routes/__tests__/contact-routes.test.ts +0 -212
  426. package/src/runtime/routes/__tests__/global-search-routes.test.ts +0 -93
  427. package/src/runtime/routes/onboarding-checkin-routes.ts +0 -86
  428. package/src/tools/filesystem/search.ts +0 -543
  429. package/src/util/telemetry-db-path.ts +0 -24
  430. package/src/workspace/migrations/111-prune-seeded-callsite-defaults.ts +0 -134
@@ -30,30 +30,13 @@
30
30
  * → loadWorkspaceTools() ← this module (first scan)
31
31
  * → loadUserPlugins()
32
32
  * → bootstrapPlugins()
33
+ * → start file watcher ← hot register/unregister (no restart)
33
34
  *
34
35
  * Plugins load *after* the initial workspace-tool scan so the registry
35
36
  * hands them a stable view of which workspace tools exist before any
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.
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.
57
40
  *
58
41
  * Per-tool isolation:
59
42
  *
@@ -79,19 +62,12 @@
79
62
  import { existsSync, readdirSync, statSync } from "node:fs";
80
63
  import { readFile } from "node:fs/promises";
81
64
  import { extname, join } from "node:path";
65
+ import { pathToFileURL } from "node:url";
82
66
 
83
67
  import { getLogger } from "../../util/logger.js";
84
68
  import { getWorkspaceToolsDir } from "../../util/platform.js";
85
69
  import { isProviderSafeToolName } from "../provider-tool-name.js";
86
- import {
87
- getCoreToolOverride,
88
- getTool,
89
- getToolOwner,
90
- registerWorkspaceTools,
91
- removeCoreToolViaWorkspace,
92
- restoreStrippedCoreTool,
93
- unregisterWorkspaceTool,
94
- } from "../registry.js";
70
+ import { registerWorkspaceTools, removeCoreToolViaWorkspace } from "../registry.js";
95
71
  import { finalizeTool } from "../tool-defaults.js";
96
72
  import type {
97
73
  RiskLevel,
@@ -163,21 +139,14 @@ function isValidToolFilenameStem(stem: string): boolean {
163
139
  */
164
140
  function classifyEntry(
165
141
  entry: string,
166
- ):
167
- | { kind: "live"; stem: string; ext: LiveToolExtension }
168
- | { kind: "removed"; stem: string }
169
- | undefined {
142
+ ): { kind: "live"; stem: string; ext: LiveToolExtension } | { kind: "removed"; stem: string } | undefined {
170
143
  const ext = extname(entry);
171
144
  if (ext === REMOVED_EXTENSION) {
172
145
  return { kind: "removed", stem: entry.slice(0, -REMOVED_EXTENSION.length) };
173
146
  }
174
147
  for (const candidate of LIVE_TOOL_EXTENSIONS) {
175
148
  if (ext === candidate) {
176
- return {
177
- kind: "live",
178
- stem: entry.slice(0, -candidate.length),
179
- ext: candidate,
180
- };
149
+ return { kind: "live", stem: entry.slice(0, -candidate.length), ext: candidate };
181
150
  }
182
151
  }
183
152
  return undefined;
@@ -193,9 +162,7 @@ interface LiveSelection {
193
162
  shadowed: LiveToolExtension[];
194
163
  }
195
164
 
196
- function selectLiveExtension(
197
- extensions: Set<LiveToolExtension>,
198
- ): LiveSelection {
165
+ function selectLiveExtension(extensions: Set<LiveToolExtension>): LiveSelection {
199
166
  for (const candidate of LIVE_TOOL_EXTENSIONS) {
200
167
  if (extensions.has(candidate)) {
201
168
  const shadowed: LiveToolExtension[] = [];
@@ -225,19 +192,14 @@ function selectLiveExtension(
225
192
  * The tool still loads cleanly with these defaults — a broken tool must
226
193
  * never block daemon boot. Always sets `category: "workspace"` so the
227
194
  * 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.
235
195
  */
236
- function applyWorkspaceToolDefaults(tool: ToolDefinition, name: string): Tool {
196
+ function applyWorkspaceToolDefaults(
197
+ tool: ToolDefinition,
198
+ name: string,
199
+ ): Tool {
237
200
  const finalized = finalizeTool(
238
201
  {
239
202
  ...tool,
240
- name,
241
203
  defaultRiskLevel:
242
204
  tool.defaultRiskLevel ?? WORKSPACE_TOOL_DEFAULTS.defaultRiskLevel,
243
205
  category: tool.category ?? "workspace",
@@ -259,18 +221,10 @@ function applyWorkspaceToolDefaults(tool: ToolDefinition, name: string): Tool {
259
221
  * module's default export, or `undefined` if the import times out, has
260
222
  * no default export, or throws.
261
223
  *
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.
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.
274
228
  *
275
229
  * All failure paths log with file attribution so operators can find the
276
230
  * broken tool quickly.
@@ -281,7 +235,7 @@ async function importToolDefaultBounded(
281
235
  entryPath: string,
282
236
  timeoutMs: number,
283
237
  ): Promise<unknown> {
284
- const url = `${entryPath}?v=${++importCounter}`;
238
+ const url = `${pathToFileURL(entryPath).href}?v=${++importCounter}`;
285
239
  let timeoutHandle: ReturnType<typeof setTimeout> | undefined;
286
240
  try {
287
241
  const timeoutSentinel = Symbol("workspace-tool-import-timeout");
@@ -331,9 +285,7 @@ async function importToolDefaultBounded(
331
285
  * exist for declarative use cases — schema-only tool stubs, override
332
286
  * placeholders, etc.
333
287
  */
334
- async function readJsonToolSpec(
335
- entryPath: string,
336
- ): Promise<ToolDefinition | undefined> {
288
+ async function readJsonToolSpec(entryPath: string): Promise<ToolDefinition | undefined> {
337
289
  let raw: string;
338
290
  try {
339
291
  raw = await readFile(entryPath, "utf8");
@@ -374,62 +326,53 @@ export interface LoadWorkspaceToolsOptions {
374
326
  }
375
327
 
376
328
  /**
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.
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.
381
332
  */
382
333
  export interface LoadWorkspaceToolsResult {
383
- /** Tool names currently registered as workspace tools. */
334
+ /** Tool names successfully registered as workspace tools. */
384
335
  readonly registered: string[];
385
- /** Core-tool names currently stripped via `<name>.removed` sentinels. */
336
+ /** Tool names stripped from the registry via `<name>.removed` sentinels. */
386
337
  readonly removed: string[];
387
338
  }
388
339
 
389
340
  /**
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.
395
- */
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.
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.
423
366
  */
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>();
367
+ export async function loadWorkspaceTools(
368
+ options: LoadWorkspaceToolsOptions = {},
369
+ ): Promise<LoadWorkspaceToolsResult> {
370
+ const importTimeoutMs = options.importTimeoutMs ?? IMPORT_TIMEOUT_MS;
371
+ const toolsDir = getWorkspaceToolsDir();
430
372
 
431
373
  if (!existsSync(toolsDir)) {
432
- return { desiredLive, removedStems };
374
+ log.debug({ toolsDir }, "Workspace tools directory does not exist — skipping");
375
+ return { registered: [], removed: [] };
433
376
  }
434
377
 
435
378
  let entries: string[];
@@ -440,15 +383,16 @@ function scanWorkspaceToolsDir(toolsDir: string): {
440
383
  { err, toolsDir },
441
384
  "loadWorkspaceTools: failed to read tools directory — continuing with no workspace tools",
442
385
  );
443
- return { desiredLive, removedStems };
386
+ return { registered: [], removed: [] };
444
387
  }
445
388
 
446
389
  // Group entries by stem so we can detect multi-extension shadowing
447
390
  // (e.g. `foo.ts` + `foo.js` claiming the same name) before we kick off
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>>();
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>();
452
396
 
453
397
  for (const entry of entries) {
454
398
  const fullPath = join(toolsDir, entry);
@@ -485,10 +429,10 @@ function scanWorkspaceToolsDir(toolsDir: string): {
485
429
  }
486
430
  let extensions = liveByStem.get(classified.stem);
487
431
  if (!extensions) {
488
- extensions = new Map<LiveToolExtension, number>();
432
+ extensions = new Set<LiveToolExtension>();
489
433
  liveByStem.set(classified.stem, extensions);
490
434
  }
491
- extensions.set(classified.ext, stats.mtimeMs);
435
+ extensions.add(classified.ext);
492
436
  }
493
437
 
494
438
  // A stem cannot both be live AND removed. Operator intent is ambiguous;
@@ -504,274 +448,226 @@ function scanWorkspaceToolsDir(toolsDir: string): {
504
448
  }
505
449
  }
506
450
 
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.
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
+
510
472
  for (const [stem, extensions] of liveByStem) {
511
- const { ext: winningExt, shadowed } = selectLiveExtension(
512
- new Set(extensions.keys()),
513
- );
473
+ const { ext: winningExt, shadowed } = selectLiveExtension(extensions);
514
474
  if (shadowed.length > 0) {
515
475
  log.warn(
516
476
  { stem, winningExt, shadowed, toolsDir },
517
477
  `loadWorkspaceTools: "${stem}" has multiple files (${[winningExt, ...shadowed].join(", ")}) — using ${winningExt} and ignoring the rest`,
518
478
  );
519
479
  }
520
- desiredLive.set(stem, {
521
- ext: winningExt,
522
- mtimeMs: extensions.get(winningExt) ?? 0,
523
- path: join(toolsDir, `${stem}${winningExt}`),
524
- });
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 };
525
516
  }
526
517
 
527
- return { desiredLive, removedStems };
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
+ }
528
539
  }
529
540
 
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
+
530
549
  /**
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.
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.
536
556
  */
537
- function teardownStem(stem: string): void {
538
- if (getToolOwner(stem)?.kind === "workspace") {
539
- unregisterWorkspaceTool(stem);
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;
540
570
  }
541
- if (getCoreToolOverride(stem) && !getTool(stem)) {
542
- restoreStrippedCoreTool(stem);
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;
543
577
  }
544
- }
545
578
 
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> {
556
579
  let toolSpec: ToolDefinition | undefined;
557
- if (entry.ext === ".json") {
558
- toolSpec = await readJsonToolSpec(entry.path);
580
+ if (classified.ext === ".json") {
581
+ toolSpec = await readJsonToolSpec(entryPath);
559
582
  } else {
560
- const defaultExport = await importToolDefaultBounded(
561
- entry.path,
562
- importTimeoutMs,
563
- );
564
- if (defaultExport === undefined) return undefined; // Failure already logged.
583
+ const defaultExport = await importToolDefaultBounded(entryPath, importTimeoutMs);
584
+ if (defaultExport === undefined) return undefined;
565
585
  if (defaultExport === null || typeof defaultExport !== "object") {
566
586
  log.error(
567
- { entryPath: entry.path, type: typeof defaultExport },
568
- `Workspace tool at ${entry.path} default export must be an object — skipping`,
587
+ { entryPath, type: typeof defaultExport },
588
+ `Workspace tool at ${entryPath} default export must be an object — skipping`,
569
589
  );
570
590
  return undefined;
571
591
  }
572
592
  toolSpec = defaultExport as ToolDefinition;
573
593
  }
574
594
  if (!toolSpec) return undefined;
575
- return applyWorkspaceToolDefaults(toolSpec, stem);
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
+ }
576
608
  }
577
609
 
578
610
  /**
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.
611
+ * Classify a single filesystem entry. Exposed for the file watcher so
612
+ * it can route events without re-implementing the extension logic.
584
613
  */
585
- let inflightReconcile: Promise<LoadWorkspaceToolsResult> | null = null;
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
+ }
586
622
 
587
623
  /**
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.
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.
608
627
  *
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.
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).
618
632
  */
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
- }
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 };
652
639
  }
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
- }
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 };
672
649
  }
673
650
 
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
- }
651
+ const liveExtensions = new Set<LiveToolExtension>();
652
+ let hasRemovedSentinel = false;
706
653
 
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);
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);
735
661
  }
736
662
  }
737
663
 
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
- }
664
+ if (liveExtensions.size === 0) {
665
+ return { livePath: null, liveExt: null, hasRemovedSentinel };
752
666
  }
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;
667
+ const { ext } = selectLiveExtension(liveExtensions);
668
+ return {
669
+ livePath: join(toolsDir, `${stem}${ext}`),
670
+ liveExt: ext,
671
+ hasRemovedSentinel,
672
+ };
777
673
  }