@openparachute/agent 0.1.2 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (608) hide show
  1. package/.parachute/module.json +124 -8
  2. package/LICENSE +2 -16
  3. package/README.md +118 -166
  4. package/package.json +35 -42
  5. package/scripts/spawn-agent.ts +371 -0
  6. package/src/_parked/interactive-spawn.test.ts +324 -0
  7. package/src/_parked/interactive-spawn.ts +701 -0
  8. package/src/agent-defs.test.ts +1504 -0
  9. package/src/agent-defs.ts +1702 -0
  10. package/src/agent-mcp-config.test.ts +115 -0
  11. package/src/agent-mcp-config.ts +115 -0
  12. package/src/agents.test.ts +360 -0
  13. package/src/agents.ts +379 -0
  14. package/src/auth.test.ts +46 -0
  15. package/src/auth.ts +140 -0
  16. package/src/backends/attached-queue.test.ts +376 -0
  17. package/src/backends/attached-queue.ts +372 -0
  18. package/src/backends/programmatic.test.ts +1715 -0
  19. package/src/backends/programmatic.ts +927 -0
  20. package/src/backends/registry.test.ts +1494 -0
  21. package/src/backends/registry.ts +1202 -0
  22. package/src/backends/stream-json.test.ts +570 -0
  23. package/src/backends/stream-json.ts +392 -0
  24. package/src/backends/types.ts +223 -0
  25. package/src/bridge.ts +417 -0
  26. package/src/channel-backend-wiring.test.ts +237 -0
  27. package/src/credentials.test.ts +274 -0
  28. package/src/credentials.ts +380 -0
  29. package/src/cron.test.ts +342 -0
  30. package/src/cron.ts +380 -0
  31. package/src/daemon-agent-def-api.test.ts +166 -0
  32. package/src/daemon-agent-defs-api.test.ts +953 -0
  33. package/src/daemon-agent-env-api.test.ts +338 -0
  34. package/src/daemon-attached-queue-store.test.ts +65 -0
  35. package/src/daemon-config-api.test.ts +962 -0
  36. package/src/daemon-jobs-api.test.ts +271 -0
  37. package/src/daemon-vault-chat.test.ts +250 -0
  38. package/src/daemon.test.ts +746 -0
  39. package/src/daemon.ts +3314 -0
  40. package/src/def-vaults.test.ts +136 -0
  41. package/src/def-vaults.ts +165 -0
  42. package/src/delivery-state.test.ts +110 -0
  43. package/src/delivery-state.ts +154 -0
  44. package/src/effective-env.test.ts +114 -0
  45. package/src/effective-env.ts +184 -0
  46. package/src/env-compat.ts +39 -0
  47. package/src/grants.test.ts +638 -0
  48. package/src/grants.ts +675 -0
  49. package/src/hub-jwt.test.ts +161 -0
  50. package/src/hub-jwt.ts +182 -0
  51. package/src/jobs.test.ts +245 -0
  52. package/src/jobs.ts +266 -0
  53. package/src/mcp-http.test.ts +265 -0
  54. package/src/mcp-http.ts +771 -0
  55. package/src/mint-token.test.ts +152 -0
  56. package/src/mint-token.ts +139 -0
  57. package/src/module-manifest.test.ts +158 -0
  58. package/src/oauth-discovery.ts +134 -0
  59. package/src/programmatic-wiring.test.ts +838 -0
  60. package/src/registry.test.ts +227 -0
  61. package/src/registry.ts +228 -0
  62. package/src/resolve-port.test.ts +64 -0
  63. package/src/routing.test.ts +184 -0
  64. package/src/routing.ts +76 -0
  65. package/src/runner.test.ts +506 -0
  66. package/src/runner.ts +255 -0
  67. package/src/sandbox/config.test.ts +150 -0
  68. package/src/sandbox/config.ts +102 -0
  69. package/src/sandbox/egress.test.ts +113 -0
  70. package/src/sandbox/egress.ts +123 -0
  71. package/src/sandbox/index.ts +180 -0
  72. package/src/sandbox/live-seatbelt.test.ts +277 -0
  73. package/src/sandbox/mounts.test.ts +154 -0
  74. package/src/sandbox/mounts.ts +133 -0
  75. package/src/sandbox/sandbox.test.ts +168 -0
  76. package/src/sandbox/types.ts +382 -0
  77. package/src/services-manifest.test.ts +106 -0
  78. package/src/services-manifest.ts +95 -0
  79. package/src/spa-serve.test.ts +116 -0
  80. package/src/spa-serve.ts +116 -0
  81. package/src/spawn-agent-cli.test.ts +172 -0
  82. package/src/spawn-agent.test.ts +1218 -0
  83. package/src/spawn-agent.ts +569 -0
  84. package/src/spawn-deps.test.ts +54 -0
  85. package/src/spawn-deps.ts +166 -0
  86. package/src/telegram/api.ts +153 -0
  87. package/src/terminal-assets.test.ts +50 -0
  88. package/src/terminal-assets.ts +79 -0
  89. package/src/terminal-ui.ts +305 -0
  90. package/src/terminal.test.ts +530 -0
  91. package/src/terminal.ts +458 -0
  92. package/src/transport.ts +270 -0
  93. package/src/transports/http-ui.test.ts +455 -0
  94. package/src/transports/http-ui.ts +201 -0
  95. package/src/transports/telegram.test.ts +174 -0
  96. package/src/transports/telegram.ts +426 -0
  97. package/src/transports/vault.test.ts +2011 -0
  98. package/src/transports/vault.ts +1790 -0
  99. package/src/ui-kit.test.ts +178 -0
  100. package/src/ui-kit.ts +402 -0
  101. package/tsconfig.json +8 -14
  102. package/web/ui/dist/assets/index-C-iWdFFV.css +1 -0
  103. package/web/ui/dist/assets/index-VFETBk0a.js +60 -0
  104. package/web/ui/dist/index.html +15 -0
  105. package/web/ui/tsconfig.json +2 -1
  106. package/.claude/scheduled_tasks.lock +0 -1
  107. package/.claude/settings.json +0 -5
  108. package/.claude/skills/add-atomic-chat-tool/SKILL.md +0 -243
  109. package/.claude/skills/add-atomic-chat-tool/atomic-chat-mcp-stdio.ts +0 -229
  110. package/.claude/skills/add-codex/SKILL.md +0 -161
  111. package/.claude/skills/add-dashboard/SKILL.md +0 -138
  112. package/.claude/skills/add-dashboard/resources/dashboard-pusher.ts +0 -495
  113. package/.claude/skills/add-emacs/SKILL.md +0 -296
  114. package/.claude/skills/add-gcal-tool/SKILL.md +0 -210
  115. package/.claude/skills/add-gchat/REMOVE.md +0 -6
  116. package/.claude/skills/add-gchat/SKILL.md +0 -92
  117. package/.claude/skills/add-gchat/VERIFY.md +0 -3
  118. package/.claude/skills/add-github/REMOVE.md +0 -6
  119. package/.claude/skills/add-github/SKILL.md +0 -148
  120. package/.claude/skills/add-github/VERIFY.md +0 -3
  121. package/.claude/skills/add-gmail-tool/SKILL.md +0 -229
  122. package/.claude/skills/add-imessage/REMOVE.md +0 -6
  123. package/.claude/skills/add-imessage/SKILL.md +0 -113
  124. package/.claude/skills/add-imessage/VERIFY.md +0 -3
  125. package/.claude/skills/add-karpathy-llm-wiki/SKILL.md +0 -110
  126. package/.claude/skills/add-karpathy-llm-wiki/llm-wiki.md +0 -75
  127. package/.claude/skills/add-linear/REMOVE.md +0 -6
  128. package/.claude/skills/add-linear/SKILL.md +0 -168
  129. package/.claude/skills/add-linear/VERIFY.md +0 -3
  130. package/.claude/skills/add-macos-statusbar/SKILL.md +0 -133
  131. package/.claude/skills/add-macos-statusbar/add/src/statusbar.swift +0 -147
  132. package/.claude/skills/add-matrix/REMOVE.md +0 -6
  133. package/.claude/skills/add-matrix/SKILL.md +0 -148
  134. package/.claude/skills/add-matrix/VERIFY.md +0 -3
  135. package/.claude/skills/add-ollama-provider/SKILL.md +0 -179
  136. package/.claude/skills/add-ollama-tool/SKILL.md +0 -193
  137. package/.claude/skills/add-opencode/SKILL.md +0 -229
  138. package/.claude/skills/add-parallel/SKILL.md +0 -290
  139. package/.claude/skills/add-resend/REMOVE.md +0 -6
  140. package/.claude/skills/add-resend/SKILL.md +0 -93
  141. package/.claude/skills/add-resend/VERIFY.md +0 -3
  142. package/.claude/skills/add-signal/REMOVE.md +0 -13
  143. package/.claude/skills/add-signal/SKILL.md +0 -318
  144. package/.claude/skills/add-signal/VERIFY.md +0 -5
  145. package/.claude/skills/add-slack/REMOVE.md +0 -6
  146. package/.claude/skills/add-slack/SKILL.md +0 -112
  147. package/.claude/skills/add-slack/VERIFY.md +0 -3
  148. package/.claude/skills/add-teams/REMOVE.md +0 -6
  149. package/.claude/skills/add-teams/SKILL.md +0 -207
  150. package/.claude/skills/add-teams/VERIFY.md +0 -3
  151. package/.claude/skills/add-vercel/SKILL.md +0 -147
  152. package/.claude/skills/add-vercel/container-skills/vercel-cli/SKILL.md +0 -103
  153. package/.claude/skills/add-webex/REMOVE.md +0 -6
  154. package/.claude/skills/add-webex/SKILL.md +0 -88
  155. package/.claude/skills/add-webex/VERIFY.md +0 -3
  156. package/.claude/skills/add-wechat/REMOVE.md +0 -49
  157. package/.claude/skills/add-wechat/SKILL.md +0 -170
  158. package/.claude/skills/add-wechat/scripts/wire-dm.ts +0 -172
  159. package/.claude/skills/add-whatsapp/SKILL.md +0 -264
  160. package/.claude/skills/add-whatsapp-cloud/REMOVE.md +0 -6
  161. package/.claude/skills/add-whatsapp-cloud/SKILL.md +0 -95
  162. package/.claude/skills/add-whatsapp-cloud/VERIFY.md +0 -3
  163. package/.claude/skills/claw/SKILL.md +0 -131
  164. package/.claude/skills/claw/scripts/claw +0 -374
  165. package/.claude/skills/convert-to-apple-container/SKILL.md +0 -212
  166. package/.claude/skills/customize/SKILL.md +0 -110
  167. package/.claude/skills/debug/SKILL.md +0 -349
  168. package/.claude/skills/get-qodo-rules/SKILL.md +0 -122
  169. package/.claude/skills/get-qodo-rules/references/output-format.md +0 -41
  170. package/.claude/skills/get-qodo-rules/references/pagination.md +0 -33
  171. package/.claude/skills/get-qodo-rules/references/repository-scope.md +0 -26
  172. package/.claude/skills/init-first-agent/SKILL.md +0 -120
  173. package/.claude/skills/init-onecli/SKILL.md +0 -270
  174. package/.claude/skills/manage-channels/SKILL.md +0 -87
  175. package/.claude/skills/manage-mounts/SKILL.md +0 -47
  176. package/.claude/skills/migrate-from-openclaw/MIGRATE_CRONS.md +0 -100
  177. package/.claude/skills/migrate-from-openclaw/SKILL.md +0 -447
  178. package/.claude/skills/migrate-from-openclaw/scripts/discover-openclaw.ts +0 -734
  179. package/.claude/skills/migrate-from-openclaw/scripts/extract-channel-credentials.ts +0 -476
  180. package/.claude/skills/migrate-nanoclaw/SKILL.md +0 -484
  181. package/.claude/skills/migrate-nanoclaw/diagnostics.md +0 -51
  182. package/.claude/skills/qodo-pr-resolver/SKILL.md +0 -326
  183. package/.claude/skills/qodo-pr-resolver/resources/providers.md +0 -329
  184. package/.claude/skills/update-nanoclaw/SKILL.md +0 -243
  185. package/.claude/skills/update-nanoclaw/diagnostics.md +0 -48
  186. package/.claude/skills/update-skills/SKILL.md +0 -130
  187. package/.claude/skills/use-native-credential-proxy/SKILL.md +0 -167
  188. package/.claude/skills/x-integration/SKILL.md +0 -417
  189. package/.claude/skills/x-integration/agent.ts +0 -243
  190. package/.claude/skills/x-integration/host.ts +0 -155
  191. package/.claude/skills/x-integration/lib/browser.ts +0 -148
  192. package/.claude/skills/x-integration/lib/config.ts +0 -62
  193. package/.claude/skills/x-integration/scripts/like.ts +0 -56
  194. package/.claude/skills/x-integration/scripts/post.ts +0 -66
  195. package/.claude/skills/x-integration/scripts/quote.ts +0 -80
  196. package/.claude/skills/x-integration/scripts/reply.ts +0 -74
  197. package/.claude/skills/x-integration/scripts/retweet.ts +0 -62
  198. package/.claude/skills/x-integration/scripts/setup.ts +0 -87
  199. package/.github/CODEOWNERS +0 -10
  200. package/.github/PULL_REQUEST_TEMPLATE.md +0 -18
  201. package/.github/workflows/bump-version.yml +0 -35
  202. package/.github/workflows/ci.yml +0 -39
  203. package/.github/workflows/label-pr.yml +0 -40
  204. package/.github/workflows/update-tokens.yml +0 -43
  205. package/.husky/pre-commit +0 -1
  206. package/.mcp.json +0 -3
  207. package/.nvmrc +0 -1
  208. package/.prettierrc +0 -4
  209. package/CHANGELOG.md +0 -263
  210. package/CLAUDE.md +0 -307
  211. package/CODE_OF_CONDUCT.md +0 -128
  212. package/CONTRIBUTING.md +0 -159
  213. package/CONTRIBUTORS.md +0 -26
  214. package/LICENSE-NANOCLAW-MIT +0 -21
  215. package/README_ja.md +0 -194
  216. package/README_zh.md +0 -194
  217. package/assets/nanoclaw-favicon.png +0 -0
  218. package/assets/nanoclaw-icon.png +0 -0
  219. package/assets/nanoclaw-logo-dark.png +0 -0
  220. package/assets/nanoclaw-logo.png +0 -0
  221. package/assets/nanoclaw-profile.jpeg +0 -0
  222. package/assets/nanoclaw-sales.png +0 -0
  223. package/assets/social-preview.jpg +0 -0
  224. package/config-examples/mount-allowlist.json +0 -25
  225. package/container/.dockerignore +0 -2
  226. package/container/CLAUDE.md +0 -21
  227. package/container/Dockerfile +0 -121
  228. package/container/agent-runner/bun.lock +0 -243
  229. package/container/agent-runner/package.json +0 -22
  230. package/container/agent-runner/scripts/sdk-signal-probe.ts +0 -169
  231. package/container/agent-runner/src/config.ts +0 -55
  232. package/container/agent-runner/src/db/connection.ts +0 -267
  233. package/container/agent-runner/src/db/index.ts +0 -20
  234. package/container/agent-runner/src/db/messages-in.ts +0 -138
  235. package/container/agent-runner/src/db/messages-out.ts +0 -143
  236. package/container/agent-runner/src/db/session-routing.ts +0 -30
  237. package/container/agent-runner/src/db/session-state.test.ts +0 -100
  238. package/container/agent-runner/src/db/session-state.ts +0 -79
  239. package/container/agent-runner/src/destinations.ts +0 -135
  240. package/container/agent-runner/src/formatter.test.ts +0 -167
  241. package/container/agent-runner/src/formatter.ts +0 -260
  242. package/container/agent-runner/src/index.ts +0 -110
  243. package/container/agent-runner/src/integration.test.ts +0 -121
  244. package/container/agent-runner/src/mcp-tools/agents.instructions.md +0 -26
  245. package/container/agent-runner/src/mcp-tools/agents.ts +0 -66
  246. package/container/agent-runner/src/mcp-tools/core.instructions.md +0 -27
  247. package/container/agent-runner/src/mcp-tools/core.ts +0 -262
  248. package/container/agent-runner/src/mcp-tools/index.ts +0 -22
  249. package/container/agent-runner/src/mcp-tools/interactive.instructions.md +0 -22
  250. package/container/agent-runner/src/mcp-tools/interactive.ts +0 -169
  251. package/container/agent-runner/src/mcp-tools/scheduling.instructions.md +0 -40
  252. package/container/agent-runner/src/mcp-tools/scheduling.ts +0 -299
  253. package/container/agent-runner/src/mcp-tools/self-mod.instructions.md +0 -25
  254. package/container/agent-runner/src/mcp-tools/self-mod.ts +0 -120
  255. package/container/agent-runner/src/mcp-tools/server.ts +0 -54
  256. package/container/agent-runner/src/mcp-tools/types.ts +0 -6
  257. package/container/agent-runner/src/poll-loop.test.ts +0 -248
  258. package/container/agent-runner/src/poll-loop.ts +0 -437
  259. package/container/agent-runner/src/providers/claude.ts +0 -379
  260. package/container/agent-runner/src/providers/factory.test.ts +0 -19
  261. package/container/agent-runner/src/providers/factory.ts +0 -13
  262. package/container/agent-runner/src/providers/index.ts +0 -6
  263. package/container/agent-runner/src/providers/mock.ts +0 -77
  264. package/container/agent-runner/src/providers/provider-registry.ts +0 -33
  265. package/container/agent-runner/src/providers/types.ts +0 -82
  266. package/container/agent-runner/src/scheduling/task-script.ts +0 -121
  267. package/container/agent-runner/src/timezone.test.ts +0 -93
  268. package/container/agent-runner/src/timezone.ts +0 -107
  269. package/container/agent-runner/tsconfig.json +0 -14
  270. package/container/build.sh +0 -48
  271. package/container/entrypoint.sh +0 -16
  272. package/container/skills/agent-browser/SKILL.md +0 -159
  273. package/container/skills/frontend-engineer/SKILL.md +0 -157
  274. package/container/skills/self-customize/SKILL.md +0 -87
  275. package/container/skills/slack-formatting/SKILL.md +0 -94
  276. package/container/skills/vercel-cli/SKILL.md +0 -111
  277. package/container/skills/welcome/SKILL.md +0 -85
  278. package/docs/APPLE-CONTAINER-NETWORKING.md +0 -90
  279. package/docs/BRANCH-FORK-MAINTENANCE.md +0 -81
  280. package/docs/README.md +0 -25
  281. package/docs/SDK_DEEP_DIVE.md +0 -643
  282. package/docs/SECURITY.md +0 -162
  283. package/docs/agent-runner-details.md +0 -749
  284. package/docs/api-details.md +0 -365
  285. package/docs/architecture-diagram.html +0 -422
  286. package/docs/architecture-diagram.md +0 -215
  287. package/docs/architecture.md +0 -751
  288. package/docs/audit/2026-04-30-channel-endpoint-audit.md +0 -36
  289. package/docs/build-and-runtime.md +0 -80
  290. package/docs/cross-mount-stress/README.md +0 -112
  291. package/docs/cross-mount-stress/container-writer-retry.mjs +0 -55
  292. package/docs/cross-mount-stress/container-writer-slow.mjs +0 -42
  293. package/docs/cross-mount-stress/container-writer.mjs +0 -47
  294. package/docs/cross-mount-stress/host-writer-retry.mjs +0 -55
  295. package/docs/cross-mount-stress/host-writer-slow.mjs +0 -43
  296. package/docs/cross-mount-stress/host-writer.mjs +0 -47
  297. package/docs/db-central.md +0 -316
  298. package/docs/db-session.md +0 -183
  299. package/docs/db.md +0 -119
  300. package/docs/design/2026-04-29-vault-management-ui.md +0 -231
  301. package/docs/design/2026-04-30-channel-wiring-rework.md +0 -234
  302. package/docs/design/2026-05-01-channel-wiring-approvals-deep-dive.md +0 -272
  303. package/docs/design/2026-05-02-channel-policy-and-approval-routing.md +0 -250
  304. package/docs/docker-sandboxes.md +0 -359
  305. package/docs/isolation-model.md +0 -88
  306. package/docs/ollama.md +0 -79
  307. package/docs/parachute-integration.md +0 -109
  308. package/docs/post-night-rebirth-reflections.md +0 -151
  309. package/eslint.config.js +0 -32
  310. package/pnpm-workspace.yaml +0 -8
  311. package/repo-tokens/README.md +0 -113
  312. package/repo-tokens/action.yml +0 -186
  313. package/repo-tokens/badge.svg +0 -23
  314. package/repo-tokens/examples/green.svg +0 -14
  315. package/repo-tokens/examples/red.svg +0 -14
  316. package/repo-tokens/examples/yellow-green.svg +0 -14
  317. package/repo-tokens/examples/yellow.svg +0 -14
  318. package/scripts/chat.ts +0 -101
  319. package/scripts/cleanup-sessions.sh +0 -150
  320. package/scripts/init-cli-agent.ts +0 -172
  321. package/scripts/init-first-agent.ts +0 -378
  322. package/scripts/parachute.ts +0 -158
  323. package/scripts/run-migrations.ts +0 -105
  324. package/scripts/sanity-live-poll.ts +0 -95
  325. package/scripts/seed-discord.ts +0 -80
  326. package/scripts/test-v2-agent.ts +0 -106
  327. package/scripts/test-v2-channel-e2e.ts +0 -265
  328. package/scripts/test-v2-host.ts +0 -184
  329. package/src/channels/adapter.ts +0 -214
  330. package/src/channels/api-translator.test.ts +0 -306
  331. package/src/channels/api-translator.ts +0 -214
  332. package/src/channels/ask-question.ts +0 -46
  333. package/src/channels/channel-registry.test.ts +0 -421
  334. package/src/channels/channel-registry.ts +0 -313
  335. package/src/channels/chat-sdk-bridge.test.ts +0 -84
  336. package/src/channels/chat-sdk-bridge.ts +0 -652
  337. package/src/channels/cli.ts +0 -276
  338. package/src/channels/discord.ts +0 -90
  339. package/src/channels/index.ts +0 -17
  340. package/src/channels/telegram-markdown-sanitize.test.ts +0 -78
  341. package/src/channels/telegram-markdown-sanitize.ts +0 -55
  342. package/src/channels/telegram-pairing.test.ts +0 -254
  343. package/src/channels/telegram-pairing.ts +0 -339
  344. package/src/channels/telegram.ts +0 -279
  345. package/src/channels/trust-hint.test.ts +0 -48
  346. package/src/channels/trust-hint.ts +0 -75
  347. package/src/claude-md-compose.migrate.test.ts +0 -64
  348. package/src/claude-md-compose.ts +0 -205
  349. package/src/command-gate.ts +0 -63
  350. package/src/config.test.ts +0 -93
  351. package/src/config.ts +0 -128
  352. package/src/container-config.ts +0 -167
  353. package/src/container-runner.test.ts +0 -32
  354. package/src/container-runner.ts +0 -576
  355. package/src/container-runtime.test.ts +0 -269
  356. package/src/container-runtime.ts +0 -167
  357. package/src/db/_bun-sqlite-shim.ts +0 -88
  358. package/src/db/agent-activity.test.ts +0 -155
  359. package/src/db/agent-activity.ts +0 -121
  360. package/src/db/agent-groups.ts +0 -77
  361. package/src/db/connection.migrate.test.ts +0 -176
  362. package/src/db/connection.ts +0 -259
  363. package/src/db/db-v2.test.ts +0 -440
  364. package/src/db/dropped-messages.ts +0 -44
  365. package/src/db/index.ts +0 -40
  366. package/src/db/messaging-groups.ts +0 -252
  367. package/src/db/migrations/001-initial.ts +0 -112
  368. package/src/db/migrations/002-chat-sdk-state.ts +0 -36
  369. package/src/db/migrations/008-dropped-messages.ts +0 -27
  370. package/src/db/migrations/009-drop-pending-credentials.ts +0 -13
  371. package/src/db/migrations/010-engage-modes.ts +0 -103
  372. package/src/db/migrations/011-pending-sender-approvals.ts +0 -40
  373. package/src/db/migrations/012-channel-registration.ts +0 -48
  374. package/src/db/migrations/013-approval-render-metadata.ts +0 -27
  375. package/src/db/migrations/014-secrets.ts +0 -44
  376. package/src/db/migrations/015-secrets-drop-host-pattern.ts +0 -18
  377. package/src/db/migrations/016-secret-assignments.ts +0 -30
  378. package/src/db/migrations/017-agent-activity.ts +0 -40
  379. package/src/db/migrations/018-oauth-app-configs.ts +0 -34
  380. package/src/db/migrations/019-oauth-app-connections.ts +0 -48
  381. package/src/db/migrations/020-agent-app-connections.ts +0 -28
  382. package/src/db/migrations/021-pending-oauth-states.ts +0 -35
  383. package/src/db/migrations/022-app-connections-provider.ts +0 -25
  384. package/src/db/migrations/023-agent-group-secret-mode.test.ts +0 -124
  385. package/src/db/migrations/023-agent-group-secret-mode.ts +0 -65
  386. package/src/db/migrations/024-collapse-approvals.test.ts +0 -249
  387. package/src/db/migrations/024-collapse-approvals.ts +0 -182
  388. package/src/db/migrations/025-secret-mode-check.test.ts +0 -155
  389. package/src/db/migrations/025-secret-mode-check.ts +0 -49
  390. package/src/db/migrations/026-user-dms-bot-id.test.ts +0 -116
  391. package/src/db/migrations/026-user-dms-bot-id.ts +0 -54
  392. package/src/db/migrations/027-provider-credentials.ts +0 -41
  393. package/src/db/migrations/_test-helpers.ts +0 -41
  394. package/src/db/migrations/index.ts +0 -127
  395. package/src/db/migrations/module-agent-to-agent-destinations.ts +0 -84
  396. package/src/db/migrations/module-approvals-pending-approvals.ts +0 -42
  397. package/src/db/migrations/module-approvals-title-options.ts +0 -40
  398. package/src/db/schema.ts +0 -258
  399. package/src/db/session-db.test.ts +0 -93
  400. package/src/db/session-db.ts +0 -325
  401. package/src/db/sessions.ts +0 -241
  402. package/src/delivery.test.ts +0 -148
  403. package/src/delivery.ts +0 -445
  404. package/src/env.ts +0 -74
  405. package/src/group-folder.test.ts +0 -35
  406. package/src/group-folder.ts +0 -44
  407. package/src/group-init.ts +0 -92
  408. package/src/host-core.test.ts +0 -456
  409. package/src/host-sweep.test.ts +0 -146
  410. package/src/host-sweep.ts +0 -287
  411. package/src/index.ts +0 -232
  412. package/src/install-slug.ts +0 -33
  413. package/src/log.test.ts +0 -81
  414. package/src/log.ts +0 -117
  415. package/src/mcp/http.ts +0 -72
  416. package/src/mcp/server.ts +0 -92
  417. package/src/mcp/stdio.ts +0 -51
  418. package/src/mcp/tools/activity.ts +0 -88
  419. package/src/mcp/tools/agent-groups.ts +0 -183
  420. package/src/mcp/tools/approvals.ts +0 -122
  421. package/src/mcp/tools/channels.test.ts +0 -126
  422. package/src/mcp/tools/channels.ts +0 -134
  423. package/src/mcp/tools/index.ts +0 -27
  424. package/src/mcp/tools/oauth.ts +0 -48
  425. package/src/mcp/tools/secrets.ts +0 -169
  426. package/src/mcp/tools/sessions.ts +0 -135
  427. package/src/mcp/types.ts +0 -51
  428. package/src/modules/agent-to-agent/agent-route.test.ts +0 -46
  429. package/src/modules/agent-to-agent/agent-route.ts +0 -223
  430. package/src/modules/agent-to-agent/create-agent.ts +0 -127
  431. package/src/modules/agent-to-agent/db/agent-destinations.ts +0 -135
  432. package/src/modules/agent-to-agent/index.ts +0 -22
  433. package/src/modules/agent-to-agent/write-destinations.ts +0 -59
  434. package/src/modules/approvals/agent.md +0 -45
  435. package/src/modules/approvals/index.ts +0 -21
  436. package/src/modules/approvals/picks.test.ts +0 -291
  437. package/src/modules/approvals/primitive.ts +0 -279
  438. package/src/modules/approvals/project.md +0 -27
  439. package/src/modules/approvals/response-handler.ts +0 -87
  440. package/src/modules/index.ts +0 -24
  441. package/src/modules/interactive/agent.md +0 -21
  442. package/src/modules/interactive/index.ts +0 -69
  443. package/src/modules/interactive/project.md +0 -12
  444. package/src/modules/mount-security/expand-path.test.ts +0 -82
  445. package/src/modules/mount-security/index.ts +0 -459
  446. package/src/modules/mount-security/migrate.test.ts +0 -91
  447. package/src/modules/permissions/access.ts +0 -28
  448. package/src/modules/permissions/channel-approval.test.ts +0 -389
  449. package/src/modules/permissions/channel-approval.ts +0 -188
  450. package/src/modules/permissions/db/agent-group-members.ts +0 -44
  451. package/src/modules/permissions/db/pending-channel-approvals.test.ts +0 -86
  452. package/src/modules/permissions/db/pending-channel-approvals.ts +0 -66
  453. package/src/modules/permissions/db/pending-sender-approvals.ts +0 -60
  454. package/src/modules/permissions/db/user-dms.ts +0 -58
  455. package/src/modules/permissions/db/user-roles.ts +0 -85
  456. package/src/modules/permissions/db/users.ts +0 -38
  457. package/src/modules/permissions/index.ts +0 -421
  458. package/src/modules/permissions/permissions.test.ts +0 -358
  459. package/src/modules/permissions/sender-approval.test.ts +0 -641
  460. package/src/modules/permissions/sender-approval.ts +0 -165
  461. package/src/modules/permissions/user-dm.ts +0 -200
  462. package/src/modules/provider-credentials/db.ts +0 -121
  463. package/src/modules/provider-credentials/index.ts +0 -12
  464. package/src/modules/provider-credentials/spawn.test.ts +0 -206
  465. package/src/modules/provider-credentials/spawn.ts +0 -114
  466. package/src/modules/scheduling/actions.ts +0 -113
  467. package/src/modules/scheduling/db.test.ts +0 -282
  468. package/src/modules/scheduling/db.ts +0 -148
  469. package/src/modules/scheduling/index.ts +0 -34
  470. package/src/modules/scheduling/recurrence.test.ts +0 -98
  471. package/src/modules/scheduling/recurrence.ts +0 -54
  472. package/src/modules/self-mod/agent.md +0 -30
  473. package/src/modules/self-mod/apply.ts +0 -85
  474. package/src/modules/self-mod/index.ts +0 -30
  475. package/src/modules/self-mod/project.md +0 -39
  476. package/src/modules/self-mod/request.ts +0 -91
  477. package/src/modules/typing/index.ts +0 -165
  478. package/src/oauth/agent-app-connections.ts +0 -103
  479. package/src/oauth/app-configs.test.ts +0 -64
  480. package/src/oauth/app-configs.ts +0 -114
  481. package/src/oauth/app-connections.test.ts +0 -109
  482. package/src/oauth/app-connections.ts +0 -178
  483. package/src/oauth/crypto.ts +0 -56
  484. package/src/oauth/flow.ts +0 -104
  485. package/src/oauth/providers/google.test.ts +0 -38
  486. package/src/oauth/providers/google.ts +0 -46
  487. package/src/oauth/providers/index.ts +0 -48
  488. package/src/oauth/state-store.test.ts +0 -54
  489. package/src/oauth/state-store.ts +0 -93
  490. package/src/parachute/README.md +0 -27
  491. package/src/parachute/create-agent.test.ts +0 -83
  492. package/src/parachute/create-agent.ts +0 -122
  493. package/src/parachute/group-status.test.ts +0 -165
  494. package/src/parachute/group-status.ts +0 -136
  495. package/src/parachute/types.ts +0 -41
  496. package/src/parachute/vault-mcp.test.ts +0 -251
  497. package/src/parachute/vault-mcp.ts +0 -232
  498. package/src/platform-id.test.ts +0 -104
  499. package/src/platform-id.ts +0 -109
  500. package/src/providers/index.ts +0 -6
  501. package/src/providers/provider-container-registry.ts +0 -58
  502. package/src/response-registry.ts +0 -45
  503. package/src/router.ts +0 -530
  504. package/src/secrets/crypto.test.ts +0 -45
  505. package/src/secrets/crypto.ts +0 -55
  506. package/src/secrets/index.ts +0 -461
  507. package/src/secrets/master-key.ts +0 -70
  508. package/src/secrets/secrets.test.ts +0 -651
  509. package/src/session-manager.attachments.test.ts +0 -171
  510. package/src/session-manager.dup-skip.test.ts +0 -173
  511. package/src/session-manager.migrate.test.ts +0 -59
  512. package/src/session-manager.ts +0 -451
  513. package/src/startup-bootstrap.test.ts +0 -226
  514. package/src/startup-bootstrap.ts +0 -207
  515. package/src/state-sqlite.ts +0 -182
  516. package/src/timezone.test.ts +0 -64
  517. package/src/timezone.ts +0 -37
  518. package/src/types.ts +0 -233
  519. package/src/web/auth.test.ts +0 -335
  520. package/src/web/auth.ts +0 -214
  521. package/src/web/discord-validate.test.ts +0 -77
  522. package/src/web/discord-validate.ts +0 -88
  523. package/src/web/hub-discovery.test.ts +0 -98
  524. package/src/web/hub-discovery.ts +0 -69
  525. package/src/web/routes/activity.ts +0 -106
  526. package/src/web/routes/agent-provider.test.ts +0 -282
  527. package/src/web/routes/agent-provider.ts +0 -309
  528. package/src/web/routes/approvals.ts +0 -185
  529. package/src/web/routes/apps.ts +0 -434
  530. package/src/web/routes/channels-mg-detail.test.ts +0 -324
  531. package/src/web/routes/channels-mga-detail.test.ts +0 -472
  532. package/src/web/routes/channels.ts +0 -311
  533. package/src/web/routes/oauth-providers.ts +0 -42
  534. package/src/web/routes/secrets.test.ts +0 -220
  535. package/src/web/routes/secrets.ts +0 -317
  536. package/src/web/routes/sessions.ts +0 -123
  537. package/src/web/routes/settings.test.ts +0 -106
  538. package/src/web/routes/settings.ts +0 -247
  539. package/src/web/routes/setup-status.ts +0 -205
  540. package/src/web/routes/vaults.test.ts +0 -389
  541. package/src/web/routes/vaults.ts +0 -225
  542. package/src/web/server-version.test.ts +0 -16
  543. package/src/web/server.ts +0 -1024
  544. package/src/web/services-manifest.test.ts +0 -148
  545. package/src/web/services-manifest.ts +0 -66
  546. package/src/web/static-serve.test.ts +0 -255
  547. package/src/web/static-serve.ts +0 -104
  548. package/src/web/telegram-validate.test.ts +0 -116
  549. package/src/web/telegram-validate.ts +0 -107
  550. package/src/web/vault-proxy.test.ts +0 -214
  551. package/src/web/vault-proxy.ts +0 -120
  552. package/src/web/wire-channel.ts +0 -181
  553. package/src/webhook-server.ts +0 -134
  554. package/vitest.config.ts +0 -18
  555. package/web/README.md +0 -63
  556. package/web/ui/index.html +0 -13
  557. package/web/ui/package.json +0 -35
  558. package/web/ui/pnpm-lock.yaml +0 -2164
  559. package/web/ui/scripts/verify-base.mjs +0 -31
  560. package/web/ui/src/App.tsx +0 -88
  561. package/web/ui/src/components/ActivityFeed.tsx +0 -444
  562. package/web/ui/src/components/AgentGroupPicker.tsx +0 -263
  563. package/web/ui/src/components/AgentProviderCards.tsx +0 -220
  564. package/web/ui/src/components/CredentialForm.tsx +0 -214
  565. package/web/ui/src/components/ScopeGrants.tsx +0 -74
  566. package/web/ui/src/components/StatusDot.tsx +0 -43
  567. package/web/ui/src/components/VaultPicker.tsx +0 -127
  568. package/web/ui/src/components/setup/AdapterInstallStep.tsx +0 -178
  569. package/web/ui/src/components/setup/AgentGroupStep.tsx +0 -43
  570. package/web/ui/src/components/setup/ChannelPickStep.tsx +0 -74
  571. package/web/ui/src/components/setup/DoneStep.tsx +0 -49
  572. package/web/ui/src/components/setup/PrereqStep.tsx +0 -129
  573. package/web/ui/src/components/setup/TestConnectionStep.tsx +0 -108
  574. package/web/ui/src/components/setup/TestMessageStep.tsx +0 -104
  575. package/web/ui/src/components/setup/WireChannelStep.tsx +0 -166
  576. package/web/ui/src/components/setup/types.ts +0 -105
  577. package/web/ui/src/lib/api.test.ts +0 -410
  578. package/web/ui/src/lib/api.ts +0 -1248
  579. package/web/ui/src/lib/auth.test.ts +0 -352
  580. package/web/ui/src/lib/auth.ts +0 -405
  581. package/web/ui/src/lib/channel-adapters.ts +0 -136
  582. package/web/ui/src/main.tsx +0 -19
  583. package/web/ui/src/routes/ApprovalsList.tsx +0 -294
  584. package/web/ui/src/routes/Apps.tsx +0 -613
  585. package/web/ui/src/routes/ChannelWireDetail.test.tsx +0 -233
  586. package/web/ui/src/routes/ChannelWireDetail.tsx +0 -403
  587. package/web/ui/src/routes/ChannelsList.tsx +0 -158
  588. package/web/ui/src/routes/GroupDetail.test.tsx +0 -206
  589. package/web/ui/src/routes/GroupDetail.tsx +0 -880
  590. package/web/ui/src/routes/GroupList.tsx +0 -187
  591. package/web/ui/src/routes/MessagingGroupDetail.test.tsx +0 -233
  592. package/web/ui/src/routes/MessagingGroupDetail.tsx +0 -306
  593. package/web/ui/src/routes/NewGroupWizard.tsx +0 -390
  594. package/web/ui/src/routes/OAuthCallback.tsx +0 -56
  595. package/web/ui/src/routes/SecretsList.tsx +0 -942
  596. package/web/ui/src/routes/SessionsList.tsx +0 -220
  597. package/web/ui/src/routes/SettingsAgentProvider.tsx +0 -109
  598. package/web/ui/src/routes/SettingsApprovals.tsx +0 -234
  599. package/web/ui/src/routes/SetupWizard.tsx +0 -219
  600. package/web/ui/src/routes/VaultDetail.test.tsx +0 -363
  601. package/web/ui/src/routes/VaultDetail.tsx +0 -960
  602. package/web/ui/src/routes/VaultsList.tsx +0 -295
  603. package/web/ui/src/routes/WireChannelPage.tsx +0 -413
  604. package/web/ui/src/styles.css +0 -608
  605. package/web/ui/src/test/setup.ts +0 -23
  606. package/web/ui/src/vite-env.d.ts +0 -10
  607. package/web/ui/vite.config.ts +0 -34
  608. package/web/ui/vitest.config.ts +0 -25
@@ -0,0 +1,1702 @@
1
+ /**
2
+ * Vault-native agent definitions — an agent IS a `#agent/definition` note
3
+ * (design `2026-06-17-vault-native-agents.md`, Phase 4a).
4
+ *
5
+ * Instead of a `channels.json` entry + a `sessions/<name>/spec.json`, a
6
+ * vault-native agent is a single vault note: the note BODY is the system prompt,
7
+ * the note METADATA is the config. The module reads `#agent/definition` notes from
8
+ * a configured DEF-VAULT and, for each one, instantiates a live agent — a vault
9
+ * channel (so inbound/outbound notes flow) + a registered programmatic agent (so an
10
+ * inbound turn runs `claude -p`). Reactively: a note created/updated/deleted →
11
+ * reload that one agent.
12
+ *
13
+ * REUSE (the design's "near-stateless executor" point — this module is small
14
+ * because it stands on the existing machinery):
15
+ * - {@link AgentSpec} (sandbox/types.ts) stays the canonical in-memory shape; only
16
+ * its SOURCE moves from `spec.json` to a note. {@link parseAgentDef} is "note →
17
+ * AgentSpec".
18
+ * - `addChannelLive` (daemon.ts) brings up the vault channel — the SAME call the
19
+ * create-agent flow + boot use; injected here as {@link InstantiateDeps.ensureChannel}.
20
+ * - `setupProgrammaticSpawn` (agents.ts) persists `spec.json` (so the existing boot
21
+ * re-register + the per-turn deliver find the workspace) and `programmatic.register`
22
+ * registers the agent — injected as {@link InstantiateDeps.setupAndRegister}.
23
+ * - The def-vault's `vault:<name>:write` token (minted by the daemon the SAME way a
24
+ * channel/job token is — `mint-token.ts`) drives BOTH the def query and the status
25
+ * stamp; the vault REST encoding mirrors `VaultTransport`.
26
+ *
27
+ * SCOPE (4a only — OWN-VAULT). An agent defined in vault X is scoped to vault X: its
28
+ * conversation + jobs live there, and its minted vault token is for X. There is NO
29
+ * cross-vault / MCP / external-service connector, NO approval flow — that is 4b.
30
+ * A def MAY declare a `uses: […]` / connections field; we PARSE + SURFACE it (so the
31
+ * status note lists what it wants) but do NOT grant it. Secrets NEVER live in a note;
32
+ * the Claude OAuth token + any service creds stay in the local store and are injected
33
+ * at run time by the programmatic backend, exactly as today.
34
+ *
35
+ * STATUS (queryable liveness — the design's "lives in the field so an MCP side knows"):
36
+ * after resolving a def, the registry PATCHes the note's metadata `status`. In 4a
37
+ * (own-vault only) a successfully-instantiated agent is `enabled`; a def that declares
38
+ * external connections is `pending` (listing them) since 4b hasn't granted them yet —
39
+ * it still runs own-vault, the declared connections are simply absent until approved.
40
+ */
41
+
42
+ import {
43
+ type AgentSpec,
44
+ type AgentBackendKind,
45
+ type AgentMode,
46
+ type SystemPromptMode,
47
+ type AgentMount,
48
+ } from "./sandbox/types.ts";
49
+ import { AGENT_DEFINITION_TAG } from "./transports/vault.ts";
50
+ import {
51
+ parseWants,
52
+ connectionKey,
53
+ resolveConnectionStatus,
54
+ WantsParseError,
55
+ GrantsClient,
56
+ type ConnectionSpec,
57
+ } from "./grants.ts";
58
+
59
+ const DEFAULT_DEF_VAULT_URL = "http://127.0.0.1:1940";
60
+
61
+ /**
62
+ * Page cap for a def-vault list. The poll's removed-def diff now DEREGISTERS (a
63
+ * destructive teardown), so a list that hits this cap is treated as possibly-
64
+ * truncated — NOT a confident set — and the removal diff is skipped that pass (see
65
+ * {@link AgentDefRegistry.loadAll}'s truncation guard). 500 comfortably exceeds any
66
+ * realistic agent count; it exists so the teardown is safe by construction.
67
+ */
68
+ const DEF_LIST_LIMIT = 500;
69
+
70
+ /** A slug: alphanumeric, dash, underscore — the agent name + wake-channel key. */
71
+ const NAME_SLUG_RE = /^[a-zA-Z0-9_-]+$/;
72
+
73
+ /**
74
+ * A def-vault the module reads `#agent/definition` notes from. The architecture is
75
+ * a LIST (default: one — the local `default` vault) so opening up multi-vault later
76
+ * is appending, not a refactor (design "Decided: multi-vault"). The token grants
77
+ * vault read (query defs) + write (stamp status + the agents' message/job notes),
78
+ * scoped to THIS vault only — an agent defined here reaches only this vault (4a).
79
+ */
80
+ export interface DefVaultBinding {
81
+ /** Vault name (the `<vault>` path segment in the REST URL). */
82
+ vault: string;
83
+ /** REST base origin. Default `http://127.0.0.1:1940`. */
84
+ vaultUrl?: string;
85
+ /** A `vault:<name>:write` hub JWT (read + write), presented as Bearer. */
86
+ token: string;
87
+ }
88
+
89
+ /** The resolved status of a def after instantiation (stamped onto the note). */
90
+ export type AgentDefStatus = "enabled" | "pending" | "error";
91
+
92
+ /**
93
+ * Per-connection grant info surfaced to the ops UI (the MCP/connections panel) so it
94
+ * can render a status pill + drive the cookie→hub "Connect" without re-deriving the
95
+ * hub's grant id client-side (that divergence class already bit this codebase — the
96
+ * id MUST come from the hub). One entry per declared `wants:` connection.
97
+ *
98
+ * - `key` — the stable {@link connectionKey} (matches a `wants` entry).
99
+ * - `kind` — `vault` | `service` | `mcp` (the panel only acts on `mcp` today).
100
+ * - `target` — the connection target (for `mcp`, the remote https URL).
101
+ * - `status` — the hub grant's lifecycle as the hub reports it
102
+ * (`pending` | `approved` | `revoked` | `needs_consent`), or `pending` when no
103
+ * grant could be resolved (no grants client / a registration error).
104
+ * - `grantId` — the hub-assigned grant id (the Connect/approve key). Absent when no
105
+ * grant was registered/resolved (then the UI can't offer Connect — it shows a
106
+ * degraded hint instead).
107
+ */
108
+ export interface ConnectionInfo {
109
+ key: string;
110
+ kind: "vault" | "service" | "mcp";
111
+ target: string;
112
+ status: string;
113
+ grantId?: string;
114
+ }
115
+
116
+ /**
117
+ * The parse of one `#agent/definition` note: the canonical {@link AgentSpec} the
118
+ * registry instantiates, plus the note bookkeeping (its id for PATCH, the declared
119
+ * connections to surface, and any parse error).
120
+ */
121
+ export interface ParsedAgentDef {
122
+ /** The vault note id/path — addresses the note for the status PATCH. */
123
+ noteId: string;
124
+ /** The agent name (= the wake channel + the spec name). */
125
+ name: string;
126
+ /** The canonical in-memory spec, ready for `programmatic.register`. */
127
+ spec: AgentSpec;
128
+ /**
129
+ * Declared cross-vault / MCP / external-service connections beyond the def-vault
130
+ * (the legacy `uses:` field — raw name strings). PARSED + surfaced in 4a; superseded
131
+ * by the structured `wants:` field in 4b. Kept for back-compat (a 4a-era note that
132
+ * declared `uses:` still surfaces its names) — but a note SHOULD use `wants:` (see
133
+ * {@link wants}). Empty = no legacy declarations.
134
+ */
135
+ declaredConnections: string[];
136
+ /**
137
+ * Declared connections in the STRUCTURED 4b form (the `wants:` field) — vault /
138
+ * service / mcp connection specs the agent wants to reach beyond its def-vault
139
+ * (design 2026-06-17-agent-connectors-4b.md). REGISTERED as pending grants on
140
+ * instantiate + injected (when approved) at spawn — granting is operator-approved
141
+ * in the hub. Empty = own-vault only.
142
+ */
143
+ wants: ConnectionSpec[];
144
+ }
145
+
146
+ /** A failed parse — the note isn't a well-formed agent def. */
147
+ export class AgentDefParseError extends Error {
148
+ constructor(message: string) {
149
+ super(message);
150
+ this.name = "AgentDefParseError";
151
+ }
152
+ }
153
+
154
+ /**
155
+ * A failed def WRITE (create/edit/delete) — carries an HTTP status the daemon route
156
+ * maps directly (400 validation, 404 unknown note, 409 name collision, 502 a
157
+ * write/instantiate failure). Distinct from {@link AgentDefParseError} (a note that's
158
+ * already in the vault but malformed).
159
+ */
160
+ export class AgentDefWriteError extends Error {
161
+ constructor(
162
+ message: string,
163
+ readonly status: number,
164
+ ) {
165
+ super(message);
166
+ this.name = "AgentDefWriteError";
167
+ }
168
+ }
169
+
170
+ /**
171
+ * Coerce a vault metadata value (the vault stores metadata as strings, but a note
172
+ * authored in another client may carry a real array/number) to a trimmed string.
173
+ */
174
+ function metaStr(v: unknown): string | undefined {
175
+ if (typeof v === "string") {
176
+ const t = v.trim();
177
+ return t.length > 0 ? t : undefined;
178
+ }
179
+ if (typeof v === "number" || typeof v === "boolean") return String(v);
180
+ return undefined;
181
+ }
182
+
183
+ /**
184
+ * Parse a comma/space-separated list field OR a real array → a clean string[].
185
+ * Used for `egress` and `uses` (a note authored as YAML front-matter may carry
186
+ * either; a vault that stringifies arrays gives us the comma form).
187
+ */
188
+ function metaList(v: unknown): string[] {
189
+ let parts: string[] = [];
190
+ if (Array.isArray(v)) {
191
+ parts = v.map((x) => (typeof x === "string" ? x : String(x)));
192
+ } else if (typeof v === "string") {
193
+ parts = v.split(/[,\s]+/);
194
+ }
195
+ return parts.map((s) => s.trim()).filter((s) => s.length > 0);
196
+ }
197
+
198
+ /**
199
+ * Best-effort, NON-throwing extraction of a def note's agent name (`metadata.name`),
200
+ * for tracking the seen set + the removed-def grant-GC diff (#96) — distinct from
201
+ * {@link parseAgentDef}, which validates + throws. Returns undefined when the note has
202
+ * no usable name (we then carry the prior last-known name forward). Does NOT slug-
203
+ * validate: a note that once instantiated already passed parse; tracking the raw name
204
+ * is enough to address its grants for the prune.
205
+ */
206
+ function nameOfDefNote(note: { metadata?: Record<string, unknown> }): string | undefined {
207
+ return metaStr(note.metadata?.name);
208
+ }
209
+
210
+ /**
211
+ * Parse one `#agent/definition` note into a {@link ParsedAgentDef}. PURE — no I/O.
212
+ *
213
+ * Mapping (the design's "note shape"):
214
+ * - note BODY (`content`) → `spec.systemPrompt` (the agent's role, in prose).
215
+ * - `metadata.name` → `spec.name` (REQUIRED, slug) = the wake channel.
216
+ * - `metadata.backend` → `spec.backend` (default `programmatic`).
217
+ * - `metadata.mode` → `spec.mode` (default `single-threaded`; `multi-threaded`
218
+ * ok; the legacy aliases `resident`/`one-shot`/`per-thread` are DUAL-ACCEPTED and
219
+ * mapped silently). The note id → `spec.definition` (provenance).
220
+ * - `metadata.systemPromptMode` → `spec.systemPromptMode` (default `append`).
221
+ * - `metadata.workspace` → `spec.workspace` (optional absolute host cwd).
222
+ * - `metadata.filesystem` → `spec.filesystem` (`workspace` | `full`).
223
+ * - `metadata.network` → `spec.network` (`open` | `restricted`).
224
+ * - `metadata.egress` → `spec.egress` (host list, for `restricted`).
225
+ * - the def-vault binding → `spec.vault` (own-vault, `write`) — passed in, since
226
+ * the note never names which vault it lives in (it's defined BY being in it).
227
+ * - `metadata.uses` → `declaredConnections` (PARSED, NOT granted — 4b).
228
+ *
229
+ * `spec.channels` is `[name]` — the wake channel IS the agent name (the design's
230
+ * "agent ≡ channel" collapse). Throws {@link AgentDefParseError} on a missing/bad
231
+ * name (the registry skips that note + stamps `error`, rather than instantiating a
232
+ * malformed agent).
233
+ *
234
+ * SECRETS: a def declares creds BY REFERENCE only (`uses:`). We deliberately do NOT
235
+ * read any token/secret field off the note — secrets stay local. `credentialRef`
236
+ * stays the local Claude-credential selector (defaults to the wake channel) and is
237
+ * never sourced from the note.
238
+ */
239
+ export function parseAgentDef(note: {
240
+ id?: string;
241
+ content?: string;
242
+ metadata?: Record<string, unknown>;
243
+ }, binding: { vault: string }): ParsedAgentDef {
244
+ const noteId = typeof note.id === "string" ? note.id : "";
245
+ if (!noteId) {
246
+ throw new AgentDefParseError("#agent/definition note has no id");
247
+ }
248
+ const meta = note.metadata ?? {};
249
+
250
+ const name = metaStr(meta.name);
251
+ if (!name) {
252
+ throw new AgentDefParseError(`#agent/definition note ${noteId} has no metadata.name`);
253
+ }
254
+ if (!NAME_SLUG_RE.test(name)) {
255
+ throw new AgentDefParseError(
256
+ `#agent/definition note ${noteId}: name "${name}" must be a slug (alphanumeric, dash, underscore)`,
257
+ );
258
+ }
259
+
260
+ // Backend — default programmatic (the reliable primary path). A vault-native def
261
+ // may select EITHER `programmatic` (the daemon runs `claude -p` turns) OR `attached`
262
+ // (the design 2026-06-18-channel-backend path — the turn is handled by a Claude Code
263
+ // session the operator connects — "attaches" — to the channel's MCP endpoint; the
264
+ // daemon runs no turn, the inbound notes accumulate as a durable queue). `interactive`
265
+ // (the retired tmux path) is REJECTED with a clear message (→ status:error on the
266
+ // note) rather than silently demoting — `attached` is what it was reaching for, done right.
267
+ //
268
+ // DUAL-READ the legacy backend VALUE, mapping silently (no operator-facing break, no
269
+ // migration of already-authored def notes / spec.json):
270
+ // legacy value → canonical value
271
+ // ──────────────────────────────
272
+ // channel → attached (the backend value was renamed `channel` → `attached`;
273
+ // the ROUTING KEY `channel` — metadata.channel, the
274
+ // `/mcp/<channel>` segment — is a SEPARATE concept, untouched)
275
+ let backend: AgentBackendKind = "programmatic";
276
+ const rawBackend = metaStr(meta.backend);
277
+ if (rawBackend !== undefined) {
278
+ if (rawBackend === "interactive") {
279
+ throw new AgentDefParseError(
280
+ `#agent/definition note ${noteId}: the "interactive" backend is retired — use ` +
281
+ `"programmatic" (daemon-run turns, the default) or "attached" (handled by a Claude ` +
282
+ `Code session you connect to the channel).`,
283
+ );
284
+ }
285
+ // DUAL-READ: the legacy backend value `"channel"` normalizes to `"attached"`.
286
+ const normalizedBackend = rawBackend === "channel" ? "attached" : rawBackend;
287
+ if (normalizedBackend !== "programmatic" && normalizedBackend !== "attached") {
288
+ throw new AgentDefParseError(
289
+ `#agent/definition note ${noteId}: backend must be "programmatic" or "attached"`,
290
+ );
291
+ }
292
+ backend = normalizedBackend;
293
+ }
294
+
295
+ // Execution-lifecycle mode (the Phase-3 prerequisite). An agent is SINGLE-THREADED
296
+ // or MULTI-THREADED. Default `single-threaded` (= today: one persistent session per
297
+ // channel, resumed + persisted each turn). `multi-threaded` is thread-keyed — today
298
+ // (no inbound thread id yet) every fire mints a fresh thread (no resume, no persist).
299
+ // BOTH modes now materialize an `#agent/thread` note (the unified model
300
+ // `definition -> thread -> message`): single-threaded upserts ONE thread note per
301
+ // channel (named after the def, rolling summary + turn_count); multi-threaded writes
302
+ // one thread note per fire.
303
+ //
304
+ // DUAL-ACCEPT the legacy aliases, mapping silently (no operator-facing break, no
305
+ // migration of already-authored notes):
306
+ // legacy value → canonical value
307
+ // ─────────────────────────────────
308
+ // resident → single-threaded
309
+ // one-shot → multi-threaded (one-shot was just multi-threaded's degenerate
310
+ // first turn — the term retires)
311
+ // per-thread → multi-threaded (per-thread continuation is the DEFERRED
312
+ // increment of multi-threaded, not its own mode)
313
+ //
314
+ // Any OTHER value is rejected with a clear, actionable error (→ status:error on the
315
+ // note) rather than silently demoting (which would hide the operator's intent).
316
+ let mode: AgentMode = "single-threaded";
317
+ const rawMode = metaStr(meta.mode);
318
+ if (rawMode !== undefined) {
319
+ if (rawMode === "single-threaded" || rawMode === "resident") {
320
+ mode = "single-threaded";
321
+ } else if (
322
+ rawMode === "multi-threaded" ||
323
+ rawMode === "one-shot" ||
324
+ rawMode === "per-thread"
325
+ ) {
326
+ mode = "multi-threaded";
327
+ } else {
328
+ throw new AgentDefParseError(
329
+ `#agent/definition note ${noteId}: mode must be "single-threaded" or "multi-threaded"`,
330
+ );
331
+ }
332
+ }
333
+
334
+ const spec: AgentSpec = {
335
+ name,
336
+ channels: [name], // wake channel = the agent name (agent ≡ channel)
337
+ backend,
338
+ mode,
339
+ // The def note id — provenance carried into the `#agent/thread` note (BOTH modes;
340
+ // interim plain id string; typed link fields are a future vault feature).
341
+ definition: noteId,
342
+ // Own-vault binding (4a): the def-vault, write-scoped. NOT sourced from the note
343
+ // — it's the vault the note LIVES in (passed in by the caller).
344
+ vault: { name: binding.vault, access: "write" },
345
+ };
346
+
347
+ // The note body IS the system prompt. A blank body → no system prompt (CC's
348
+ // default, untouched) rather than an empty `--append-system-prompt-file`.
349
+ const body = typeof note.content === "string" ? note.content.trim() : "";
350
+ if (body.length > 0) {
351
+ spec.systemPrompt = note.content!; // keep the untrimmed body (whitespace may matter in prose)
352
+ const mode = metaStr(meta.systemPromptMode);
353
+ if (mode !== undefined) {
354
+ if (mode !== "append" && mode !== "replace") {
355
+ throw new AgentDefParseError(
356
+ `#agent/definition note ${noteId}: systemPromptMode must be "append" or "replace"`,
357
+ );
358
+ }
359
+ spec.systemPromptMode = mode as SystemPromptMode;
360
+ }
361
+ }
362
+
363
+ // Model (optional) — passed to `claude -p --model` by the programmatic backend.
364
+ // A CC alias (`opus`/`sonnet`/`haiku`) or a full id (`claude-opus-4-8`). We
365
+ // validate only the CHARSET (no membership list — models evolve), so a typo'd-
366
+ // but-wellformed value still reaches `--model` and the turn errors clearly,
367
+ // while a malformed value (spaces/control chars) fails fast as a def error.
368
+ const model = metaStr(meta.model);
369
+ if (model !== undefined && model.length > 0) {
370
+ if (!/^[a-zA-Z0-9][a-zA-Z0-9._:-]*$/.test(model)) {
371
+ throw new AgentDefParseError(
372
+ `#agent/definition note ${noteId}: model "${model}" is not a valid model name (letters, numbers, dot, underscore, colon, dash)`,
373
+ );
374
+ }
375
+ spec.model = model;
376
+ }
377
+
378
+ // Working directory (optional absolute host cwd). We do NOT statSync here (parse is
379
+ // pure + may run on a box where the dir is mounted differently); the spawn path's
380
+ // own checks apply when the turn runs.
381
+ const workspace = metaStr(meta.workspace);
382
+ if (workspace !== undefined) {
383
+ if (!workspace.startsWith("/")) {
384
+ throw new AgentDefParseError(
385
+ `#agent/definition note ${noteId}: workspace must be an absolute path (start with "/")`,
386
+ );
387
+ }
388
+ spec.workspace = workspace;
389
+ }
390
+
391
+ // Filesystem read scope.
392
+ const filesystem = metaStr(meta.filesystem);
393
+ if (filesystem !== undefined) {
394
+ if (filesystem !== "workspace" && filesystem !== "full") {
395
+ throw new AgentDefParseError(
396
+ `#agent/definition note ${noteId}: filesystem must be "workspace" or "full"`,
397
+ );
398
+ }
399
+ spec.filesystem = filesystem;
400
+ }
401
+
402
+ // Network egress mode + (under restricted) the additional host allowlist.
403
+ const network = metaStr(meta.network);
404
+ if (network !== undefined) {
405
+ if (network !== "open" && network !== "restricted") {
406
+ throw new AgentDefParseError(
407
+ `#agent/definition note ${noteId}: network must be "open" or "restricted"`,
408
+ );
409
+ }
410
+ spec.network = network;
411
+ }
412
+ const egress = metaList(meta.egress);
413
+ if (egress.length > 0) spec.egress = egress;
414
+
415
+ // Filesystem mounts — JSON-encoded array in metadata (the note can't carry a
416
+ // structured array natively in a string vault), parsed defensively. Optional; a
417
+ // malformed value is ignored (not fatal — mounts are an advanced knob).
418
+ const mounts = parseMounts(meta.mounts);
419
+ if (mounts.length > 0) spec.mounts = mounts;
420
+
421
+ // Declared connections beyond the def-vault (the legacy `uses:` field). PARSED +
422
+ // surfaced; never a secret — these are NAMES (`github`, `vault:research:read`).
423
+ const declaredConnections = metaList(meta.uses);
424
+
425
+ // STRUCTURED connection declarations (the 4b `wants:` field — design
426
+ // 2026-06-17-agent-connectors-4b.md). Comma-separated connection specs parsed into
427
+ // {@link ConnectionSpec}s. A MALFORMED `wants:` → the def is an ERROR (we re-throw
428
+ // as AgentDefParseError so the registry stamps status:error + doesn't half-
429
+ // instantiate, design §1). The def-vault is implicit — never appears in `wants:`.
430
+ let wants: ConnectionSpec[];
431
+ try {
432
+ wants = parseWants(meta.wants);
433
+ } catch (err) {
434
+ if (err instanceof WantsParseError) {
435
+ throw new AgentDefParseError(`#agent/definition note ${noteId}: ${err.message}`);
436
+ }
437
+ throw err;
438
+ }
439
+
440
+ return { noteId, name, spec, declaredConnections, wants };
441
+ }
442
+
443
+ /** Parse a metadata `mounts` value (JSON array string or real array) → AgentMount[]. */
444
+ function parseMounts(v: unknown): AgentMount[] {
445
+ let arr: unknown;
446
+ if (typeof v === "string") {
447
+ const t = v.trim();
448
+ if (t.length === 0) return [];
449
+ try {
450
+ arr = JSON.parse(t);
451
+ } catch {
452
+ return [];
453
+ }
454
+ } else {
455
+ arr = v;
456
+ }
457
+ if (!Array.isArray(arr)) return [];
458
+ const out: AgentMount[] = [];
459
+ for (const raw of arr) {
460
+ if (!raw || typeof raw !== "object") continue;
461
+ const m = raw as Record<string, unknown>;
462
+ if (typeof m.hostPath !== "string" || !m.hostPath.startsWith("/")) continue;
463
+ if (typeof m.mountPath !== "string" || !m.mountPath.startsWith("/")) continue;
464
+ if (m.mode !== "ro" && m.mode !== "rw") continue;
465
+ const mount: AgentMount = { hostPath: m.hostPath, mountPath: m.mountPath, mode: m.mode };
466
+ if (typeof m.shared === "string" && m.shared.length > 0) mount.shared = m.shared;
467
+ out.push(mount);
468
+ }
469
+ return out;
470
+ }
471
+
472
+ /**
473
+ * Resolve the status a parsed def gets WITHOUT grant information — the fallback path
474
+ * (no grants client wired, e.g. hub not provisioned). Own-vault only → `enabled`; a
475
+ * def that declares ANY connection (legacy `uses:` names OR structured `wants:`) →
476
+ * `pending` (listing them) since nothing has been granted yet. The agent still runs
477
+ * own-vault either way; this is the queryable signal.
478
+ *
479
+ * When a grants client IS wired, the registry instead registers each `wants:`
480
+ * connection + resolves status from the hub's grant statuses
481
+ * (`resolveConnectionStatus` in grants.ts) — `enabled` only once every connection is
482
+ * approved. This pure function is the no-hub fallback + the legacy-`uses:` path.
483
+ */
484
+ export function resolveDefStatus(def: ParsedAgentDef): {
485
+ status: AgentDefStatus;
486
+ pending?: string[];
487
+ } {
488
+ const pending = [
489
+ ...def.declaredConnections,
490
+ ...def.wants.map((c) => connectionKey(c)),
491
+ ];
492
+ if (pending.length > 0) {
493
+ return { status: "pending", pending };
494
+ }
495
+ return { status: "enabled" };
496
+ }
497
+
498
+ /**
499
+ * A thin vault client for ONE def-vault — the def-query + the status-PATCH. Mirrors
500
+ * `VaultTransport`'s REST encoding (the `#` + `/` in a tag → `%23`/`%2F`; the note
501
+ * PATCH route is `PATCH /vault/<vault>/api/notes/<id>`). `fetchFn` is injectable so
502
+ * tests drive it with a recorder, deterministic, no global mock leak.
503
+ */
504
+ export class DefVaultClient {
505
+ private readonly vault: string;
506
+ private readonly vaultUrl: string;
507
+ private readonly token: string;
508
+ private readonly fetchFn: typeof fetch;
509
+
510
+ constructor(binding: DefVaultBinding, fetchFn?: typeof fetch) {
511
+ if (!binding.vault) throw new Error("DefVaultClient: binding.vault is required");
512
+ if (!binding.token) throw new Error("DefVaultClient: binding.token is required");
513
+ this.vault = binding.vault;
514
+ this.vaultUrl = (binding.vaultUrl ?? DEFAULT_DEF_VAULT_URL).replace(/\/$/, "");
515
+ this.token = binding.token;
516
+ this.fetchFn = fetchFn ?? fetch;
517
+ }
518
+
519
+ /** The def-vault name (for routing reload events to the right client). */
520
+ get vaultName(): string {
521
+ return this.vault;
522
+ }
523
+
524
+ /**
525
+ * List the `#agent/definition` notes in this vault. INDEX-FREE: queries by the
526
+ * exact tag (the leaf — we never rely on namespace prefix expansion) with
527
+ * `include_content=true` (we need the body = the system prompt). Throws on a
528
+ * non-ok vault response so the caller surfaces a clear error rather than a
529
+ * silently-empty agent set.
530
+ */
531
+ async listDefNotes(opts?: { limit?: number }): Promise<
532
+ Array<{ id: string; content?: string; metadata?: Record<string, unknown> }>
533
+ > {
534
+ const limit = opts?.limit ?? DEF_LIST_LIMIT;
535
+ const params = new URLSearchParams();
536
+ params.set("tag", AGENT_DEFINITION_TAG); // URLSearchParams encodes `#`→`%23`, `/`→`%2F`
537
+ params.set("include_content", "true");
538
+ params.set("limit", String(limit));
539
+ const url = `${this.vaultUrl}/vault/${this.vault}/api/notes?${params.toString()}`;
540
+ const res = await this.fetchFn(url, { headers: { authorization: `Bearer ${this.token}` } });
541
+ if (!res.ok) {
542
+ const detail = await res.text().catch(() => "");
543
+ throw new Error(`def-vault "${this.vault}": list defs failed (${res.status}) ${detail}`.trim());
544
+ }
545
+ let parsed: unknown;
546
+ try {
547
+ parsed = await res.json();
548
+ } catch (err) {
549
+ throw new Error(`def-vault "${this.vault}": list defs — bad JSON: ${(err as Error).message}`);
550
+ }
551
+ type RawNote = { id?: string; content?: string; metadata?: Record<string, unknown> };
552
+ const notes: RawNote[] = Array.isArray(parsed)
553
+ ? (parsed as RawNote[])
554
+ : ((parsed as { notes?: RawNote[] })?.notes ?? []);
555
+ const out: Array<{ id: string; content?: string; metadata?: Record<string, unknown> }> = [];
556
+ for (const n of notes) {
557
+ if (typeof n.id === "string" && n.id) {
558
+ out.push({ id: n.id, content: n.content, metadata: n.metadata });
559
+ }
560
+ }
561
+ return out;
562
+ }
563
+
564
+ /** Fetch ONE note by id (for a created/updated reload). Null on 404/miss. */
565
+ async getNote(
566
+ id: string,
567
+ ): Promise<{ id: string; content?: string; metadata?: Record<string, unknown> } | null> {
568
+ const url = `${this.vaultUrl}/vault/${this.vault}/api/notes/${encodeURIComponent(id)}?include_content=true`;
569
+ const res = await this.fetchFn(url, { headers: { authorization: `Bearer ${this.token}` } });
570
+ if (res.status === 404) return null;
571
+ if (!res.ok) {
572
+ const detail = await res.text().catch(() => "");
573
+ throw new Error(`def-vault "${this.vault}": get note ${id} failed (${res.status}) ${detail}`.trim());
574
+ }
575
+ let parsed: unknown;
576
+ try {
577
+ parsed = await res.json();
578
+ } catch (err) {
579
+ throw new Error(`def-vault "${this.vault}": get note ${id} — bad JSON: ${(err as Error).message}`);
580
+ }
581
+ const n = (parsed ?? {}) as { id?: string; note?: { id?: string; content?: string; metadata?: Record<string, unknown> }; content?: string; metadata?: Record<string, unknown> };
582
+ const note = n.note ?? n;
583
+ if (typeof note.id !== "string" || !note.id) return null;
584
+ return { id: note.id, content: note.content, metadata: note.metadata };
585
+ }
586
+
587
+ /**
588
+ * Stamp the resolved status onto the def note's metadata. PATCH merges the changed
589
+ * fields (the vault merges metadata). `pending` is written as a comma-joined string
590
+ * when present (the vault stores metadata as strings) and CLEARED (empty string)
591
+ * otherwise, so a flip enabled→pending→enabled doesn't leave a stale list. Throws
592
+ * on a non-ok response; the caller logs + continues (status is best-effort — a
593
+ * failed stamp must not prevent the agent from running).
594
+ */
595
+ async patchStatus(
596
+ noteId: string,
597
+ status: AgentDefStatus,
598
+ pending?: string[],
599
+ ): Promise<void> {
600
+ const metadata: Record<string, string> = { status };
601
+ // Always set `pending` (to the list, or empty) so it never goes stale across flips.
602
+ metadata.pending = pending && pending.length > 0 ? pending.join(", ") : "";
603
+ const url = `${this.vaultUrl}/vault/${this.vault}/api/notes/${encodeURIComponent(noteId)}`;
604
+ const res = await this.fetchFn(url, {
605
+ method: "PATCH",
606
+ headers: { "content-type": "application/json", authorization: `Bearer ${this.token}` },
607
+ // `force: true` satisfies the vault's mutation precondition (it 428s without
608
+ // `if_updated_at` or `force`). Safe: `status`/`pending` are the module's OWN
609
+ // authoritative derived fields, the body carries no content, and the vault
610
+ // MERGES metadata ({...existing, ...body.metadata}) so name/backend are kept.
611
+ // (Without this the status stamp silently 428'd — caught via live testing.)
612
+ body: JSON.stringify({ metadata, force: true }),
613
+ });
614
+ if (!res.ok) {
615
+ const detail = await res.text().catch(() => "");
616
+ throw new Error(`def-vault "${this.vault}": patch status ${noteId} failed (${res.status}) ${detail}`.trim());
617
+ }
618
+ }
619
+
620
+ /**
621
+ * Create a `#agent/definition` note: body = the system prompt, metadata = the
622
+ * config, tagged the exact def tag (the same tag {@link listDefNotes} queries). The
623
+ * vault assigns the note id; we return the created note (id + content + metadata) so
624
+ * the caller can reload it into a live agent immediately. Throws on a non-ok vault
625
+ * response. The path defaults under `Agents/<name>` (a flat, predictable slug) so a
626
+ * vault surface groups them; the vault is free to relocate it.
627
+ */
628
+ async createNote(args: {
629
+ content: string;
630
+ metadata: Record<string, string>;
631
+ path?: string;
632
+ }): Promise<{ id: string; content?: string; metadata?: Record<string, unknown> }> {
633
+ const url = `${this.vaultUrl}/vault/${this.vault}/api/notes`;
634
+ const body: Record<string, unknown> = {
635
+ content: args.content,
636
+ tags: [AGENT_DEFINITION_TAG],
637
+ metadata: args.metadata,
638
+ };
639
+ if (args.path) body.path = args.path;
640
+ const res = await this.fetchFn(url, {
641
+ method: "POST",
642
+ headers: { "content-type": "application/json", authorization: `Bearer ${this.token}` },
643
+ body: JSON.stringify(body),
644
+ });
645
+ if (!res.ok) {
646
+ const detail = await res.text().catch(() => "");
647
+ throw new Error(`def-vault "${this.vault}": create def failed (${res.status}) ${detail}`.trim());
648
+ }
649
+ let parsed: unknown;
650
+ try {
651
+ parsed = await res.json();
652
+ } catch (err) {
653
+ throw new Error(`def-vault "${this.vault}": create def — bad JSON: ${(err as Error).message}`);
654
+ }
655
+ const n = (parsed ?? {}) as {
656
+ id?: string;
657
+ note?: { id?: string; content?: string; metadata?: Record<string, unknown> };
658
+ content?: string;
659
+ metadata?: Record<string, unknown>;
660
+ };
661
+ const note = n.note ?? n;
662
+ if (typeof note.id !== "string" || !note.id) {
663
+ throw new Error(`def-vault "${this.vault}": create def succeeded but response had no note id`);
664
+ }
665
+ return { id: note.id, content: note.content, metadata: note.metadata };
666
+ }
667
+
668
+ /**
669
+ * Edit an existing def note: update its body (system prompt) and/or merge metadata
670
+ * fields. `force: true` satisfies the vault's 428 mutation precondition (the module's
671
+ * own authoritative edit; the vault MERGES metadata so unspecified fields are kept).
672
+ * Only the provided fields are sent. Throws on a non-ok vault response.
673
+ */
674
+ async patchNote(
675
+ noteId: string,
676
+ fields: { content?: string; metadata?: Record<string, string> },
677
+ ): Promise<void> {
678
+ const body: Record<string, unknown> = { force: true };
679
+ if (fields.content !== undefined) body.content = fields.content;
680
+ if (fields.metadata !== undefined) body.metadata = fields.metadata;
681
+ const url = `${this.vaultUrl}/vault/${this.vault}/api/notes/${encodeURIComponent(noteId)}`;
682
+ const res = await this.fetchFn(url, {
683
+ method: "PATCH",
684
+ headers: { "content-type": "application/json", authorization: `Bearer ${this.token}` },
685
+ body: JSON.stringify(body),
686
+ });
687
+ if (!res.ok) {
688
+ const detail = await res.text().catch(() => "");
689
+ throw new Error(`def-vault "${this.vault}": patch def ${noteId} failed (${res.status}) ${detail}`.trim());
690
+ }
691
+ }
692
+
693
+ /** Delete a def note by id. Throws on a non-ok vault response (404 IS ok — gone is gone). */
694
+ async deleteNote(noteId: string): Promise<void> {
695
+ const url = `${this.vaultUrl}/vault/${this.vault}/api/notes/${encodeURIComponent(noteId)}`;
696
+ const res = await this.fetchFn(url, {
697
+ method: "DELETE",
698
+ headers: { authorization: `Bearer ${this.token}` },
699
+ });
700
+ if (!res.ok && res.status !== 404) {
701
+ const detail = await res.text().catch(() => "");
702
+ throw new Error(`def-vault "${this.vault}": delete def ${noteId} failed (${res.status}) ${detail}`.trim());
703
+ }
704
+ }
705
+ }
706
+
707
+ /**
708
+ * The side-effects the registry needs to bring a def to life, injected so the
709
+ * registry is unit-testable WITHOUT a daemon, a vault, a sandbox, or tmux.
710
+ *
711
+ * - {@link ensureChannel} — bring up (or replace) the vault channel for the agent's
712
+ * wake channel. The daemon wires this to `addChannelLive` with a vault
713
+ * `ChannelEntry` built from the def-vault binding (the SAME path create-agent +
714
+ * boot use). Awaited so the transport is live before we register the agent.
715
+ * - {@link setupAndRegister} — persist `spec.json` (so the existing boot
716
+ * re-register + per-turn deliver find the workspace) + register the programmatic
717
+ * agent. The daemon wires this to `setupProgrammaticSpawn` + `programmatic.register`.
718
+ * - {@link deregister} — tear an agent down by name (drop its programmatic
719
+ * registration). The daemon wires this to `programmatic.deregister`.
720
+ * - {@link removeChannel} — stop + drop the wake channel (on delete). The daemon
721
+ * wires this to `removeChannelLive`.
722
+ */
723
+ export interface InstantiateDeps {
724
+ /** Bring up the vault channel for `name`, bound to `binding`. */
725
+ ensureChannel(name: string, binding: DefVaultBinding): Promise<void>;
726
+ /** Persist spec.json + register the programmatic agent for `spec`. */
727
+ setupAndRegister(spec: AgentSpec): Promise<void>;
728
+ /** Deregister the programmatic agent `name`. Returns whether one was registered. */
729
+ deregister(name: string): Promise<boolean>;
730
+ /** Stop + remove the wake channel `name`. Returns whether one existed. */
731
+ removeChannel(name: string): Promise<boolean>;
732
+ }
733
+
734
+ /** The live record of an instantiated def (so a reload/delete can address it). */
735
+ interface LiveDef {
736
+ /** The def-vault this agent belongs to. */
737
+ vault: string;
738
+ /** The note id (the reload/delete key within a vault). */
739
+ noteId: string;
740
+ /** The agent name (= wake channel) — for channel/registry teardown. */
741
+ name: string;
742
+ /** The resolved status (for /health + observability). */
743
+ status: AgentDefStatus;
744
+ /** The agent backend the def selected (`programmatic` | `attached`). */
745
+ backend: AgentBackendKind;
746
+ /** The execution-lifecycle mode the def selected (`single-threaded` | `multi-threaded`). */
747
+ mode: AgentMode;
748
+ /** First ~200 chars of the system prompt (the note body) — a preview, NOT a secret. */
749
+ systemPromptPreview: string;
750
+ /** Declared connections still pending approval (the status `pending` list), if any. */
751
+ pending: string[];
752
+ /** Structured `wants:` connection keys (surfaced for the UI; never a secret). */
753
+ wants: string[];
754
+ /**
755
+ * Per-connection grant info (key, kind, target, hub grant status, grant id) — the
756
+ * source the connections/MCP panel renders + drives Connect from. One entry per
757
+ * declared `wants:` connection. Never a secret (status + id only, no token).
758
+ */
759
+ connections: ConnectionInfo[];
760
+ /** The model the programmatic backend runs turns on (from `metadata.model`); unset = CC default. */
761
+ model?: string;
762
+ }
763
+
764
+ /**
765
+ * The detailed view of one live vault-native agent the `GET /api/agent-defs` route
766
+ * returns — everything a UI needs to render + edit it, NO secrets (no tokens). The
767
+ * channel == the agent name (agent ≡ channel); the vault is the def-vault.
768
+ */
769
+ export interface AgentDefDetail {
770
+ /** The vault note id (the create/edit/delete key). */
771
+ noteId: string;
772
+ /** The agent name (= wake channel + spec name). */
773
+ name: string;
774
+ /** The agent backend (`programmatic` | `attached`). */
775
+ backend: AgentBackendKind;
776
+ /** The execution-lifecycle mode (`single-threaded` | `multi-threaded`). */
777
+ mode: AgentMode;
778
+ /** The def-vault this agent is defined in. */
779
+ vault: string;
780
+ /** The resolved liveness status (`enabled` | `pending` | `error`). */
781
+ status: AgentDefStatus;
782
+ /** Declared connections still pending approval (empty when none). */
783
+ pending: string[];
784
+ /** First ~200 chars of the system prompt (the note body) — a preview, NOT the full text. */
785
+ systemPromptPreview: string;
786
+ /** Structured `wants:` connection keys the agent declared (empty when own-vault only). */
787
+ wants: string[];
788
+ /**
789
+ * Per-connection grant info (key, kind, target, hub grant status, grant id) — the
790
+ * connections/MCP panel renders status pills + drives the cookie→hub Connect from
791
+ * this. Additive (a back-compat field; older clients ignore it). One entry per
792
+ * declared `wants:` connection. NO secrets (status + id, never a token).
793
+ */
794
+ connections: ConnectionInfo[];
795
+ /** The model the programmatic backend runs turns on (e.g. `opus`); undefined = CC default. */
796
+ model?: string;
797
+ /** The wake channel inbound routes to this agent on (== name). */
798
+ channel: string;
799
+ }
800
+
801
+ /** How many chars of the system prompt the detail preview surfaces. */
802
+ export const SYSTEM_PROMPT_PREVIEW_LEN = 200;
803
+
804
+ /**
805
+ * The FULL editable view of one live vault-native agent the `GET /api/agent-defs/<id>`
806
+ * route returns — everything the edit form needs to pre-fill, including the FULL system
807
+ * prompt (the whole note body, not the {@link AgentDefDetail} ~200-char preview). NO
808
+ * secrets (no tokens). The list endpoint deliberately returns only the preview (cheap +
809
+ * non-sensitive); this single-def fetch reads the note body fresh so an edit pre-fills
810
+ * the actual prompt rather than a truncation.
811
+ */
812
+ export interface AgentDefFull {
813
+ /** The vault note id (the edit/delete key). */
814
+ noteId: string;
815
+ /** The agent name (= wake channel + spec name). */
816
+ name: string;
817
+ /** The agent backend (`programmatic` | `attached`). */
818
+ backend: AgentBackendKind;
819
+ /** The def-vault this agent is defined in. */
820
+ vault: string;
821
+ /** The execution-lifecycle mode (`single-threaded` | `multi-threaded`). */
822
+ mode: AgentMode;
823
+ /** Structured `wants:` connection keys the agent declared (empty when own-vault only). */
824
+ wants: string[];
825
+ /**
826
+ * Per-connection grant info (key, kind, target, hub grant status, grant id) — same
827
+ * additive field {@link AgentDefDetail} carries, so the edit view's connections panel
828
+ * can render status + drive Connect without a second fetch. NO secrets.
829
+ */
830
+ connections: ConnectionInfo[];
831
+ /** The model the programmatic backend runs turns on (e.g. `opus`); undefined = CC default. */
832
+ model?: string;
833
+ /** The FULL system prompt — the whole note body (NOT truncated). */
834
+ systemPrompt: string;
835
+ /** The resolved liveness status (`enabled` | `pending` | `error`). */
836
+ status: AgentDefStatus;
837
+ }
838
+
839
+ /**
840
+ * The vault-native agent-def registry — reads `#agent/definition` notes from the
841
+ * configured def-vaults and keeps the live agent set in sync with them.
842
+ *
843
+ * Lifecycle (the design's reactive model):
844
+ * - {@link loadAll} (boot) — for each def-vault, list its defs + instantiate each.
845
+ * - {@link reload} (trigger / poll) — re-read ONE note: created/updated →
846
+ * re-instantiate; deleted (note gone) → deregister. Per-note granularity via the
847
+ * `vault + noteId → LiveDef` map.
848
+ * - {@link deregisterAllForVault} — drop a whole vault's agents (config change).
849
+ *
850
+ * Grant-GC (#96): the registry also keeps the hub's grant rows in sync with the live
851
+ * def set so a removed connection / a deleted def doesn't orphan an approved grant. On
852
+ * a CONFIDENT signal only — a clean successful instantiate (prune to the def's current
853
+ * `wants:` keys) or a CONFIRMED removal (deleted/404 → prune ALL) — it POSTs the hub's
854
+ * reconcile endpoint; a transient parse/list/fetch failure NEVER prunes (safety guard).
855
+ *
856
+ * Idempotent: re-instantiating the same name swaps the registration in place
857
+ * (`programmatic.register` + `addChannelLive` both replace-by-name), so an update is
858
+ * a clean re-instantiate, not a duplicate. A name collision ACROSS def-vaults (two
859
+ * vaults both defining `uni-dev`) is resolved last-writer-wins on the shared wake
860
+ * channel; we log it (the operator owns their vaults — 4a is own-box).
861
+ */
862
+ export class AgentDefRegistry {
863
+ /** def-vault name → its client. */
864
+ private readonly clients = new Map<string, DefVaultClient>();
865
+ /** def-vault name → its binding (for `ensureChannel`). */
866
+ private readonly bindings = new Map<string, DefVaultBinding>();
867
+ /** `${vault}\u0000${noteId}` → the live record. */
868
+ private readonly live = new Map<string, LiveDef>();
869
+ /**
870
+ * Per-vault set of `#agent/definition` notes seen on the LAST CONFIDENT read —
871
+ * `noteId → agentName` — the prior-known set the removed-def diff (grant-GC, #96)
872
+ * compares against. ONLY mutated from a confident signal: a successful vault LIST
873
+ * (loadAll) or a confirmed single-note removal/instantiate (reload). A note's name is
874
+ * its parsed `metadata.name`; a present-but-parse-failing note KEEPS its last-known
875
+ * name (carry-forward) so a transient parse error never drops it from the tracked set
876
+ * — which would wrongly flag it as removed (safety guard, design "only prune from a
877
+ * confident live set"). Keyed `vault → (noteId → agentName)`.
878
+ */
879
+ private readonly seenDefs = new Map<string, Map<string, string>>();
880
+ private readonly deps: InstantiateDeps;
881
+ /**
882
+ * The hub grants client (4b) — used to REGISTER each def's `wants:` connections as
883
+ * pending grants on instantiate + resolve status from the hub's grant statuses.
884
+ * Optional: null when the hub isn't provisioned yet (no manager bearer) — then the
885
+ * registry falls back to {@link resolveDefStatus} (pending if any connection is
886
+ * declared) and never registers, so the vault-native path still runs own-vault.
887
+ */
888
+ private grants: GrantsClient | null;
889
+
890
+ constructor(
891
+ deps: InstantiateDeps,
892
+ opts?: { bindings?: DefVaultBinding[]; fetchFn?: typeof fetch; grants?: GrantsClient | null },
893
+ ) {
894
+ this.deps = deps;
895
+ this.grants = opts?.grants ?? null;
896
+ for (const b of opts?.bindings ?? []) {
897
+ this.addVault(b, opts?.fetchFn);
898
+ }
899
+ }
900
+
901
+ /** Wire (or replace) the hub grants client — set once the manager bearer resolves
902
+ * at boot (the constructor runs before the operator token is read). */
903
+ setGrantsClient(grants: GrantsClient | null): void {
904
+ this.grants = grants;
905
+ }
906
+
907
+ /** Register a def-vault binding (additive — multi-vault is appending). */
908
+ addVault(binding: DefVaultBinding, fetchFn?: typeof fetch): void {
909
+ this.clients.set(binding.vault, new DefVaultClient(binding, fetchFn));
910
+ this.bindings.set(binding.vault, binding);
911
+ }
912
+
913
+ /**
914
+ * Remove a def-vault binding (the client + binding indexes). The caller
915
+ * ({@link deregisterAllForVault}) tears down the vault's live agents FIRST; this
916
+ * drops the registry's knowledge of the vault so a later `loadAll` no longer queries
917
+ * it. Idempotent. Does NOT touch the persisted `agent-vaults.json` — that's the
918
+ * daemon route's job (the registry has no file knowledge).
919
+ */
920
+ removeVault(vault: string): void {
921
+ this.clients.delete(vault);
922
+ this.bindings.delete(vault);
923
+ this.seenDefs.delete(vault);
924
+ }
925
+
926
+ /** The number of def-vaults bound (for /health + tests). */
927
+ get vaultCount(): number {
928
+ return this.clients.size;
929
+ }
930
+
931
+ /** The sole bound def-vault's name, or undefined when not exactly one. Lets the
932
+ * reload webhook default `vault` when the install is single-vault (the common case). */
933
+ soleVaultName(): string | undefined {
934
+ if (this.clients.size !== 1) return undefined;
935
+ return [...this.clients.keys()][0];
936
+ }
937
+
938
+ /** The live instantiated defs (for /health + the agents list + tests). */
939
+ list(): ReadonlyArray<{ vault: string; noteId: string; name: string; status: AgentDefStatus }> {
940
+ return [...this.live.values()].map((d) => ({
941
+ vault: d.vault,
942
+ noteId: d.noteId,
943
+ name: d.name,
944
+ status: d.status,
945
+ }));
946
+ }
947
+
948
+ /**
949
+ * The live instantiated defs in the DETAILED `GET /api/agent-defs` shape — backend,
950
+ * vault, status, pending, the system-prompt PREVIEW (not the full body), wants, and
951
+ * the wake channel. NO secrets (no tokens). Sorted by name for a stable list.
952
+ */
953
+ listDetailed(): AgentDefDetail[] {
954
+ return [...this.live.values()]
955
+ .map((d) => ({
956
+ noteId: d.noteId,
957
+ name: d.name,
958
+ backend: d.backend,
959
+ mode: d.mode,
960
+ vault: d.vault,
961
+ status: d.status,
962
+ pending: [...d.pending],
963
+ systemPromptPreview: d.systemPromptPreview,
964
+ wants: [...d.wants],
965
+ connections: d.connections.map((c) => ({ ...c })),
966
+ ...(d.model ? { model: d.model } : {}),
967
+ channel: d.name, // agent ≡ channel.
968
+ }))
969
+ .sort((a, b) => a.name.localeCompare(b.name));
970
+ }
971
+
972
+ /** Whether a def-vault by this name is configured (the write-path vault guard). */
973
+ hasVault(vault: string): boolean {
974
+ return this.clients.has(vault);
975
+ }
976
+
977
+ /** The configured def-vault names (for /api/agent-vaults + the write-path guard). */
978
+ vaultNames(): string[] {
979
+ return [...this.clients.keys()];
980
+ }
981
+
982
+ /**
983
+ * The configured def-vault bindings VERBATIM (carrying their tokens) — for
984
+ * persisting the live set back to `agent-vaults.json` on an add (so a boot-minted
985
+ * default's real token is preserved, never clobbered to empty). INTERNAL: this
986
+ * carries SECRETS — never serialize it to the wire (the wire view is
987
+ * {@link vaultStatuses}). Returns copies so a caller can't mutate the registry's
988
+ * bindings in place.
989
+ */
990
+ liveBindings(): DefVaultBinding[] {
991
+ return [...this.bindings.values()].map((b) => ({ ...b }));
992
+ }
993
+
994
+ /**
995
+ * The configured def-vaults as a non-secret view — name + url + whether a token is
996
+ * present (NEVER the token VALUE). The `GET /api/agent-vaults` listing's source of
997
+ * truth (the live registry, not the on-disk file, so a boot-minted binding shows its
998
+ * token even before the file write lands). Sorted by name.
999
+ */
1000
+ vaultStatuses(): Array<{ vault: string; url: string; tokenPresent: boolean }> {
1001
+ return [...this.bindings.values()]
1002
+ .map((b) => ({
1003
+ vault: b.vault,
1004
+ url: b.vaultUrl ?? DEFAULT_DEF_VAULT_URL,
1005
+ tokenPresent: typeof b.token === "string" && b.token.length > 0,
1006
+ }))
1007
+ .sort((a, b) => a.vault.localeCompare(b.vault));
1008
+ }
1009
+
1010
+ /**
1011
+ * Whether a note id is a CURRENTLY-LIVE def in a given vault — the write-path guard
1012
+ * for PATCH/DELETE so the routes only ever touch notes this module actually
1013
+ * instantiated as `#agent/definition` agents in a configured def-vault (not an
1014
+ * arbitrary note id an operator passes). Returns the live detail when it is, else
1015
+ * null.
1016
+ */
1017
+ liveDef(vault: string, noteId: string): AgentDefDetail | null {
1018
+ const d = this.live.get(this.keyOf(vault, noteId));
1019
+ if (!d) return null;
1020
+ return {
1021
+ noteId: d.noteId,
1022
+ name: d.name,
1023
+ backend: d.backend,
1024
+ mode: d.mode,
1025
+ vault: d.vault,
1026
+ status: d.status,
1027
+ pending: [...d.pending],
1028
+ systemPromptPreview: d.systemPromptPreview,
1029
+ wants: [...d.wants],
1030
+ connections: d.connections.map((c) => ({ ...c })),
1031
+ ...(d.model ? { model: d.model } : {}),
1032
+ channel: d.name,
1033
+ };
1034
+ }
1035
+
1036
+ /** Find a live def by note id across ALL configured vaults (PATCH/DELETE address
1037
+ * a note by id; the vault is resolved here). Returns the {vault, detail} or null.
1038
+ * AMBIGUITY GUARD (#106 review): if two configured def-vaults each vend a note at
1039
+ * the SAME id, picking the first match is non-deterministic — so throw a 409-class
1040
+ * {@link AgentDefWriteError} ("specify vault") rather than silently mutating one of
1041
+ * them. The single-match happy path is unchanged. */
1042
+ findLiveByNote(noteId: string): { vault: string; detail: AgentDefDetail } | null {
1043
+ const matches: string[] = [];
1044
+ for (const d of this.live.values()) {
1045
+ if (d.noteId === noteId) matches.push(d.vault);
1046
+ }
1047
+ if (matches.length === 0) return null;
1048
+ if (matches.length > 1) {
1049
+ throw new AgentDefWriteError(
1050
+ `note ${noteId} is a live agent definition in multiple def-vaults (${matches
1051
+ .sort()
1052
+ .join(", ")}); ambiguous note id across vaults — specify vault`,
1053
+ 409,
1054
+ );
1055
+ }
1056
+ const vault = matches[0]!;
1057
+ return { vault, detail: this.liveDef(vault, noteId)! };
1058
+ }
1059
+
1060
+ /**
1061
+ * Fetch ONE live def's FULL editable view (the `GET /api/agent-defs/<id>` route) — the
1062
+ * same fields {@link liveDef} carries, but with the FULL system prompt read fresh from
1063
+ * the note body (the list/detail carries only the ~200-char preview, which can't pre-
1064
+ * fill an edit form). The note MUST be a currently-live def we instantiated in a
1065
+ * configured vault — same guard as the PATCH/DELETE write paths (resolves the vault via
1066
+ * {@link findLiveByNote}, so an unknown/non-def id → null and the route 404s; an
1067
+ * ambiguous-across-vaults id throws the 409-class {@link AgentDefWriteError}). NO
1068
+ * secrets — the body is the prompt, never a token. Returns null when the note isn't a
1069
+ * live def OR the vault no longer vends it (a delete that races the fetch).
1070
+ */
1071
+ async getFullDef(noteId: string): Promise<AgentDefFull | null> {
1072
+ const found = this.findLiveByNote(noteId);
1073
+ if (!found) return null;
1074
+ const client = this.clients.get(found.vault);
1075
+ if (!client) return null;
1076
+ const note = await client.getNote(noteId);
1077
+ if (!note) return null;
1078
+ const detail = found.detail;
1079
+ return {
1080
+ noteId: detail.noteId,
1081
+ name: detail.name,
1082
+ backend: detail.backend,
1083
+ vault: detail.vault,
1084
+ mode: detail.mode,
1085
+ wants: [...detail.wants],
1086
+ connections: detail.connections.map((c) => ({ ...c })),
1087
+ ...(detail.model ? { model: detail.model } : {}),
1088
+ systemPrompt: typeof note.content === "string" ? note.content : "",
1089
+ status: detail.status,
1090
+ };
1091
+ }
1092
+
1093
+ private keyOf(vault: string, noteId: string): string {
1094
+ return `${vault}\u0000${noteId}`;
1095
+ }
1096
+
1097
+ /**
1098
+ * Read all defs from every bound def-vault + instantiate each. Best-effort per
1099
+ * vault AND per note: a single vault's list failure (or one note's parse/instantiate
1100
+ * failure) is logged and never aborts the others, so one bad def can't sink the set.
1101
+ * Returns the count successfully instantiated.
1102
+ *
1103
+ * Removed-def convergence: after a CONFIDENT read (a successful, non-truncated list of
1104
+ * the vault's whole def set), diff the prior-known def set against it — any note that
1105
+ * was present and is now GONE has had its `#agent/definition` note deleted, so the agent
1106
+ * is TORN DOWN (deregistered + wake channel removed) and its grants pruned ALL
1107
+ * (`reconcileGrants(agent, [])`). This is the ONLY automatic path for a deletion — there
1108
+ * is no vault `deleted` trigger (see {@link pruneRemovedDefs}). Two guards keep the
1109
+ * teardown safe: a list FAILURE skips the diff (we `continue` BEFORE touching the prior
1110
+ * set) and a TRUNCATED list (>= the page cap) skips it too, so neither a transient vault
1111
+ * outage nor a partial page presents an under-set that wrongly tears down live agents.
1112
+ */
1113
+ async loadAll(): Promise<number> {
1114
+ let count = 0;
1115
+ for (const [vault, client] of this.clients) {
1116
+ let notes: Awaited<ReturnType<DefVaultClient["listDefNotes"]>>;
1117
+ try {
1118
+ notes = await client.listDefNotes({ limit: DEF_LIST_LIMIT });
1119
+ } catch (err) {
1120
+ console.error(`agent-defs: listing defs from vault "${vault}" failed (continuing): ${(err as Error).message}`);
1121
+ // CONFIDENT-SET GUARD: a failed list is NOT a confident read — leave the prior
1122
+ // seen set untouched (no removed-def diff) so a hub/vault blip can't prune grants.
1123
+ continue;
1124
+ }
1125
+ // TRUNCATION GUARD (the second way a read is non-confident): a list at the page cap
1126
+ // may be partial. The removed-def diff now performs a DESTRUCTIVE teardown
1127
+ // (pruneRemovedDefs deregisters), so a truncated read that omits the tail must NOT be
1128
+ // mistaken for deletions. Skip the diff + the seen-set rebuild (rebuilding from a
1129
+ // truncated list would drop the omitted tail and mis-flag it removed next pass); still
1130
+ // (re)instantiate what we got — instantiate only adds/updates, never tears down.
1131
+ // Practically unreachable at today's agent counts; the guard makes the teardown safe
1132
+ // by construction.
1133
+ // `< cap` ⇒ the result fit on one page → it cannot be truncated; `>= cap` is the
1134
+ // (possibly-)truncated case the `else` defers.
1135
+ const confident = notes.length < DEF_LIST_LIMIT;
1136
+ if (confident) {
1137
+ // Detect removed defs by diffing the prior seen set (noteId→name) against the ids
1138
+ // present now, BEFORE we mutate it.
1139
+ const presentIds = new Set(notes.map((n) => n.id));
1140
+ await this.pruneRemovedDefs(vault, presentIds);
1141
+ // Rebuild the seen set from this confident read (carry-forward last-known names for
1142
+ // notes that fail to parse, so a transient parse error doesn't drop them).
1143
+ this.rebuildSeenDefs(vault, notes);
1144
+ } else {
1145
+ console.warn(
1146
+ `agent-defs: def list for "${vault}" returned ${notes.length} notes (>= the ${DEF_LIST_LIMIT} ` +
1147
+ `page cap) — skipping the removed-def reconcile this pass to avoid a truncated-read teardown.`,
1148
+ );
1149
+ }
1150
+ for (const note of notes) {
1151
+ if (await this.instantiate(vault, note)) count++;
1152
+ }
1153
+ }
1154
+ return count;
1155
+ }
1156
+
1157
+ /**
1158
+ * Reconcile every def that was in the prior seen set for `vault` but is NOT in
1159
+ * `presentIds` (its note was deleted) — tear the agent DOWN (drop the live
1160
+ * programmatic registration + the wake channel) AND `reconcileGrants(agent, [])`
1161
+ * prune ALL its grants. Best-effort throughout; grant cleanup is a no-op without a
1162
+ * grants client. Called ONLY with a confident current id set (a successful,
1163
+ * non-truncated list); never on a list failure or a truncated read (see {@link loadAll}).
1164
+ *
1165
+ * Why the poll MUST deregister (not just prune grants): there is NO vault `deleted`
1166
+ * trigger — the hub's connection engine maps only `note.created`/`note.updated` to
1167
+ * vault-trigger verbs (parachute-hub `admin-connections` `eventToVaultEvents`), so a
1168
+ * def deleted out-of-band NEVER fires the reactive `reload(...,"deleted")` teardown.
1169
+ * This poll is the ONLY automatic convergence path for a deletion, so it must do the
1170
+ * SAME full teardown {@link confirmedRemoval} does, or a deleted agent stays live (an
1171
+ * orphan: gone from the vault, still answering messages) until the daemon restarts.
1172
+ */
1173
+ private async pruneRemovedDefs(vault: string, presentIds: Set<string>): Promise<void> {
1174
+ const prior = this.seenDefs.get(vault);
1175
+ if (!prior) return; // first confident read of this vault — nothing to compare against.
1176
+ for (const [noteId, name] of prior) {
1177
+ if (presentIds.has(noteId)) continue; // still present — not a removal.
1178
+ // Confirmed removal: the def note is gone from a confident vault read. Tear the
1179
+ // agent + wake channel down, then prune its grants. (The seen-set entry is cleared
1180
+ // by the `rebuildSeenDefs` that runs right after this in `loadAll`.)
1181
+ await this.deregisterByNote(vault, noteId);
1182
+ await this.reconcileForRemovedAgent(name);
1183
+ }
1184
+ }
1185
+
1186
+ /**
1187
+ * Rebuild the per-vault seen set from a confident list. Each present note maps
1188
+ * noteId→its parsed `metadata.name`; a note that fails to parse keeps its prior
1189
+ * last-known name (so a transient parse error doesn't drop it from the tracked set
1190
+ * and wrongly flag it removed next pass). A note that never had a name (parse-failed
1191
+ * on first sight) is tracked id-only (empty name) so it isn't re-detected as removed.
1192
+ */
1193
+ private rebuildSeenDefs(
1194
+ vault: string,
1195
+ notes: Array<{ id: string; content?: string; metadata?: Record<string, unknown> }>,
1196
+ ): void {
1197
+ const prior = this.seenDefs.get(vault);
1198
+ const next = new Map<string, string>();
1199
+ for (const note of notes) {
1200
+ const name = nameOfDefNote(note) ?? prior?.get(note.id) ?? "";
1201
+ next.set(note.id, name);
1202
+ }
1203
+ this.seenDefs.set(vault, next);
1204
+ }
1205
+
1206
+ /**
1207
+ * Reconcile a CONFIRMED-removed agent's grants away (prune ALL). Best-effort + no-op
1208
+ * without a grants client / without a known name.
1209
+ *
1210
+ * FIX 5 (PR #3) — make the failure NON-SILENT. A hub-unreachable reconcile used to be
1211
+ * caught + logged + ignored, ORPHANING the agent's approved grants on the hub (a
1212
+ * re-created same-named agent resurrects them). It's still BEST-EFFORT (we don't block
1213
+ * the note delete on grant cleanup — the def IS gone), but we now (a) `console.warn`
1214
+ * loudly AND (b) RETURN a structured signal so the caller can surface a PARTIAL success
1215
+ * (delete succeeded, grant cleanup didn't) rather than claiming a clean full success.
1216
+ * `skipped` = no grants client / no name (nothing to reconcile — a true no-op).
1217
+ */
1218
+ private async reconcileForRemovedAgent(
1219
+ name: string,
1220
+ ): Promise<{ ok: true; pruned: number } | { ok: false; error: string } | { skipped: true }> {
1221
+ if (!this.grants || !name) return { skipped: true };
1222
+ try {
1223
+ const { pruned } = await this.grants.reconcileGrants(name, []);
1224
+ if (pruned > 0) {
1225
+ console.log(`agent-defs: pruned ${pruned} stale grant(s) for removed agent "${name}".`);
1226
+ }
1227
+ return { ok: true, pruned };
1228
+ } catch (err) {
1229
+ const error = (err as Error).message;
1230
+ // NON-SILENT (FIX 5): a swallowed grant-GC failure orphans approved grants on the
1231
+ // hub. Warn loudly + return the failure so the delete path reports partial success.
1232
+ console.warn(
1233
+ `agent-defs: pruning grants for removed agent "${name}" FAILED — its approved hub ` +
1234
+ `grants may be ORPHANED (re-creating a same-named agent would resurrect them); ` +
1235
+ `the note delete still completed (best-effort grant cleanup): ${error}`,
1236
+ );
1237
+ return { ok: false, error };
1238
+ }
1239
+ }
1240
+
1241
+ /**
1242
+ * Reload ONE def by note id (the reactive path — a vault trigger / poll says this
1243
+ * note changed). Re-reads the note from its vault: present → (re)instantiate;
1244
+ * absent (deleted) → deregister. `event` is a hint from the trigger
1245
+ * (`created`/`updated`/`deleted`); we still re-read so a stale/racing event
1246
+ * resolves to ground truth (a "created" that was since deleted tears down, not up).
1247
+ *
1248
+ * Returns the resulting state so the webhook can ack meaningfully.
1249
+ */
1250
+ async reload(
1251
+ vault: string,
1252
+ noteId: string,
1253
+ event?: "created" | "updated" | "deleted",
1254
+ ): Promise<"instantiated" | "deregistered" | "skipped"> {
1255
+ const client = this.clients.get(vault);
1256
+ if (!client) {
1257
+ console.warn(`agent-defs: reload for unknown def-vault "${vault}" — ignoring.`);
1258
+ return "skipped";
1259
+ }
1260
+ // A delete event: the note is gone — tear down without a fetch (the GET would 404
1261
+ // anyway; skipping it is faster + avoids a confusing 404 log). A delete is a
1262
+ // CONFIRMED removal → prune the agent's grants (#96).
1263
+ if (event === "deleted") {
1264
+ await this.confirmedRemoval(vault, noteId);
1265
+ return "deregistered";
1266
+ }
1267
+ let note: Awaited<ReturnType<DefVaultClient["getNote"]>>;
1268
+ try {
1269
+ note = await client.getNote(noteId);
1270
+ } catch (err) {
1271
+ console.error(`agent-defs: reload fetch of ${noteId} from "${vault}" failed: ${(err as Error).message}`);
1272
+ // A fetch FAILURE is NOT a confirmed removal — skip without pruning grants (safety
1273
+ // guard: never prune from an inconclusive read). The agent + grants stay intact.
1274
+ return "skipped";
1275
+ }
1276
+ if (!note) {
1277
+ // Re-read 404 says it's gone (deleted, or no longer carries the def tag we can
1278
+ // see) — a CONFIRMED removal → prune the agent's grants (#96).
1279
+ await this.confirmedRemoval(vault, noteId);
1280
+ return "deregistered";
1281
+ }
1282
+ return (await this.instantiate(vault, note)) ? "instantiated" : "skipped";
1283
+ }
1284
+
1285
+ // ---------------------------------------------------------------------------
1286
+ // Def write path (the v2 API layer) — create / edit / delete a `#agent/definition`
1287
+ // note in a configured def-vault, then reload it into a LIVE agent immediately (the
1288
+ // per-note reload, NOT the 60s poll). The daemon owns the def-vault write token
1289
+ // (def-vaults.ts); these methods drive its client. Validation is the registry's job
1290
+ // (vault configured, name slug, backend valid) so the daemon route stays thin.
1291
+ // ---------------------------------------------------------------------------
1292
+
1293
+ /**
1294
+ * Create a new `#agent/definition` note in `vault` (body = system prompt, metadata =
1295
+ * name/backend/wants/extra), then reload it so the agent is LIVE immediately (no
1296
+ * wait for the trigger or the poll). Returns the created def in the {@link
1297
+ * AgentDefDetail} shape. Throws {@link AgentDefWriteError} on a validation failure
1298
+ * (unknown vault, bad name, bad backend) or a write/reload failure.
1299
+ */
1300
+ async createDef(args: {
1301
+ vault: string;
1302
+ name: string;
1303
+ backend: AgentBackendKind;
1304
+ systemPrompt: string;
1305
+ wants?: string;
1306
+ metadata?: Record<string, string>;
1307
+ }): Promise<AgentDefDetail> {
1308
+ const client = this.clients.get(args.vault);
1309
+ if (!client) {
1310
+ throw new AgentDefWriteError(`unknown def-vault "${args.vault}" (configure it first)`, 400);
1311
+ }
1312
+ if (!NAME_SLUG_RE.test(args.name)) {
1313
+ throw new AgentDefWriteError(
1314
+ `name "${args.name}" must be a slug (alphanumeric, dash, underscore)`,
1315
+ 400,
1316
+ );
1317
+ }
1318
+ // DUAL-READ the legacy backend value `"channel"` → canonical `"attached"`, so a
1319
+ // caller (or a hand-driven API client) passing the pre-rename value still WRITES the
1320
+ // canonical value. The routing key `channel` is a separate concept, unchanged.
1321
+ const backend: AgentBackendKind =
1322
+ (args.backend as string) === "channel" ? "attached" : args.backend;
1323
+ if (backend !== "programmatic" && backend !== "attached") {
1324
+ throw new AgentDefWriteError(`backend must be "programmatic" or "attached"`, 400);
1325
+ }
1326
+ // A name collision with a live def (in ANY vault — the wake channel is shared) would
1327
+ // resurrect last-writer-wins on the channel; reject up front for a clean error.
1328
+ for (const d of this.live.values()) {
1329
+ if (d.name === args.name) {
1330
+ throw new AgentDefWriteError(
1331
+ `an agent named "${args.name}" already exists (note ${d.noteId} in "${d.vault}")`,
1332
+ 409,
1333
+ );
1334
+ }
1335
+ }
1336
+ const metadata = this.buildDefMetadata({ ...args, backend });
1337
+ const created = await client.createNote({
1338
+ content: args.systemPrompt,
1339
+ metadata,
1340
+ path: `Agents/${args.name}`,
1341
+ });
1342
+ // Reload the just-created note → instantiate it LIVE now (the immediate path).
1343
+ await this.reload(args.vault, created.id, "created");
1344
+ const detail = this.liveDef(args.vault, created.id);
1345
+ if (!detail) {
1346
+ // Instantiation didn't take (a parse/instantiate failure stamps status:error on
1347
+ // the note + returns false). Surface that the note was written but isn't live.
1348
+ throw new AgentDefWriteError(
1349
+ `def note ${created.id} written to "${args.vault}" but failed to instantiate ` +
1350
+ `(check the note's status field for the error)`,
1351
+ 502,
1352
+ );
1353
+ }
1354
+ return detail;
1355
+ }
1356
+
1357
+ /**
1358
+ * Edit an existing live def note (body and/or metadata), then reload it so the change
1359
+ * is LIVE immediately. The note MUST be a currently-live def we instantiated in a
1360
+ * configured vault (the daemon resolves the vault; we re-guard here). Returns the
1361
+ * updated detail. Throws {@link AgentDefWriteError} on a miss or a write/reload failure.
1362
+ */
1363
+ async editDef(
1364
+ noteId: string,
1365
+ fields: { systemPrompt?: string; wants?: string; metadata?: Record<string, string> },
1366
+ ): Promise<AgentDefDetail> {
1367
+ const found = this.findLiveByNote(noteId);
1368
+ if (!found) {
1369
+ throw new AgentDefWriteError(`note ${noteId} is not a live agent definition`, 404);
1370
+ }
1371
+ const client = this.clients.get(found.vault);
1372
+ if (!client) {
1373
+ throw new AgentDefWriteError(`unknown def-vault "${found.vault}"`, 400);
1374
+ }
1375
+ const patch: { content?: string; metadata?: Record<string, string> } = {};
1376
+ if (fields.systemPrompt !== undefined) patch.content = fields.systemPrompt;
1377
+ const metadata: Record<string, string> = { ...(fields.metadata ?? {}) };
1378
+ if (fields.wants !== undefined) metadata.wants = fields.wants;
1379
+ if (Object.keys(metadata).length > 0) patch.metadata = metadata;
1380
+ if (patch.content === undefined && patch.metadata === undefined) {
1381
+ throw new AgentDefWriteError(`nothing to edit (provide systemPrompt, wants, or metadata)`, 400);
1382
+ }
1383
+ await client.patchNote(noteId, patch);
1384
+ await this.reload(found.vault, noteId, "updated");
1385
+ const detail = this.liveDef(found.vault, noteId);
1386
+ if (!detail) {
1387
+ throw new AgentDefWriteError(
1388
+ `def note ${noteId} edited but failed to re-instantiate (check the note's status field)`,
1389
+ 502,
1390
+ );
1391
+ }
1392
+ return detail;
1393
+ }
1394
+
1395
+ /**
1396
+ * Delete a live def note, then deregister the agent immediately. The note MUST be a
1397
+ * currently-live def we instantiated. Returns the (vault, name) of what was removed,
1398
+ * plus a `grantsReconciled` flag (FIX 5, PR #3) — `false` when the best-effort grant
1399
+ * cleanup FAILED so the caller can report a PARTIAL success rather than a clean one.
1400
+ *
1401
+ * ORDERING (FIX 4, PR #3) — the VAULT NOTE DELETE happens FIRST; only after it
1402
+ * SUCCEEDS do we deregister the live agent. So a vault-delete 502 throws here BEFORE
1403
+ * any in-memory teardown — the def stays REGISTERED (it reappears coherently on the
1404
+ * next poll), never orphaned (gone from memory but still in the vault, the confusing
1405
+ * half-state). This mirrors the `agent-vaults` removal path's "persist the durable
1406
+ * change first, then tear down in-memory state" discipline (daemon.ts #106). Throws
1407
+ * {@link AgentDefWriteError} on a miss; a vault-delete failure throws (un-torn-down).
1408
+ */
1409
+ async deleteDef(
1410
+ noteId: string,
1411
+ ): Promise<{ vault: string; name: string; grantsReconciled: boolean }> {
1412
+ const found = this.findLiveByNote(noteId);
1413
+ if (!found) {
1414
+ throw new AgentDefWriteError(`note ${noteId} is not a live agent definition`, 404);
1415
+ }
1416
+ const client = this.clients.get(found.vault);
1417
+ if (!client) {
1418
+ throw new AgentDefWriteError(`unknown def-vault "${found.vault}"`, 400);
1419
+ }
1420
+ // STEP 1 — delete the vault note FIRST (the durable change). A non-ok (non-404)
1421
+ // response throws out of here, BEFORE any deregister, so the in-memory def is left
1422
+ // intact (FIX 4): no orphan, the next poll re-converges. (404 is fine — gone is gone.)
1423
+ await client.deleteNote(noteId);
1424
+ // STEP 2 — the note is gone → tear the agent down + prune grants (the confirmed-
1425
+ // removal path). Capture the grant-reconcile outcome to surface a partial success.
1426
+ const reconcile = await this.confirmedRemoval(found.vault, noteId);
1427
+ const grantsReconciled = !("ok" in reconcile) || reconcile.ok === true;
1428
+ return { vault: found.vault, name: found.detail.name, grantsReconciled };
1429
+ }
1430
+
1431
+ /**
1432
+ * Build the metadata for a created/edited def note from the API inputs. `name` +
1433
+ * `backend` are the load-bearing config; `wants` is the comma-separated connection
1434
+ * list (omitted when empty); any extra `metadata` the caller passes is merged FIRST
1435
+ * so the explicit name/backend/wants win (the route can't override the validated
1436
+ * name/backend via the metadata bag). NEVER carries a token/secret — secrets stay
1437
+ * local (the parse path never reads creds off a note).
1438
+ */
1439
+ private buildDefMetadata(args: {
1440
+ name: string;
1441
+ backend: AgentBackendKind;
1442
+ wants?: string;
1443
+ metadata?: Record<string, string>;
1444
+ }): Record<string, string> {
1445
+ const metadata: Record<string, string> = { ...(args.metadata ?? {}) };
1446
+ metadata.name = args.name;
1447
+ metadata.backend = args.backend;
1448
+ if (args.wants !== undefined && args.wants.trim().length > 0) {
1449
+ metadata.wants = args.wants;
1450
+ }
1451
+ return metadata;
1452
+ }
1453
+
1454
+ /**
1455
+ * A CONFIRMED removed def (a `deleted` trigger, or a re-read 404): tear the agent
1456
+ * down AND prune ALL its grants (#96 grant-GC) so a deleted `#agent/definition` note
1457
+ * doesn't orphan live approved rows. The seen-set entry is cleared so a later loadAll
1458
+ * doesn't re-detect (and re-prune) the same removal. Reconcile is best-effort.
1459
+ *
1460
+ * Returns the grant-reconcile outcome (FIX 5, PR #3) so the API delete path can report
1461
+ * a PARTIAL success when grant cleanup failed (delete done, grants possibly orphaned).
1462
+ */
1463
+ private async confirmedRemoval(
1464
+ vault: string,
1465
+ noteId: string,
1466
+ ): Promise<{ ok: true; pruned: number } | { ok: false; error: string } | { skipped: true }> {
1467
+ // The grant holder name comes from the live record if present, else the last-known
1468
+ // name we tracked for this note (a def removed before it ever instantiated).
1469
+ const name =
1470
+ this.live.get(this.keyOf(vault, noteId))?.name ?? this.seenDefs.get(vault)?.get(noteId);
1471
+ await this.deregisterByNote(vault, noteId);
1472
+ this.seenDefs.get(vault)?.delete(noteId);
1473
+ if (!name) return { skipped: true };
1474
+ return this.reconcileForRemovedAgent(name);
1475
+ }
1476
+
1477
+ /**
1478
+ * Instantiate (or re-instantiate) one def note: parse → bring up the channel →
1479
+ * persist+register the agent → stamp status. Returns true on success. A parse
1480
+ * failure stamps `error` (so the note surfaces the problem) and returns false; an
1481
+ * instantiate failure is logged + returns false (the prior registration, if any,
1482
+ * is left intact — we don't tear down a working agent on a transient failure).
1483
+ */
1484
+ private async instantiate(
1485
+ vault: string,
1486
+ note: { id: string; content?: string; metadata?: Record<string, unknown> },
1487
+ ): Promise<boolean> {
1488
+ const binding = this.bindings.get(vault);
1489
+ const client = this.clients.get(vault);
1490
+ if (!binding || !client) return false;
1491
+
1492
+ let def: ParsedAgentDef;
1493
+ try {
1494
+ def = parseAgentDef(note, { vault });
1495
+ } catch (err) {
1496
+ console.error(`agent-defs: skipping malformed def ${note.id} in "${vault}": ${(err as Error).message}`);
1497
+ // Best-effort: surface the problem on the note itself.
1498
+ await client.patchStatus(note.id, "error").catch(() => {});
1499
+ return false;
1500
+ }
1501
+
1502
+ try {
1503
+ await this.deps.ensureChannel(def.name, binding);
1504
+ await this.deps.setupAndRegister(def.spec);
1505
+ } catch (err) {
1506
+ console.error(`agent-defs: instantiating "${def.name}" (${note.id} in "${vault}") failed: ${(err as Error).message}`);
1507
+ await client.patchStatus(note.id, "error").catch(() => {});
1508
+ return false;
1509
+ }
1510
+
1511
+ // Resolve status. 4b: when a grants client is wired AND the def declares `wants:`
1512
+ // connections, REGISTER each as a pending grant with the hub + derive status from
1513
+ // the hub's grant statuses (`enabled` only once every connection is approved).
1514
+ // Otherwise fall back to the pure {@link resolveDefStatus} (pending if anything is
1515
+ // declared, enabled if nothing is). Either way the agent ALREADY ran its own-vault
1516
+ // setup above — an unapproved connection is absent at spawn, never a failure here.
1517
+ const { status, pending, connections } = await this.resolveStatusWithGrants(def);
1518
+ const fullPrompt = def.spec.systemPrompt ?? "";
1519
+ const systemPromptPreview =
1520
+ fullPrompt.length > SYSTEM_PROMPT_PREVIEW_LEN
1521
+ ? fullPrompt.slice(0, SYSTEM_PROMPT_PREVIEW_LEN)
1522
+ : fullPrompt;
1523
+ this.live.set(this.keyOf(vault, note.id), {
1524
+ vault,
1525
+ noteId: note.id,
1526
+ name: def.name,
1527
+ status,
1528
+ backend: def.spec.backend ?? "programmatic",
1529
+ mode: def.spec.mode ?? "single-threaded",
1530
+ systemPromptPreview,
1531
+ pending: pending ?? [],
1532
+ wants: def.wants.map((c) => connectionKey(c)),
1533
+ connections,
1534
+ ...(def.spec.model ? { model: def.spec.model } : {}),
1535
+ });
1536
+ // Track this note in the per-vault seen set (a confident, freshly-parsed read) so the
1537
+ // removed-def diff (loadAll) and the reload-delete path both address it by name. This
1538
+ // covers the reload single-note path where loadAll's rebuild didn't run.
1539
+ this.recordSeen(vault, note.id, def.name);
1540
+ // Stamp status — best-effort: a failed stamp doesn't unmake the running agent.
1541
+ try {
1542
+ await client.patchStatus(note.id, status, pending);
1543
+ } catch (err) {
1544
+ console.warn(`agent-defs: status stamp for "${def.name}" failed (continuing): ${(err as Error).message}`);
1545
+ }
1546
+ // Grant-GC (#96): a CLEAN successful load is a confident live set, so prune any grant
1547
+ // the agent no longer declares — e.g. a `wants:` entry removed from the def. We send
1548
+ // the CURRENTLY-declared connection SPECS; the hub re-derives the keys with its own
1549
+ // connectionKey. SAFETY: only reached AFTER a successful parse + instantiate; a
1550
+ // parse/instantiate failure returns above WITHOUT reconciling, so a transient error
1551
+ // never presents a stale/empty live set that nukes approved grants.
1552
+ await this.reconcileLiveKeys(def);
1553
+ console.log(`agent-defs: instantiated "${def.name}" from ${note.id} in "${vault}" (status=${status}).`);
1554
+ return true;
1555
+ }
1556
+
1557
+ /** Record a note in the per-vault seen set (noteId → agent name) — a confident read. */
1558
+ private recordSeen(vault: string, noteId: string, name: string): void {
1559
+ let m = this.seenDefs.get(vault);
1560
+ if (!m) {
1561
+ m = new Map<string, string>();
1562
+ this.seenDefs.set(vault, m);
1563
+ }
1564
+ m.set(noteId, name);
1565
+ }
1566
+
1567
+ /**
1568
+ * Prune the agent's grants down to its CURRENTLY-declared connections (#96 grant-GC,
1569
+ * the clean-load case). POSTs reconcile with the live connection SPECS (`def.wants`);
1570
+ * the hub re-derives each key with its own connectionKey and tears down + removes every
1571
+ * grant NOT in that set (e.g. a removed want). A def with no `wants:` sends an empty
1572
+ * set, which prunes any leftover grant from a prior `wants:` it no longer declares.
1573
+ * Best-effort: no grants client → no-op; a reconcile failure logs a warning and never
1574
+ * throws out of the load path.
1575
+ */
1576
+ private async reconcileLiveKeys(def: ParsedAgentDef): Promise<void> {
1577
+ if (!this.grants) return;
1578
+ // Pass the live connection SPECS (def.wants) — the hub derives the keys with
1579
+ // its own connectionKey. (Sending keys we computed via grants.ts connectionKey
1580
+ // would diverge from the hub's for service/tagged/mcp grants → wrong prunes.)
1581
+ try {
1582
+ const { pruned } = await this.grants.reconcileGrants(def.name, def.wants);
1583
+ if (pruned > 0) {
1584
+ console.log(`agent-defs: pruned ${pruned} stale grant(s) for "${def.name}".`);
1585
+ }
1586
+ } catch (err) {
1587
+ console.warn(
1588
+ `agent-defs: reconciling grants for "${def.name}" failed (continuing): ${(err as Error).message}`,
1589
+ );
1590
+ }
1591
+ }
1592
+
1593
+ /**
1594
+ * Resolve a def's status, registering its `wants:` connections as PENDING grants
1595
+ * when a grants client is wired (4b). For each declared connection: `PUT
1596
+ * /admin/grants {agent, connection}` (idempotent upsert), collect the returned
1597
+ * status, then derive `enabled` (every connection approved) vs `pending` (listing
1598
+ * the unapproved connection keys). Legacy `uses:` names are appended to `pending`
1599
+ * (they have no grants flow — informational only).
1600
+ *
1601
+ * Best-effort + non-fatal: NO grants client, NO `wants:`, or a registration failure
1602
+ * all fall back to {@link resolveDefStatus} (a connection that couldn't register
1603
+ * counts as unapproved → the def is `pending`, not `error` — the agent still runs
1604
+ * own-vault, the operator can retry the hub). A single connection's PUT failing is
1605
+ * logged + that connection counts as unapproved; the others still register.
1606
+ */
1607
+ private async resolveStatusWithGrants(
1608
+ def: ParsedAgentDef,
1609
+ ): Promise<{ status: AgentDefStatus; pending?: string[]; connections: ConnectionInfo[] }> {
1610
+ if (!this.grants || def.wants.length === 0) {
1611
+ // No hub wiring / no structured connections → the pure fallback. The connections
1612
+ // list is still surfaced (status `pending`, NO grant id) so the ops panel can
1613
+ // list the agent's declared `mcp:` connections + show the degraded hint when
1614
+ // there's nothing to Connect against (no grant could be resolved here).
1615
+ const fallback = resolveDefStatus(def);
1616
+ const connections: ConnectionInfo[] = def.wants.map((c) => ({
1617
+ key: connectionKey(c),
1618
+ kind: c.kind,
1619
+ target: c.target,
1620
+ status: "pending",
1621
+ }));
1622
+ return { ...fallback, connections };
1623
+ }
1624
+ const grants = this.grants;
1625
+ const statusByKey = new Map<string, string>();
1626
+ // Per-connection grant info (id + status) for the ops panel — keyed by connectionKey
1627
+ // so it lines up with the def's wants. The grant id comes FROM the hub (registerGrant
1628
+ // is an idempotent upsert that echoes the existing grant's id + current status); we
1629
+ // never derive it client-side (the hub's id-slug impl must not be duplicated).
1630
+ const infoByKey = new Map<string, ConnectionInfo>();
1631
+ for (const conn of def.wants) {
1632
+ const key = connectionKey(conn);
1633
+ try {
1634
+ const rec = await grants.registerGrant(def.name, conn);
1635
+ statusByKey.set(key, rec.status);
1636
+ infoByKey.set(key, {
1637
+ key,
1638
+ kind: conn.kind,
1639
+ target: conn.target,
1640
+ status: rec.status,
1641
+ ...(rec.id ? { grantId: rec.id } : {}),
1642
+ });
1643
+ } catch (err) {
1644
+ // A failed registration → the connection counts as unapproved (absent from
1645
+ // statusByKey). Never fatal — the agent runs own-vault; the operator retries.
1646
+ // Surface it with status `pending` + no grant id (the panel shows it un-Connectable).
1647
+ infoByKey.set(key, { key, kind: conn.kind, target: conn.target, status: "pending" });
1648
+ console.warn(
1649
+ `agent-defs: registering grant for "${def.name}" (${key}) failed ` +
1650
+ `(treating as pending): ${(err as Error).message}`,
1651
+ );
1652
+ }
1653
+ }
1654
+ const connections = def.wants.map(
1655
+ (c) =>
1656
+ infoByKey.get(connectionKey(c)) ?? {
1657
+ key: connectionKey(c),
1658
+ kind: c.kind,
1659
+ target: c.target,
1660
+ status: "pending",
1661
+ },
1662
+ );
1663
+ const resolved = resolveConnectionStatus(def.wants, statusByKey);
1664
+ // Surface legacy `uses:` names alongside the structured pending keys (no grant flow).
1665
+ const pending = [...(resolved.pending ?? []), ...def.declaredConnections];
1666
+ if (resolved.status === "enabled" && pending.length === 0) {
1667
+ return { status: "enabled", connections };
1668
+ }
1669
+ return { status: "pending", pending, connections };
1670
+ }
1671
+
1672
+ /** Tear down the agent for a given (vault, noteId): deregister + drop its channel. */
1673
+ private async deregisterByNote(vault: string, noteId: string): Promise<void> {
1674
+ const key = this.keyOf(vault, noteId);
1675
+ const rec = this.live.get(key);
1676
+ if (!rec) return; // never instantiated (a delete for a note we don't track) — no-op.
1677
+ this.live.delete(key);
1678
+ try {
1679
+ await this.deps.deregister(rec.name);
1680
+ } catch (err) {
1681
+ console.error(`agent-defs: deregistering "${rec.name}" failed (continuing): ${(err as Error).message}`);
1682
+ }
1683
+ try {
1684
+ await this.deps.removeChannel(rec.name);
1685
+ } catch (err) {
1686
+ console.error(`agent-defs: removing channel "${rec.name}" failed (continuing): ${(err as Error).message}`);
1687
+ }
1688
+ console.log(`agent-defs: deregistered "${rec.name}" (${noteId} in "${vault}").`);
1689
+ }
1690
+
1691
+ /** Tear down every agent from a def-vault (e.g. the vault binding is removed). */
1692
+ async deregisterAllForVault(vault: string): Promise<void> {
1693
+ // Drop the seen-defs entry for this vault FIRST (reviewer nit): otherwise the
1694
+ // next confident loadAll would diff the now-unbound vault's stale entries as
1695
+ // "removed" and issue spurious reconcile(agent, []) prunes — but the binding
1696
+ // was dropped, the defs weren't deleted, so their grants must NOT be GC'd.
1697
+ this.seenDefs.delete(vault);
1698
+ for (const rec of [...this.live.values()]) {
1699
+ if (rec.vault === vault) await this.deregisterByNote(vault, rec.noteId);
1700
+ }
1701
+ }
1702
+ }