@vellumai/assistant 0.8.8 → 0.8.9-dev.202606091853.fbaa2ae

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 (475) hide show
  1. package/ARCHITECTURE.md +6 -6
  2. package/bun.lock +2 -2
  3. package/docs/activation-funnel-telemetry.md +310 -0
  4. package/examples/plugins/echo/README.md +61 -60
  5. package/examples/plugins/echo/hooks/post-tool-use.ts +18 -0
  6. package/examples/plugins/echo/hooks/stop.ts +16 -0
  7. package/examples/plugins/echo/hooks/user-prompt-submit.ts +18 -0
  8. package/examples/plugins/echo/package.json +1 -2
  9. package/examples/plugins/echo/src/emit.ts +19 -0
  10. package/node_modules/@vellumai/skill-host-contracts/src/skill-host.ts +7 -6
  11. package/openapi.yaml +263 -121
  12. package/package.json +2 -2
  13. package/src/__tests__/activation-early-marking.test.ts +120 -0
  14. package/src/__tests__/agent-loop-callsite-precedence.test.ts +69 -14
  15. package/src/__tests__/agent-loop-exit-reason.test.ts +204 -144
  16. package/src/__tests__/agent-loop-mutable-latest-user-message.test.ts +50 -35
  17. package/src/__tests__/agent-loop-output-hooks.test.ts +357 -0
  18. package/src/__tests__/agent-loop-override-profile.test.ts +25 -6
  19. package/src/__tests__/agent-loop-provider-error-recording.test.ts +41 -21
  20. package/src/__tests__/agent-loop-thinking.test.ts +36 -20
  21. package/src/__tests__/agent-loop.test.ts +441 -96
  22. package/src/__tests__/agent-wake-disk-pressure-callsite.test.ts +14 -14
  23. package/src/__tests__/agent-wake-override-profile.test.ts +17 -21
  24. package/src/__tests__/anthropic-provider.test.ts +23 -8
  25. package/src/__tests__/app-builder-tool-scripts.test.ts +21 -0
  26. package/src/__tests__/app-control-flow.test.ts +1 -1
  27. package/src/__tests__/app-dir-path-guard.test.ts +1 -0
  28. package/src/__tests__/app-executors.test.ts +132 -0
  29. package/src/__tests__/approval-cascade.test.ts +6 -5
  30. package/src/__tests__/approval-routes-http.test.ts +4 -1
  31. package/src/__tests__/background-workers-disk-pressure.test.ts +1 -1
  32. package/src/__tests__/channel-approval-routes.test.ts +1 -1
  33. package/src/__tests__/channel-approvals.test.ts +1 -1
  34. package/src/__tests__/compaction-circuit.test.ts +258 -0
  35. package/src/__tests__/compaction-direct.test.ts +146 -0
  36. package/src/__tests__/compaction-events.test.ts +7 -7
  37. package/src/__tests__/compaction.benchmark.test.ts +1 -1
  38. package/src/__tests__/compactor-web-search-strip.test.ts +213 -0
  39. package/src/__tests__/context-overflow-reducer.test.ts +265 -125
  40. package/src/__tests__/context-window-manager-compact-retry.test.ts +121 -15
  41. package/src/__tests__/conversation-abort-tool-results.test.ts +7 -5
  42. package/src/__tests__/conversation-agent-loop-disk-pressure.test.ts +7 -10
  43. package/src/__tests__/conversation-agent-loop-inference-profile.test.ts +78 -119
  44. package/src/__tests__/conversation-agent-loop-overflow.test.ts +158 -234
  45. package/src/__tests__/conversation-agent-loop.test.ts +342 -623
  46. package/src/__tests__/conversation-clean-command.test.ts +5 -2
  47. package/src/__tests__/conversation-confirmation-signals.test.ts +6 -5
  48. package/src/__tests__/conversation-crud-inference-profile.test.ts +7 -9
  49. package/src/__tests__/conversation-error.test.ts +15 -1
  50. package/src/__tests__/conversation-history-web-search.test.ts +6 -1
  51. package/src/__tests__/conversation-media-retry.test.ts +1 -1
  52. package/src/__tests__/conversation-process-app-control-preactivation.test.ts +44 -4
  53. package/src/__tests__/conversation-process-callsite.test.ts +15 -15
  54. package/src/__tests__/conversation-provider-retry-repair.test.ts +66 -65
  55. package/src/__tests__/conversation-queue.test.ts +10 -10
  56. package/src/__tests__/conversation-runtime-assembly.test.ts +1059 -231
  57. package/src/__tests__/conversation-runtime-workspace.test.ts +115 -20
  58. package/src/__tests__/conversation-slash-queue.test.ts +7 -5
  59. package/src/__tests__/conversation-slash-unknown.test.ts +7 -5
  60. package/src/__tests__/conversation-speed-override.test.ts +11 -10
  61. package/src/__tests__/conversation-starter-routes.test.ts +14 -6
  62. package/src/__tests__/conversation-surfaces-activation-emit.test.ts +395 -0
  63. package/src/__tests__/conversation-surfaces-app-control.test.ts +48 -1
  64. package/src/__tests__/conversation-tool-setup-app-refresh.test.ts +73 -4
  65. package/src/__tests__/conversation-undo.test.ts +2 -2
  66. package/src/__tests__/conversation-workspace-cache-state.test.ts +24 -21
  67. package/src/__tests__/conversation-workspace-injection.test.ts +69 -7
  68. package/src/__tests__/conversation-workspace-tool-tracking.test.ts +15 -12
  69. package/src/__tests__/conversations-import-system-filter.test.ts +101 -0
  70. package/src/__tests__/corrected-target.test.ts +93 -0
  71. package/src/__tests__/credential-security-invariants.test.ts +1 -0
  72. package/src/__tests__/cu-unified-flow.test.ts +36 -0
  73. package/src/__tests__/db-acp-history.test.ts +101 -0
  74. package/src/__tests__/dynamic-page-surface.test.ts +31 -0
  75. package/src/__tests__/empty-response-hook.test.ts +1 -1
  76. package/src/__tests__/file-write-tool.test.ts +63 -0
  77. package/src/__tests__/gateway-only-guard.test.ts +17 -2
  78. package/src/__tests__/guardian-grant-minting.test.ts +1 -1
  79. package/src/__tests__/guardian-routing-invariants.test.ts +93 -4
  80. package/src/__tests__/handlers-user-message-approval-consumption.test.ts +1 -1
  81. package/src/__tests__/heartbeat-disk-pressure.test.ts +1 -0
  82. package/src/__tests__/heartbeat-service.test.ts +1 -0
  83. package/src/__tests__/history-repair-hook.test.ts +5 -1
  84. package/src/__tests__/host-app-control-proxy.test.ts +45 -0
  85. package/src/__tests__/host-app-control-routes.test.ts +1 -1
  86. package/src/__tests__/host-cu-routes-targeted.test.ts +3 -3
  87. package/src/__tests__/inference-profile-reaper.test.ts +62 -0
  88. package/src/__tests__/inference-profile-session-handler.test.ts +86 -0
  89. package/src/__tests__/injector-background-turn.test.ts +13 -23
  90. package/src/__tests__/injector-chain.test.ts +268 -44
  91. package/src/__tests__/injector-disk-pressure.test.ts +210 -52
  92. package/src/__tests__/injector-document-comments.test.ts +97 -114
  93. package/src/__tests__/injector-pkb-v2-silenced.test.ts +2 -2
  94. package/src/__tests__/injector-v3-suppression.test.ts +4 -4
  95. package/src/__tests__/list-messages-client-message-id.test.ts +91 -0
  96. package/src/__tests__/list-messages-hidden-metadata.test.ts +38 -0
  97. package/src/__tests__/llm-resolver.test.ts +73 -0
  98. package/src/__tests__/memory-retrieval-hook.test.ts +156 -38
  99. package/src/__tests__/memory-v2-static-injector.test.ts +86 -8
  100. package/src/__tests__/notification-decision-strategy.test.ts +3 -3
  101. package/src/__tests__/parallel-tool.benchmark.test.ts +35 -8
  102. package/src/__tests__/persist-unsendable-image-downscale.test.ts +145 -0
  103. package/src/__tests__/persist-unsendable-image.test.ts +97 -1
  104. package/src/__tests__/plugin-api-shim.test.ts +6 -9
  105. package/src/__tests__/plugin-bootstrap.test.ts +91 -81
  106. package/src/__tests__/plugin-registry.test.ts +3 -49
  107. package/src/__tests__/plugin-skill-contribution.test.ts +15 -14
  108. package/src/__tests__/plugin-tool-contribution.test.ts +7 -4
  109. package/src/__tests__/plugin-types.test.ts +0 -70
  110. package/src/__tests__/post-turn-tool-result-truncation.test.ts +69 -0
  111. package/src/__tests__/pre-model-call-sanitize.test.ts +109 -0
  112. package/src/__tests__/published-app-updater.test.ts +138 -0
  113. package/src/__tests__/reaction-persistence.test.ts +1 -1
  114. package/src/__tests__/send-endpoint-busy.test.ts +4 -1
  115. package/src/__tests__/skill-feature-flags-integration.test.ts +31 -0
  116. package/src/__tests__/steer-tool-repair.test.ts +1 -1
  117. package/src/__tests__/subagent-call-site-routing.test.ts +1 -1
  118. package/src/__tests__/subagent-detail.test.ts +25 -7
  119. package/src/__tests__/subagent-fork-notifications.test.ts +1 -3
  120. package/src/__tests__/subagent-fork-spawn.test.ts +1 -1
  121. package/src/__tests__/subagent-manager-notify.test.ts +1 -3
  122. package/src/__tests__/subagent-notify-parent.test.ts +1 -3
  123. package/src/__tests__/subagent-spawn-tool-fork.test.ts +1 -1
  124. package/src/__tests__/title-generate-hook.test.ts +5 -1
  125. package/src/__tests__/tool-error-hook.test.ts +1 -1
  126. package/src/__tests__/tool-result-truncate-hook.test.ts +1 -1
  127. package/src/__tests__/user-plugin-loader.test.ts +54 -286
  128. package/src/__tests__/web-fetch.test.ts +45 -0
  129. package/src/acp/__tests__/agent-process.test.ts +161 -0
  130. package/src/acp/__tests__/client-handler.test.ts +40 -0
  131. package/src/acp/__tests__/helpers/acp-config-stub.ts +0 -2
  132. package/src/acp/__tests__/helpers/acp-history-db.ts +82 -0
  133. package/src/acp/__tests__/helpers/exec-file-stub.ts +106 -0
  134. package/src/acp/__tests__/prepare-agent-env.test.ts +97 -0
  135. package/src/acp/__tests__/session-manager-persistence.test.ts +95 -28
  136. package/src/acp/__tests__/session-manager-resume.test.ts +888 -0
  137. package/src/acp/agent-process.ts +61 -1
  138. package/src/acp/auto-install.test.ts +280 -0
  139. package/src/acp/auto-install.ts +232 -0
  140. package/src/acp/client-handler.ts +31 -0
  141. package/src/acp/prepare-agent-env.ts +80 -27
  142. package/src/acp/resolve-agent.test.ts +188 -28
  143. package/src/acp/resolve-agent.ts +119 -42
  144. package/src/acp/resume-hint.ts +23 -0
  145. package/src/acp/session-manager.ts +507 -73
  146. package/src/agent/compaction-circuit.ts +60 -102
  147. package/src/agent/loop.ts +403 -251
  148. package/src/api/responses/conversation-message.ts +22 -2
  149. package/src/api/responses/memory-v3-selection-log.ts +19 -10
  150. package/src/approvals/guardian-request-resolvers.ts +27 -1
  151. package/src/background-wake/next-wake.ts +1 -0
  152. package/src/cli/commands/__tests__/memory-v3.test.ts +191 -210
  153. package/src/cli/commands/channel-verification-sessions.ts +6 -6
  154. package/src/cli/commands/db/__tests__/repair.test.ts +3 -1
  155. package/src/cli/commands/memory-v3.ts +57 -199
  156. package/src/cli/commands/plugins.ts +43 -37
  157. package/src/cli/lib/__tests__/install-from-github.test.ts +790 -111
  158. package/src/cli/lib/__tests__/plugin-catalog-cache.test.ts +196 -0
  159. package/src/cli/lib/__tests__/plugin-details.test.ts +381 -0
  160. package/src/cli/lib/__tests__/plugin-marketplace.test.ts +270 -0
  161. package/src/cli/lib/__tests__/search-plugins.test.ts +233 -32
  162. package/src/cli/lib/install-from-github.ts +797 -60
  163. package/src/cli/lib/plugin-catalog-cache.ts +84 -0
  164. package/src/cli/lib/plugin-details.ts +416 -0
  165. package/src/cli/lib/plugin-marketplace.ts +215 -0
  166. package/src/cli/lib/search-plugins.ts +201 -29
  167. package/src/config/__tests__/feature-flag-registry-guard.test.ts +2 -2
  168. package/src/config/acp-defaults.test.ts +10 -0
  169. package/src/config/acp-defaults.ts +9 -3
  170. package/src/config/acp-schema.ts +1 -7
  171. package/src/config/bundled-skills/acp/SKILL.md +80 -41
  172. package/src/config/bundled-skills/acp/TOOLS.json +4 -4
  173. package/src/config/bundled-skills/app-builder/SKILL.md +224 -381
  174. package/src/config/bundled-skills/app-builder/TOOLS.json +72 -2
  175. package/src/config/bundled-skills/app-builder/references/DESIGN_SYSTEM.md +48 -0
  176. package/src/config/bundled-skills/app-builder/references/RESPONSIVE.md +57 -0
  177. package/src/config/bundled-skills/app-builder/references/SLIDES.md +38 -0
  178. package/src/config/bundled-skills/app-builder/tools/app-list.ts +62 -0
  179. package/src/config/bundled-skills/app-builder/tools/app-update.ts +18 -0
  180. package/src/config/bundled-skills/document-editor/SKILL.md +28 -23
  181. package/src/config/bundled-skills/document-editor/TOOLS.json +1 -1
  182. package/src/config/bundled-tool-registry.ts +4 -0
  183. package/src/config/call-site-defaults.ts +0 -3
  184. package/src/config/feature-flag-registry.json +10 -16
  185. package/src/config/llm-resolver.ts +39 -7
  186. package/src/config/schemas/__tests__/memory-v3.test.ts +25 -9
  187. package/src/config/schemas/call-site-catalog.ts +0 -21
  188. package/src/config/schemas/heartbeat.ts +9 -0
  189. package/src/config/schemas/llm.ts +17 -3
  190. package/src/config/schemas/memory-v3.ts +58 -8
  191. package/src/config/seed-inference-profiles.ts +18 -0
  192. package/src/context/compactor.ts +22 -4
  193. package/src/context/post-turn-tool-result-truncation.ts +39 -1
  194. package/src/context/strip-injections.ts +8 -2
  195. package/src/daemon/conversation-agent-loop-handlers.ts +18 -36
  196. package/src/daemon/conversation-agent-loop.ts +308 -1297
  197. package/src/daemon/conversation-error.ts +31 -4
  198. package/src/daemon/conversation-history.ts +1 -1
  199. package/src/daemon/conversation-lifecycle.ts +11 -255
  200. package/src/daemon/conversation-media-retry.ts +19 -6
  201. package/src/daemon/conversation-messaging.ts +17 -0
  202. package/src/daemon/conversation-process.ts +22 -141
  203. package/src/daemon/conversation-queue-manager.ts +8 -0
  204. package/src/daemon/conversation-registry.ts +159 -0
  205. package/src/daemon/conversation-runtime-assembly.ts +361 -393
  206. package/src/daemon/conversation-store.ts +9 -90
  207. package/src/daemon/conversation-surfaces.ts +165 -11
  208. package/src/daemon/conversation-workspace.ts +17 -0
  209. package/src/daemon/conversation.ts +444 -62
  210. package/src/daemon/disk-pressure-policy.ts +0 -1
  211. package/src/daemon/external-plugins-bootstrap.ts +36 -46
  212. package/src/daemon/handlers/config-channels.ts +11 -2
  213. package/src/daemon/handlers/conversations.ts +3 -1
  214. package/src/daemon/handlers/skills.ts +4 -1
  215. package/src/daemon/host-app-control-proxy.ts +72 -57
  216. package/src/daemon/host-proxy-preactivation.ts +1 -3
  217. package/src/daemon/lifecycle.ts +21 -7
  218. package/src/daemon/persist-unsendable-image.ts +62 -25
  219. package/src/daemon/process-message.ts +1 -1
  220. package/src/daemon/server.ts +2 -0
  221. package/src/daemon/tool-side-effects.ts +15 -0
  222. package/src/daemon/wake-conversation-ops.ts +269 -0
  223. package/src/embedded/plugin-api.ts +2 -2
  224. package/src/export/__tests__/transcript-formatter.test.ts +5 -0
  225. package/src/heartbeat/__tests__/heartbeat-service.test.ts +3 -0
  226. package/src/heartbeat/heartbeat-run-store.ts +23 -1
  227. package/src/heartbeat/heartbeat-service.ts +26 -0
  228. package/src/ipc/__tests__/browser-ipc.test.ts +1 -1
  229. package/src/ipc/__tests__/ui-request-route.test.ts +3 -3
  230. package/src/ipc/skill-routes/__tests__/memory.test.ts +15 -0
  231. package/src/ipc/skill-routes/memory.ts +4 -2
  232. package/src/memory/__tests__/activation-session-store.test.ts +41 -0
  233. package/src/memory/__tests__/jobs-worker-v2-schedule.test.ts +87 -0
  234. package/src/memory/__tests__/onboarding-events-store.test.ts +80 -0
  235. package/src/memory/activation-session-store.ts +43 -0
  236. package/src/memory/conversation-crud.ts +29 -19
  237. package/src/memory/conversation-starter-checkpoints.ts +1 -0
  238. package/src/memory/db-init.ts +6 -0
  239. package/src/memory/job-handlers/conversation-starters.ts +13 -2
  240. package/src/memory/jobs/__tests__/embed-concept-page.test.ts +5 -4
  241. package/src/memory/jobs-worker.ts +25 -1
  242. package/src/memory/migrations/272-acp-session-history-cwd.ts +36 -0
  243. package/src/memory/migrations/273-onboarding-events-funnel-columns.ts +46 -0
  244. package/src/memory/migrations/274-create-activation-sessions.ts +15 -0
  245. package/src/memory/migrations/index.ts +3 -0
  246. package/src/memory/onboarding-events-store.ts +66 -18
  247. package/src/memory/schema/acp.ts +4 -0
  248. package/src/memory/schema/infrastructure.ts +13 -0
  249. package/src/memory/v2/__tests__/consolidation-job.test.ts +4 -97
  250. package/src/memory/v2/__tests__/page-store.test.ts +22 -0
  251. package/src/memory/v2/consolidation-job.ts +2 -63
  252. package/src/memory/v2/types.ts +5 -0
  253. package/src/messaging/providers/telegram-bot/api.ts +14 -5
  254. package/src/notifications/__tests__/copy-composer.test.ts +244 -0
  255. package/src/notifications/access-request-copy.ts +298 -0
  256. package/src/notifications/adapters/slack.ts +3 -3
  257. package/src/notifications/adapters/telegram.ts +9 -2
  258. package/src/notifications/copy-composer.ts +49 -267
  259. package/src/notifications/decision-engine.ts +16 -35
  260. package/src/notifications/home-feed-side-effect.ts +1 -6
  261. package/src/plugin-api/constants.ts +4 -0
  262. package/src/plugin-api/index.ts +40 -11
  263. package/src/plugin-api/types.ts +108 -0
  264. package/src/plugins/defaults/compaction/compact.ts +106 -0
  265. package/src/{daemon → plugins/defaults/compaction}/context-overflow-reducer.ts +250 -42
  266. package/src/plugins/defaults/compaction/corrected-target.ts +53 -0
  267. package/src/plugins/defaults/compaction/manager-store.ts +57 -0
  268. package/src/plugins/defaults/compaction/package.json +1 -2
  269. package/src/{context → plugins/defaults/compaction}/window-manager.ts +95 -25
  270. package/src/plugins/defaults/empty-response/package.json +0 -1
  271. package/src/plugins/defaults/history-repair/package.json +0 -1
  272. package/src/plugins/defaults/index.ts +156 -26
  273. package/src/plugins/defaults/memory-retrieval/hooks/post-compact.ts +100 -52
  274. package/src/plugins/defaults/memory-retrieval/hooks/user-prompt-submit.ts +285 -0
  275. package/src/plugins/defaults/memory-retrieval/injector-chain.ts +2 -2
  276. package/src/plugins/defaults/{injectors/register.ts → memory-retrieval/injectors.ts} +148 -73
  277. package/src/plugins/defaults/memory-retrieval/package.json +14 -0
  278. package/src/plugins/defaults/memory-retrieval/unified-turn-context.ts +223 -0
  279. package/src/plugins/defaults/memory-v3-shadow/__tests__/capabilities.test.ts +71 -0
  280. package/src/plugins/defaults/memory-v3-shadow/__tests__/dense.test.ts +181 -0
  281. package/src/plugins/defaults/memory-v3-shadow/__tests__/edge.test.ts +247 -0
  282. package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/__tests__/live-integration.test.ts +147 -134
  283. package/src/plugins/defaults/memory-v3-shadow/__tests__/maintain-job.test.ts +456 -0
  284. package/src/plugins/defaults/memory-v3-shadow/__tests__/orchestrate.test.ts +662 -0
  285. package/src/plugins/defaults/memory-v3-shadow/__tests__/pool-select.test.ts +306 -0
  286. package/src/plugins/defaults/memory-v3-shadow/__tests__/render-injection.test.ts +91 -0
  287. package/src/plugins/defaults/memory-v3-shadow/__tests__/section-dense-store.test.ts +402 -0
  288. package/src/plugins/defaults/memory-v3-shadow/__tests__/section-needle.test.ts +135 -0
  289. package/src/plugins/defaults/memory-v3-shadow/__tests__/sections.test.ts +125 -0
  290. package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/__tests__/selection-log-store.test.ts +74 -19
  291. package/src/plugins/defaults/memory-v3-shadow/__tests__/shadow-integration.test.ts +446 -0
  292. package/src/plugins/defaults/memory-v3-shadow/__tests__/shadow-plugin.test.ts +592 -0
  293. package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/__tests__/types.test.ts +2 -24
  294. package/src/plugins/defaults/memory-v3-shadow/capabilities.ts +91 -0
  295. package/src/plugins/defaults/memory-v3-shadow/dense.ts +97 -0
  296. package/src/plugins/defaults/memory-v3-shadow/edge.ts +252 -0
  297. package/src/plugins/defaults/memory-v3-shadow/hooks/post-compact.ts +14 -0
  298. package/src/plugins/defaults/memory-v3-shadow/hooks/user-prompt-submit.ts +19 -0
  299. package/src/plugins/defaults/memory-v3-shadow/injector.ts +76 -0
  300. package/src/plugins/defaults/memory-v3-shadow/maintain-job.ts +555 -0
  301. package/src/plugins/defaults/memory-v3-shadow/orchestrate.ts +266 -0
  302. package/src/plugins/defaults/memory-v3-shadow/package.json +14 -0
  303. package/src/plugins/defaults/memory-v3-shadow/page-content.ts +71 -0
  304. package/src/plugins/defaults/memory-v3-shadow/pool-select.ts +204 -0
  305. package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/render-injection.ts +16 -8
  306. package/src/plugins/defaults/memory-v3-shadow/section-dense-store.ts +236 -0
  307. package/src/plugins/defaults/memory-v3-shadow/section-needle.ts +200 -0
  308. package/src/plugins/defaults/memory-v3-shadow/sections.ts +115 -0
  309. package/src/plugins/defaults/memory-v3-shadow/selection-log-store.ts +156 -0
  310. package/src/plugins/defaults/memory-v3-shadow/shadow-plugin.ts +382 -0
  311. package/src/plugins/defaults/memory-v3-shadow/types.ts +92 -0
  312. package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/working-set.ts +4 -1
  313. package/src/plugins/defaults/title-generate/package.json +0 -1
  314. package/src/plugins/defaults/tool-error/package.json +0 -1
  315. package/src/plugins/defaults/tool-result-truncate/package.json +0 -1
  316. package/src/plugins/external-api.ts +12 -2
  317. package/src/plugins/pipeline.ts +6 -293
  318. package/src/plugins/registry.ts +9 -37
  319. package/src/plugins/types.ts +76 -381
  320. package/src/plugins/user-loader.ts +30 -127
  321. package/src/prompts/__tests__/system-prompt.test.ts +6 -0
  322. package/src/prompts/system-prompt.ts +61 -10
  323. package/src/prompts/templates/BOOTSTRAP-ACTIVATION-RAIL.md +71 -4
  324. package/src/providers/anthropic/client.ts +9 -10
  325. package/src/providers/inference/kimi-cjk-token-ids.ts +493 -0
  326. package/src/providers/inference/logit-bias.ts +55 -0
  327. package/src/providers/model-catalog.ts +34 -0
  328. package/src/providers/openai/__tests__/vision-not-supported.test.ts +75 -0
  329. package/src/providers/openai/chat-completions-provider.ts +44 -1
  330. package/src/providers/retry.ts +22 -0
  331. package/src/providers/types.ts +6 -0
  332. package/src/runtime/__tests__/agent-wake.test.ts +555 -691
  333. package/src/runtime/__tests__/interactive-ui.test.ts +1 -1
  334. package/src/runtime/agent-wake.ts +108 -209
  335. package/src/runtime/assistant-event-hub.ts +1 -1
  336. package/src/runtime/channel-approvals.ts +1 -1
  337. package/src/runtime/interactive-ui.ts +1 -1
  338. package/src/runtime/routes/__tests__/acp-routes.test.ts +466 -55
  339. package/src/runtime/routes/__tests__/conversation-list-routes.test.ts +1 -1
  340. package/src/runtime/routes/__tests__/plugins-routes.test.ts +423 -73
  341. package/src/runtime/routes/__tests__/stt-routes.test.ts +112 -0
  342. package/src/runtime/routes/__tests__/surface-action-routes.test.ts +5 -4
  343. package/src/runtime/routes/__tests__/surface-content-routes.test.ts +4 -1
  344. package/src/runtime/routes/acp-routes.test.ts +185 -32
  345. package/src/runtime/routes/acp-routes.ts +328 -30
  346. package/src/runtime/routes/app-management-routes.ts +3 -0
  347. package/src/runtime/routes/approval-routes.ts +1 -1
  348. package/src/runtime/routes/browser-routes.ts +1 -1
  349. package/src/runtime/routes/browser-tabs-routes.ts +6 -10
  350. package/src/runtime/routes/channel-verification-routes.ts +14 -5
  351. package/src/runtime/routes/conversation-cli-routes.ts +1 -1
  352. package/src/runtime/routes/conversation-list-routes.ts +1 -1
  353. package/src/runtime/routes/conversation-query-routes.ts +1 -1
  354. package/src/runtime/routes/conversation-routes.ts +30 -2
  355. package/src/runtime/routes/conversation-starter-routes.ts +13 -7
  356. package/src/runtime/routes/conversations-import-routes.ts +24 -7
  357. package/src/runtime/routes/host-app-control-routes.ts +1 -1
  358. package/src/runtime/routes/host-cu-routes.ts +1 -1
  359. package/src/runtime/routes/identity-routes.ts +18 -3
  360. package/src/runtime/routes/inbound-message-handler.ts +1 -1
  361. package/src/runtime/routes/inbound-stages/acl-enforcement.ts +33 -34
  362. package/src/runtime/routes/inference-profile-session-handler.ts +11 -0
  363. package/src/runtime/routes/inference-profile-session-reaper.ts +6 -0
  364. package/src/runtime/routes/memory-v3-routes.ts +65 -305
  365. package/src/runtime/routes/playground/__tests__/force-compact.test.ts +1 -1
  366. package/src/runtime/routes/playground/helpers.ts +1 -1
  367. package/src/runtime/routes/plugins-routes.ts +337 -35
  368. package/src/runtime/routes/stt-routes.ts +45 -12
  369. package/src/runtime/routes/surface-conversation-resolver.ts +4 -3
  370. package/src/runtime/routes/work-items-routes.ts +2 -4
  371. package/src/runtime/routes/workspace-routes.ts +50 -15
  372. package/src/runtime/services/conversation-serializer.ts +1 -1
  373. package/src/runtime/verification-outbound-actions.ts +147 -2
  374. package/src/runtime/verification-templates.ts +29 -3
  375. package/src/services/published-app-updater.ts +30 -8
  376. package/src/signals/cancel.ts +2 -4
  377. package/src/subagent/manager.ts +21 -5
  378. package/src/telemetry/__tests__/activation-funnel.test.ts +95 -0
  379. package/src/telemetry/activation-funnel.ts +167 -0
  380. package/src/telemetry/types.ts +13 -0
  381. package/src/telemetry/usage-telemetry-reporter.test.ts +154 -0
  382. package/src/telemetry/usage-telemetry-reporter.ts +26 -1
  383. package/src/tools/acp/context.ts +20 -0
  384. package/src/tools/acp/list-agents.test.ts +9 -19
  385. package/src/tools/acp/list-agents.ts +3 -15
  386. package/src/tools/acp/spawn.test.ts +173 -202
  387. package/src/tools/acp/spawn.ts +37 -172
  388. package/src/tools/acp/steer.test.ts +105 -8
  389. package/src/tools/acp/steer.ts +48 -17
  390. package/src/tools/apps/executors.ts +166 -50
  391. package/src/tools/browser/browser-execution.ts +12 -2
  392. package/src/tools/filesystem/write.ts +34 -0
  393. package/src/tools/network/web-fetch.ts +65 -24
  394. package/src/tools/skills/load.ts +1 -1
  395. package/src/tools/subagent/spawn.ts +2 -4
  396. package/src/tools/ui-surface/definitions.ts +32 -5
  397. package/src/workspace/migrations/051-seed-conversation-summarization-callsite.ts +4 -5
  398. package/src/workspace/migrations/097-enable-adaptive-thinking-managed-profiles.ts +69 -45
  399. package/docs/plugins.md +0 -832
  400. package/examples/plugins/echo/register.ts +0 -143
  401. package/src/__tests__/circuit-breaker-pipeline.test.ts +0 -405
  402. package/src/__tests__/compaction-pipeline.test.ts +0 -210
  403. package/src/__tests__/compaction-timeout-recovery.test.ts +0 -251
  404. package/src/__tests__/overflow-reduce-pipeline.test.ts +0 -667
  405. package/src/__tests__/pipeline-runner.test.ts +0 -554
  406. package/src/daemon/wake-target-adapter.ts +0 -253
  407. package/src/memory/v3/__tests__/assign.test.ts +0 -242
  408. package/src/memory/v3/__tests__/capabilities.test.ts +0 -118
  409. package/src/memory/v3/__tests__/core.test.ts +0 -39
  410. package/src/memory/v3/__tests__/health.test.ts +0 -219
  411. package/src/memory/v3/__tests__/maintain-job.test.ts +0 -288
  412. package/src/memory/v3/__tests__/needle.test.ts +0 -107
  413. package/src/memory/v3/__tests__/orchestrate.test.ts +0 -436
  414. package/src/memory/v3/__tests__/provider-blocks.test.ts +0 -13
  415. package/src/memory/v3/__tests__/reconcile.test.ts +0 -274
  416. package/src/memory/v3/__tests__/render-injection.test.ts +0 -61
  417. package/src/memory/v3/__tests__/router.test.ts +0 -332
  418. package/src/memory/v3/__tests__/selector.test.ts +0 -470
  419. package/src/memory/v3/__tests__/shadow-plugin.test.ts +0 -432
  420. package/src/memory/v3/__tests__/snapshot.test.ts +0 -168
  421. package/src/memory/v3/__tests__/tree.test.ts +0 -192
  422. package/src/memory/v3/assign.ts +0 -268
  423. package/src/memory/v3/capabilities.ts +0 -124
  424. package/src/memory/v3/core.ts +0 -26
  425. package/src/memory/v3/health.ts +0 -0
  426. package/src/memory/v3/maintain-job.ts +0 -314
  427. package/src/memory/v3/needle.ts +0 -115
  428. package/src/memory/v3/orchestrate.ts +0 -126
  429. package/src/memory/v3/page-content.ts +0 -34
  430. package/src/memory/v3/provider-blocks.ts +0 -26
  431. package/src/memory/v3/reconcile.ts +0 -523
  432. package/src/memory/v3/router.ts +0 -190
  433. package/src/memory/v3/selection-log-store.ts +0 -84
  434. package/src/memory/v3/selector.ts +0 -226
  435. package/src/memory/v3/shadow-plugin.ts +0 -411
  436. package/src/memory/v3/snapshot.ts +0 -209
  437. package/src/memory/v3/tree.ts +0 -174
  438. package/src/memory/v3/types.ts +0 -59
  439. package/src/notifications/__tests__/emit-signal-home-feed.test.ts +0 -187
  440. package/src/plugins/defaults/circuit-breaker/middlewares/circuitBreaker.ts +0 -93
  441. package/src/plugins/defaults/circuit-breaker/package.json +0 -15
  442. package/src/plugins/defaults/circuit-breaker/register.ts +0 -39
  443. package/src/plugins/defaults/compaction/middlewares/compaction.ts +0 -25
  444. package/src/plugins/defaults/compaction/register.ts +0 -35
  445. package/src/plugins/defaults/compaction/terminal.ts +0 -73
  446. package/src/plugins/defaults/empty-response/register.ts +0 -23
  447. package/src/plugins/defaults/history-repair/register.ts +0 -24
  448. package/src/plugins/defaults/memory-retrieval/hooks/user-prompt-submit-temp.ts +0 -216
  449. package/src/plugins/defaults/overflow-reduce/middlewares/overflowReduce.ts +0 -126
  450. package/src/plugins/defaults/overflow-reduce/package.json +0 -15
  451. package/src/plugins/defaults/overflow-reduce/register.ts +0 -42
  452. package/src/plugins/defaults/title-generate/register.ts +0 -35
  453. package/src/plugins/defaults/tool-error/register.ts +0 -23
  454. package/src/plugins/defaults/tool-result-truncate/register.ts +0 -24
  455. package/src/proactive-artifact/aux-message-injector.ts +0 -97
  456. package/src/proactive-artifact/decision.test.ts +0 -226
  457. package/src/proactive-artifact/decision.ts +0 -165
  458. package/src/proactive-artifact/index.ts +0 -7
  459. package/src/proactive-artifact/job.test.ts +0 -962
  460. package/src/proactive-artifact/job.ts +0 -372
  461. package/src/proactive-artifact/message-copy.ts +0 -58
  462. package/src/proactive-artifact/trigger-state.test.ts +0 -286
  463. package/src/proactive-artifact/trigger-state.ts +0 -123
  464. package/src/util/map-limit.ts +0 -27
  465. /package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/__tests__/fixtures/eval-turns.json +0 -0
  466. /package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/__tests__/fixtures/live-turns.json +0 -0
  467. /package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/__tests__/working-set-eviction.test.ts +0 -0
  468. /package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/__tests__/working-set-skeleton.test.ts +0 -0
  469. /package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/data/README.md +0 -0
  470. /package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/data/assignments.json +0 -0
  471. /package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/data/core.json +0 -0
  472. /package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/data/leaves/domain-a/topic-x.md +0 -0
  473. /package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/data/leaves/domain-a/topic-y.md +0 -0
  474. /package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/data/leaves/domain-b/topic-z.md +0 -0
  475. /package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/llm-retry.ts +0 -0
@@ -1,11 +1,24 @@
1
1
  /**
2
- * Install an external plugin by name from the canonical GitHub source.
2
+ * Install a plugin by name from the canonical GitHub source.
3
3
  *
4
- * The plugin source convention is fixed at
5
- * `vellum-ai/vellum-assistant/experimental/plugins/<name>/` on the configured
6
- * git ref. The {@link installPlugin} entry point fetches the directory tree
7
- * via the GitHub Contents API and materializes it into
8
- * `<workspacePluginsDir>/<name>/` so the daemon discovers it on next start.
4
+ * A name resolves to one of two sources, materialized into
5
+ * `<workspacePluginsDir>/<name>/` so the daemon discovers it on next start:
6
+ * 1. A whitelisted external ecosystem plugin, when the name matches an entry
7
+ * in the curated `experimental/plugins/marketplace.json` manifest. The
8
+ * pinned `owner/repo[/path]@ref` (see {@link ./plugin-marketplace}) is
9
+ * fetched with a shallow `git` clone at that ref — one network operation
10
+ * regardless of repo size, immune to GitHub's unauthenticated API
11
+ * rate-limit, and recording the exact resolved commit for provenance.
12
+ * When we curate an adapter stub for the plugin (an
13
+ * `experimental/plugins/<name>/` directory in this repo with a
14
+ * `scripts.postinstall` command), the stub is overlaid onto the clone and
15
+ * its postinstall runs to translate a foreign-ecosystem layout into the
16
+ * shape Vellum's loader runs (see {@link applyAdapterStub}).
17
+ * 2. Otherwise the first-party convention
18
+ * `vellum-ai/vellum-assistant/experimental/plugins/<name>/` at the
19
+ * configured ref, fetched via the GitHub Contents API (a small handful of
20
+ * in-repo files — cloning the whole monorepo to install one would be
21
+ * wasteful).
9
22
  *
10
23
  * Designed for direct programmatic use. The CLI command
11
24
  * `assistant plugins install <name>` is a thin wrapper that supplies
@@ -15,10 +28,29 @@
15
28
  * a test fixture) and an override workspace directory.
16
29
  */
17
30
 
18
- import { existsSync, mkdirSync, renameSync, rmSync, writeFileSync } from "node:fs";
19
- import { dirname, join } from "node:path";
31
+ import { execFile } from "node:child_process";
32
+ import {
33
+ copyFileSync,
34
+ existsSync,
35
+ mkdirSync,
36
+ readdirSync,
37
+ readFileSync,
38
+ renameSync,
39
+ rmSync,
40
+ statSync,
41
+ writeFileSync,
42
+ } from "node:fs";
43
+ import { dirname, join, resolve, sep } from "node:path";
44
+ import { promisify } from "node:util";
20
45
 
46
+ import { ensureBun } from "../../util/bun-runtime.js";
21
47
  import { getWorkspacePluginsDir } from "../../util/platform.js";
48
+ import {
49
+ fetchMarketplaceEntries,
50
+ resolveMarketplaceSource,
51
+ } from "./plugin-marketplace.js";
52
+
53
+ const execFileAsync = promisify(execFile);
22
54
 
23
55
  const PLUGIN_SOURCE_OWNER = "vellum-ai";
24
56
  const PLUGIN_SOURCE_REPO = "vellum-assistant";
@@ -47,6 +79,28 @@ export type FetchLike = (
47
79
  init?: RequestInit,
48
80
  ) => Promise<Response>;
49
81
 
82
+ /**
83
+ * Runs a `git` subcommand in `cwd` and resolves its stdout. Injected so tests
84
+ * can simulate a clone without spawning a real git process; production callers
85
+ * fall back to {@link defaultGitRunner}.
86
+ */
87
+ export type GitRunner = (
88
+ args: readonly string[],
89
+ opts: { readonly cwd: string },
90
+ ) => Promise<{ readonly stdout: string }>;
91
+
92
+ /**
93
+ * Runs a plugin's postinstall adapter script in `cwd`. Injected so tests can
94
+ * assert the adapter is invoked (and simulate its effects) without spawning a
95
+ * real subprocess; production callers fall back to {@link defaultPostinstallRunner}.
96
+ */
97
+ export type PostinstallRunner = (opts: {
98
+ /** The staged install directory the adapter transforms in place. */
99
+ readonly cwd: string;
100
+ /** Absolute path to the adapter script to execute. */
101
+ readonly script: string;
102
+ }) => Promise<void>;
103
+
50
104
  /** Options that control which plugin to install and how. */
51
105
  export interface InstallPluginOptions {
52
106
  readonly name: string;
@@ -63,6 +117,10 @@ export interface InstallPluginDeps {
63
117
  readonly fetch: FetchLike;
64
118
  /** Override the workspace plugins directory. Falls back to {@link getWorkspacePluginsDir}. */
65
119
  readonly workspacePluginsDir?: string;
120
+ /** Override the git runner used to clone external plugin sources. Falls back to {@link defaultGitRunner}. */
121
+ readonly runGit?: GitRunner;
122
+ /** Override the runner used to execute a plugin's postinstall adapter. Falls back to {@link defaultPostinstallRunner}. */
123
+ readonly runPostinstall?: PostinstallRunner;
66
124
  }
67
125
 
68
126
  /** Successful install result. */
@@ -72,16 +130,36 @@ export interface InstallPluginResult {
72
130
  readonly target: string;
73
131
  readonly fileCount: number;
74
132
  readonly ref: string;
133
+ /** Resolved commit SHA for git-cloned external sources; null for first-party (no clone). */
134
+ readonly commit: string | null;
75
135
  }
76
136
 
77
137
  /** Plugin name failed sanitization. */
78
138
  export class InvalidPluginNameError extends Error {
79
139
  constructor(name: string) {
80
- super(`Invalid plugin name "${name}". Names must match /^[a-z0-9][a-z0-9_-]*$/.`);
140
+ super(
141
+ `Invalid plugin name "${name}". Names must match /^[a-z0-9][a-z0-9_-]*$/.`,
142
+ );
81
143
  this.name = "InvalidPluginNameError";
82
144
  }
83
145
  }
84
146
 
147
+ /**
148
+ * A plugin's curated postinstall adapter failed — its `scripts.postinstall`
149
+ * command was malformed/unsupported, its script was missing, or the script
150
+ * exited non-zero. The install is aborted and rolled back rather than
151
+ * materializing a half-transformed, non-functional plugin.
152
+ */
153
+ export class PluginPostinstallError extends Error {
154
+ constructor(
155
+ readonly pluginName: string,
156
+ detail: string,
157
+ ) {
158
+ super(`Postinstall adapter for "${pluginName}" failed: ${detail}`);
159
+ this.name = "PluginPostinstallError";
160
+ }
161
+ }
162
+
85
163
  /** A plugin with the same name is already installed and `--force` was not passed. */
86
164
  export class PluginAlreadyInstalledError extends Error {
87
165
  constructor(
@@ -98,13 +176,115 @@ export class PluginNotFoundError extends Error {
98
176
  constructor(
99
177
  readonly pluginName: string,
100
178
  readonly ref: string,
179
+ /** `owner/repo/path` the plugin was looked for at. */
180
+ sourceLabel: string,
101
181
  ) {
102
- const sourcePath = `${PLUGIN_SOURCE_OWNER}/${PLUGIN_SOURCE_REPO}/${PLUGIN_SOURCE_PATH_PREFIX}/${pluginName}`;
103
- super(`Plugin "${pluginName}" not found at ${sourcePath} (ref ${ref}).`);
182
+ super(`Plugin "${pluginName}" not found at ${sourceLabel} (ref ${ref}).`);
104
183
  this.name = "PluginNotFoundError";
105
184
  }
106
185
  }
107
186
 
187
+ /**
188
+ * The plugin source is temporarily unreachable — GitHub rate-limited us or
189
+ * returned a 5xx. Distinct from a hard failure (the plugin genuinely doesn't
190
+ * exist) so the caller can surface a retryable 503 instead of a 500.
191
+ */
192
+ export class PluginSourceUnavailableError extends Error {
193
+ readonly status: number;
194
+ constructor(message: string, status: number) {
195
+ super(message);
196
+ this.name = "PluginSourceUnavailableError";
197
+ this.status = status;
198
+ }
199
+ }
200
+
201
+ /**
202
+ * Classify an upstream GitHub status as transient (worth retrying) vs hard.
203
+ * A 429 or 5xx is always transient. A 403 is GitHub's unauthenticated
204
+ * rate-limit signal only when the remaining-quota header is exhausted —
205
+ * a 403 without it is a genuine authorization failure and stays hard.
206
+ */
207
+ function isTransientUpstreamStatus(res: Response): boolean {
208
+ if (res.status === 429 || res.status >= 500) return true;
209
+ if (res.status === 403) {
210
+ return res.headers.get("x-ratelimit-remaining") === "0";
211
+ }
212
+ return false;
213
+ }
214
+
215
+ /** Resolved GitHub coordinates a plugin name is fetched from. */
216
+ interface PluginFetchSource {
217
+ /** Whether the plugin lives in our monorepo or an external whitelisted repo. */
218
+ readonly kind: "first-party" | "external";
219
+ readonly owner: string;
220
+ readonly repo: string;
221
+ /** Repo-relative directory holding the plugin root; `""` = repo root. */
222
+ readonly rootPath: string;
223
+ readonly ref: string;
224
+ }
225
+
226
+ /** Build the `owner/repo/path` label used in not-found errors. */
227
+ function sourceLabel(source: PluginFetchSource): string {
228
+ return source.rootPath
229
+ ? `${source.owner}/${source.repo}/${source.rootPath}`
230
+ : `${source.owner}/${source.repo}`;
231
+ }
232
+
233
+ /** First-party `experimental/plugins/<name>` coordinates at a given ref. */
234
+ function firstPartySource(name: string, ref: string): PluginFetchSource {
235
+ return {
236
+ kind: "first-party",
237
+ owner: PLUGIN_SOURCE_OWNER,
238
+ repo: PLUGIN_SOURCE_REPO,
239
+ rootPath: `${PLUGIN_SOURCE_PATH_PREFIX}/${name}`,
240
+ ref,
241
+ };
242
+ }
243
+
244
+ /**
245
+ * Resolve a plugin name to concrete GitHub coordinates.
246
+ *
247
+ * A name claimed by the curated marketplace resolves to its pinned external
248
+ * repo; any other name resolves to the first-party `experimental/plugins/<name>`
249
+ * convention. The marketplace is external-only by construction — a same-named
250
+ * `experimental/plugins/<name>` directory is the plugin's optional *adapter
251
+ * stub* (a curated `package.json` + postinstall script overlaid onto the clone
252
+ * to translate it into Vellum's shape; see {@link applyAdapterStub}), not a
253
+ * standalone first-party plugin. So letting the marketplace win the name is
254
+ * what makes the stub apply to the external clone, and the search catalog
255
+ * surfaces the same name as external — install and search stay in agreement.
256
+ *
257
+ * A missing or malformed manifest degrades to first-party resolution — the
258
+ * whitelist is supplementary and must never block installing a first-party
259
+ * plugin. An external name then surfaces a clear not-found error downstream.
260
+ */
261
+ async function resolvePluginSource(
262
+ name: string,
263
+ marketplaceRef: string,
264
+ fetchFn: FetchLike,
265
+ ): Promise<PluginFetchSource> {
266
+ let resolved = null;
267
+ try {
268
+ const entries = await fetchMarketplaceEntries(
269
+ { fetch: fetchFn },
270
+ { ref: marketplaceRef },
271
+ );
272
+ resolved = resolveMarketplaceSource(name, entries);
273
+ } catch {
274
+ // Degrade to first-party resolution below.
275
+ }
276
+
277
+ if (!resolved) return firstPartySource(name, marketplaceRef);
278
+
279
+ return {
280
+ kind: "external",
281
+ owner: resolved.owner,
282
+ repo: resolved.repo,
283
+ rootPath: resolved.path,
284
+ ref: resolved.ref,
285
+ };
286
+ }
287
+
108
288
  /**
109
289
  * Reject plugin names that could escape the canonical source path or the
110
290
  * install target. The source convention is a flat namespace under
@@ -141,7 +321,9 @@ function assertSafeFilename(label: string, candidate: string): void {
141
321
  candidate.includes("\0") ||
142
322
  candidate.split(/[/\\]/).some((seg) => seg === "..")
143
323
  ) {
144
- throw new Error(`Unsafe ${label} from GitHub response: ${JSON.stringify(candidate)}`);
324
+ throw new Error(
325
+ `Unsafe ${label} from GitHub response: ${JSON.stringify(candidate)}`,
326
+ );
145
327
  }
146
328
  }
147
329
 
@@ -158,9 +340,12 @@ export async function installPlugin(
158
340
  deps: InstallPluginDeps,
159
341
  ): Promise<InstallPluginResult> {
160
342
  const name = sanitizePluginName(opts.name);
161
- const ref = opts.ref ?? DEFAULT_PLUGIN_REF;
343
+ const marketplaceRef = opts.ref ?? DEFAULT_PLUGIN_REF;
162
344
  const force = opts.force ?? false;
163
345
 
346
+ const source = await resolvePluginSource(name, marketplaceRef, deps.fetch);
347
+ const ref = source.ref;
348
+
164
349
  const pluginsDir = deps.workspacePluginsDir ?? getWorkspacePluginsDir();
165
350
  const target = join(pluginsDir, name);
166
351
 
@@ -168,23 +353,74 @@ export async function installPlugin(
168
353
  throw new PluginAlreadyInstalledError(name, target);
169
354
  }
170
355
 
171
- // Stage into a sibling temp dir so an in-progress install never destroys
172
- // the currently installed version. `process.pid` keeps concurrent installs
173
- // of the same plugin from clobbering each other's staging.
174
- const stagingDir = `${target}.installing.${process.pid}`;
356
+ // Stage *outside* the served `plugins/` directory. The daemon watches that
357
+ // directory and its startup loader enumerates it, so a staging dir living
358
+ // inside it is observed mid-install — before the adapter overlay runs, an
359
+ // external clone still carries its upstream `package.json` (wrong name, no
360
+ // plugin-api peer dep), which the loader rejects with spurious name-mismatch
361
+ // and missing-peer-dependency warnings. Staging in a sibling directory keeps
362
+ // the half-built tree invisible until the final swap. The root is on the
363
+ // same filesystem as the target, so that swap stays an atomic rename.
364
+ // `process.pid` keeps concurrent installs of the same plugin from clobbering
365
+ // each other's staging.
366
+ const stagingRoot = join(dirname(pluginsDir), ".plugins-staging");
367
+ mkdirSync(stagingRoot, { recursive: true });
368
+ const stagingDir = join(stagingRoot, `${name}.installing.${process.pid}`);
175
369
  if (existsSync(stagingDir)) {
176
370
  rmSync(stagingDir, { recursive: true, force: true });
177
371
  }
178
372
  mkdirSync(stagingDir, { recursive: true });
179
373
 
180
374
  let fileCount: number;
375
+ let commit: string | null = null;
181
376
  try {
182
- fileCount = await copyDir(
183
- `${PLUGIN_SOURCE_PATH_PREFIX}/${name}`,
184
- ref,
185
- stagingDir,
186
- deps.fetch,
187
- );
377
+ if (source.kind === "external") {
378
+ const cloned = await copyExternalViaGit(
379
+ source,
380
+ stagingDir,
381
+ deps.runGit ?? defaultGitRunner,
382
+ );
383
+ fileCount = cloned.fileCount;
384
+ commit = cloned.commit;
385
+ // An external clone is often a foreign-ecosystem plugin (e.g. a Claude
386
+ // Code plugin) that the Vellum loader can't run as-is. When we curate an
387
+ // adapter stub for it, overlay the stub and run its transform so the
388
+ // materialized tree is a valid Vellum plugin. Raw clones (no stub) are
389
+ // left untouched.
390
+ if (fileCount > 0) {
391
+ await applyAdapterStub(name, marketplaceRef, stagingDir, deps);
392
+ }
393
+ } else {
394
+ fileCount = await copyDir(
395
+ source.owner,
396
+ source.repo,
397
+ source.rootPath,
398
+ ref,
399
+ stagingDir,
400
+ deps.fetch,
401
+ );
402
+ // We only land in the first-party branch for this name when the
403
+ // marketplace lookup returned no claim. A *healthy* marketplace that
404
+ // claims the name routes to the external+adapter branch above; reaching
405
+ // here for a directory that is actually an adapter stub (declares a
406
+ // `scripts.postinstall`) therefore means the marketplace failed to load
407
+ // (rate-limit / 5xx / malformed) and we degraded past it. A stub has no
408
+ // hooks/tools of its own — it only transforms an external clone — so
409
+ // installing it alone would materialize a non-functional plugin. Fail
410
+ // loudly and retryably instead of silently shipping a broken plugin.
411
+ // Genuine first-party plugins (no postinstall) install normally.
412
+ if (
413
+ fileCount > 0 &&
414
+ resolvePostinstallScript(name, stagingDir) !== null
415
+ ) {
416
+ throw new PluginPostinstallError(
417
+ name,
418
+ "resolved to a first-party adapter stub, but its marketplace entry " +
419
+ "could not be read to locate the external source it adapts — the " +
420
+ "marketplace lookup likely failed transiently. Retry the install.",
421
+ );
422
+ }
423
+ }
188
424
  } catch (err) {
189
425
  rmSync(stagingDir, { recursive: true, force: true });
190
426
  throw err;
@@ -192,65 +428,587 @@ export async function installPlugin(
192
428
 
193
429
  if (fileCount === 0) {
194
430
  rmSync(stagingDir, { recursive: true, force: true });
195
- throw new PluginNotFoundError(name, ref);
431
+ throw new PluginNotFoundError(name, ref, sourceLabel(source));
196
432
  }
197
433
 
434
+ // Record install provenance (source coordinates + resolved commit) as a
435
+ // hidden sidecar before the swap so it lands atomically with the files. The
436
+ // daemon loader enumerates plugin directories and reads each plugin's
437
+ // `package.json`, skipping dotfiles — so this never gets mistaken for code.
438
+ writeInstallManifest(stagingDir, name, source, ref, commit);
439
+
198
440
  // Atomic-ish swap: rmSync + renameSync. On POSIX the rename itself is
199
441
  // atomic, so the only window where the target is absent is between the
200
442
  // rm and the rename — and at that point the staging dir is fully populated.
443
+ // Ensure the served `plugins/` directory exists: staging now lives outside
444
+ // it, so the target's parent is no longer created as a side effect.
445
+ mkdirSync(pluginsDir, { recursive: true });
201
446
  if (existsSync(target)) {
202
447
  rmSync(target, { recursive: true, force: true });
203
448
  }
204
449
  renameSync(stagingDir, target);
205
450
 
206
- return { name, target, fileCount, ref };
451
+ return { name, target, fileCount, ref, commit };
452
+ }
453
+
454
+ /** Cap on any single git invocation; a shallow fetch is well under this. */
455
+ const GIT_TIMEOUT_MS = 120_000;
456
+
457
+ /** Install-provenance sidecar written at the plugin root. */
458
+ const INSTALL_MANIFEST_FILENAME = ".vellum-plugin.json";
459
+
460
+ /**
461
+ * Materialize an external plugin by shallow-cloning its repo at the pinned ref.
462
+ *
463
+ * A single `git fetch --depth 1 <ref>` transfers the tree in one network
464
+ * operation regardless of how many directories the plugin spans, so it is
465
+ * immune to GitHub's 60/hr unauthenticated Contents-API rate limit — the
466
+ * failure mode a recursive per-directory walk hit on plugins like caveman
467
+ * (dozens of nested folders, one API request each). Cloning also resolves the
468
+ * exact commit the ref points at, recorded for version provenance.
469
+ *
470
+ * The clone lands in a sibling scratch dir; only the plugin root (the repo
471
+ * root, or `source.rootPath` within it) is copied into `destDir`, minus the
472
+ * `.git` metadata and any symlinks (the loader follows neither). Returns the
473
+ * file count and resolved commit; zero files means the ref exists but the
474
+ * declared sub-path doesn't, which the caller maps to not-found.
475
+ */
476
+ async function copyExternalViaGit(
477
+ source: PluginFetchSource,
478
+ destDir: string,
479
+ runGit: GitRunner,
480
+ ): Promise<{ fileCount: number; commit: string | null }> {
481
+ const cloneDir = `${destDir}.gitclone`;
482
+ rmSync(cloneDir, { recursive: true, force: true });
483
+ mkdirSync(cloneDir, { recursive: true });
484
+
485
+ try {
486
+ const repoUrl = `https://github.com/${source.owner}/${source.repo}.git`;
487
+ await runGit(["init", "--quiet"], { cwd: cloneDir });
488
+ await runGit(["remote", "add", "origin", repoUrl], { cwd: cloneDir });
489
+
490
+ try {
491
+ await runGit(["fetch", "--depth", "1", "--quiet", "origin", source.ref], {
492
+ cwd: cloneDir,
493
+ });
494
+ } catch (err) {
495
+ // A missing repo/ref (or a private one we can't reach) is a hard
496
+ // not-found, surfaced as zero files. Anything else — network loss, a
497
+ // transient GitHub outage — is retryable, so map it to a 503.
498
+ if (isGitRefNotFound(err)) return { fileCount: 0, commit: null };
499
+ throw new PluginSourceUnavailableError(
500
+ `git clone failed for ${sourceLabel(source)} @ ${source.ref}: ${subprocessErrorText(err)}`,
501
+ 503,
502
+ );
503
+ }
504
+
505
+ await runGit(["checkout", "--quiet", "FETCH_HEAD"], { cwd: cloneDir });
506
+
507
+ let commit: string | null = null;
508
+ try {
509
+ const { stdout } = await runGit(["rev-parse", "HEAD"], { cwd: cloneDir });
510
+ commit = stdout.trim() || null;
511
+ } catch {
512
+ // Provenance is best-effort; a missing commit must not fail the install.
513
+ commit = null;
514
+ }
515
+
516
+ // Defense in depth: external marketplace refs are full commit SHAs (the
517
+ // manifest schema rejects mutable tags/branches), so the checked-out
518
+ // commit must equal the requested ref. If it ever diverges, refuse the
519
+ // install rather than materialize and `import()` unexpected code.
520
+ if (commit && commit.toLowerCase() !== source.ref.toLowerCase()) {
521
+ throw new PluginSourceUnavailableError(
522
+ `git checkout of ${sourceLabel(source)} resolved to ${commit}, ` +
523
+ `which does not match the pinned commit ${source.ref}`,
524
+ 502,
525
+ );
526
+ }
527
+
528
+ const srcRoot = source.rootPath
529
+ ? join(cloneDir, source.rootPath)
530
+ : cloneDir;
531
+ if (!existsSync(srcRoot) || !statSync(srcRoot).isDirectory()) {
532
+ return { fileCount: 0, commit };
533
+ }
534
+
535
+ const fileCount = copyTreeSkippingGit(srcRoot, destDir);
536
+ return { fileCount, commit };
537
+ } finally {
538
+ rmSync(cloneDir, { recursive: true, force: true });
539
+ }
540
+ }
541
+
542
+ /** Cap on a postinstall adapter; the curated transforms are fast and file-only. */
543
+ const POSTINSTALL_TIMEOUT_MS = 60_000;
544
+
545
+ /**
546
+ * Overlay our curated adapter stub onto a freshly cloned external plugin and
547
+ * run its postinstall transform, returning whether a transform ran.
548
+ *
549
+ * The stub lives at `experimental/plugins/<name>/` in our own repo and carries
550
+ * a `package.json` (with a `scripts.postinstall` adapter command) plus the
551
+ * adapter script it names. We fetch it via the Contents API — a couple of
552
+ * small files, well within the rate limit — and copy it over the clone so the
553
+ * postinstall we run is ours, never the upstream repo's lifecycle script. The
554
+ * overlaid stub `package.json` exists only to name that adapter; the installed
555
+ * plugin's manifest is rebuilt from the upstream `package.json` afterwards (see
556
+ * {@link normalizeInstalledManifest}). Absent a stub (the common case for a
557
+ * plugin already in Vellum shape), nothing is overlaid and the clone is
558
+ * installed as-is.
559
+ *
560
+ * On any adapter failure the error propagates so {@link installPlugin} rolls
561
+ * back staging — better to fail loudly than ship a half-transformed plugin.
562
+ */
563
+ async function applyAdapterStub(
564
+ name: string,
565
+ ref: string,
566
+ stagingDir: string,
567
+ deps: InstallPluginDeps,
568
+ ): Promise<boolean> {
569
+ // Capture the cloned upstream manifest before the stub overlay replaces it,
570
+ // so the installed plugin can preserve it verbatim except for the two fields
571
+ // the Vellum loader requires (name + plugin-api peer dep).
572
+ const upstreamPkg = readPackageJson(join(stagingDir, "package.json"));
573
+
574
+ const stubFileCount = await copyDir(
575
+ PLUGIN_SOURCE_OWNER,
576
+ PLUGIN_SOURCE_REPO,
577
+ `${PLUGIN_SOURCE_PATH_PREFIX}/${name}`,
578
+ ref,
579
+ stagingDir,
580
+ deps.fetch,
581
+ );
582
+ if (stubFileCount === 0) return false;
583
+
584
+ const script = resolvePostinstallScript(name, stagingDir);
585
+ if (script === null) return false;
586
+
587
+ const run = deps.runPostinstall ?? defaultPostinstallRunner;
588
+ try {
589
+ await run({ cwd: stagingDir, script });
590
+ } catch (err) {
591
+ throw new PluginPostinstallError(name, subprocessErrorText(err));
592
+ }
593
+
594
+ normalizeInstalledManifest(name, stagingDir, upstreamPkg);
595
+ return true;
596
+ }
597
+
598
+ /**
599
+ * Default `@vellumai/plugin-api` peer-dependency range stamped onto an adapted
600
+ * plugin that doesn't already declare one.
601
+ */
602
+ const PLUGIN_API_PEER_RANGE = ">=0.8.0";
603
+
604
+ type PackageManifest = Record<string, unknown>;
605
+
606
+ /** Parse the `package.json` at `path`, or null if it's absent or unparseable. */
607
+ function readPackageJson(path: string): PackageManifest | null {
608
+ if (!existsSync(path)) return null;
609
+ try {
610
+ const parsed: unknown = JSON.parse(readFileSync(path, "utf8"));
611
+ return typeof parsed === "object" &&
612
+ parsed !== null &&
613
+ !Array.isArray(parsed)
614
+ ? (parsed as PackageManifest)
615
+ : null;
616
+ } catch {
617
+ return null;
618
+ }
619
+ }
620
+
621
+ /**
622
+ * Write the installed plugin's final `package.json` after the adapter has run.
623
+ *
624
+ * A curated adapter stub deliberately overlays its own `package.json` onto the
625
+ * clone so the installer can find and run the stub's `scripts.postinstall`.
626
+ * That stub is install-time machinery, not the plugin's manifest, so once the
627
+ * adapter has run we rebuild the manifest from the upstream `package.json`
628
+ * captured before the overlay — preserving its `version`, `description`,
629
+ * `license`, and every other field — and mutate only what the Vellum loader
630
+ * requires: `name` must equal the install directory, and `@vellumai/plugin-api`
631
+ * must be declared as a peer dependency. The spent `postinstall` script is
632
+ * dropped so the installed plugin carries no install-time machinery.
633
+ *
634
+ * When the upstream repo shipped no `package.json`, the overlaid stub is the
635
+ * only manifest available, so it becomes the base instead.
636
+ */
637
+ function normalizeInstalledManifest(
638
+ name: string,
639
+ stagingDir: string,
640
+ upstreamPkg: PackageManifest | null,
641
+ ): void {
642
+ const manifestPath = join(stagingDir, "package.json");
643
+ const base = upstreamPkg ?? readPackageJson(manifestPath) ?? {};
644
+
645
+ const peer =
646
+ typeof base.peerDependencies === "object" && base.peerDependencies !== null
647
+ ? (base.peerDependencies as Record<string, unknown>)
648
+ : {};
649
+ const existingRange = peer["@vellumai/plugin-api"];
650
+
651
+ const manifest: PackageManifest = {
652
+ ...base,
653
+ name,
654
+ peerDependencies: {
655
+ ...peer,
656
+ "@vellumai/plugin-api":
657
+ typeof existingRange === "string"
658
+ ? existingRange
659
+ : PLUGIN_API_PEER_RANGE,
660
+ },
661
+ };
662
+
663
+ if (typeof manifest.scripts === "object" && manifest.scripts !== null) {
664
+ const scripts = { ...(manifest.scripts as Record<string, unknown>) };
665
+ delete scripts.postinstall;
666
+ if (Object.keys(scripts).length === 0) {
667
+ delete manifest.scripts;
668
+ } else {
669
+ manifest.scripts = scripts;
670
+ }
671
+ }
672
+
673
+ writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`, "utf8");
674
+ }
675
+
676
+ /**
677
+ * Resolve the absolute path of the adapter script named by the (overlaid stub)
678
+ * `package.json`'s `scripts.postinstall`, or `null` when there is no stub
679
+ * package.json / postinstall script.
680
+ *
681
+ * Curated adapters declare a single `bun <script>` invocation; bun is resolved
682
+ * via {@link ensureBun} at execution time (see {@link defaultPostinstallRunner})
683
+ * so the `bun` token marks the convention without hard-coding the binary path.
684
+ * Anything else — extra args, a shell pipeline, a non-script file — is rejected
685
+ * rather than executed, and the script path is constrained to a file inside the
686
+ * staging dir so a stub can never escape it.
687
+ */
688
+ function resolvePostinstallScript(
689
+ name: string,
690
+ stagingDir: string,
691
+ ): string | null {
692
+ const pkgPath = join(stagingDir, "package.json");
693
+ if (!existsSync(pkgPath)) return null;
694
+
695
+ let parsed: unknown;
696
+ try {
697
+ parsed = JSON.parse(readFileSync(pkgPath, "utf8"));
698
+ } catch {
699
+ return null;
700
+ }
701
+
702
+ const scripts =
703
+ typeof parsed === "object" && parsed !== null && "scripts" in parsed
704
+ ? (parsed as { scripts?: unknown }).scripts
705
+ : undefined;
706
+ const command =
707
+ typeof scripts === "object" && scripts !== null && "postinstall" in scripts
708
+ ? (scripts as { postinstall?: unknown }).postinstall
709
+ : undefined;
710
+ if (typeof command !== "string" || command.trim() === "") return null;
711
+
712
+ const match = /^bun\s+(\S+)$/.exec(command.trim());
713
+ if (!match) {
714
+ throw new PluginPostinstallError(
715
+ name,
716
+ `unsupported postinstall command ${JSON.stringify(command)} — ` +
717
+ "curated adapters must be a single `bun <script>` invocation",
718
+ );
719
+ }
720
+
721
+ let rel = match[1]!;
722
+ if (rel.startsWith("./")) rel = rel.slice(2);
723
+ if (!/\.(?:ts|mts|cts|mjs|cjs|js)$/.test(rel)) {
724
+ throw new PluginPostinstallError(
725
+ name,
726
+ `postinstall script ${JSON.stringify(rel)} must be a ` +
727
+ ".ts/.mts/.cts/.mjs/.cjs/.js file",
728
+ );
729
+ }
730
+ for (const segment of rel.split("/")) {
731
+ assertSafeFilename("postinstall script segment", segment);
732
+ }
733
+
734
+ const abs = resolve(stagingDir, rel);
735
+ if (
736
+ abs !== resolve(stagingDir) &&
737
+ !abs.startsWith(`${resolve(stagingDir)}${sep}`)
738
+ ) {
739
+ throw new PluginPostinstallError(
740
+ name,
741
+ `postinstall script ${JSON.stringify(rel)} escapes the plugin directory`,
742
+ );
743
+ }
744
+ if (!existsSync(abs)) {
745
+ throw new PluginPostinstallError(
746
+ name,
747
+ `postinstall script ${JSON.stringify(rel)} was not found in the plugin`,
748
+ );
749
+ }
750
+ return abs;
751
+ }
752
+
753
+ /**
754
+ * Production postinstall runner: executes the adapter with a real `bun` binary
755
+ * resolved via {@link ensureBun}, under a stripped environment and a timeout.
756
+ *
757
+ * `process.execPath` is unusable here: inside a `bun build --compile` binary it
758
+ * is the compiled assistant app, not the bun CLI (see `util/bun-runtime.ts`),
759
+ * so passing the adapter script to it would launch the daemon rather than
760
+ * interpret the script. `ensureBun()` locates (or downloads) a standalone bun
761
+ * the same way every other subsystem that spawns bun does. The minimal env
762
+ * (bun's dir + standard bins, `HOME` only) keeps the adapter from inheriting
763
+ * surprising config while still finding the runtime.
764
+ */
765
+ export const defaultPostinstallRunner: PostinstallRunner = async ({
766
+ cwd,
767
+ script,
768
+ }) => {
769
+ const bun = await ensureBun();
770
+ await execFileAsync(bun, [script], {
771
+ cwd,
772
+ encoding: "utf8",
773
+ timeout: POSTINSTALL_TIMEOUT_MS,
774
+ maxBuffer: 16 * 1024 * 1024,
775
+ env: pluginPostinstallEnv(bun),
776
+ });
777
+ };
778
+
779
+ function pluginPostinstallEnv(bun: string): NodeJS.ProcessEnv {
780
+ const env: NodeJS.ProcessEnv = {
781
+ PATH: [
782
+ dirname(bun),
783
+ "/opt/homebrew/bin",
784
+ "/usr/local/bin",
785
+ "/usr/bin",
786
+ "/bin",
787
+ ]
788
+ .filter(Boolean)
789
+ .join(":"),
790
+ };
791
+ if (process.env.HOME) env.HOME = process.env.HOME;
792
+ return env;
793
+ }
794
+
795
+ /**
796
+ * Recursively copy regular files from `srcRoot` into `destDir`, skipping the
797
+ * top-level `.git` directory, a top-level `bunfig.toml` (see below), and any
798
+ * symlinks. Returns the file count.
799
+ */
800
+ function copyTreeSkippingGit(srcRoot: string, destDir: string): number {
801
+ let count = 0;
802
+ const walk = (relDir: string): void => {
803
+ const absDir = relDir ? join(srcRoot, relDir) : srcRoot;
804
+ for (const entry of readdirSync(absDir, { withFileTypes: true })) {
805
+ // Drop git metadata and symlinks: the loader follows neither, and a
806
+ // symlink could otherwise point outside the staging tree.
807
+ if (relDir === "" && entry.name === ".git") continue;
808
+ // Drop a top-level `bunfig.toml`. The adapter postinstall runs `bun` with
809
+ // its cwd at the staged root, and Bun auto-loads `$cwd/bunfig.toml` as
810
+ // project config — including a `preload` list it executes before the
811
+ // entry point. An upstream config would therefore run arbitrary code
812
+ // ahead of the curated adapter, defeating the command/env guards. Bun
813
+ // reads only the cwd's file (it neither walks up nor descends), so
814
+ // dropping it at the root closes the vector; a Vellum plugin never
815
+ // consumes `bunfig.toml`. Match case-insensitively because the macOS
816
+ // install target's filesystem is case-insensitive, where Bun would still
817
+ // open a clone-supplied `BUNFIG.TOML`.
818
+ if (relDir === "" && entry.name.toLowerCase() === "bunfig.toml") continue;
819
+ if (entry.isSymbolicLink()) continue;
820
+
821
+ const rel = relDir ? join(relDir, entry.name) : entry.name;
822
+ if (entry.isDirectory()) {
823
+ walk(rel);
824
+ continue;
825
+ }
826
+ if (!entry.isFile()) continue;
827
+
828
+ const dest = join(destDir, rel);
829
+ mkdirSync(dirname(dest), { recursive: true });
830
+ copyFileSync(join(srcRoot, rel), dest);
831
+ count++;
832
+ }
833
+ };
834
+ walk("");
835
+ return count;
836
+ }
837
+
838
+ /** True when a git fetch failed because the repo or ref is unreachable. */
839
+ function isGitRefNotFound(err: unknown): boolean {
840
+ const text = subprocessErrorText(err).toLowerCase();
841
+ return [
842
+ "could not find remote ref",
843
+ "couldn't find remote ref",
844
+ "remote branch",
845
+ "repository not found",
846
+ "could not read from remote repository",
847
+ "could not read username",
848
+ "terminal prompts disabled",
849
+ "authentication failed",
850
+ ].some((needle) => text.includes(needle));
851
+ }
852
+
853
+ /** Extract a stderr/message blob from a spawn error for classification/logging. */
854
+ function subprocessErrorText(err: unknown): string {
855
+ if (err instanceof Error) {
856
+ const withStreams = err as Error & { stderr?: unknown };
857
+ const stderr =
858
+ typeof withStreams.stderr === "string" ? withStreams.stderr : "";
859
+ return `${err.message} ${stderr}`.trim();
860
+ }
861
+ return String(err);
862
+ }
863
+
864
+ /**
865
+ * Hardened `git` runner used in production. Strips inherited `GIT_*` vars
866
+ * (which could redirect config, hooks, or the object store), disables the
867
+ * credential prompt so a private/missing repo fails fast instead of hanging
868
+ * the daemon, and augments `PATH` so the real git is found when the daemon is
869
+ * launched from a macOS `.app` bundle with a minimal environment.
870
+ */
871
+ export const defaultGitRunner: GitRunner = async (args, opts) => {
872
+ const { stdout } = await execFileAsync("git", [...args], {
873
+ cwd: opts.cwd,
874
+ encoding: "utf8",
875
+ timeout: GIT_TIMEOUT_MS,
876
+ maxBuffer: 64 * 1024 * 1024,
877
+ env: pluginGitEnv(),
878
+ });
879
+ return { stdout };
880
+ };
881
+
882
+ function pluginGitEnv(): NodeJS.ProcessEnv {
883
+ const env: NodeJS.ProcessEnv = {};
884
+ for (const [key, value] of Object.entries(process.env)) {
885
+ if (value !== undefined && !key.startsWith("GIT_")) env[key] = value;
886
+ }
887
+ env.GIT_TERMINAL_PROMPT = "0";
888
+ const extraPaths = ["/opt/homebrew/bin", "/usr/local/bin"];
889
+ const current = (env.PATH ?? "").split(":").filter(Boolean);
890
+ const missing = extraPaths.filter((p) => !current.includes(p));
891
+ if (missing.length > 0) {
892
+ env.PATH = [...current, ...missing].join(":");
893
+ }
894
+ return env;
895
+ }
896
+
897
+ /**
898
+ * Write the install-provenance sidecar into the staged plugin root, recording
899
+ * the resolved source coordinates and commit so we can later report or verify
900
+ * exactly what is installed. Hidden (dot-prefixed) so the daemon loader, which
901
+ * skips dotfiles, never mistakes it for plugin code.
902
+ */
903
+ function writeInstallManifest(
904
+ stagingDir: string,
905
+ name: string,
906
+ source: PluginFetchSource,
907
+ ref: string,
908
+ commit: string | null,
909
+ ): void {
910
+ const manifest = {
911
+ name,
912
+ source: {
913
+ kind: source.kind,
914
+ owner: source.owner,
915
+ repo: source.repo,
916
+ path: source.rootPath || undefined,
917
+ ref,
918
+ },
919
+ commit: commit ?? undefined,
920
+ installedAt: new Date().toISOString(),
921
+ };
922
+ writeFileSync(
923
+ join(stagingDir, INSTALL_MANIFEST_FILENAME),
924
+ `${JSON.stringify(manifest, null, 2)}\n`,
925
+ );
207
926
  }
208
927
 
928
+ /**
929
+ * Recursively copy a first-party plugin directory via the GitHub Contents API.
930
+ *
931
+ * First-party plugins live in our own monorepo as a small handful of files, so
932
+ * the per-directory walk stays well within the unauthenticated rate limit —
933
+ * and avoids cloning the entire repository just to install one plugin. Returns
934
+ * the number of files written; zero means the directory doesn't exist at this
935
+ * ref, which the caller maps to a not-found error.
936
+ */
209
937
  async function copyDir(
938
+ owner: string,
939
+ repo: string,
210
940
  apiPath: string,
211
941
  ref: string,
212
942
  destDir: string,
213
943
  fetchFn: FetchLike,
214
944
  ): Promise<number> {
215
- const entries = await listDir(apiPath, ref, fetchFn);
945
+ const entries = await listDir(owner, repo, apiPath, ref, fetchFn);
216
946
  if (entries === null) return 0;
217
947
 
218
948
  let count = 0;
219
949
  for (const entry of entries) {
950
+ // The daemon loader follows neither symlinks nor submodules; skip them.
951
+ if (entry.type === "symlink" || entry.type === "submodule") continue;
220
952
  assertSafeFilename("entry name", entry.name);
953
+
221
954
  if (entry.type === "dir") {
222
955
  const subDest = join(destDir, entry.name);
223
956
  mkdirSync(subDest, { recursive: true });
224
- count += await copyDir(entry.path, ref, subDest, fetchFn);
225
- continue;
226
- }
227
- if (entry.type === "file") {
228
- await copyFile(entry, destDir, fetchFn);
229
- count++;
957
+ count += await copyDir(owner, repo, entry.path, ref, subDest, fetchFn);
230
958
  continue;
231
959
  }
232
- // Skip symlink + submodule deliberately. The daemon-side loader does not
233
- // follow either, so reproducing them in the install target adds risk
234
- // without value.
960
+
961
+ await copyFile(entry, join(destDir, entry.name), fetchFn);
962
+ count++;
235
963
  }
236
964
  return count;
237
965
  }
238
966
 
967
+ /** Download one file entry from the Contents API into `dest`. */
968
+ async function copyFile(
969
+ entry: GitHubContentEntry,
970
+ dest: string,
971
+ fetchFn: FetchLike,
972
+ ): Promise<void> {
973
+ if (!entry.download_url) {
974
+ throw new Error(`No download URL for ${entry.path}`);
975
+ }
976
+ const res = await githubFetch(
977
+ entry.download_url,
978
+ "application/octet-stream",
979
+ fetchFn,
980
+ );
981
+ if (!res.ok) {
982
+ const label = `Download failed for ${entry.path}: HTTP ${res.status}`;
983
+ if (isTransientUpstreamStatus(res)) {
984
+ throw new PluginSourceUnavailableError(label, res.status);
985
+ }
986
+ throw new Error(label);
987
+ }
988
+ const buf = Buffer.from(await res.arrayBuffer());
989
+ mkdirSync(dirname(dest), { recursive: true });
990
+ writeFileSync(dest, buf);
991
+ }
992
+
239
993
  async function listDir(
994
+ owner: string,
995
+ repo: string,
240
996
  apiPath: string,
241
997
  ref: string,
242
998
  fetchFn: FetchLike,
243
999
  ): Promise<readonly GitHubContentEntry[] | null> {
244
1000
  const url =
245
- `https://api.github.com/repos/${PLUGIN_SOURCE_OWNER}/${PLUGIN_SOURCE_REPO}` +
1001
+ `https://api.github.com/repos/${owner}/${repo}` +
246
1002
  `/contents/${encodeURIComponent(apiPath).replaceAll("%2F", "/")}?ref=${encodeURIComponent(ref)}`;
247
1003
 
248
1004
  const res = await githubFetch(url, "application/vnd.github+json", fetchFn);
249
1005
  if (res.status === 404) return null;
250
1006
  if (!res.ok) {
251
- throw new Error(
252
- `GitHub contents listing failed for ${apiPath} @ ${ref}: HTTP ${res.status}`,
253
- );
1007
+ const label = `GitHub contents listing failed for ${apiPath} @ ${ref}: HTTP ${res.status}`;
1008
+ if (isTransientUpstreamStatus(res)) {
1009
+ throw new PluginSourceUnavailableError(label, res.status);
1010
+ }
1011
+ throw new Error(label);
254
1012
  }
255
1013
 
256
1014
  const body = (await res.json()) as unknown;
@@ -263,27 +1021,6 @@ async function listDir(
263
1021
  return body as readonly GitHubContentEntry[];
264
1022
  }
265
1023
 
266
- async function copyFile(
267
- entry: GitHubContentEntry,
268
- destDir: string,
269
- fetchFn: FetchLike,
270
- ): Promise<void> {
271
- if (!entry.download_url) {
272
- throw new Error(`GitHub contents entry has no download_url: ${entry.path}`);
273
- }
274
- const res = await githubFetch(entry.download_url, "application/octet-stream", fetchFn);
275
- if (!res.ok) {
276
- throw new Error(`Download failed for ${entry.path}: HTTP ${res.status}`);
277
- }
278
- const buf = Buffer.from(await res.arrayBuffer());
279
- // entry.name was already validated by the caller; assert again as a
280
- // belt-and-braces guard so copyFile is safe to call from future paths.
281
- assertSafeFilename("file entry name", entry.name);
282
- const dest = join(destDir, entry.name);
283
- mkdirSync(dirname(dest), { recursive: true });
284
- writeFileSync(dest, buf);
285
- }
286
-
287
1024
  /**
288
1025
  * Wraps `fetchFn` with the headers we want to send to GitHub for every
289
1026
  * request. Unauthenticated — the canonical source is a public repo, so