@vellumai/assistant 0.11.7 → 0.11.8-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 (446) hide show
  1. package/ARCHITECTURE.md +10 -10
  2. package/Dockerfile +1 -0
  3. package/docs/architecture/security.md +1 -1
  4. package/docs/error-handling.md +1 -2
  5. package/docs/guardian-request-flow.md +51 -5
  6. package/docs/trusted-contact-access.md +13 -12
  7. package/knip.json +1 -0
  8. package/node_modules/@vellumai/avatar-catalog/package.json +19 -0
  9. package/{src/avatar/character-components.ts → node_modules/@vellumai/avatar-catalog/src/catalog.ts} +12 -44
  10. package/node_modules/@vellumai/avatar-catalog/src/colors.ts +19 -0
  11. package/node_modules/@vellumai/avatar-catalog/src/index.ts +25 -0
  12. package/node_modules/@vellumai/avatar-catalog/src/types.ts +47 -0
  13. package/node_modules/@vellumai/avatar-catalog/tsconfig.json +20 -0
  14. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/package.json +1 -0
  15. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/channels.ts +7 -0
  16. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/index.ts +1 -0
  17. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/no-response.ts +84 -0
  18. package/node_modules/@vellumai/credential-storage/src/static-credentials.ts +43 -12
  19. package/node_modules/@vellumai/environments/package.json +2 -1
  20. package/node_modules/@vellumai/environments/src/__tests__/test-path-guard.test.ts +89 -0
  21. package/node_modules/@vellumai/environments/src/test-path-guard.ts +113 -0
  22. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/package.json +1 -0
  23. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/channels.ts +7 -0
  24. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/index.ts +1 -0
  25. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/no-response.ts +84 -0
  26. package/node_modules/@vellumai/gateway-client/src/__tests__/guardian-request-contract.test.ts +8 -18
  27. package/node_modules/@vellumai/gateway-client/src/__tests__/inbound-event-kind.test.ts +46 -0
  28. package/node_modules/@vellumai/gateway-client/src/__tests__/invite-contract.test.ts +1 -0
  29. package/node_modules/@vellumai/gateway-client/src/gateway-ipc-contracts.ts +46 -0
  30. package/node_modules/@vellumai/gateway-client/src/guardian-request-contract.ts +62 -21
  31. package/node_modules/@vellumai/gateway-client/src/inbound-contract.ts +14 -0
  32. package/node_modules/@vellumai/gateway-client/src/inbound-event-kind.ts +53 -0
  33. package/node_modules/@vellumai/gateway-client/src/index.ts +11 -5
  34. package/node_modules/@vellumai/gateway-client/src/invite-contract.ts +1 -0
  35. package/node_modules/@vellumai/service-contracts/package.json +1 -0
  36. package/node_modules/@vellumai/service-contracts/src/channels.ts +7 -0
  37. package/node_modules/@vellumai/service-contracts/src/index.ts +1 -0
  38. package/node_modules/@vellumai/service-contracts/src/no-response.ts +84 -0
  39. package/openapi.yaml +481 -9
  40. package/package.json +3 -1
  41. package/scripts/sync-llm-catalog.ts +3 -0
  42. package/src/__tests__/agent-loop-callsite-precedence.test.ts +16 -0
  43. package/src/__tests__/agent-loop-compacted-history-transform.test.ts +304 -0
  44. package/src/__tests__/agent-wake-disk-pressure-callsite.test.ts +3 -0
  45. package/src/__tests__/agent-wake-override-profile.test.ts +3 -0
  46. package/src/__tests__/anthropic-provider.test.ts +56 -0
  47. package/src/__tests__/approval-cascade.test.ts +0 -13
  48. package/src/__tests__/attachments-store.test.ts +117 -6
  49. package/src/__tests__/attachments.test.ts +122 -0
  50. package/src/__tests__/bundled-asset.test.ts +22 -1
  51. package/src/__tests__/channel-approval.test.ts +14 -7
  52. package/src/__tests__/channel-approvals.test.ts +3 -3
  53. package/src/__tests__/channel-delivery-store.test.ts +58 -0
  54. package/src/__tests__/channel-inbound-disk-pressure.test.ts +1 -0
  55. package/src/__tests__/channel-invite-transport.test.ts +1 -0
  56. package/src/__tests__/channel-readiness-slack-remote.test.ts +3 -3
  57. package/src/__tests__/compaction-events.test.ts +0 -12
  58. package/src/__tests__/compactor-call-site-logging.test.ts +7 -1
  59. package/src/__tests__/compactor-retained-image-validation.test.ts +15 -0
  60. package/src/__tests__/config-schema.test.ts +35 -0
  61. package/src/__tests__/contacts-write.test.ts +141 -3
  62. package/src/__tests__/conversation-abort-tool-results.test.ts +0 -13
  63. package/src/__tests__/conversation-agent-loop-inference-profile.test.ts +0 -12
  64. package/src/__tests__/conversation-agent-loop-overflow.test.ts +0 -13
  65. package/src/__tests__/conversation-agent-loop.test.ts +95 -13
  66. package/src/__tests__/conversation-confirmation-signals.test.ts +0 -13
  67. package/src/__tests__/conversation-load-history-repair.test.ts +418 -0
  68. package/src/__tests__/conversation-media-retry.test.ts +33 -2
  69. package/src/__tests__/conversation-pairing.test.ts +54 -0
  70. package/src/__tests__/conversation-process-callsite.test.ts +0 -12
  71. package/src/__tests__/conversation-provider-retry-repair.test.ts +0 -13
  72. package/src/__tests__/conversation-queue.test.ts +4 -13
  73. package/src/__tests__/conversation-retry-route.test.ts +5 -0
  74. package/src/__tests__/conversation-routes-disk-view.test.ts +1 -1
  75. package/src/__tests__/conversation-routes-enabled-plugins.test.ts +1 -1
  76. package/src/__tests__/conversation-routes-hidden-queue.test.ts +1 -1
  77. package/src/__tests__/conversation-routes-slash-commands.test.ts +1 -1
  78. package/src/__tests__/conversation-sight-frames.test.ts +615 -0
  79. package/src/__tests__/conversation-slash-queue.test.ts +0 -13
  80. package/src/__tests__/conversation-slash-unknown.test.ts +0 -13
  81. package/src/__tests__/conversation-speed-override.test.ts +0 -12
  82. package/src/__tests__/conversation-store-ephemeral.test.ts +0 -12
  83. package/src/__tests__/conversation-summarize-route.test.ts +5 -0
  84. package/src/__tests__/conversation-summarize-up-to.test.ts +102 -0
  85. package/src/__tests__/conversation-wait-for-idle.test.ts +0 -12
  86. package/src/__tests__/conversation-workspace-cache-state.test.ts +0 -5
  87. package/src/__tests__/conversation-workspace-injection.test.ts +0 -17
  88. package/src/__tests__/conversation-workspace-tool-tracking.test.ts +0 -17
  89. package/src/__tests__/credential-health-service.test.ts +45 -0
  90. package/src/__tests__/credential-record-retire.test.ts +256 -0
  91. package/src/__tests__/credential-record-write-through.test.ts +8 -4
  92. package/src/__tests__/db-conversation-fork-lineage-migration.test.ts +0 -4
  93. package/src/__tests__/db-conversation-inference-profile-migration.test.ts +0 -4
  94. package/src/__tests__/db-llm-request-log-provider-migration.test.ts +0 -8
  95. package/src/__tests__/delete-propagation.test.ts +121 -3
  96. package/src/__tests__/evict-conversations-for-reload.test.ts +46 -6
  97. package/src/__tests__/file-ops-service.test.ts +8 -3
  98. package/src/__tests__/gemini-provider.test.ts +64 -0
  99. package/src/__tests__/guardian-action-service-scope.test.ts +118 -0
  100. package/src/__tests__/guardian-card-withdrawal.test.ts +299 -5
  101. package/src/__tests__/guardian-expiry-notifier.test.ts +117 -13
  102. package/src/__tests__/guardian-gateway-sim.test.ts +119 -0
  103. package/src/__tests__/guardian-gateway-sim.ts +56 -20
  104. package/src/__tests__/guardian-reply-router-answer-mode.test.ts +6 -0
  105. package/src/__tests__/guardian-request-status-sync.test.ts +162 -0
  106. package/src/__tests__/guardian-wait-controller.test.ts +6 -0
  107. package/src/__tests__/headless-browser-mode.test.ts +27 -0
  108. package/src/__tests__/helpers/mock-conversation.ts +2 -0
  109. package/src/__tests__/list-messages-system-card.test.ts +70 -0
  110. package/src/__tests__/llm-catalog-parity.test.ts +2 -0
  111. package/src/__tests__/managed-profile-guard.test.ts +2 -2
  112. package/src/__tests__/media-resolve-image-validation.test.ts +106 -0
  113. package/src/__tests__/media-stream-server-integration.test.ts +6 -0
  114. package/src/__tests__/non-member-access-request.test.ts +12 -0
  115. package/src/__tests__/notification-discord-adapter.test.ts +76 -4
  116. package/src/__tests__/oauth-commands-routes.test.ts +21 -0
  117. package/src/__tests__/openai-provider.test.ts +63 -0
  118. package/src/__tests__/outbound-slack-persistence.test.ts +114 -2
  119. package/src/__tests__/persist-unsendable-image-downscale.test.ts +63 -1
  120. package/src/__tests__/persist-unsendable-image.test.ts +19 -0
  121. package/src/__tests__/plugin-app-serve-routes.test.ts +2 -2
  122. package/src/__tests__/plugin-import-boundary-guard.test.ts +1 -0
  123. package/src/__tests__/process-message-background-slack.test.ts +1 -1
  124. package/src/__tests__/process-message-display-content.test.ts +1 -1
  125. package/src/__tests__/processing-flag-persist-failure.test.ts +0 -5
  126. package/src/__tests__/prompt-cache-cross-turn-stability.test.ts +0 -17
  127. package/src/__tests__/qdrant-manager.test.ts +6 -0
  128. package/src/__tests__/reaction-persistence.test.ts +58 -62
  129. package/src/__tests__/retry-thinking-adaptive-only.test.ts +67 -0
  130. package/src/__tests__/server-history-render.test.ts +22 -0
  131. package/src/__tests__/sight-frame-retention.test.ts +362 -0
  132. package/src/__tests__/slack-reaction-approvals.test.ts +44 -24
  133. package/src/__tests__/slack-reaction-guardian-approval.test.ts +29 -1
  134. package/src/__tests__/subagent-fork-notifications.test.ts +2 -0
  135. package/src/__tests__/subagent-manager-notify.test.ts +2 -0
  136. package/src/__tests__/subagent-notify-parent.test.ts +2 -0
  137. package/src/__tests__/subagent-notify-stale-rebuild.test.ts +85 -0
  138. package/src/__tests__/subagent-tool-gate-mode.test.ts +4 -4
  139. package/src/__tests__/terminal-tools.test.ts +9 -9
  140. package/src/__tests__/tool-result-spool.test.ts +5 -8
  141. package/src/__tests__/usage-attribution.test.ts +26 -0
  142. package/src/acp/__tests__/acp-auth-marker-store.test.ts +191 -0
  143. package/src/acp/__tests__/acp-claude-oauth.test.ts +262 -0
  144. package/src/acp/__tests__/acp-connect-card-state.test.ts +17 -0
  145. package/src/acp/__tests__/helpers/acp-history-db.ts +10 -3
  146. package/src/acp/acp-auth-marker-store.ts +196 -0
  147. package/src/acp/acp-claude-oauth.ts +154 -4
  148. package/src/acp/acp-connect-card-state.ts +49 -8
  149. package/src/acp/agent-process.ts +1 -0
  150. package/src/acp/auto-install.ts +1 -0
  151. package/src/acp/client-handler.ts +1 -0
  152. package/src/acp/prepare-agent-env.ts +79 -1
  153. package/src/acp/resolve-agent.ts +12 -0
  154. package/src/acp/session-manager.test.ts +183 -0
  155. package/src/acp/session-manager.ts +110 -6
  156. package/src/acp/types.ts +17 -0
  157. package/src/agent/attachments.ts +7 -2
  158. package/src/agent/loop.ts +59 -1
  159. package/src/api/events/contact-form-closed.ts +32 -0
  160. package/src/api/events/contact-record-request.ts +63 -0
  161. package/src/api/events/contact-request.ts +10 -3
  162. package/src/api/index.ts +20 -0
  163. package/src/api/responses/conversation-message.ts +20 -0
  164. package/src/api/responses/home.ts +101 -0
  165. package/src/approvals/AGENTS.md +1 -1
  166. package/src/approvals/guardian-card-withdrawal.ts +127 -30
  167. package/src/approvals/guardian-decision-primitive.ts +9 -0
  168. package/src/approvals/guardian-expiry-notifier.ts +22 -16
  169. package/src/approvals/guardian-request-status-sync.ts +98 -0
  170. package/src/avatar/svg-compositor.ts +1 -1
  171. package/src/avatar/traits-png-sync.ts +1 -1
  172. package/src/bundler/app-compiler.ts +1 -0
  173. package/src/bundler/compiler-tools.ts +1 -0
  174. package/src/bundler/package-resolver.ts +1 -0
  175. package/src/calls/__tests__/voice-session-bridge.test.ts +183 -3
  176. package/src/calls/guardian-action-sweep.ts +6 -1
  177. package/src/calls/voice-session-bridge.ts +46 -1
  178. package/src/channels/__tests__/gateway-guardian-requests.test.ts +13 -18
  179. package/src/channels/gateway-guardian-requests.ts +37 -18
  180. package/src/cli/__tests__/contacts-record-prompt-cli.test.ts +329 -0
  181. package/src/cli/commands/__tests__/browser.test.ts +65 -1
  182. package/src/cli/commands/browser.ts +30 -10
  183. package/src/cli/commands/contacts.help.ts +173 -11
  184. package/src/cli/commands/contacts.ts +343 -4
  185. package/src/cli/commands/db/refresh.ts +1 -0
  186. package/src/cli/commands/oauth/index.help.ts +6 -3
  187. package/src/cli/commands/oauth/request.test.ts +37 -0
  188. package/src/cli/commands/oauth/request.ts +7 -6
  189. package/src/cli/lib/bundled-marketplace.json +1 -1
  190. package/src/cli/lib/install-from-github.ts +20 -19
  191. package/src/cli/lib/install-plugin-dependencies.ts +3 -5
  192. package/src/cli/lib/merge-plugin-tree.ts +1 -0
  193. package/src/cli/lib/publish-plugin.ts +4 -1
  194. package/src/config/__tests__/balanced-model-experiment.test.ts +63 -31
  195. package/src/config/__tests__/default-profile-catalog.test.ts +2 -2
  196. package/src/config/balanced-model-experiment.ts +15 -9
  197. package/src/config/bundled-skills/contacts/SKILL.md +61 -18
  198. package/src/config/default-profile-catalog.ts +12 -11
  199. package/src/config/feature-flag-registry.json +22 -13
  200. package/src/config/schema.ts +2 -0
  201. package/src/config/schemas/__tests__/live-voice.test.ts +13 -0
  202. package/src/config/schemas/live-voice.ts +7 -0
  203. package/src/config/schemas/sight.ts +45 -0
  204. package/src/config/skills.ts +2 -1
  205. package/src/contacts/__tests__/mirror-reconciler.test.ts +352 -0
  206. package/src/contacts/contact-store.ts +47 -4
  207. package/src/contacts/contacts-write.ts +5 -2
  208. package/src/contacts/mirror-reconciler.ts +210 -0
  209. package/src/contacts/mirror-tombstones.ts +42 -0
  210. package/src/contacts/notify-contacts-changed.ts +11 -2
  211. package/src/context/compactor.ts +47 -19
  212. package/src/context/tool-result-spool.ts +21 -14
  213. package/src/conversations/message-consolidation.ts +2 -2
  214. package/src/credential-health/credential-health-service.ts +37 -6
  215. package/src/daemon/conversation-agent-loop-handlers.ts +19 -2
  216. package/src/daemon/conversation-agent-loop.ts +100 -1
  217. package/src/daemon/conversation-media-retry.ts +11 -6
  218. package/src/daemon/conversation-messaging.ts +293 -30
  219. package/src/daemon/conversation-runtime-assembly.ts +67 -0
  220. package/src/daemon/conversation-sight-frames.ts +187 -0
  221. package/src/daemon/conversation-store.ts +18 -28
  222. package/src/daemon/conversation-surfaces.ts +12 -2
  223. package/src/daemon/conversation-tool-setup.ts +39 -10
  224. package/src/daemon/conversation.ts +159 -16
  225. package/src/daemon/daemon-control.ts +6 -17
  226. package/src/daemon/handlers/config-model.test.ts +1 -0
  227. package/src/daemon/handlers/conversations.ts +10 -11
  228. package/src/daemon/install-assistant-command.ts +1 -0
  229. package/src/daemon/lifecycle.ts +10 -13
  230. package/src/daemon/message-types/sync.ts +4 -0
  231. package/src/daemon/process-message.ts +2 -17
  232. package/src/daemon/reaction-history-render.test.ts +162 -0
  233. package/src/daemon/reaction-history-render.ts +115 -0
  234. package/src/daemon/reaction-record.test.ts +162 -0
  235. package/src/daemon/reaction-record.ts +83 -0
  236. package/src/daemon/shutdown-handlers.ts +0 -2
  237. package/src/daemon/summarize-boundary.ts +12 -0
  238. package/src/daemon/video-thumbnail.ts +1 -1
  239. package/src/home/feed-types.ts +4 -0
  240. package/src/home/feed-writer.ts +25 -0
  241. package/src/live-voice/__tests__/live-voice-attach-frame.test.ts +679 -0
  242. package/src/live-voice/__tests__/live-voice-attach-image.test.ts +8 -5
  243. package/src/live-voice/__tests__/live-voice-photo.test.ts +637 -1
  244. package/src/live-voice/__tests__/live-voice-sight-frame-inline.test.ts +787 -0
  245. package/src/live-voice/__tests__/live-voice-sight-frame.test.ts +347 -0
  246. package/src/live-voice/__tests__/live-voice-vad.test.ts +109 -0
  247. package/src/live-voice/live-voice-photo.ts +420 -65
  248. package/src/live-voice/live-voice-session.ts +311 -27
  249. package/src/live-voice/protocol.ts +147 -11
  250. package/src/messaging/provider-message-metadata.ts +7 -0
  251. package/src/messaging/providers/__tests__/transport-dispatch.test.ts +113 -1
  252. package/src/messaging/providers/channel-transport.ts +37 -0
  253. package/src/messaging/providers/discord/api.ts +4 -4
  254. package/src/messaging/providers/discord/send.test.ts +177 -2
  255. package/src/messaging/providers/discord/send.ts +197 -10
  256. package/src/messaging/providers/discord/transport.ts +10 -0
  257. package/src/messaging/providers/index.ts +42 -0
  258. package/src/messaging/providers/slack/__tests__/adapter-mention-rendering.test.ts +129 -3
  259. package/src/messaging/providers/slack/__tests__/auth.test.ts +35 -0
  260. package/src/messaging/providers/slack/__tests__/message-content.test.ts +152 -0
  261. package/src/messaging/providers/slack/adapter.ts +112 -27
  262. package/src/messaging/providers/slack/api.test.ts +10 -0
  263. package/src/messaging/providers/slack/auth.ts +32 -6
  264. package/src/messaging/providers/slack/client.ts +0 -15
  265. package/src/messaging/providers/slack/message-content.ts +137 -0
  266. package/src/messaging/providers/slack/send.test.ts +43 -2
  267. package/src/messaging/providers/slack/send.ts +18 -5
  268. package/src/messaging/providers/slack/transport.ts +10 -0
  269. package/src/messaging/providers/slack/types.ts +21 -2
  270. package/src/messaging/providers/telegram-bot/adapter.ts +5 -1
  271. package/src/messaging/providers/telegram-bot/send.test.ts +49 -2
  272. package/src/messaging/providers/telegram-bot/send.ts +41 -1
  273. package/src/messaging/providers/telegram-bot/transport.ts +10 -0
  274. package/src/messaging/providers/telegram-bot/withdraw.ts +13 -5
  275. package/src/messaging/reaction-envelopes.ts +79 -0
  276. package/src/monitoring/control.ts +2 -2
  277. package/src/monitoring/page-cache.ts +5 -1
  278. package/src/notifications/AGENTS.md +1 -1
  279. package/src/notifications/__tests__/guardian-feed-projection.test.ts +360 -0
  280. package/src/notifications/__tests__/home-feed-side-effect.test.ts +8 -0
  281. package/src/notifications/adapters/discord.ts +62 -9
  282. package/src/notifications/conversation-pairing.ts +31 -4
  283. package/src/notifications/emit-signal.ts +8 -5
  284. package/src/notifications/guardian-delivery-recorder.ts +47 -6
  285. package/src/notifications/guardian-feed-projection.ts +492 -0
  286. package/src/notifications/home-feed-side-effect.ts +59 -11
  287. package/src/oauth/byo-connection.test.ts +22 -0
  288. package/src/oauth/byo-connection.ts +11 -5
  289. package/src/oauth/connection.ts +3 -2
  290. package/src/oauth/platform-connection.test.ts +35 -0
  291. package/src/oauth/platform-connection.ts +18 -13
  292. package/src/oauth/scope-utils.ts +48 -0
  293. package/src/oauth/seed-providers.ts +12 -0
  294. package/src/outbound-proxy/certs.ts +5 -4
  295. package/src/permissions/question-guardian-request.test.ts +7 -1
  296. package/src/persistence/attachments-store.ts +134 -31
  297. package/src/persistence/conversation-crud.ts +137 -4
  298. package/src/persistence/conversation-types.ts +109 -0
  299. package/src/persistence/db-connection.ts +3 -21
  300. package/src/persistence/delivery-crud.ts +42 -12
  301. package/src/persistence/embeddings/__tests__/embedding-local-lifecycle.test.ts +15 -2
  302. package/src/persistence/embeddings/embedding-local.ts +7 -112
  303. package/src/persistence/embeddings/qdrant-manager.ts +4 -1
  304. package/src/persistence/llm-request-log-store.ts +1 -1
  305. package/src/persistence/migrations/370-acp-session-history-auth-error-code.ts +36 -0
  306. package/src/persistence/migrations/371-acp-session-history-auth-error-credential.ts +40 -0
  307. package/src/persistence/migrations/372-create-acp-refused-credentials.ts +34 -0
  308. package/src/persistence/migrations/373-acp-auth-marker-index.ts +27 -0
  309. package/src/persistence/migrations/__tests__/370-acp-session-history-auth-error-code.test.ts +65 -0
  310. package/src/persistence/migrations/__tests__/371-acp-session-history-auth-error-credential.test.ts +68 -0
  311. package/src/persistence/migrations/__tests__/372-create-acp-refused-credentials.test.ts +64 -0
  312. package/src/persistence/migrations/__tests__/373-acp-auth-marker-index.test.ts +82 -0
  313. package/src/persistence/schema/acp.ts +25 -0
  314. package/src/persistence/steps.ts +8 -0
  315. package/src/platform/platform-patch-queue.test.ts +43 -41
  316. package/src/plugin-api/index.ts +12 -0
  317. package/src/plugins/defaults/compaction/window-manager.ts +2 -0
  318. package/src/plugins/defaults/image-recovery/recover.ts +12 -1
  319. package/src/plugins/defaults/memory/__tests__/memory-retrospective-accounting.test.ts +166 -1
  320. package/src/plugins/defaults/memory/__tests__/memory-retrospective-provider-path.test.ts +3 -0
  321. package/src/plugins/defaults/memory/__tests__/memory-retrospective-wake-chain.test.ts +3 -0
  322. package/src/plugins/defaults/memory/jobs-worker.ts +1 -2
  323. package/src/plugins/defaults/memory/memory-retrospective-accounting.ts +53 -9
  324. package/src/plugins/defaults/memory/src/__tests__/memory-worker-routes.test.ts +4 -7
  325. package/src/plugins/defaults/memory/src/memory-worker-routes.ts +1 -1
  326. package/src/plugins/defaults/memory/v1/job-handlers/index-maintenance.ts +15 -1
  327. package/src/plugins/defaults/memory/worker-control.ts +1 -2
  328. package/src/providers/content-block-schema.ts +1 -0
  329. package/src/providers/fireworks/client.ts +9 -1
  330. package/src/providers/gemini/client.ts +22 -15
  331. package/src/providers/media-resolve.ts +24 -10
  332. package/src/providers/model-catalog.ts +91 -0
  333. package/src/providers/openai/chat-completions-provider.ts +44 -5
  334. package/src/providers/retry.ts +16 -11
  335. package/src/providers/types.ts +56 -0
  336. package/src/routes/__tests__/control.test.ts +4 -3
  337. package/src/routes/control.ts +1 -1
  338. package/src/routes/route-host-client.ts +1 -1
  339. package/src/runtime/AGENTS.md +30 -12
  340. package/src/runtime/__tests__/agent-wake.test.ts +71 -6
  341. package/src/runtime/__tests__/guardian-form-registry.test.ts +242 -0
  342. package/src/runtime/access-request-helper.ts +15 -7
  343. package/src/runtime/agent-wake.ts +16 -4
  344. package/src/runtime/channel-approval-types.ts +8 -5
  345. package/src/runtime/channel-invite-transport.ts +2 -0
  346. package/src/runtime/channel-invite-transports/discord.ts +27 -0
  347. package/src/runtime/channel-readiness-service.ts +4 -4
  348. package/src/runtime/channel-reply-delivery.ts +72 -28
  349. package/src/runtime/guardian-action-service.ts +9 -5
  350. package/src/runtime/guardian-form-registry.ts +256 -0
  351. package/src/runtime/guardian-reply-router.ts +33 -14
  352. package/src/runtime/invite-instruction-generator.ts +14 -2
  353. package/src/runtime/migrations/vbundle-metadata-merge.ts +4 -7
  354. package/src/runtime/no-response.ts +13 -74
  355. package/src/runtime/routes/__tests__/acp-routes.test.ts +527 -0
  356. package/src/runtime/routes/__tests__/app-management-shared-apps.test.ts +74 -0
  357. package/src/runtime/routes/__tests__/contact-form-kinds.test.ts +22 -0
  358. package/src/runtime/routes/__tests__/contact-record-prompt-route.test.ts +303 -0
  359. package/src/runtime/routes/__tests__/monitoring-routes.test.ts +3 -4
  360. package/src/runtime/routes/__tests__/pending-interactions-route.test.ts +6 -0
  361. package/src/runtime/routes/__tests__/route-host-worker-routes.test.ts +3 -4
  362. package/src/runtime/routes/__tests__/schedule-worker-routes.test.ts +5 -6
  363. package/src/runtime/routes/acp-routes-list.test.ts +58 -0
  364. package/src/runtime/routes/acp-routes.ts +253 -47
  365. package/src/runtime/routes/app-management-routes.ts +80 -47
  366. package/src/runtime/routes/approval-routes.ts +9 -0
  367. package/src/runtime/routes/archive-utils.ts +6 -1
  368. package/src/runtime/routes/avatar-routes.ts +1 -1
  369. package/src/runtime/routes/channel-route-shared.ts +18 -20
  370. package/src/runtime/routes/contact-prompt-routes.ts +271 -105
  371. package/src/runtime/routes/conversation-routes.ts +41 -32
  372. package/src/runtime/routes/credential-prompt-routes.ts +10 -1
  373. package/src/runtime/routes/debug-bash-routes.ts +1 -0
  374. package/src/runtime/routes/guardian-expiry-sweep.ts +89 -31
  375. package/src/runtime/routes/guardian-form-routes.ts +115 -0
  376. package/src/runtime/routes/inbound-message-handler.ts +82 -29
  377. package/src/runtime/routes/inbound-stages/acl-enforcement.ts +16 -3
  378. package/src/runtime/routes/inbound-stages/edit-intercept.ts +6 -0
  379. package/src/runtime/routes/inbound-stages/guardian-reply-intercept.test.ts +6 -0
  380. package/src/runtime/routes/inbound-stages/guardian-reply-intercept.ts +6 -1
  381. package/src/runtime/routes/inbound-stages/reaction-intercept.test.ts +50 -17
  382. package/src/runtime/routes/inbound-stages/reaction-intercept.ts +97 -94
  383. package/src/runtime/routes/index.ts +2 -0
  384. package/src/runtime/routes/integrations/slack/channel.ts +39 -18
  385. package/src/runtime/routes/migration-routes.ts +1 -3
  386. package/src/runtime/routes/monitoring-routes.ts +0 -3
  387. package/src/runtime/routes/oauth-commands-routes.ts +18 -5
  388. package/src/runtime/routes/route-host-worker-routes.ts +1 -1
  389. package/src/runtime/routes/schedule-worker-routes.ts +1 -1
  390. package/src/runtime/tool-grant-request-helper.ts +4 -0
  391. package/src/schedule/run-script.ts +1 -0
  392. package/src/schedule/scheduler.ts +1 -1
  393. package/src/schedule/worker-control.ts +2 -2
  394. package/src/security/oauth2.ts +2 -11
  395. package/src/security/secure-keys.ts +55 -8
  396. package/src/skills/catalog-install.ts +7 -4
  397. package/src/skills/clawhub.ts +1 -0
  398. package/src/skills/inline-command-runner.ts +1 -0
  399. package/src/stt/__tests__/room-noise-floor.test.ts +108 -0
  400. package/src/stt/room-noise-floor.ts +140 -0
  401. package/src/subagent/notify.ts +49 -3
  402. package/src/tools/__tests__/tool-input-schemas.test.ts +2 -0
  403. package/src/tools/acp/spawn.ts +2 -2
  404. package/src/tools/browser/__tests__/browser-status.test.ts +9 -0
  405. package/src/tools/browser/browser-execution.ts +10 -43
  406. package/src/tools/browser/browser-manager.ts +1 -0
  407. package/src/tools/browser/browser-status-constants.ts +6 -5
  408. package/src/tools/browser/runtime-check.ts +2 -0
  409. package/src/tools/channel/react-to-message.test.ts +193 -0
  410. package/src/tools/channel/react-to-message.ts +159 -0
  411. package/src/tools/credentials/metadata-store.ts +115 -19
  412. package/src/tools/credentials/store.ts +0 -2
  413. package/src/tools/filesystem/read.ts +2 -2
  414. package/src/tools/host-filesystem/read.ts +2 -2
  415. package/src/tools/host-terminal/host-shell.test.ts +24 -0
  416. package/src/tools/host-terminal/host-shell.ts +11 -38
  417. package/src/tools/shared/filesystem/file-ops-service.ts +10 -7
  418. package/src/tools/shared/shell-output.test.ts +104 -0
  419. package/src/tools/shared/shell-output.ts +112 -39
  420. package/src/tools/skills/sandbox-runner.ts +1 -0
  421. package/src/tools/system/request-permission.test.ts +34 -0
  422. package/src/tools/system/request-permission.ts +29 -5
  423. package/src/tools/terminal/shell.test.ts +63 -0
  424. package/src/tools/terminal/shell.ts +12 -44
  425. package/src/tools/tool-input-schemas.ts +2 -0
  426. package/src/tools/tool-manifest.ts +2 -0
  427. package/src/usage/attribution.ts +12 -4
  428. package/src/util/__tests__/worker-ownership.test.ts +77 -0
  429. package/src/util/__tests__/worker-process-command.test.ts +246 -1
  430. package/src/util/bun-runtime.ts +44 -13
  431. package/src/util/bundled-asset.ts +9 -1
  432. package/src/util/disk-usage.ts +1 -0
  433. package/src/util/file-use.ts +4 -1
  434. package/src/util/guardian-form-timeouts.ts +50 -0
  435. package/src/util/image-conversion.ts +1 -0
  436. package/src/util/oauth-request-body.test.ts +59 -0
  437. package/src/util/oauth-request-body.ts +56 -5
  438. package/src/util/platform.ts +69 -73
  439. package/src/util/process-table.ts +16 -5
  440. package/src/util/read-stdin.ts +5 -0
  441. package/src/util/spawn.ts +1 -0
  442. package/src/util/sqlite3-runtime.ts +25 -5
  443. package/src/util/worker-ownership.ts +193 -0
  444. package/src/util/worker-process.ts +375 -37
  445. package/src/workspace/git-service.ts +4 -12
  446. package/src/workspace/migrations/010-app-dir-rename.ts +1 -0
@@ -0,0 +1,492 @@
1
+ /**
2
+ * Canonical "Needs attention" projection of guardian requests onto the
3
+ * home feed.
4
+ *
5
+ * One pending guardian request is exactly one feed item, keyed by
6
+ * `guardianFeedItemId(requestId)` so the feed writer's replace-in-place
7
+ * merge keeps it canonical: re-emits update the same row, and the
8
+ * terminal-status fan-out rewrites it into a receipt instead of leaving
9
+ * a stale actionable card. The item's `guardianRequest` field
10
+ * (`FeedItemGuardianRequestSchema`) is a read projection of the
11
+ * gateway-owned `guardian_requests` row, never an independent delivery
12
+ * record: clients derive every affordance from its `status`, and the
13
+ * daemon updates it wherever `withdrawGuardianRequestCards` settles the
14
+ * other surfaces.
15
+ */
16
+ import { z } from "zod";
17
+
18
+ import {
19
+ type FeedItem,
20
+ type FeedItemGuardianIntent,
21
+ type FeedItemGuardianRequest,
22
+ FeedItemGuardianStatusSchema,
23
+ isPendingGuardianFeedItem,
24
+ } from "../api/responses/home.js";
25
+ import {
26
+ getGuardianRequest,
27
+ type GuardianRequestStatus,
28
+ type GuardianRequestWire,
29
+ listGuardianRequests,
30
+ } from "../channels/gateway-guardian-requests.js";
31
+ import { getConfig } from "../config/loader.js";
32
+ import {
33
+ appendFeedItem,
34
+ patchFeedItemContent,
35
+ readHomeFeed,
36
+ } from "../home/feed-writer.js";
37
+ import { buildSlackMessageDeepLinks } from "../messaging/providers/slack/deep-link.js";
38
+ import {
39
+ DEFAULT_USER_REFERENCE,
40
+ resolveGuardianName,
41
+ } from "../prompts/user-reference.js";
42
+ import { getLogger } from "../util/logger.js";
43
+ import {
44
+ buildToolApprovalSourceView,
45
+ type GuardianQuestionRequestKind,
46
+ LenientToolApprovalPayloadSchema,
47
+ resolveGuardianInstructionModeFromFields,
48
+ resolveGuardianQuestionInstructionMode,
49
+ } from "./guardian-question-mode.js";
50
+ import { readPayloadString } from "./notification-utils.js";
51
+ import type { NotificationDeliveryResult } from "./types.js";
52
+
53
+ const log = getLogger("guardian-feed-projection");
54
+
55
+ // The wire enum in `api/responses/home.ts` cannot import the gateway
56
+ // contract (the api directory is copied verbatim into client packages),
57
+ // so it mirrors `GuardianRequestStatusSchema` by value. This assignment
58
+ // fails to compile if the enums ever diverge in either direction.
59
+ type FeedStatus = z.infer<typeof FeedItemGuardianStatusSchema>;
60
+ type StatusEnumsAligned = [GuardianRequestStatus] extends [FeedStatus]
61
+ ? [FeedStatus] extends [GuardianRequestStatus]
62
+ ? true
63
+ : never
64
+ : never;
65
+ const _statusEnumsAligned: StatusEnumsAligned = true;
66
+ void _statusEnumsAligned;
67
+
68
+ /**
69
+ * The signal events that carry a guardian request. `guardian.question`
70
+ * covers tool approvals, tool grants, and questions; access requests
71
+ * predate that registry and still ride their own event with an implied
72
+ * `access_request` kind.
73
+ */
74
+ export function isGuardianRequestSignalEvent(sourceEventName: string): boolean {
75
+ return (
76
+ sourceEventName === "guardian.question" ||
77
+ sourceEventName === "ingress.access_request"
78
+ );
79
+ }
80
+
81
+ /** Feed-item id of the canonical projection for a guardian request. */
82
+ export function guardianFeedItemId(requestId: string): string {
83
+ return `guardian:${requestId}`;
84
+ }
85
+
86
+ /** The `requestId` a guardian feed-item id was built from, or null. */
87
+ export function requestIdFromGuardianFeedItemId(itemId: string): string | null {
88
+ return itemId.startsWith("guardian:")
89
+ ? itemId.slice("guardian:".length)
90
+ : null;
91
+ }
92
+
93
+ /**
94
+ * Guardian display name for receipt copy, or undefined when only the
95
+ * prompt-voice fallback ("my human") is available. A receipt with no
96
+ * decider label renders as the bare status word.
97
+ */
98
+ function guardianReceiptName(): string | undefined {
99
+ const name = resolveGuardianName();
100
+ return name === DEFAULT_USER_REFERENCE ? undefined : name;
101
+ }
102
+
103
+ /**
104
+ * Build the pending `guardianRequest` projection for a `guardian.question`
105
+ * signal's feed item. Returns null when the payload does not carry the
106
+ * request id the projection is keyed by.
107
+ *
108
+ * `slackDelivery` is the signal's Slack delivery outcome, when one
109
+ * shipped: its destination chat + message ts locate the guardian-DM
110
+ * approval card, and the derived deep links are what the client's
111
+ * "Open in Slack" affordance opens.
112
+ */
113
+ export function buildPendingGuardianProjection(
114
+ contextPayload: unknown,
115
+ slackDelivery?: NotificationDeliveryResult,
116
+ fallbackKind?: GuardianQuestionRequestKind,
117
+ ): FeedItemGuardianRequest | null {
118
+ // Access-request payloads predate the kind registry and carry no
119
+ // `requestKind`; the producer's event name supplies it instead.
120
+ const normalizedPayload =
121
+ contextPayload &&
122
+ typeof contextPayload === "object" &&
123
+ !Array.isArray(contextPayload) &&
124
+ !("requestKind" in contextPayload) &&
125
+ fallbackKind
126
+ ? { ...contextPayload, requestKind: fallbackKind }
127
+ : contextPayload;
128
+ const parsed = LenientToolApprovalPayloadSchema.safeParse(normalizedPayload);
129
+ if (!parsed.success) {
130
+ return null;
131
+ }
132
+ const payload = parsed.data;
133
+ const requestId = payload.requestId?.trim();
134
+ if (!requestId) {
135
+ return null;
136
+ }
137
+
138
+ const intent = intentFromInstructionMode(
139
+ resolveGuardianQuestionInstructionMode(payload).mode,
140
+ );
141
+
142
+ const sourceView = buildToolApprovalSourceView(payload);
143
+ const slackLinks = buildSlackCardLinks(slackDelivery);
144
+ // Access-request payloads name their requester differently.
145
+ const requesterLabel =
146
+ payload.requesterIdentifier?.trim() ||
147
+ readPayloadString(contextPayload, "actorDisplayName")?.trim() ||
148
+ readPayloadString(contextPayload, "senderIdentifier")?.trim();
149
+
150
+ return {
151
+ requestId,
152
+ kind: payload.requestKind,
153
+ intent,
154
+ status: "pending",
155
+ ...(requesterLabel ? { requesterLabel } : {}),
156
+ ...(payload.toolName?.trim() ? { toolName: payload.toolName.trim() } : {}),
157
+ ...(sourceView?.channel ? { sourceChannel: sourceView.channel } : {}),
158
+ ...(sourceView
159
+ ? { sourceContextLabel: describeApprovalSourceContext(sourceView) }
160
+ : {}),
161
+ ...(sourceView?.permalink ? { sourceUrl: sourceView.permalink } : {}),
162
+ ...(slackLinks?.webUrl ? { slackCardUrl: slackLinks.webUrl } : {}),
163
+ ...(slackLinks?.appUrl ? { slackCardAppUrl: slackLinks.appUrl } : {}),
164
+ };
165
+ }
166
+
167
+ /**
168
+ * A request in answer mode asks the guardian a question; every other
169
+ * mode asks for an approval. The instruction mode already encodes this
170
+ * (it decides whether cards render answer options or approve/reject),
171
+ * so the projection's `intent` is a straight reading of it.
172
+ */
173
+ function intentFromInstructionMode(
174
+ mode: "approval" | "answer" | undefined,
175
+ ): FeedItemGuardianIntent {
176
+ return mode === "answer" ? "question" : "approval";
177
+ }
178
+
179
+ /**
180
+ * Display label for the originating chat, mirroring the wording the
181
+ * in-app approval card's source row uses (`sourceMetadataRow` in
182
+ * `approval-card-data.ts`): Slack chats are named, other channels fall
183
+ * back to the channel id.
184
+ */
185
+ function describeApprovalSourceContext(view: {
186
+ channel: string;
187
+ chatId?: string | null;
188
+ isSlackDm: boolean;
189
+ }): string {
190
+ if (view.channel === "slack" && view.chatId) {
191
+ return view.isSlackDm ? "Slack direct message" : `Slack #${view.chatId}`;
192
+ }
193
+ return view.channel;
194
+ }
195
+
196
+ /** Deep links to the Slack guardian-DM approval card, when one shipped. */
197
+ function buildSlackCardLinks(
198
+ slackDelivery?: NotificationDeliveryResult,
199
+ ): { appUrl?: string; webUrl?: string } | undefined {
200
+ if (
201
+ !slackDelivery ||
202
+ slackDelivery.status !== "sent" ||
203
+ !slackDelivery.destination ||
204
+ !slackDelivery.messageId
205
+ ) {
206
+ return undefined;
207
+ }
208
+ const slackConfig = getConfig().slack;
209
+ return buildSlackMessageDeepLinks({
210
+ teamId: slackConfig?.teamId,
211
+ teamUrl: slackConfig?.teamUrl,
212
+ channelId: slackDelivery.destination,
213
+ messageTs: slackDelivery.messageId,
214
+ });
215
+ }
216
+
217
+ export interface GuardianFeedReceiptParams {
218
+ requestId: string;
219
+ status: GuardianRequestStatus;
220
+ /** Action that produced the terminal status, when it was a decision. */
221
+ decidedAction?: string;
222
+ /** Epoch ms the request reached its terminal status. */
223
+ decidedAtMs?: number;
224
+ /** Non-decision cause of the terminal status (e.g. "superseded"). */
225
+ terminalReason?: string;
226
+ }
227
+
228
+ /**
229
+ * Rewrite a request's canonical feed item into its terminal receipt.
230
+ *
231
+ * The item keeps its title and summary; `guardianRequest` flips to the
232
+ * terminal status (which is what removes the client's action buttons),
233
+ * and urgency drops to `medium` so the item leaves the protected
234
+ * "Needs attention" treatment and becomes an ordinary clearable
235
+ * notification. Returns false only when the write itself failed, so the
236
+ * withdrawal fan-out can hold its per-request receipt back and retry
237
+ * (same contract as the other surfaces it settles). A request with no
238
+ * projection item resolves true: the pending writer converges the
239
+ * write-vs-resolve race by re-checking canonical status after its
240
+ * append, so there is nothing here for a retry to fix.
241
+ */
242
+ export async function writeGuardianFeedReceipt(
243
+ params: GuardianFeedReceiptParams,
244
+ ): Promise<boolean> {
245
+ const itemId = guardianFeedItemId(params.requestId);
246
+ try {
247
+ // `patchFeedItemContent` resolves null both for a missing item and
248
+ // for a failed file write, and only the second may gate a retry.
249
+ // The pre-check splits them: an id present here that nulls below is
250
+ // a lost write (or a concurrent removal, where a retry is harmless).
251
+ if (!readHomeFeed().items.some((item) => item.id === itemId)) {
252
+ // No projection item: either the request predates the projection
253
+ // or the pending write raced the resolution and has not landed
254
+ // yet. The pending writer converges the race by re-checking
255
+ // canonical status after its append.
256
+ log.debug(
257
+ { requestId: params.requestId, status: params.status },
258
+ "No guardian feed item to receipt",
259
+ );
260
+ return true;
261
+ }
262
+ const updated = await patchFeedItemContent(itemId, {
263
+ urgency: "medium",
264
+ guardianRequest: (existing) => ({
265
+ ...existing,
266
+ status: params.status,
267
+ ...(params.decidedAction
268
+ ? { decidedAction: params.decidedAction }
269
+ : {}),
270
+ // A decider label only when a person decided: a non-decision
271
+ // terminal (superseded, expired) must not read as the guardian's
272
+ // own rejection.
273
+ ...((params.status === "approved" || params.status === "denied") &&
274
+ !params.terminalReason
275
+ ? decidedByLabelPatch()
276
+ : {}),
277
+ decidedAt: new Date(params.decidedAtMs ?? Date.now()).toISOString(),
278
+ ...(params.terminalReason
279
+ ? { terminalReason: params.terminalReason }
280
+ : {}),
281
+ }),
282
+ });
283
+ if (!updated) {
284
+ log.warn(
285
+ { requestId: params.requestId, status: params.status },
286
+ "Guardian feed receipt did not persist",
287
+ );
288
+ return false;
289
+ }
290
+ return true;
291
+ } catch (err) {
292
+ log.warn(
293
+ { err, requestId: params.requestId, status: params.status },
294
+ "Failed to write guardian feed receipt",
295
+ );
296
+ return false;
297
+ }
298
+ }
299
+
300
+ /** `{ decidedByLabel }` when a real display name exists, `{}` otherwise. */
301
+ function decidedByLabelPatch(): Pick<
302
+ Partial<FeedItemGuardianRequest>,
303
+ "decidedByLabel"
304
+ > {
305
+ const name = guardianReceiptName();
306
+ return name === undefined ? {} : { decidedByLabel: name };
307
+ }
308
+
309
+ /**
310
+ * Rewrite a request's feed item into its receipt when the canonical row
311
+ * is already terminal. Converges the write-vs-resolve race from the
312
+ * writer's side: a request decided while a pending item was being
313
+ * written already ran its receipt fan-out against an item that did not
314
+ * exist yet. Best-effort: a transport failure skips (never misreads a
315
+ * hiccup as "request gone"), and the reconciliation sweep heals what
316
+ * this misses.
317
+ */
318
+ export async function receiptGuardianFeedItemIfRequestTerminal(
319
+ requestId: string,
320
+ ): Promise<void> {
321
+ try {
322
+ const request = await getGuardianRequest(requestId);
323
+ if (!request || request.status === "pending") {
324
+ return;
325
+ }
326
+ await writeGuardianFeedReceipt({
327
+ requestId,
328
+ status: request.status,
329
+ decidedAtMs: request.updatedAt,
330
+ });
331
+ } catch (err) {
332
+ log.warn(
333
+ { err, requestId },
334
+ "Failed to converge guardian feed item with terminal request",
335
+ );
336
+ }
337
+ }
338
+
339
+ // ---------------------------------------------------------------------------
340
+ // Reconciliation
341
+ // ---------------------------------------------------------------------------
342
+
343
+ /**
344
+ * Ceiling on backfills per reconciliation round, mirroring the expiry
345
+ * sweep's batch bound: one round's IPC and file writes stay bounded
346
+ * however large a backlog grows, and the remainder drains on later
347
+ * rounds (the sweep runs every minute).
348
+ */
349
+ const RECONCILE_BACKFILL_LIMIT = 50;
350
+
351
+ /**
352
+ * Converge the feed against canonical guardian state. Run from the
353
+ * guardian expiry sweep, so drift heals on the same cadence expiry does:
354
+ *
355
+ * 1. A pending request with no feed item (it predates the projection,
356
+ * or its write was lost) gets one backfilled from the request row.
357
+ * No conversation is generated: the item links to the request's
358
+ * source conversation when it names one, and nothing else.
359
+ * 2. A feed item still offering actions for a request that is
360
+ * terminal (or gone) is rewritten into its receipt, and the drift
361
+ * is logged loudly: a terminal request with an actionable
362
+ * projection is the invariant this system exists to prevent.
363
+ *
364
+ * Skips the round when the gateway is unreachable; an empty list must
365
+ * not be mistaken for "everything resolved".
366
+ */
367
+ export async function reconcileGuardianFeedProjections(): Promise<void> {
368
+ let pending: GuardianRequestWire[];
369
+ try {
370
+ pending = await listGuardianRequests({ status: "pending" });
371
+ } catch (err) {
372
+ log.debug(
373
+ { err },
374
+ "Guardian feed reconciliation skipped: gateway unreachable",
375
+ );
376
+ return;
377
+ }
378
+
379
+ const items = readHomeFeed().items;
380
+ const itemsByRequestId = new Map<string, FeedItem>();
381
+ for (const item of items) {
382
+ const requestId = requestIdFromGuardianFeedItemId(item.id);
383
+ if (requestId) {
384
+ itemsByRequestId.set(requestId, item);
385
+ }
386
+ }
387
+
388
+ const pendingIds = new Set(pending.map((request) => request.id));
389
+
390
+ const missing = pending.filter(
391
+ (request) => !itemsByRequestId.has(request.id),
392
+ );
393
+ if (missing.length > RECONCILE_BACKFILL_LIMIT) {
394
+ log.warn(
395
+ { missing: missing.length, limit: RECONCILE_BACKFILL_LIMIT },
396
+ "Guardian feed backfill backlog exceeds one round; remainder drains next round",
397
+ );
398
+ }
399
+ for (const request of missing.slice(0, RECONCILE_BACKFILL_LIMIT)) {
400
+ log.info(
401
+ { requestId: request.id, kind: request.kind, surface: "home_feed" },
402
+ "Backfilling feed projection for pending guardian request",
403
+ );
404
+ await appendFeedItem(buildBackfillGuardianFeedItem(request));
405
+ // The request may have resolved between the pending listing and this
406
+ // append; without the recheck the fresh item would restore live
407
+ // actions for a terminal request until the next round.
408
+ await receiptGuardianFeedItemIfRequestTerminal(request.id);
409
+ }
410
+
411
+ for (const [requestId, item] of itemsByRequestId) {
412
+ if (!isPendingGuardianFeedItem(item) || pendingIds.has(requestId)) {
413
+ continue;
414
+ }
415
+ // The item says pending but the canonical row does not: read the row
416
+ // to learn its terminal status. Deliberately the throwing lookup: the
417
+ // degrading variant returns null on a transport failure too, and a
418
+ // hiccup must skip the row rather than receipt a live request as
419
+ // cancelled. A row absent from the gateway reads as
420
+ // cancelled.
421
+ let request: GuardianRequestWire | null;
422
+ try {
423
+ request = await getGuardianRequest(requestId);
424
+ } catch {
425
+ continue;
426
+ }
427
+ if (request?.status === "pending") {
428
+ // Raced a fresh listing; the request really is pending.
429
+ continue;
430
+ }
431
+ log.warn(
432
+ {
433
+ requestId,
434
+ surface: "home_feed",
435
+ canonicalStatus: request?.status ?? "missing",
436
+ },
437
+ "Terminal guardian request still had an actionable feed projection",
438
+ );
439
+ await writeGuardianFeedReceipt({
440
+ requestId,
441
+ status: request?.status ?? "cancelled",
442
+ ...(request ? { decidedAtMs: request.updatedAt } : {}),
443
+ });
444
+ }
445
+ }
446
+
447
+ /**
448
+ * Minimal feed item for a pending request discovered without one,
449
+ * assembled from the canonical row alone (reconciliation has no
450
+ * notification copy to draw on). `questionText` is the human-readable
451
+ * ask every producer writes; `activityText` names the action in plain
452
+ * language when the producer recorded one.
453
+ */
454
+ function buildBackfillGuardianFeedItem(request: GuardianRequestWire): FeedItem {
455
+ const summary =
456
+ request.questionText?.trim() ||
457
+ request.activityText?.trim() ||
458
+ `Guardian request ${request.requestCode ?? request.id}`;
459
+ const intent = intentFromInstructionMode(
460
+ resolveGuardianInstructionModeFromFields(
461
+ request.kind,
462
+ request.toolName ?? undefined,
463
+ )?.mode,
464
+ );
465
+ const now = new Date().toISOString();
466
+ return {
467
+ id: guardianFeedItemId(request.id),
468
+ type: "notification",
469
+ priority: 50,
470
+ summary,
471
+ timestamp: new Date(request.createdAt).toISOString(),
472
+ createdAt: now,
473
+ status: "new",
474
+ category: "security",
475
+ noteworthy: true,
476
+ urgency: "high",
477
+ detailPanel: { kind: "permissionChat" },
478
+ ...(request.sourceConversationId
479
+ ? { conversationId: request.sourceConversationId }
480
+ : {}),
481
+ guardianRequest: {
482
+ requestId: request.id,
483
+ kind: request.kind,
484
+ intent,
485
+ status: "pending",
486
+ ...(request.toolName ? { toolName: request.toolName } : {}),
487
+ ...(request.sourceChannel
488
+ ? { sourceChannel: request.sourceChannel }
489
+ : {}),
490
+ },
491
+ };
492
+ }
@@ -29,6 +29,12 @@ import { getLogger } from "../util/logger.js";
29
29
  import { normalizeTitle, stripMarkdown } from "../util/short-title.js";
30
30
  import { isConversationSeedSane } from "./conversation-seed-composer.js";
31
31
  import { deriveTitle } from "./copy-composer.js";
32
+ import {
33
+ buildPendingGuardianProjection,
34
+ guardianFeedItemId,
35
+ isGuardianRequestSignalEvent,
36
+ receiptGuardianFeedItemIfRequestTerminal,
37
+ } from "./guardian-feed-projection.js";
32
38
  import { readPayloadString } from "./notification-utils.js";
33
39
  import type { NotificationSignal } from "./signal.js";
34
40
  import type {
@@ -79,6 +85,7 @@ export async function writeHomeFeedItemForSignal(
79
85
  signal: NotificationSignal,
80
86
  decision: NotificationDecision,
81
87
  vellumDelivery?: NotificationDeliveryResult,
88
+ deliveryResults?: NotificationDeliveryResult[],
82
89
  ): Promise<FeedItem | null> {
83
90
  const { mirror, sourceConversationId, sourceScheduleJobId } =
84
91
  resolveHomeFeedMirror(signal, vellumDelivery?.conversationId);
@@ -107,7 +114,14 @@ export async function writeHomeFeedItemForSignal(
107
114
  : undefined) ||
108
115
  renderedCopy?.body?.trim() ||
109
116
  payloadBody?.trim() ||
110
- "";
117
+ // Guardian requests must never be dropped for missing copy: the
118
+ // producer's questionText (or, for access requests, the triggering
119
+ // message preview) is already the human-readable ask.
120
+ (isGuardianRequestSignalEvent(signal.sourceEventName)
121
+ ? (readPayloadString(signal.contextPayload, "questionText")?.trim() ??
122
+ readPayloadString(signal.contextPayload, "messagePreview")?.trim() ??
123
+ "")
124
+ : "");
111
125
  if (!resolvedSummary) {
112
126
  log.warn(
113
127
  { signalId: signal.signalId, sourceEventName: signal.sourceEventName },
@@ -155,8 +169,28 @@ export async function writeHomeFeedItemForSignal(
155
169
  ? { ...(baseMetadata ?? {}), scheduleId }
156
170
  : baseMetadata;
157
171
 
172
+ // A guardian request's item is keyed by the request rather than the
173
+ // signal, so the feed's replace-in-place merge keeps exactly one
174
+ // canonical "Needs attention" item per request across re-emits and
175
+ // the terminal-status receipt rewrite.
176
+ const guardianProjection = isGuardianRequestSignalEvent(
177
+ signal.sourceEventName,
178
+ )
179
+ ? buildPendingGuardianProjection(
180
+ signal.contextPayload,
181
+ deliveryResults?.find(
182
+ (r) => r.channel === "slack" && r.status === "sent",
183
+ ),
184
+ signal.sourceEventName === "ingress.access_request"
185
+ ? "access_request"
186
+ : undefined,
187
+ )
188
+ : null;
189
+
158
190
  const item: FeedItem = {
159
- id: `notif:${signal.signalId}`,
191
+ id: guardianProjection
192
+ ? guardianFeedItemId(guardianProjection.requestId)
193
+ : `notif:${signal.signalId}`,
160
194
  type: "notification",
161
195
  priority: 50,
162
196
  title: resolvedTitle,
@@ -167,6 +201,7 @@ export async function writeHomeFeedItemForSignal(
167
201
  category,
168
202
  noteworthy: deriveNoteworthy(signal),
169
203
  fromAssistant: signal.sourceChannel === "assistant_tool",
204
+ ...(guardianProjection ? { guardianRequest: guardianProjection } : {}),
170
205
  ...(urgency ? { urgency } : {}),
171
206
  ...(sourceConversationId ? { conversationId: sourceConversationId } : {}),
172
207
  ...(panelKind ? { detailPanel: { kind: panelKind } } : {}),
@@ -205,6 +240,16 @@ export async function writeHomeFeedItemForSignal(
205
240
  : item;
206
241
 
207
242
  await appendFeedItem(card);
243
+
244
+ // Converge the write-vs-resolve race: a request decided while this
245
+ // write was in flight already ran its receipt fan-out against an item
246
+ // that did not exist yet. Mirrors the delivery recorder's
247
+ // `withdrawIfRequestAlreadyTerminal`.
248
+ if (guardianProjection) {
249
+ await receiptGuardianFeedItemIfRequestTerminal(
250
+ guardianProjection.requestId,
251
+ );
252
+ }
208
253
  return card;
209
254
  }
210
255
 
@@ -418,15 +463,12 @@ function deriveDetailPanelKind(
418
463
  return "toolPermission";
419
464
  }
420
465
 
421
- if (signal.sourceEventName === "guardian.question") {
422
- const payload = signal.contextPayload;
423
- const kind =
424
- payload && typeof payload === "object" && "requestKind" in payload
425
- ? payload.requestKind
426
- : undefined;
427
- if (kind === "tool_approval" || kind === "tool_grant_request") {
428
- return "permissionChat";
429
- }
466
+ // Every guardian request opens the permission-style panel: the client
467
+ // dispatches the guardian card off the item's `guardianRequest`
468
+ // projection, and the panel kind keeps pre-projection clients (and
469
+ // bucket-fallback derivations) treating the row as an approval.
470
+ if (isGuardianRequestSignalEvent(signal.sourceEventName)) {
471
+ return "permissionChat";
430
472
  }
431
473
 
432
474
  return undefined;
@@ -479,6 +521,12 @@ function resolveHomeFeedMirror(
479
521
  if (signal.attentionHints.isAsyncBackground) {
480
522
  return { mirror: true, sourceConversationId, sourceScheduleJobId };
481
523
  }
524
+ // Guardian requests always project into the feed: the "Needs
525
+ // attention" item is the request's canonical home, and the request
526
+ // blocks on the guardian whatever kind of conversation raised it.
527
+ if (isGuardianRequestSignalEvent(signal.sourceEventName)) {
528
+ return { mirror: true, sourceConversationId, sourceScheduleJobId };
529
+ }
482
530
  if (isBackgroundConversationType(sourceRow?.conversationType)) {
483
531
  return { mirror: true, sourceConversationId, sourceScheduleJobId };
484
532
  }
@@ -358,6 +358,28 @@ describe("BYOOAuthConnection", () => {
358
358
  expect(headers.get("Content-Type")).toBe("application/json");
359
359
  });
360
360
 
361
+ test("sends a Buffer body as raw bytes under the caller's Content-Type", async () => {
362
+ await setupCredential("google");
363
+ const conn = createConnection();
364
+ const binary = Buffer.from([
365
+ 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0xff, 0xfe,
366
+ ]);
367
+
368
+ await conn.request({
369
+ method: "POST",
370
+ path: "/upload/drive/v3/files",
371
+ headers: { "Content-Type": "application/pdf" },
372
+ body: binary,
373
+ });
374
+
375
+ const [, init] = mockFetch.mock.calls[0];
376
+ const sent = (init as RequestInit).body;
377
+ expect(Buffer.isBuffer(sent)).toBe(true);
378
+ expect(Buffer.from(sent as Buffer).equals(binary)).toBe(true);
379
+ const headers = (init as RequestInit).headers as Headers;
380
+ expect(headers.get("Content-Type")).toBe("application/pdf");
381
+ });
382
+
361
383
  test("sends a string body verbatim under the caller's Content-Type", async () => {
362
384
  await setupCredential("google");
363
385
  const conn = createConnection();
@@ -14,7 +14,7 @@ import type {
14
14
  OAuthConnectionRequest,
15
15
  OAuthConnectionResponse,
16
16
  } from "./connection.js";
17
- import { decodeOAuthResponseBytes } from "./connection.js";
17
+ import { decodeOAuthResponseBytes, isBinaryOAuthBody } from "./connection.js";
18
18
 
19
19
  const log = getLogger("byo-oauth-connection");
20
20
 
@@ -83,15 +83,17 @@ export class BYOOAuthConnection implements OAuthConnection {
83
83
 
84
84
  // A string body is already in its wire form (multipart, XML,
85
85
  // form-encoded, or pre-serialized JSON) and travels verbatim under
86
- // the caller's own Content-Type. Objects and arrays are serialized
87
- // as JSON and get the JSON Content-Type by default.
86
+ // the caller's own Content-Type. A Buffer travels as raw bytes.
87
+ // Objects and arrays are serialized as JSON and get the JSON
88
+ // Content-Type by default.
88
89
  const hasBody = req.body !== undefined && req.body !== null;
90
+ const binaryBody = isBinaryOAuthBody(req.body) ? req.body : undefined;
89
91
  const rawBody = typeof req.body === "string" ? req.body : undefined;
90
92
 
91
93
  // Use the Headers API for case-insensitive merging. Set defaults
92
94
  // first so caller-supplied headers (in any casing) override them.
93
95
  const headers = new Headers();
94
- if (hasBody && rawBody === undefined) {
96
+ if (hasBody && rawBody === undefined && binaryBody === undefined) {
95
97
  headers.set("Content-Type", "application/json");
96
98
  }
97
99
  if (req.headers) {
@@ -106,7 +108,11 @@ export class BYOOAuthConnection implements OAuthConnection {
106
108
  const resp = await fetch(fullUrl, {
107
109
  method: req.method,
108
110
  headers,
109
- body: hasBody ? (rawBody ?? JSON.stringify(req.body)) : undefined,
111
+ body: hasBody
112
+ ? (binaryBody !== undefined
113
+ ? Buffer.from(binaryBody)
114
+ : (rawBody ?? JSON.stringify(req.body)))
115
+ : undefined,
110
116
  signal: req.signal
111
117
  ? AbortSignal.any([
112
118
  req.signal,