@vellumai/assistant 0.11.4 → 0.11.5-staging.1

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 (358) hide show
  1. package/AGENTS.md +2 -2
  2. package/ARCHITECTURE.md +29 -4
  3. package/README.md +1 -1
  4. package/docs/architecture/memory.md +17 -5
  5. package/docs/architecture/security.md +96 -152
  6. package/docs/guardian-request-flow.md +13 -2
  7. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/package.json +1 -0
  8. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/__tests__/url-normalization.test.ts +135 -0
  9. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/index.ts +1 -0
  10. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/url-normalization.ts +107 -0
  11. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/package.json +1 -0
  12. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/__tests__/url-normalization.test.ts +135 -0
  13. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/index.ts +1 -0
  14. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/url-normalization.ts +107 -0
  15. package/node_modules/@vellumai/gateway-client/src/gateway-ipc-contracts.ts +166 -1
  16. package/node_modules/@vellumai/gateway-client/src/http-delivery.ts +7 -14
  17. package/node_modules/@vellumai/gateway-client/src/index.ts +2 -0
  18. package/node_modules/@vellumai/gateway-client/src/outbound-contract.ts +29 -31
  19. package/node_modules/@vellumai/service-contracts/package.json +1 -0
  20. package/node_modules/@vellumai/service-contracts/src/__tests__/url-normalization.test.ts +135 -0
  21. package/node_modules/@vellumai/service-contracts/src/index.ts +1 -0
  22. package/node_modules/@vellumai/service-contracts/src/url-normalization.ts +107 -0
  23. package/openapi.yaml +193 -3
  24. package/package.json +1 -1
  25. package/src/__tests__/anthropic-provider.test.ts +10 -3
  26. package/src/__tests__/approval-interception-trust-gates.test.ts +40 -0
  27. package/src/__tests__/background-workers-disk-pressure.test.ts +1 -1
  28. package/src/__tests__/channel-approval-routes.test.ts +4 -0
  29. package/src/__tests__/channel-inbound-disk-pressure.test.ts +5 -6
  30. package/src/__tests__/channel-reply-delivery.test.ts +26 -13
  31. package/src/__tests__/checker.test.ts +20 -314
  32. package/src/__tests__/cli-memory-v2-reembed-skills.test.ts +6 -2
  33. package/src/__tests__/client-os-metadata-persistence.test.ts +16 -0
  34. package/src/__tests__/compactor-retained-image-validation.test.ts +142 -0
  35. package/src/__tests__/container-cpu-sampler.test.ts +153 -0
  36. package/src/__tests__/context-search-memory-v2-source.test.ts +0 -1
  37. package/src/__tests__/conversation-attachments.test.ts +0 -1
  38. package/src/__tests__/conversation-error.test.ts +17 -0
  39. package/src/__tests__/conversation-lifecycle.test.ts +20 -26
  40. package/src/__tests__/conversation-pairing.test.ts +186 -0
  41. package/src/__tests__/conversation-runtime-assembly.test.ts +21 -2
  42. package/src/__tests__/credential-broker-server-use.test.ts +24 -5
  43. package/src/__tests__/credential-routes.test.ts +22 -3
  44. package/src/__tests__/credential-security-invariants.test.ts +2 -1
  45. package/src/__tests__/daemon-credential-client.test.ts +88 -0
  46. package/src/__tests__/default-plugin-names-guard.test.ts +12 -1
  47. package/src/__tests__/dm-backfill.test.ts +63 -0
  48. package/src/__tests__/dynamic-skill-background-guard.test.ts +0 -2
  49. package/src/__tests__/edit-propagation.test.ts +107 -4
  50. package/src/__tests__/events-client-registration.test.ts +28 -0
  51. package/src/__tests__/file-write-tool.test.ts +4 -2
  52. package/src/__tests__/guardian-prompt-notice-privacy.test.ts +133 -0
  53. package/src/__tests__/guardian-verify-setup-skill-regression.test.ts +143 -97
  54. package/src/__tests__/helpers/gateway-classify-mock.ts +27 -6
  55. package/src/__tests__/host-proxy-interface.test.ts +11 -1
  56. package/src/__tests__/host-shell-tool.test.ts +23 -4
  57. package/src/__tests__/image-conversion.test.ts +143 -1
  58. package/src/__tests__/inline-skill-load-permissions.test.ts +47 -36
  59. package/src/__tests__/input-repairs.test.ts +207 -0
  60. package/src/__tests__/live-workspace-guard.test.ts +75 -0
  61. package/src/__tests__/mcp-abort-signal.test.ts +1 -1
  62. package/src/__tests__/mcp-client-auth.test.ts +1 -1
  63. package/src/__tests__/mcp-tool-annotations-risk.test.ts +1 -1
  64. package/src/__tests__/media-resolve-image-validation.test.ts +310 -0
  65. package/src/__tests__/mtime-cache.test.ts +2 -0
  66. package/src/__tests__/notification-vellum-adapter.test.ts +124 -1
  67. package/src/__tests__/openai-provider.test.ts +22 -0
  68. package/src/__tests__/personal-memory-auth-bypass.test.ts +22 -6
  69. package/src/__tests__/platform-bash-auto-approve.test.ts +0 -4
  70. package/src/__tests__/platform.test.ts +16 -1
  71. package/src/__tests__/plugin-api-resolve-credential.test.ts +22 -13
  72. package/src/__tests__/plugin-api-shim.test.ts +5 -0
  73. package/src/__tests__/plugin-api-store-credential.test.ts +268 -0
  74. package/src/__tests__/plugin-config-data-migration.test.ts +8 -1
  75. package/src/__tests__/plugin-disabled-state.test.ts +2 -0
  76. package/src/__tests__/plugin-effective-enabled-set.test.ts +55 -3
  77. package/src/__tests__/plugin-execution-context.test.ts +73 -0
  78. package/src/__tests__/plugin-import-boundary-guard.test.ts +0 -1
  79. package/src/__tests__/plugin-import-boundary-reverse-guard.test.ts +0 -1
  80. package/src/__tests__/reaction-persistence.test.ts +200 -7
  81. package/src/__tests__/require-fresh-approval.test.ts +0 -4
  82. package/src/__tests__/resource-pressure-guard.test.ts +428 -0
  83. package/src/__tests__/resource-pressure-routes.test.ts +113 -0
  84. package/src/__tests__/risk-classification-boundary-guard.test.ts +115 -0
  85. package/src/__tests__/run-conversation-turn-persistence.test.ts +27 -1
  86. package/src/__tests__/secret-routes-platform-proxy.test.ts +7 -0
  87. package/src/__tests__/skill-tool-factory.test.ts +161 -0
  88. package/src/__tests__/tool-execution-pipeline.benchmark.test.ts +1 -1
  89. package/src/__tests__/tool-executor-lifecycle-events.test.ts +132 -5
  90. package/src/__tests__/tool-executor.test.ts +63 -38
  91. package/src/__tests__/tool-policy.test.ts +97 -1
  92. package/src/__tests__/trusted-contact-inline-approval-integration.test.ts +7 -4
  93. package/src/__tests__/user-plugin-loader.test.ts +2 -0
  94. package/src/__tests__/validate-input.test.ts +243 -7
  95. package/src/__tests__/verification-control-plane-policy.test.ts +0 -2
  96. package/src/acp/__tests__/acp-claude-oauth.test.ts +97 -29
  97. package/src/acp/__tests__/prepare-agent-env.test.ts +376 -153
  98. package/src/acp/acp-claude-oauth.ts +38 -21
  99. package/src/acp/prepare-agent-env.ts +146 -55
  100. package/src/agent/loop-tool-dedup.test.ts +161 -0
  101. package/src/agent/loop.ts +68 -22
  102. package/src/api/constants/app-tools.ts +34 -0
  103. package/src/api/events/resource-pressure-status-changed.ts +53 -0
  104. package/src/api/index.ts +19 -0
  105. package/src/api/responses/resource-pressure-status.ts +25 -0
  106. package/src/approvals/guardian-channel-delivery.ts +70 -6
  107. package/src/approvals/guardian-request-resolvers.ts +32 -66
  108. package/src/channels/__tests__/message-audience.test.ts +49 -0
  109. package/src/channels/__tests__/types.test.ts +17 -3
  110. package/src/channels/message-audience.ts +40 -0
  111. package/src/channels/types.ts +16 -12
  112. package/src/cli/__tests__/catalog-search-help.test.ts +50 -0
  113. package/src/cli/commands/__tests__/channel-verification-sessions.test.ts +31 -0
  114. package/src/cli/commands/__tests__/conversations-search.test.ts +289 -0
  115. package/src/cli/commands/__tests__/conversations-slack.test.ts +1 -0
  116. package/src/cli/commands/__tests__/inference-providers.test.ts +68 -0
  117. package/src/cli/commands/__tests__/keys.test.ts +89 -8
  118. package/src/cli/commands/__tests__/plugins.test.ts +57 -1
  119. package/src/cli/commands/channel-verification-sessions.help.ts +11 -9
  120. package/src/cli/commands/channel-verification-sessions.ts +11 -21
  121. package/src/cli/commands/conversations.help.ts +34 -0
  122. package/src/cli/commands/conversations.ts +125 -0
  123. package/src/cli/commands/inference-providers.ts +9 -4
  124. package/src/cli/commands/inference.help.ts +9 -4
  125. package/src/cli/commands/keys.help.ts +13 -1
  126. package/src/cli/commands/keys.ts +26 -15
  127. package/src/cli/commands/memory/__tests__/memory-v2.test.ts +57 -7
  128. package/src/cli/commands/memory/index.help.ts +41 -27
  129. package/src/cli/commands/memory/index.ts +2 -0
  130. package/src/cli/commands/memory/memory-v2.ts +58 -54
  131. package/src/cli/commands/memory/memory-validate.ts +18 -0
  132. package/src/cli/commands/monitoring.ts +1 -0
  133. package/src/cli/commands/plugins.help.ts +28 -31
  134. package/src/cli/commands/plugins.ts +10 -0
  135. package/src/cli/commands/skills.help.ts +13 -16
  136. package/src/cli/commands/trust.ts +3 -14
  137. package/src/cli/lib/__tests__/install-from-github.test.ts +38 -0
  138. package/src/cli/lib/__tests__/merge-plugin-tree.test.ts +34 -0
  139. package/src/cli/lib/__tests__/plugin-surfaces.test.ts +32 -1
  140. package/src/cli/lib/__tests__/toggle-plugin.test.ts +2 -1
  141. package/src/cli/lib/__tests__/upgrade-plugin.test.ts +64 -0
  142. package/src/cli/lib/bundled-marketplace.json +13 -0
  143. package/src/cli/lib/daemon-credential-client.ts +25 -3
  144. package/src/cli/lib/install-from-github.ts +35 -24
  145. package/src/cli/lib/merge-plugin-tree.ts +22 -4
  146. package/src/cli/lib/plugin-surfaces.ts +27 -0
  147. package/src/config/feature-flag-registry.json +35 -19
  148. package/src/config/webhook-routing.ts +8 -0
  149. package/src/context/compactor.ts +31 -2
  150. package/src/daemon/__tests__/provider-rejection-log-fields.test.ts +272 -0
  151. package/src/daemon/conversation-agent-loop-handlers.ts +4 -1
  152. package/src/daemon/conversation-error.ts +13 -0
  153. package/src/daemon/conversation-messaging.ts +22 -2
  154. package/src/daemon/conversation-process.ts +11 -0
  155. package/src/daemon/conversation-store.ts +10 -0
  156. package/src/daemon/conversation-surfaces.ts +21 -8
  157. package/src/daemon/conversation.ts +12 -10
  158. package/src/daemon/lifecycle.ts +6 -2
  159. package/src/daemon/message-types/conversations.ts +5 -7
  160. package/src/daemon/provider-rejection-log-fields.ts +124 -0
  161. package/src/daemon/resource-pressure-guard-lifecycle.ts +66 -0
  162. package/src/daemon/resource-pressure-guard.ts +387 -0
  163. package/src/daemon/shutdown-handlers.ts +2 -0
  164. package/src/daemon/startup-error.ts +16 -0
  165. package/src/daemon/trust-context.ts +14 -10
  166. package/src/daemon/unsendable-image-notice.ts +76 -0
  167. package/src/hooks/registry.ts +65 -51
  168. package/src/ipc/__tests__/socket-path.test.ts +15 -7
  169. package/src/ipc/gateway-client.test.ts +1 -1
  170. package/src/ipc/gateway-client.ts +14 -21
  171. package/src/ipc/socket-cleanup.ts +2 -11
  172. package/src/mcp/__tests__/mcp-auth-orchestrator.test.ts +1 -1
  173. package/src/messaging/provider-message-metadata.ts +128 -0
  174. package/src/messaging/providers/__tests__/transport-dispatch.test.ts +116 -68
  175. package/src/messaging/providers/channel-transport.ts +90 -13
  176. package/src/messaging/providers/discord/send.ts +16 -0
  177. package/src/messaging/providers/discord/transport.ts +11 -1
  178. package/src/messaging/providers/index.ts +109 -16
  179. package/src/messaging/providers/slack/message-metadata.ts +45 -0
  180. package/src/messaging/providers/slack/render-transcript.ts +1 -4
  181. package/src/messaging/providers/slack/send.test.ts +6 -9
  182. package/src/messaging/providers/slack/send.ts +40 -28
  183. package/src/messaging/providers/slack/transport.ts +22 -26
  184. package/src/messaging/providers/telegram-bot/send.test.ts +2 -8
  185. package/src/messaging/providers/telegram-bot/transport.ts +3 -6
  186. package/src/messaging/read-provider-metadata.test.ts +157 -0
  187. package/src/messaging/read-provider-metadata.ts +51 -0
  188. package/src/notifications/__tests__/assistant-reply-producer.test.ts +94 -0
  189. package/src/notifications/__tests__/edit-notification.test.ts +174 -2
  190. package/src/notifications/__tests__/home-feed-side-effect.test.ts +536 -4
  191. package/src/notifications/adapters/macos.ts +55 -0
  192. package/src/notifications/adapters/slack.ts +10 -5
  193. package/src/notifications/assistant-reply-producer.ts +37 -4
  194. package/src/notifications/conversation-pairing.ts +111 -21
  195. package/src/notifications/edit-notification.ts +38 -8
  196. package/src/notifications/emit-signal.ts +12 -14
  197. package/src/notifications/home-feed-side-effect.ts +218 -15
  198. package/src/notifications/types.ts +5 -0
  199. package/src/permissions/AGENTS.md +16 -0
  200. package/src/permissions/checker.test.ts +75 -122
  201. package/src/permissions/checker.ts +44 -560
  202. package/src/permissions/confirmation-guardian-request.test.ts +28 -3
  203. package/src/permissions/confirmation-guardian-request.ts +5 -1
  204. package/src/persistence/conversation-crud.ts +23 -20
  205. package/src/persistence/conversation-queries.ts +14 -1
  206. package/src/persistence/conversation-types.ts +22 -0
  207. package/src/persistence/delivery-crud.ts +122 -15
  208. package/src/plugin-api/__tests__/import-graph-partial-mock.test.ts +67 -0
  209. package/src/plugin-api/conversation-turn.ts +27 -0
  210. package/src/plugin-api/credential-scope.test.ts +15 -0
  211. package/src/plugin-api/credential-scope.ts +14 -0
  212. package/src/plugin-api/index.ts +19 -1
  213. package/src/plugin-api/resolve-credential.ts +15 -10
  214. package/src/plugin-api/store-credential.ts +148 -0
  215. package/src/plugin-api/system-card.ts +36 -0
  216. package/src/plugin-api/vision-support.test.ts +82 -0
  217. package/src/plugin-api/vision-support.ts +14 -2
  218. package/src/plugins/defaults/image-fallback/__tests__/image-fallback.test.ts +361 -110
  219. package/src/plugins/defaults/image-fallback/__tests__/vision-recovery.test.ts +4 -0
  220. package/src/plugins/defaults/image-fallback/hooks/user-prompt-submit.ts +58 -0
  221. package/src/plugins/defaults/image-fallback/src/caption-blocks.ts +64 -23
  222. package/src/plugins/defaults/image-recovery/detect.ts +24 -1
  223. package/src/plugins/defaults/main.ts +15 -0
  224. package/src/plugins/defaults/memory/AGENTS.md +11 -3
  225. package/src/plugins/defaults/memory/__tests__/memory-tier-boundary-guard.test.ts +0 -1
  226. package/src/plugins/defaults/memory/graph-topology/pending-buffer.ts +9 -12
  227. package/src/plugins/defaults/memory/memory-retrospective-prompt.ts +1 -1
  228. package/src/plugins/defaults/memory/src/memory-v2-routes.ts +12 -10
  229. package/src/plugins/defaults/memory/substrate/__tests__/consolidation-job.test.ts +114 -0
  230. package/src/plugins/defaults/memory/substrate/__tests__/consolidation-prompt-flag-gating-guard.test.ts +8 -0
  231. package/src/plugins/defaults/memory/substrate/__tests__/edge-index.test.ts +0 -30
  232. package/src/plugins/defaults/memory/substrate/__tests__/ingest.test.ts +73 -1
  233. package/src/plugins/defaults/memory/substrate/__tests__/page-index.test.ts +37 -0
  234. package/src/plugins/defaults/memory/substrate/__tests__/page-links.test.ts +208 -0
  235. package/src/plugins/defaults/memory/substrate/__tests__/prompts-consolidation.test.ts +158 -1
  236. package/src/plugins/defaults/memory/substrate/__tests__/static-context.test.ts +1 -61
  237. package/src/plugins/defaults/memory/substrate/consolidation-job.ts +68 -6
  238. package/src/plugins/defaults/memory/substrate/edge-index.ts +0 -31
  239. package/src/plugins/defaults/memory/substrate/ingest.ts +59 -0
  240. package/src/plugins/defaults/memory/substrate/page-index.ts +35 -8
  241. package/src/plugins/defaults/memory/substrate/page-links.ts +133 -0
  242. package/src/plugins/defaults/memory/substrate/page-store.ts +10 -0
  243. package/src/plugins/defaults/memory/substrate/prompts/consolidation.ts +124 -34
  244. package/src/plugins/defaults/memory/substrate/static-context.ts +0 -29
  245. package/src/plugins/defaults/memory/v3/__tests__/shadow-plugin.test.ts +3 -1
  246. package/src/plugins/defaults/memory/v3/card.ts +9 -9
  247. package/src/plugins/defaults/memory/v3/core-set.test.ts +7 -0
  248. package/src/plugins/defaults/memory/v3/core-set.ts +5 -1
  249. package/src/plugins/defaults/memory/v3/edge.ts +13 -57
  250. package/src/plugins/mtime-cache.ts +1 -1
  251. package/src/plugins/pipeline.ts +14 -7
  252. package/src/plugins/plugin-execution-context.ts +35 -11
  253. package/src/plugins/plugin-tree-walk.ts +63 -4
  254. package/src/providers/anthropic/__tests__/pause-turn-continuation.test.ts +170 -0
  255. package/src/providers/anthropic/client.ts +332 -241
  256. package/src/providers/connection-resolution.ts +2 -1
  257. package/src/providers/content-blocks.ts +22 -0
  258. package/src/providers/gemini/client.ts +5 -2
  259. package/src/providers/inference/__tests__/adapter-factory-litellm.test.ts +47 -1
  260. package/src/providers/inference/__tests__/adapter-factory-ollama.test.ts +83 -0
  261. package/src/providers/inference/__tests__/adapter-factory-openai-compatible.test.ts +98 -1
  262. package/src/providers/inference/__tests__/base-url-route-validation.test.ts +38 -0
  263. package/src/providers/inference/__tests__/base-url-security.test.ts +10 -0
  264. package/src/providers/inference/__tests__/connections-ollama.test.ts +90 -0
  265. package/src/providers/inference/__tests__/missing-credential-guard.test.ts +117 -0
  266. package/src/providers/inference/adapter-factory.ts +33 -2
  267. package/src/providers/inference/auth.ts +13 -2
  268. package/src/providers/inference/credential-usage.ts +37 -0
  269. package/src/providers/inference/missing-credential-guard.ts +110 -0
  270. package/src/providers/inference/resolve-auth.ts +7 -7
  271. package/src/providers/media-resolve.ts +176 -15
  272. package/src/providers/openai/__tests__/chat-completions-provider-reasoning.test.ts +576 -46
  273. package/src/providers/openai/__tests__/tool-choice-mapping.test.ts +125 -2
  274. package/src/providers/openai/chat-completions-provider.ts +322 -42
  275. package/src/routes/route-host-protocol.ts +7 -0
  276. package/src/routes/worker.ts +31 -10
  277. package/src/runtime/AGENTS.md +35 -0
  278. package/src/runtime/__tests__/runtime-http-port-collision.test.ts +131 -0
  279. package/src/runtime/__tests__/web-presence.test.ts +234 -0
  280. package/src/runtime/assistant-event-hub.ts +38 -0
  281. package/src/runtime/channel-reply-delivery.ts +54 -31
  282. package/src/runtime/channel-retry-sweep.ts +6 -4
  283. package/src/runtime/effective-capabilities.test.ts +19 -0
  284. package/src/runtime/effective-capabilities.ts +25 -0
  285. package/src/runtime/guardian-reply-router.ts +6 -2
  286. package/src/runtime/http-errors.ts +1 -0
  287. package/src/runtime/http-server.ts +20 -5
  288. package/src/runtime/routes/__tests__/client-routes.test.ts +150 -0
  289. package/src/runtime/routes/__tests__/conversation-list-routes.test.ts +133 -0
  290. package/src/runtime/routes/__tests__/credential-delete-in-use.test.ts +155 -0
  291. package/src/runtime/routes/__tests__/inference-provider-connection-routes.test.ts +52 -1
  292. package/src/runtime/routes/__tests__/monitoring-routes.test.ts +1 -0
  293. package/src/runtime/routes/__tests__/pending-interactions-route.test.ts +175 -0
  294. package/src/runtime/routes/__tests__/plugins-routes.test.ts +88 -35
  295. package/src/runtime/routes/__tests__/surface-action-routes.test.ts +55 -6
  296. package/src/runtime/routes/__tests__/system-card-turn-termination.test.ts +107 -0
  297. package/src/runtime/routes/__tests__/user-route-dispatcher-host.test.ts +52 -1
  298. package/src/runtime/routes/__tests__/user-route-dispatcher.test.ts +75 -4
  299. package/src/runtime/routes/approval-routes.ts +37 -4
  300. package/src/runtime/routes/approval-strategies/guardian-text-engine-strategy.ts +16 -22
  301. package/src/runtime/routes/canned-message-complete.ts +68 -29
  302. package/src/runtime/routes/client-routes.ts +113 -0
  303. package/src/runtime/routes/conversation-list-routes.ts +22 -7
  304. package/src/runtime/routes/conversation-management-routes.ts +15 -9
  305. package/src/runtime/routes/conversation-routes.ts +36 -22
  306. package/src/runtime/routes/credential-in-use.ts +85 -0
  307. package/src/runtime/routes/credential-routes.ts +55 -85
  308. package/src/runtime/routes/guardian-approval-interception.ts +22 -22
  309. package/src/runtime/routes/guardian-approval-reply-helpers.ts +14 -12
  310. package/src/runtime/routes/identity-routes.ts +5 -121
  311. package/src/runtime/routes/inbound-message-handler.ts +71 -27
  312. package/src/runtime/routes/inbound-stages/acl-enforcement.ts +16 -17
  313. package/src/runtime/routes/inbound-stages/background-dispatch.test.ts +77 -50
  314. package/src/runtime/routes/inbound-stages/background-dispatch.ts +61 -97
  315. package/src/runtime/routes/inbound-stages/edit-intercept.ts +101 -77
  316. package/src/runtime/routes/inbound-stages/guardian-reply-intercept.ts +17 -8
  317. package/src/runtime/routes/inbound-stages/reaction-intercept.test.ts +91 -7
  318. package/src/runtime/routes/inbound-stages/reaction-intercept.ts +72 -70
  319. package/src/runtime/routes/index.ts +2 -0
  320. package/src/runtime/routes/inference-provider-connection-routes.ts +9 -7
  321. package/src/runtime/routes/monitoring-routes.ts +9 -1
  322. package/src/runtime/routes/plugins-routes.ts +17 -5
  323. package/src/runtime/routes/resource-pressure-routes.ts +22 -0
  324. package/src/runtime/routes/secret-routes.ts +39 -8
  325. package/src/runtime/routes/settings-routes.ts +6 -6
  326. package/src/runtime/routes/surface-action-routes.ts +20 -19
  327. package/src/runtime/routes/user-route-dispatcher.ts +31 -2
  328. package/src/runtime/routes/user-route-resolution.ts +18 -1
  329. package/src/runtime/slack-reply-session.test.ts +23 -13
  330. package/src/runtime/slack-reply-session.ts +38 -55
  331. package/src/runtime/web-presence.ts +90 -0
  332. package/src/skills/validate-input.ts +177 -40
  333. package/src/subagent/__tests__/consult-context-gating.test.ts +6 -10
  334. package/src/subagent/consult-context.ts +7 -23
  335. package/src/tools/credentials/broker.ts +24 -79
  336. package/src/tools/credentials/ref-parse.ts +35 -0
  337. package/src/tools/credentials/resolve.ts +4 -10
  338. package/src/tools/credentials/store.ts +168 -0
  339. package/src/tools/credentials/tool-policy.ts +67 -0
  340. package/src/tools/executor.ts +53 -15
  341. package/src/tools/network/__tests__/web-search.test.ts +41 -1
  342. package/src/tools/network/url-safety.ts +9 -16
  343. package/src/tools/network/web-search.ts +21 -3
  344. package/src/tools/permission-checker.ts +43 -52
  345. package/src/tools/schema-transforms.ts +40 -5
  346. package/src/tools/shared/input-repairs.ts +160 -0
  347. package/src/tools/skills/skill-tool-factory.ts +18 -4
  348. package/src/tools/subagent/spawn.ts +0 -1
  349. package/src/tools/tool-approval-handler.ts +16 -10
  350. package/src/tools/tool-types.ts +26 -13
  351. package/src/tools/types.ts +6 -6
  352. package/src/util/__tests__/cgroup-memory.test.ts +3 -0
  353. package/src/util/cgroup-memory.ts +3 -0
  354. package/src/util/container-cpu-sampler.ts +250 -0
  355. package/src/util/image-conversion.ts +178 -14
  356. package/src/util/platform.ts +93 -10
  357. package/src/permissions/ipc-risk-types.ts +0 -143
  358. package/src/permissions/risk-types.ts +0 -76
@@ -29,6 +29,7 @@ import {
29
29
  } from "../persistence/conversation-types.js";
30
30
  import { stringifyMessageContent } from "../persistence/message-content.js";
31
31
  import { isDesktopAttended } from "../runtime/desktop-presence.js";
32
+ import { isWebConversationFocused } from "../runtime/web-presence.js";
32
33
  import { safeParseRecord } from "../util/json.js";
33
34
  import { emitNotificationSignal } from "./emit-signal.js";
34
35
  import {
@@ -45,6 +46,9 @@ const ASSISTANT_REPLY_PUSH_FLAG = "assistant-reply-push" as const;
45
46
  /** Gates the desktop-attended suppression below, on by default. */
46
47
  const DESKTOP_PRESENCE_FLAG = "desktop-presence-suppression" as const;
47
48
 
49
+ /** Gates the web-focused suppression below, on by default. */
50
+ const WEB_PRESENCE_FLAG = "web-presence-suppression" as const;
51
+
48
52
  /**
49
53
  * Collapse whitespace runs ahead of the sanitizers' truncation: blank lines and
50
54
  * list indentation would otherwise eat into the copy's length budget.
@@ -129,6 +133,27 @@ function readDesktopAttended(rlog: pino.Logger): boolean {
129
133
  }
130
134
  }
131
135
 
136
+ /**
137
+ * Web presence, kept fail-open here for the same reason as
138
+ * {@link readDesktopAttended}: a presence read that fails has to send the
139
+ * push, not reach the producer's catch and silence it.
140
+ *
141
+ * No `actorPrincipalId`: the platform delivers this push to the assistant
142
+ * owner's device tokens, and a pod has exactly one owner, so any focused web
143
+ * tab is that owner's.
144
+ */
145
+ function readWebConversationFocused(
146
+ conversationId: string,
147
+ rlog: pino.Logger,
148
+ ): boolean {
149
+ try {
150
+ return isWebConversationFocused(conversationId);
151
+ } catch (err) {
152
+ rlog.warn({ err }, "Web presence read failed; treating as unfocused");
153
+ return false;
154
+ }
155
+ }
156
+
132
157
  export async function emitAssistantReplyNotification(params: {
133
158
  conversationId: string;
134
159
  assistantMessageId: string;
@@ -229,6 +254,14 @@ export async function emitAssistantReplyNotification(params: {
229
254
  isMacOriginatedUserMessage(initiatingMetadata) &&
230
255
  readDesktopAttended(rlog);
231
256
 
257
+ // Conversation-scoped web presence applies regardless of which device
258
+ // initiated the turn: a visible matching tab proves where this reply is
259
+ // currently being displayed, while the conversation id prevents an
260
+ // unrelated tab from suppressing the push.
261
+ const webFocused =
262
+ isAssistantFeatureFlagEnabled(WEB_PRESENCE_FLAG) &&
263
+ readWebConversationFocused(conversationId, rlog);
264
+
232
265
  await emitNotificationSignal({
233
266
  sourceEventName: "chat.assistant_reply",
234
267
  sourceChannel: "vellum",
@@ -243,10 +276,10 @@ export async function emitAssistantReplyNotification(params: {
243
276
  // opting into v2.
244
277
  urgency: "medium",
245
278
  isAsyncBackground: false,
246
- // Read weakly, as "at the machine this landed on": the attended Mac
247
- // that opened the turn renders the reply in-app, and its Dock unread
248
- // badge carries it while the window is hidden.
249
- visibleInSourceNow: desktopAttended,
279
+ // Read weakly, as "at the surface this landed on": the attended Mac
280
+ // or focused web tab that opened the turn renders the reply in-app,
281
+ // so a redundant push would only duplicate what's already on screen.
282
+ visibleInSourceNow: desktopAttended || webFocused,
250
283
  },
251
284
  contextPayload: {
252
285
  ...(requestedTitle ? { requestedTitle } : {}),
@@ -30,6 +30,7 @@ import {
30
30
  getBindingByChannelChat,
31
31
  upsertOutboundBinding,
32
32
  } from "../persistence/external-conversation-store.js";
33
+ import { publishConversationMessagesChanged } from "../runtime/sync/resource-sync-events.js";
33
34
  import { getLogger } from "../util/logger.js";
34
35
  import { withSqliteRetry } from "../util/sqlite-retry.js";
35
36
  import {
@@ -92,6 +93,13 @@ export interface PairingOptions {
92
93
  *
93
94
  * Invalid/stale targets at any level fall through to the next.
94
95
  *
96
+ * Passive vellum notifications (those without `requiresConversation`) never
97
+ * reach that order. They create nothing, and instead append their body to the
98
+ * producing conversation named by `sourceContextId` when it resolves, since
99
+ * that is the row the home feed's "Go to Conversation" button already targets.
100
+ * The returned `conversationId` is that producing conversation, with
101
+ * `createdNewConversation` false.
102
+ *
95
103
  * Errors are caught and logged — this function never throws so the
96
104
  * notification pipeline is not disrupted by pairing failures.
97
105
  */
@@ -117,20 +125,47 @@ export async function pairDeliveryWithConversation(
117
125
  const conversationAction = options?.conversationAction;
118
126
  const bindingContext = options?.bindingContext;
119
127
 
120
- // Passive vellum notifications surface via the home feed alone and link
121
- // back to the originating conversation via `signal.sourceContextId`.
122
- // Materializing a fresh per-notification conversation just to host the
123
- // seed message leaves a graveyard entry in the sidebar; skip it
124
- // unconditionally when the producer did not opt in via
125
- // `requiresConversation`. The decision engine's `reuse_existing` hint is
126
- // also ignored here: even a successful reuse would append a seed message
127
- // to a conversation that the user didn't ask for, and a failed reuse
128
- // (stale target / source mismatch) falls through to `createConversation`
129
- // — producing exactly the graveyard entry we want to avoid.
128
+ // Structured content blocks take precedence. They enable Surface-based
129
+ // rendering in the web/macOS/iOS apps (e.g. a card widget instead of
130
+ // plain text). Falls back to model-provided seed or runtime composer.
131
+ const messageContent = copy.seedContentBlocks
132
+ ? JSON.stringify(copy.seedContentBlocks)
133
+ : isConversationSeedSane(copy.conversationSeedMessage)
134
+ ? copy.conversationSeedMessage
135
+ : composeConversationSeed(signal, channel, copy);
136
+
137
+ // Passive vellum notifications link back to the originating conversation
138
+ // via `signal.sourceContextId` rather than materializing one of their own.
139
+ // A fresh per-notification conversation just to host the seed message
140
+ // leaves a graveyard entry in the sidebar, so nothing is created here when
141
+ // the producer did not opt in via `requiresConversation`. The decision
142
+ // engine's `reuse_existing` hint is ignored for the same reason: a failed
143
+ // reuse (stale target / source mismatch) falls through to
144
+ // `createConversation`, producing exactly the graveyard entry we want to
145
+ // avoid.
146
+ //
147
+ // The body is still appended to the producing conversation when
148
+ // `sourceContextId` resolves, because every route the user has into this
149
+ // notification ends at that conversation. Tapping the banner deep-links
150
+ // there, and resolves there with or without this append, since the
151
+ // broadcaster falls back to `sourceContextId` for the vellum deep link.
152
+ // The client suppresses the banner outright when that conversation is
153
+ // already on screen, leaving the transcript as the only place the
154
+ // notification can appear. The home feed aims its "Go to Conversation"
155
+ // button at the same row whenever it mirrors the signal.
156
+ //
157
+ // So this is deliberately not gated on home-feed eligibility: a signal the
158
+ // feed declines to mirror still reaches the user through the banner, and
159
+ // this row is what makes that landing honest.
130
160
  if (strategy === "start_new_conversation" && !signal.requiresConversation) {
161
+ const appended = await appendBodyToSourceConversation(
162
+ signal,
163
+ channel,
164
+ messageContent,
165
+ );
131
166
  return {
132
- conversationId: null,
133
- messageId: null,
167
+ conversationId: appended?.conversationId ?? null,
168
+ messageId: appended?.messageId ?? null,
134
169
  strategy,
135
170
  createdNewConversation: false,
136
171
  conversationFallbackUsed: false,
@@ -147,15 +182,6 @@ export async function pairDeliveryWithConversation(
147
182
  signal.conversationMetadata?.conversationType ??
148
183
  (strategy === "start_new_conversation" ? "standard" : "background");
149
184
 
150
- // Structured content blocks take precedence — they enable Surface-based
151
- // rendering in the web/macOS/iOS apps (e.g. a card widget instead of
152
- // plain text). Falls back to model-provided seed or runtime composer.
153
- const messageContent = copy.seedContentBlocks
154
- ? JSON.stringify(copy.seedContentBlocks)
155
- : isConversationSeedSane(copy.conversationSeedMessage)
156
- ? copy.conversationSeedMessage
157
- : composeConversationSeed(signal, channel, copy);
158
-
159
185
  // Attempt to reuse an existing conversation when the model requests it
160
186
  if (conversationAction?.action === "reuse_existing") {
161
187
  const targetId = conversationAction.conversationId;
@@ -456,3 +482,67 @@ export async function pairDeliveryWithConversation(
456
482
  };
457
483
  }
458
484
  }
485
+
486
+ /**
487
+ * Append a delivered notification body to the conversation that produced it.
488
+ *
489
+ * Passive notifications never materialize a conversation of their own, so the
490
+ * home feed points "Go to Conversation" at the producing conversation
491
+ * (`resolveHomeFeedMirror` prefers `sourceContextId`). Writing the body there
492
+ * makes that button truthful: whenever it renders, the conversation it opens
493
+ * contains the notification the user tapped.
494
+ *
495
+ * Reuse only. Producers may pass sentinels (job ids, call session ids,
496
+ * `access-req-*` strings) as `sourceContextId`; those resolve to nothing and
497
+ * append nothing, which matches the button staying hidden for them.
498
+ *
499
+ * This covers vellum deliveries only, since no other channel takes the
500
+ * passive branch. A signal routed away from vellum still gets a card, so
501
+ * `writeHomeFeedItemForSignal` writes the body itself when no vellum
502
+ * conversation was paired. Between them the button holds whatever the
503
+ * routing was, and only one of the two ever writes.
504
+ *
505
+ * Indexing is skipped for parity with the other notification write paths:
506
+ * notification copy is delivery audit, not conversational memory.
507
+ */
508
+ async function appendBodyToSourceConversation(
509
+ signal: NotificationSignal,
510
+ channel: NotificationChannel,
511
+ messageContent: string,
512
+ ): Promise<{ conversationId: string; messageId: string } | null> {
513
+ const sourceContextId = signal.sourceContextId;
514
+ if (!sourceContextId) {
515
+ return null;
516
+ }
517
+
518
+ let existing: ReturnType<typeof getConversation>;
519
+ try {
520
+ existing = getConversation(sourceContextId);
521
+ } catch {
522
+ return null;
523
+ }
524
+ if (!existing) {
525
+ return null;
526
+ }
527
+
528
+ const message = await addMessage(existing.id, "assistant", messageContent, {
529
+ skipIndexing: true,
530
+ });
531
+ // `addMessage` projects attention metadata alone, so a client with this
532
+ // conversation open needs the messages tag to refetch the transcript. A
533
+ // notification the user taps through to has every chance of landing on an
534
+ // already-open conversation.
535
+ publishConversationMessagesChanged(existing.id);
536
+
537
+ log.info(
538
+ {
539
+ signalId: signal.signalId,
540
+ channel,
541
+ conversationId: existing.id,
542
+ messageId: message.id,
543
+ },
544
+ "Appended notification body to producing conversation",
545
+ );
546
+
547
+ return { conversationId: existing.id, messageId: message.id };
548
+ }
@@ -22,6 +22,7 @@ import {
22
22
  updateDeliveryRenderedCopy,
23
23
  } from "./deliveries-store.js";
24
24
  import { getBroadcaster } from "./emit-signal.js";
25
+ import { updateFeedItemConversationMessage } from "./home-feed-side-effect.js";
25
26
  import { nonEmpty } from "./notification-utils.js";
26
27
  import type { NotificationChannel } from "./types.js";
27
28
 
@@ -119,24 +120,49 @@ export async function editNotification(
119
120
  { feedItemId, signalId },
120
121
  "Feed item has no persisted decision — skipping channel updates",
121
122
  );
122
- return { feedItem, channels: [] };
123
123
  }
124
124
 
125
- const deliveries = findDeliveriesByDecisionId(decision.id);
126
- const channels = await updateChannelDeliveries(deliveries, {
127
- title,
128
- body: params.body,
129
- });
125
+ const deliveries = decision ? findDeliveriesByDecisionId(decision.id) : [];
126
+ const { channels, rewrittenMessageIds } = await updateChannelDeliveries(
127
+ deliveries,
128
+ { title, body: params.body },
129
+ );
130
+
131
+ // The delivery walk covers a conversation row only while its delivery reads
132
+ // sent, so the card carries the id of the row behind it and this closes
133
+ // whatever the walk missed. Runs after it, and skips a row the walk just
134
+ // rewrote, so the two never both write. Body edits only: a title-only patch
135
+ // leaves the feed summary alone, and the row holds the body.
136
+ if (params.body !== undefined) {
137
+ updateFeedItemConversationMessage(
138
+ feedItem,
139
+ params.body,
140
+ rewrittenMessageIds,
141
+ );
142
+ }
130
143
 
131
144
  return { feedItem, channels };
132
145
  }
133
146
 
147
+ /**
148
+ * Walk the recorded deliveries and let each channel adapter apply the patch.
149
+ *
150
+ * Alongside the per-channel outcomes this reports the message ids the
151
+ * adapters rewrote, which is what tells the caller whether the row behind the
152
+ * card still needs writing. Only body patches contribute: an adapter reports
153
+ * its message id on a title-only patch too, without having touched the
154
+ * conversation row.
155
+ */
134
156
  async function updateChannelDeliveries(
135
157
  deliveries: NotificationDeliveryRow[],
136
158
  patch: { title?: string; body?: string },
137
- ): Promise<ChannelEditResult[]> {
159
+ ): Promise<{
160
+ channels: ChannelEditResult[];
161
+ rewrittenMessageIds: ReadonlySet<string>;
162
+ }> {
138
163
  const broadcaster = getBroadcaster();
139
164
  const results: ChannelEditResult[] = [];
165
+ const rewrittenMessageIds = new Set<string>();
140
166
 
141
167
  for (const delivery of deliveries) {
142
168
  const channel = delivery.channel as NotificationChannel;
@@ -167,6 +193,7 @@ async function updateChannelDeliveries(
167
193
  deliveryId: delivery.id,
168
194
  destination: delivery.destination,
169
195
  messageId: delivery.messageId,
196
+ conversationId: delivery.conversationId,
170
197
  },
171
198
  patch,
172
199
  );
@@ -183,6 +210,9 @@ async function updateChannelDeliveries(
183
210
  renderedTitle: patch.title,
184
211
  renderedBody: patch.body,
185
212
  });
213
+ if (patch.body !== undefined && result.messageId) {
214
+ rewrittenMessageIds.add(result.messageId);
215
+ }
186
216
  results.push({
187
217
  channel,
188
218
  deliveryId: delivery.id,
@@ -203,5 +233,5 @@ async function updateChannelDeliveries(
203
233
  }
204
234
  }
205
235
 
206
- return results;
236
+ return { channels: results, rewrittenMessageIds };
207
237
  }
@@ -525,21 +525,19 @@ export async function emitNotificationSignal<TEventName extends string>(
525
525
  // Step 5: Mirror background-origin signals into the home activity feed.
526
526
  // The helper itself decides whether to write (background filter); we
527
527
  // catch and log so a feed-write failure cannot poison the dispatch result.
528
- // Pass the paired vellum delivery conversation as a fallback so producers
529
- // whose `sourceContextId` is a sentinel string (e.g. heartbeat startup,
530
- // credential health, watcher emits, scheduler retries-exhausted) still
531
- // get a "Go to Convo" button — pointing at the conversation the
532
- // broadcaster paired the notification with.
533
- const pairedVellumConversationId = dispatchResult.deliveryResults.find(
528
+ // Pass the vellum delivery outcome so producers whose `sourceContextId` is
529
+ // a sentinel string (e.g. heartbeat startup, credential health, watcher
530
+ // emits, scheduler retries-exhausted) still get a "Go to Convo" button
531
+ // pointing at the conversation the broadcaster paired, and so the feed can
532
+ // tell whether that delivery left a conversation row an edit can reach.
533
+ const vellumDelivery = dispatchResult.deliveryResults.find(
534
534
  (r) => r.channel === "vellum",
535
- )?.conversationId;
536
- await writeHomeFeedItemForSignal(
537
- signal,
538
- decision,
539
- pairedVellumConversationId,
540
- ).catch((err) => {
541
- log.warn({ err, signalId }, "writeHomeFeedItemForSignal threw");
542
- });
535
+ );
536
+ await writeHomeFeedItemForSignal(signal, decision, vellumDelivery).catch(
537
+ (err) => {
538
+ log.warn({ err, signalId }, "writeHomeFeedItemForSignal threw");
539
+ },
540
+ );
543
541
 
544
542
  log.info(
545
543
  {
@@ -17,31 +17,59 @@ import {
17
17
  feedItemSchema,
18
18
  } from "../home/feed-types.js";
19
19
  import { appendFeedItem } from "../home/feed-writer.js";
20
- import { getConversation } from "../persistence/conversation-crud.js";
20
+ import {
21
+ addMessage,
22
+ getConversation,
23
+ getMessageById,
24
+ updateMessageContent,
25
+ } from "../persistence/conversation-crud.js";
21
26
  import { isBackgroundConversationType } from "../persistence/conversation-types.js";
27
+ import { publishConversationMessagesChanged } from "../runtime/sync/resource-sync-events.js";
22
28
  import { getLogger } from "../util/logger.js";
23
29
  import { normalizeTitle, stripMarkdown } from "../util/short-title.js";
24
30
  import { isConversationSeedSane } from "./conversation-seed-composer.js";
25
31
  import { deriveTitle } from "./copy-composer.js";
26
32
  import { readPayloadString } from "./notification-utils.js";
27
33
  import type { NotificationSignal } from "./signal.js";
28
- import type { NotificationDecision, RenderedChannelCopy } from "./types.js";
34
+ import type {
35
+ NotificationDecision,
36
+ NotificationDeliveryResult,
37
+ RenderedChannelCopy,
38
+ } from "./types.js";
29
39
 
30
40
  const log = getLogger("home-feed-side-effect");
31
41
 
42
+ /**
43
+ * Metadata key holding the id of the conversation message this module wrote
44
+ * for a card.
45
+ *
46
+ * Only cards holding a row no delivery adapter will rewrite carry it, and it
47
+ * is the handle `updateFeedItemConversationMessage` rewrites on an edit.
48
+ * `metadata` is a free-form record on the wire, so this stays a daemon-side
49
+ * convention and needs no schema field, the same way `scheduleId` rides along
50
+ * below.
51
+ *
52
+ * Reserved: producer `contextPayload` also lands in `metadata`, so the key is
53
+ * stripped from it before this module writes its own. A producer able to set
54
+ * it would otherwise hand an edit an arbitrary message id to overwrite.
55
+ */
56
+ const CONVERSATION_MESSAGE_ID_KEY = "notificationConversationMessageId";
57
+
32
58
  /**
33
59
  * Append a `FeedItem` for the given notification signal when the
34
60
  * filter criteria pass.
35
61
  *
36
- * `fallbackConversationId` is used as the feed item's "Go to Convo"
37
- * navigation target when `signal.sourceContextId` doesn't resolve to a
38
- * real conversation row. The notification broadcaster pairs the vellum
39
- * delivery with a conversation (newly created or reused) before this
40
- * function runs, so callers can thread that paired id through here for
41
- * producers whose `sourceContextId` is a sentinel (heartbeat startup,
42
- * credential health, watcher emits, scheduler retries-exhausted) — the
43
- * feed item will then carry the paired delivery conversation and the
44
- * "Go to Convo" button can render.
62
+ * `vellumDelivery` is this signal's vellum delivery outcome, and it settles
63
+ * two questions here.
64
+ *
65
+ * Its conversation is a navigation target standing in for a
66
+ * `signal.sourceContextId` that doesn't resolve to a real conversation row,
67
+ * so producers passing a sentinel (heartbeat startup, credential health,
68
+ * watcher emits, scheduler retries-exhausted) still render a "Go to Convo"
69
+ * button.
70
+ *
71
+ * It also settles which row backs the card and who rewrites it on an edit,
72
+ * which `resolveOwnedConversationMessageId` works through below.
45
73
  *
46
74
  * Returns the persisted `FeedItem`, or `null` if the signal does not
47
75
  * qualify for home-feed mirroring (non-background origin AND no
@@ -50,10 +78,10 @@ const log = getLogger("home-feed-side-effect");
50
78
  export async function writeHomeFeedItemForSignal(
51
79
  signal: NotificationSignal,
52
80
  decision: NotificationDecision,
53
- fallbackConversationId?: string,
81
+ vellumDelivery?: NotificationDeliveryResult,
54
82
  ): Promise<FeedItem | null> {
55
83
  const { mirror, sourceConversationId, sourceScheduleJobId } =
56
- resolveHomeFeedMirror(signal, fallbackConversationId);
84
+ resolveHomeFeedMirror(signal, vellumDelivery?.conversationId);
57
85
  if (!mirror) {
58
86
  return null;
59
87
  }
@@ -109,6 +137,9 @@ export async function writeHomeFeedItemForSignal(
109
137
  !Array.isArray(signal.contextPayload)
110
138
  ? { ...signal.contextPayload }
111
139
  : undefined;
140
+ // Producer payloads reach the card verbatim, and this key addresses a
141
+ // message row for rewriting, so only this module may set it.
142
+ delete baseMetadata?.[CONVERSATION_MESSAGE_ID_KEY];
112
143
 
113
144
  // Link scheduled-run notifications back to their schedule. `notify`-mode
114
145
  // jobs put `scheduleId` directly in the context payload; `execute`-mode (and
@@ -152,8 +183,180 @@ export async function writeHomeFeedItemForSignal(
152
183
  return null;
153
184
  }
154
185
 
155
- await appendFeedItem(item);
156
- return item;
186
+ // Resolved once the card is known to be writable, since the only reason to
187
+ // put a notification body in a conversation is that a card sends the user
188
+ // there. Validating first keeps the one rejection this function controls
189
+ // from leaving a message behind with no card to explain it. The id joins
190
+ // free-form metadata, which no schema rule can turn away.
191
+ const conversationMessageId = await resolveOwnedConversationMessageId(
192
+ signal,
193
+ vellumDelivery,
194
+ sourceConversationId,
195
+ resolvedSummary,
196
+ );
197
+ const card: FeedItem = conversationMessageId
198
+ ? {
199
+ ...item,
200
+ metadata: {
201
+ ...(item.metadata ?? {}),
202
+ [CONVERSATION_MESSAGE_ID_KEY]: conversationMessageId,
203
+ },
204
+ }
205
+ : item;
206
+
207
+ await appendFeedItem(card);
208
+ return card;
209
+ }
210
+
211
+ /**
212
+ * Resolve the conversation row backing this card, writing one if the card
213
+ * would otherwise have no body in the conversation it opens.
214
+ *
215
+ * The card records the row whoever wrote it, rather than only the rows it
216
+ * expects an edit to miss. Whether a delivery adapter will actually rewrite a
217
+ * row is a property of the durable delivery state at edit time, which a
218
+ * dispatch result cannot predict: a send can succeed and lose its status
219
+ * write, leaving a row that reads pending. `editNotification` settles that
220
+ * question by rewriting through the card only when the delivery walk did not.
221
+ *
222
+ * A vellum delivery that paired a conversation already wrote the body there,
223
+ * so its row is the one to record. Guardian producers pair a fresh
224
+ * conversation instead of the one the card opens, and their row is left
225
+ * unrecorded: a handle has to address the conversation behind the button, and
226
+ * `updateFeedItemConversationMessage` refuses anything else.
227
+ */
228
+ async function resolveOwnedConversationMessageId(
229
+ signal: NotificationSignal,
230
+ vellumDelivery: NotificationDeliveryResult | undefined,
231
+ sourceConversationId: string | undefined,
232
+ summary: string,
233
+ ): Promise<string | undefined> {
234
+ if (vellumDelivery?.conversationId) {
235
+ return vellumDelivery.conversationId === sourceConversationId
236
+ ? vellumDelivery.messageId
237
+ : undefined;
238
+ }
239
+ if (!sourceConversationId) {
240
+ return undefined;
241
+ }
242
+ return appendSummaryToFeedTarget(signal, sourceConversationId, summary);
243
+ }
244
+
245
+ /**
246
+ * Write the card's own summary into the conversation its "Go to Convo"
247
+ * button opens, returning the id of the row written.
248
+ *
249
+ * That button targets the producing conversation whatever the routing, but
250
+ * only the vellum delivery writes a notification body into a conversation
251
+ * (`pairDeliveryWithConversation` appends there for passive signals). A
252
+ * signal the decision engine routes to Slack or Telegram alone pairs no
253
+ * vellum conversation, so without this the button opens a conversation that
254
+ * never received what the card shows. A paired vellum id means that write
255
+ * already happened, and this is skipped.
256
+ *
257
+ * The caller has already read `sourceConversationId` back from the store to
258
+ * gate the mirror, so the row is known to exist and needs no second lookup:
259
+ * with no paired id to fall back on, a resolved target can only be the
260
+ * producing conversation itself.
261
+ *
262
+ * Indexing is skipped for parity with the other notification write paths:
263
+ * notification copy is delivery audit, not conversational memory. Failures
264
+ * are logged rather than thrown, so a conversation write cannot cost the
265
+ * user a card; the card just goes out without a rewritable row.
266
+ */
267
+ async function appendSummaryToFeedTarget(
268
+ signal: NotificationSignal,
269
+ conversationId: string,
270
+ summary: string,
271
+ ): Promise<string | undefined> {
272
+ try {
273
+ const message = await addMessage(conversationId, "assistant", summary, {
274
+ skipIndexing: true,
275
+ });
276
+ publishConversationMessagesChanged(conversationId);
277
+ log.info(
278
+ {
279
+ signalId: signal.signalId,
280
+ conversationId,
281
+ messageId: message.id,
282
+ },
283
+ "Appended notification body to the feed item's conversation",
284
+ );
285
+ return message.id;
286
+ } catch (err) {
287
+ log.warn(
288
+ { err, signalId: signal.signalId, conversationId },
289
+ "Failed to append notification body to the feed item's conversation",
290
+ );
291
+ return undefined;
292
+ }
293
+ }
294
+
295
+ /**
296
+ * Rewrite the conversation message behind a card, keeping an edited card and
297
+ * the conversation it opens in step.
298
+ *
299
+ * Notification edits reach conversation content through the delivery rows the
300
+ * broadcaster recorded, which is how the vellum adapter rewrites the row its
301
+ * pairing wrote. That walk covers a row only while its delivery reads sent, so
302
+ * this is the backstop for every way it can miss one: a delivery that failed,
303
+ * a row that was never recorded, a signal with no deliveries at all, and a
304
+ * send whose status write was lost after the message went out.
305
+ *
306
+ * `alreadyRewritten` carries the message ids the delivery walk reported
307
+ * rewriting, so the common case where the adapter got there first costs
308
+ * nothing and no row is written twice.
309
+ *
310
+ * The row holds the body, and the feed rewrites its summary only when the
311
+ * patch carries one, so the caller applies this for body edits alone.
312
+ *
313
+ * Never throws. It runs last, after the feed patch and the channel updates
314
+ * have landed, so anything it cannot do is reported as "no rewrite" rather
315
+ * than failing an edit that has already partly applied. Returns whether a row
316
+ * was rewritten.
317
+ */
318
+ export function updateFeedItemConversationMessage(
319
+ item: FeedItem,
320
+ body: string,
321
+ alreadyRewritten?: ReadonlySet<string>,
322
+ ): boolean {
323
+ const messageId = item.metadata?.[CONVERSATION_MESSAGE_ID_KEY];
324
+ if (typeof messageId !== "string" || messageId.length === 0) {
325
+ return false;
326
+ }
327
+ if (alreadyRewritten?.has(messageId)) {
328
+ return false;
329
+ }
330
+ try {
331
+ // Scoped to the card's own conversation so the handle can only ever
332
+ // address a row inside what the card opens, whatever put it in the
333
+ // metadata. Guarded with the write: a store that cannot answer the
334
+ // question is not grounds for failing an edit already applied to the feed
335
+ // and the channels.
336
+ if (
337
+ !item.conversationId ||
338
+ !getMessageById(messageId, item.conversationId)
339
+ ) {
340
+ log.warn(
341
+ { feedItemId: item.id, messageId },
342
+ "Feed item message handle does not address a row in its conversation, leaving it alone",
343
+ );
344
+ return false;
345
+ }
346
+ updateMessageContent(messageId, body);
347
+ publishConversationMessagesChanged(item.conversationId);
348
+ log.info(
349
+ { feedItemId: item.id, messageId },
350
+ "Rewrote the conversation message behind an edited feed item",
351
+ );
352
+ return true;
353
+ } catch (err) {
354
+ log.error(
355
+ { err, feedItemId: item.id, messageId },
356
+ "Failed to rewrite the conversation message behind an edited feed item",
357
+ );
358
+ return false;
359
+ }
157
360
  }
158
361
 
159
362
  /**
@@ -150,6 +150,11 @@ export interface ChannelUpdateContext {
150
150
  deliveryId: string;
151
151
  destination: string;
152
152
  messageId: string | null;
153
+ /**
154
+ * Conversation the delivery paired, for adapters whose message lives in one.
155
+ * Channel-native adapters patch a channel message and leave this unread.
156
+ */
157
+ conversationId?: string | null;
153
158
  }
154
159
 
155
160
  // -- Conversation action types ------------------------------------------------
@@ -0,0 +1,16 @@
1
+ # Permissions: Agent Instructions
2
+
3
+ This directory is the assistant's **client** of gateway-owned risk classification and trust rules, plus the assistant-owned decision that follows. Keep the split:
4
+
5
+ - **Gateway owns:** risk classification (`gateway/src/risk/*`, entered through the `classify_risk` IPC method), the trust rules that lower or raise a classified risk (applied inside the gateway classifiers and surfaced only as the classified level plus `matchType: "user_rule"`), the auto-approve thresholds and channel-permission cells, and the per-actor `TrustClass` verdict.
6
+ - **Assistant owns:** the turn's actor and its capabilities (`runtime/capabilities.ts`), the sensitive-tool capability floor (`tools/tool-approval-handler.ts`), the allow / prompt / deny decision over risk × threshold × capabilities (`check()` here, `DefaultApprovalPolicy`), the prompt UX (`prompter.ts`), execution, and the `tool_invocations` audit row.
7
+
8
+ Rules that follow from the split:
9
+
10
+ - **Do not add a risk classifier, risk table, or trust-rule matcher under `assistant/src`.** If a tool's risk is wrong or a rule cannot cover it, the fix is in `gateway/src/risk/` (or the tool's `defaultRiskLevel`, which the gateway uses for tools without a dedicated classifier).
11
+ - **Classify once per tool invocation.** `classifyRisk()` is the only call into the gateway; the executor takes it before the pre-execution gates and hands the result to `checkPermission` and `check()`. Do not re-classify downstream, and do not memoise the result in the daemon: the inputs (rules, config, skill content) change out from under it, and the gateway already memoises the expensive part.
12
+ - **Read the classification, do not re-derive it.** The allowlist ladder is `classification.allowlistOptions`; there are no per-tool option builders here. Elevations, `matchType` checks, and option ladders come from the `RiskClassificationWithMeta` the gateway returned. A daemon-side "defense in depth" copy of a gateway rule is a second source of truth, not a defense.
13
+ - **Types cross the boundary from the wire contract.** The `classify_risk` request and response are `ClassifyRiskIpcParamsSchema` / `ClassifyRiskIpcResponseSchema` in `@vellumai/gateway-client`; fields the daemon carries from the response are typed off `ClassifyRiskIpcResponse`, never re-declared. `src/__tests__/risk-classification-boundary-guard.test.ts` pins the single call site and the absence of local classifiers.
14
+ - The one daemon-side adjustment to a classification is the bash sandbox symlink-escape re-check (`applyBashSymlinkEscapeCheck`), because it needs the assistant's filesystem; it only ever revokes auto-approve.
15
+
16
+ The architecture doc for this area is [`docs/architecture/security.md`](../../docs/architecture/security.md).