@openparachute/agent 0.1.2 → 0.2.0

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 (605) hide show
  1. package/.parachute/module.json +124 -8
  2. package/LICENSE +2 -16
  3. package/README.md +118 -166
  4. package/package.json +32 -43
  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/tsconfig.json +2 -1
  103. package/.claude/scheduled_tasks.lock +0 -1
  104. package/.claude/settings.json +0 -5
  105. package/.claude/skills/add-atomic-chat-tool/SKILL.md +0 -243
  106. package/.claude/skills/add-atomic-chat-tool/atomic-chat-mcp-stdio.ts +0 -229
  107. package/.claude/skills/add-codex/SKILL.md +0 -161
  108. package/.claude/skills/add-dashboard/SKILL.md +0 -138
  109. package/.claude/skills/add-dashboard/resources/dashboard-pusher.ts +0 -495
  110. package/.claude/skills/add-emacs/SKILL.md +0 -296
  111. package/.claude/skills/add-gcal-tool/SKILL.md +0 -210
  112. package/.claude/skills/add-gchat/REMOVE.md +0 -6
  113. package/.claude/skills/add-gchat/SKILL.md +0 -92
  114. package/.claude/skills/add-gchat/VERIFY.md +0 -3
  115. package/.claude/skills/add-github/REMOVE.md +0 -6
  116. package/.claude/skills/add-github/SKILL.md +0 -148
  117. package/.claude/skills/add-github/VERIFY.md +0 -3
  118. package/.claude/skills/add-gmail-tool/SKILL.md +0 -229
  119. package/.claude/skills/add-imessage/REMOVE.md +0 -6
  120. package/.claude/skills/add-imessage/SKILL.md +0 -113
  121. package/.claude/skills/add-imessage/VERIFY.md +0 -3
  122. package/.claude/skills/add-karpathy-llm-wiki/SKILL.md +0 -110
  123. package/.claude/skills/add-karpathy-llm-wiki/llm-wiki.md +0 -75
  124. package/.claude/skills/add-linear/REMOVE.md +0 -6
  125. package/.claude/skills/add-linear/SKILL.md +0 -168
  126. package/.claude/skills/add-linear/VERIFY.md +0 -3
  127. package/.claude/skills/add-macos-statusbar/SKILL.md +0 -133
  128. package/.claude/skills/add-macos-statusbar/add/src/statusbar.swift +0 -147
  129. package/.claude/skills/add-matrix/REMOVE.md +0 -6
  130. package/.claude/skills/add-matrix/SKILL.md +0 -148
  131. package/.claude/skills/add-matrix/VERIFY.md +0 -3
  132. package/.claude/skills/add-ollama-provider/SKILL.md +0 -179
  133. package/.claude/skills/add-ollama-tool/SKILL.md +0 -193
  134. package/.claude/skills/add-opencode/SKILL.md +0 -229
  135. package/.claude/skills/add-parallel/SKILL.md +0 -290
  136. package/.claude/skills/add-resend/REMOVE.md +0 -6
  137. package/.claude/skills/add-resend/SKILL.md +0 -93
  138. package/.claude/skills/add-resend/VERIFY.md +0 -3
  139. package/.claude/skills/add-signal/REMOVE.md +0 -13
  140. package/.claude/skills/add-signal/SKILL.md +0 -318
  141. package/.claude/skills/add-signal/VERIFY.md +0 -5
  142. package/.claude/skills/add-slack/REMOVE.md +0 -6
  143. package/.claude/skills/add-slack/SKILL.md +0 -112
  144. package/.claude/skills/add-slack/VERIFY.md +0 -3
  145. package/.claude/skills/add-teams/REMOVE.md +0 -6
  146. package/.claude/skills/add-teams/SKILL.md +0 -207
  147. package/.claude/skills/add-teams/VERIFY.md +0 -3
  148. package/.claude/skills/add-vercel/SKILL.md +0 -147
  149. package/.claude/skills/add-vercel/container-skills/vercel-cli/SKILL.md +0 -103
  150. package/.claude/skills/add-webex/REMOVE.md +0 -6
  151. package/.claude/skills/add-webex/SKILL.md +0 -88
  152. package/.claude/skills/add-webex/VERIFY.md +0 -3
  153. package/.claude/skills/add-wechat/REMOVE.md +0 -49
  154. package/.claude/skills/add-wechat/SKILL.md +0 -170
  155. package/.claude/skills/add-wechat/scripts/wire-dm.ts +0 -172
  156. package/.claude/skills/add-whatsapp/SKILL.md +0 -264
  157. package/.claude/skills/add-whatsapp-cloud/REMOVE.md +0 -6
  158. package/.claude/skills/add-whatsapp-cloud/SKILL.md +0 -95
  159. package/.claude/skills/add-whatsapp-cloud/VERIFY.md +0 -3
  160. package/.claude/skills/claw/SKILL.md +0 -131
  161. package/.claude/skills/claw/scripts/claw +0 -374
  162. package/.claude/skills/convert-to-apple-container/SKILL.md +0 -212
  163. package/.claude/skills/customize/SKILL.md +0 -110
  164. package/.claude/skills/debug/SKILL.md +0 -349
  165. package/.claude/skills/get-qodo-rules/SKILL.md +0 -122
  166. package/.claude/skills/get-qodo-rules/references/output-format.md +0 -41
  167. package/.claude/skills/get-qodo-rules/references/pagination.md +0 -33
  168. package/.claude/skills/get-qodo-rules/references/repository-scope.md +0 -26
  169. package/.claude/skills/init-first-agent/SKILL.md +0 -120
  170. package/.claude/skills/init-onecli/SKILL.md +0 -270
  171. package/.claude/skills/manage-channels/SKILL.md +0 -87
  172. package/.claude/skills/manage-mounts/SKILL.md +0 -47
  173. package/.claude/skills/migrate-from-openclaw/MIGRATE_CRONS.md +0 -100
  174. package/.claude/skills/migrate-from-openclaw/SKILL.md +0 -447
  175. package/.claude/skills/migrate-from-openclaw/scripts/discover-openclaw.ts +0 -734
  176. package/.claude/skills/migrate-from-openclaw/scripts/extract-channel-credentials.ts +0 -476
  177. package/.claude/skills/migrate-nanoclaw/SKILL.md +0 -484
  178. package/.claude/skills/migrate-nanoclaw/diagnostics.md +0 -51
  179. package/.claude/skills/qodo-pr-resolver/SKILL.md +0 -326
  180. package/.claude/skills/qodo-pr-resolver/resources/providers.md +0 -329
  181. package/.claude/skills/update-nanoclaw/SKILL.md +0 -243
  182. package/.claude/skills/update-nanoclaw/diagnostics.md +0 -48
  183. package/.claude/skills/update-skills/SKILL.md +0 -130
  184. package/.claude/skills/use-native-credential-proxy/SKILL.md +0 -167
  185. package/.claude/skills/x-integration/SKILL.md +0 -417
  186. package/.claude/skills/x-integration/agent.ts +0 -243
  187. package/.claude/skills/x-integration/host.ts +0 -155
  188. package/.claude/skills/x-integration/lib/browser.ts +0 -148
  189. package/.claude/skills/x-integration/lib/config.ts +0 -62
  190. package/.claude/skills/x-integration/scripts/like.ts +0 -56
  191. package/.claude/skills/x-integration/scripts/post.ts +0 -66
  192. package/.claude/skills/x-integration/scripts/quote.ts +0 -80
  193. package/.claude/skills/x-integration/scripts/reply.ts +0 -74
  194. package/.claude/skills/x-integration/scripts/retweet.ts +0 -62
  195. package/.claude/skills/x-integration/scripts/setup.ts +0 -87
  196. package/.github/CODEOWNERS +0 -10
  197. package/.github/PULL_REQUEST_TEMPLATE.md +0 -18
  198. package/.github/workflows/bump-version.yml +0 -35
  199. package/.github/workflows/ci.yml +0 -39
  200. package/.github/workflows/label-pr.yml +0 -40
  201. package/.github/workflows/update-tokens.yml +0 -43
  202. package/.husky/pre-commit +0 -1
  203. package/.mcp.json +0 -3
  204. package/.nvmrc +0 -1
  205. package/.prettierrc +0 -4
  206. package/CHANGELOG.md +0 -263
  207. package/CLAUDE.md +0 -307
  208. package/CODE_OF_CONDUCT.md +0 -128
  209. package/CONTRIBUTING.md +0 -159
  210. package/CONTRIBUTORS.md +0 -26
  211. package/LICENSE-NANOCLAW-MIT +0 -21
  212. package/README_ja.md +0 -194
  213. package/README_zh.md +0 -194
  214. package/assets/nanoclaw-favicon.png +0 -0
  215. package/assets/nanoclaw-icon.png +0 -0
  216. package/assets/nanoclaw-logo-dark.png +0 -0
  217. package/assets/nanoclaw-logo.png +0 -0
  218. package/assets/nanoclaw-profile.jpeg +0 -0
  219. package/assets/nanoclaw-sales.png +0 -0
  220. package/assets/social-preview.jpg +0 -0
  221. package/config-examples/mount-allowlist.json +0 -25
  222. package/container/.dockerignore +0 -2
  223. package/container/CLAUDE.md +0 -21
  224. package/container/Dockerfile +0 -121
  225. package/container/agent-runner/bun.lock +0 -243
  226. package/container/agent-runner/package.json +0 -22
  227. package/container/agent-runner/scripts/sdk-signal-probe.ts +0 -169
  228. package/container/agent-runner/src/config.ts +0 -55
  229. package/container/agent-runner/src/db/connection.ts +0 -267
  230. package/container/agent-runner/src/db/index.ts +0 -20
  231. package/container/agent-runner/src/db/messages-in.ts +0 -138
  232. package/container/agent-runner/src/db/messages-out.ts +0 -143
  233. package/container/agent-runner/src/db/session-routing.ts +0 -30
  234. package/container/agent-runner/src/db/session-state.test.ts +0 -100
  235. package/container/agent-runner/src/db/session-state.ts +0 -79
  236. package/container/agent-runner/src/destinations.ts +0 -135
  237. package/container/agent-runner/src/formatter.test.ts +0 -167
  238. package/container/agent-runner/src/formatter.ts +0 -260
  239. package/container/agent-runner/src/index.ts +0 -110
  240. package/container/agent-runner/src/integration.test.ts +0 -121
  241. package/container/agent-runner/src/mcp-tools/agents.instructions.md +0 -26
  242. package/container/agent-runner/src/mcp-tools/agents.ts +0 -66
  243. package/container/agent-runner/src/mcp-tools/core.instructions.md +0 -27
  244. package/container/agent-runner/src/mcp-tools/core.ts +0 -262
  245. package/container/agent-runner/src/mcp-tools/index.ts +0 -22
  246. package/container/agent-runner/src/mcp-tools/interactive.instructions.md +0 -22
  247. package/container/agent-runner/src/mcp-tools/interactive.ts +0 -169
  248. package/container/agent-runner/src/mcp-tools/scheduling.instructions.md +0 -40
  249. package/container/agent-runner/src/mcp-tools/scheduling.ts +0 -299
  250. package/container/agent-runner/src/mcp-tools/self-mod.instructions.md +0 -25
  251. package/container/agent-runner/src/mcp-tools/self-mod.ts +0 -120
  252. package/container/agent-runner/src/mcp-tools/server.ts +0 -54
  253. package/container/agent-runner/src/mcp-tools/types.ts +0 -6
  254. package/container/agent-runner/src/poll-loop.test.ts +0 -248
  255. package/container/agent-runner/src/poll-loop.ts +0 -437
  256. package/container/agent-runner/src/providers/claude.ts +0 -379
  257. package/container/agent-runner/src/providers/factory.test.ts +0 -19
  258. package/container/agent-runner/src/providers/factory.ts +0 -13
  259. package/container/agent-runner/src/providers/index.ts +0 -6
  260. package/container/agent-runner/src/providers/mock.ts +0 -77
  261. package/container/agent-runner/src/providers/provider-registry.ts +0 -33
  262. package/container/agent-runner/src/providers/types.ts +0 -82
  263. package/container/agent-runner/src/scheduling/task-script.ts +0 -121
  264. package/container/agent-runner/src/timezone.test.ts +0 -93
  265. package/container/agent-runner/src/timezone.ts +0 -107
  266. package/container/agent-runner/tsconfig.json +0 -14
  267. package/container/build.sh +0 -48
  268. package/container/entrypoint.sh +0 -16
  269. package/container/skills/agent-browser/SKILL.md +0 -159
  270. package/container/skills/frontend-engineer/SKILL.md +0 -157
  271. package/container/skills/self-customize/SKILL.md +0 -87
  272. package/container/skills/slack-formatting/SKILL.md +0 -94
  273. package/container/skills/vercel-cli/SKILL.md +0 -111
  274. package/container/skills/welcome/SKILL.md +0 -85
  275. package/docs/APPLE-CONTAINER-NETWORKING.md +0 -90
  276. package/docs/BRANCH-FORK-MAINTENANCE.md +0 -81
  277. package/docs/README.md +0 -25
  278. package/docs/SDK_DEEP_DIVE.md +0 -643
  279. package/docs/SECURITY.md +0 -162
  280. package/docs/agent-runner-details.md +0 -749
  281. package/docs/api-details.md +0 -365
  282. package/docs/architecture-diagram.html +0 -422
  283. package/docs/architecture-diagram.md +0 -215
  284. package/docs/architecture.md +0 -751
  285. package/docs/audit/2026-04-30-channel-endpoint-audit.md +0 -36
  286. package/docs/build-and-runtime.md +0 -80
  287. package/docs/cross-mount-stress/README.md +0 -112
  288. package/docs/cross-mount-stress/container-writer-retry.mjs +0 -55
  289. package/docs/cross-mount-stress/container-writer-slow.mjs +0 -42
  290. package/docs/cross-mount-stress/container-writer.mjs +0 -47
  291. package/docs/cross-mount-stress/host-writer-retry.mjs +0 -55
  292. package/docs/cross-mount-stress/host-writer-slow.mjs +0 -43
  293. package/docs/cross-mount-stress/host-writer.mjs +0 -47
  294. package/docs/db-central.md +0 -316
  295. package/docs/db-session.md +0 -183
  296. package/docs/db.md +0 -119
  297. package/docs/design/2026-04-29-vault-management-ui.md +0 -231
  298. package/docs/design/2026-04-30-channel-wiring-rework.md +0 -234
  299. package/docs/design/2026-05-01-channel-wiring-approvals-deep-dive.md +0 -272
  300. package/docs/design/2026-05-02-channel-policy-and-approval-routing.md +0 -250
  301. package/docs/docker-sandboxes.md +0 -359
  302. package/docs/isolation-model.md +0 -88
  303. package/docs/ollama.md +0 -79
  304. package/docs/parachute-integration.md +0 -109
  305. package/docs/post-night-rebirth-reflections.md +0 -151
  306. package/eslint.config.js +0 -32
  307. package/pnpm-workspace.yaml +0 -8
  308. package/repo-tokens/README.md +0 -113
  309. package/repo-tokens/action.yml +0 -186
  310. package/repo-tokens/badge.svg +0 -23
  311. package/repo-tokens/examples/green.svg +0 -14
  312. package/repo-tokens/examples/red.svg +0 -14
  313. package/repo-tokens/examples/yellow-green.svg +0 -14
  314. package/repo-tokens/examples/yellow.svg +0 -14
  315. package/scripts/chat.ts +0 -101
  316. package/scripts/cleanup-sessions.sh +0 -150
  317. package/scripts/init-cli-agent.ts +0 -172
  318. package/scripts/init-first-agent.ts +0 -378
  319. package/scripts/parachute.ts +0 -158
  320. package/scripts/run-migrations.ts +0 -105
  321. package/scripts/sanity-live-poll.ts +0 -95
  322. package/scripts/seed-discord.ts +0 -80
  323. package/scripts/test-v2-agent.ts +0 -106
  324. package/scripts/test-v2-channel-e2e.ts +0 -265
  325. package/scripts/test-v2-host.ts +0 -184
  326. package/src/channels/adapter.ts +0 -214
  327. package/src/channels/api-translator.test.ts +0 -306
  328. package/src/channels/api-translator.ts +0 -214
  329. package/src/channels/ask-question.ts +0 -46
  330. package/src/channels/channel-registry.test.ts +0 -421
  331. package/src/channels/channel-registry.ts +0 -313
  332. package/src/channels/chat-sdk-bridge.test.ts +0 -84
  333. package/src/channels/chat-sdk-bridge.ts +0 -652
  334. package/src/channels/cli.ts +0 -276
  335. package/src/channels/discord.ts +0 -90
  336. package/src/channels/index.ts +0 -17
  337. package/src/channels/telegram-markdown-sanitize.test.ts +0 -78
  338. package/src/channels/telegram-markdown-sanitize.ts +0 -55
  339. package/src/channels/telegram-pairing.test.ts +0 -254
  340. package/src/channels/telegram-pairing.ts +0 -339
  341. package/src/channels/telegram.ts +0 -279
  342. package/src/channels/trust-hint.test.ts +0 -48
  343. package/src/channels/trust-hint.ts +0 -75
  344. package/src/claude-md-compose.migrate.test.ts +0 -64
  345. package/src/claude-md-compose.ts +0 -205
  346. package/src/command-gate.ts +0 -63
  347. package/src/config.test.ts +0 -93
  348. package/src/config.ts +0 -128
  349. package/src/container-config.ts +0 -167
  350. package/src/container-runner.test.ts +0 -32
  351. package/src/container-runner.ts +0 -576
  352. package/src/container-runtime.test.ts +0 -269
  353. package/src/container-runtime.ts +0 -167
  354. package/src/db/_bun-sqlite-shim.ts +0 -88
  355. package/src/db/agent-activity.test.ts +0 -155
  356. package/src/db/agent-activity.ts +0 -121
  357. package/src/db/agent-groups.ts +0 -77
  358. package/src/db/connection.migrate.test.ts +0 -176
  359. package/src/db/connection.ts +0 -259
  360. package/src/db/db-v2.test.ts +0 -440
  361. package/src/db/dropped-messages.ts +0 -44
  362. package/src/db/index.ts +0 -40
  363. package/src/db/messaging-groups.ts +0 -252
  364. package/src/db/migrations/001-initial.ts +0 -112
  365. package/src/db/migrations/002-chat-sdk-state.ts +0 -36
  366. package/src/db/migrations/008-dropped-messages.ts +0 -27
  367. package/src/db/migrations/009-drop-pending-credentials.ts +0 -13
  368. package/src/db/migrations/010-engage-modes.ts +0 -103
  369. package/src/db/migrations/011-pending-sender-approvals.ts +0 -40
  370. package/src/db/migrations/012-channel-registration.ts +0 -48
  371. package/src/db/migrations/013-approval-render-metadata.ts +0 -27
  372. package/src/db/migrations/014-secrets.ts +0 -44
  373. package/src/db/migrations/015-secrets-drop-host-pattern.ts +0 -18
  374. package/src/db/migrations/016-secret-assignments.ts +0 -30
  375. package/src/db/migrations/017-agent-activity.ts +0 -40
  376. package/src/db/migrations/018-oauth-app-configs.ts +0 -34
  377. package/src/db/migrations/019-oauth-app-connections.ts +0 -48
  378. package/src/db/migrations/020-agent-app-connections.ts +0 -28
  379. package/src/db/migrations/021-pending-oauth-states.ts +0 -35
  380. package/src/db/migrations/022-app-connections-provider.ts +0 -25
  381. package/src/db/migrations/023-agent-group-secret-mode.test.ts +0 -124
  382. package/src/db/migrations/023-agent-group-secret-mode.ts +0 -65
  383. package/src/db/migrations/024-collapse-approvals.test.ts +0 -249
  384. package/src/db/migrations/024-collapse-approvals.ts +0 -182
  385. package/src/db/migrations/025-secret-mode-check.test.ts +0 -155
  386. package/src/db/migrations/025-secret-mode-check.ts +0 -49
  387. package/src/db/migrations/026-user-dms-bot-id.test.ts +0 -116
  388. package/src/db/migrations/026-user-dms-bot-id.ts +0 -54
  389. package/src/db/migrations/027-provider-credentials.ts +0 -41
  390. package/src/db/migrations/_test-helpers.ts +0 -41
  391. package/src/db/migrations/index.ts +0 -127
  392. package/src/db/migrations/module-agent-to-agent-destinations.ts +0 -84
  393. package/src/db/migrations/module-approvals-pending-approvals.ts +0 -42
  394. package/src/db/migrations/module-approvals-title-options.ts +0 -40
  395. package/src/db/schema.ts +0 -258
  396. package/src/db/session-db.test.ts +0 -93
  397. package/src/db/session-db.ts +0 -325
  398. package/src/db/sessions.ts +0 -241
  399. package/src/delivery.test.ts +0 -148
  400. package/src/delivery.ts +0 -445
  401. package/src/env.ts +0 -74
  402. package/src/group-folder.test.ts +0 -35
  403. package/src/group-folder.ts +0 -44
  404. package/src/group-init.ts +0 -92
  405. package/src/host-core.test.ts +0 -456
  406. package/src/host-sweep.test.ts +0 -146
  407. package/src/host-sweep.ts +0 -287
  408. package/src/index.ts +0 -232
  409. package/src/install-slug.ts +0 -33
  410. package/src/log.test.ts +0 -81
  411. package/src/log.ts +0 -117
  412. package/src/mcp/http.ts +0 -72
  413. package/src/mcp/server.ts +0 -92
  414. package/src/mcp/stdio.ts +0 -51
  415. package/src/mcp/tools/activity.ts +0 -88
  416. package/src/mcp/tools/agent-groups.ts +0 -183
  417. package/src/mcp/tools/approvals.ts +0 -122
  418. package/src/mcp/tools/channels.test.ts +0 -126
  419. package/src/mcp/tools/channels.ts +0 -134
  420. package/src/mcp/tools/index.ts +0 -27
  421. package/src/mcp/tools/oauth.ts +0 -48
  422. package/src/mcp/tools/secrets.ts +0 -169
  423. package/src/mcp/tools/sessions.ts +0 -135
  424. package/src/mcp/types.ts +0 -51
  425. package/src/modules/agent-to-agent/agent-route.test.ts +0 -46
  426. package/src/modules/agent-to-agent/agent-route.ts +0 -223
  427. package/src/modules/agent-to-agent/create-agent.ts +0 -127
  428. package/src/modules/agent-to-agent/db/agent-destinations.ts +0 -135
  429. package/src/modules/agent-to-agent/index.ts +0 -22
  430. package/src/modules/agent-to-agent/write-destinations.ts +0 -59
  431. package/src/modules/approvals/agent.md +0 -45
  432. package/src/modules/approvals/index.ts +0 -21
  433. package/src/modules/approvals/picks.test.ts +0 -291
  434. package/src/modules/approvals/primitive.ts +0 -279
  435. package/src/modules/approvals/project.md +0 -27
  436. package/src/modules/approvals/response-handler.ts +0 -87
  437. package/src/modules/index.ts +0 -24
  438. package/src/modules/interactive/agent.md +0 -21
  439. package/src/modules/interactive/index.ts +0 -69
  440. package/src/modules/interactive/project.md +0 -12
  441. package/src/modules/mount-security/expand-path.test.ts +0 -82
  442. package/src/modules/mount-security/index.ts +0 -459
  443. package/src/modules/mount-security/migrate.test.ts +0 -91
  444. package/src/modules/permissions/access.ts +0 -28
  445. package/src/modules/permissions/channel-approval.test.ts +0 -389
  446. package/src/modules/permissions/channel-approval.ts +0 -188
  447. package/src/modules/permissions/db/agent-group-members.ts +0 -44
  448. package/src/modules/permissions/db/pending-channel-approvals.test.ts +0 -86
  449. package/src/modules/permissions/db/pending-channel-approvals.ts +0 -66
  450. package/src/modules/permissions/db/pending-sender-approvals.ts +0 -60
  451. package/src/modules/permissions/db/user-dms.ts +0 -58
  452. package/src/modules/permissions/db/user-roles.ts +0 -85
  453. package/src/modules/permissions/db/users.ts +0 -38
  454. package/src/modules/permissions/index.ts +0 -421
  455. package/src/modules/permissions/permissions.test.ts +0 -358
  456. package/src/modules/permissions/sender-approval.test.ts +0 -641
  457. package/src/modules/permissions/sender-approval.ts +0 -165
  458. package/src/modules/permissions/user-dm.ts +0 -200
  459. package/src/modules/provider-credentials/db.ts +0 -121
  460. package/src/modules/provider-credentials/index.ts +0 -12
  461. package/src/modules/provider-credentials/spawn.test.ts +0 -206
  462. package/src/modules/provider-credentials/spawn.ts +0 -114
  463. package/src/modules/scheduling/actions.ts +0 -113
  464. package/src/modules/scheduling/db.test.ts +0 -282
  465. package/src/modules/scheduling/db.ts +0 -148
  466. package/src/modules/scheduling/index.ts +0 -34
  467. package/src/modules/scheduling/recurrence.test.ts +0 -98
  468. package/src/modules/scheduling/recurrence.ts +0 -54
  469. package/src/modules/self-mod/agent.md +0 -30
  470. package/src/modules/self-mod/apply.ts +0 -85
  471. package/src/modules/self-mod/index.ts +0 -30
  472. package/src/modules/self-mod/project.md +0 -39
  473. package/src/modules/self-mod/request.ts +0 -91
  474. package/src/modules/typing/index.ts +0 -165
  475. package/src/oauth/agent-app-connections.ts +0 -103
  476. package/src/oauth/app-configs.test.ts +0 -64
  477. package/src/oauth/app-configs.ts +0 -114
  478. package/src/oauth/app-connections.test.ts +0 -109
  479. package/src/oauth/app-connections.ts +0 -178
  480. package/src/oauth/crypto.ts +0 -56
  481. package/src/oauth/flow.ts +0 -104
  482. package/src/oauth/providers/google.test.ts +0 -38
  483. package/src/oauth/providers/google.ts +0 -46
  484. package/src/oauth/providers/index.ts +0 -48
  485. package/src/oauth/state-store.test.ts +0 -54
  486. package/src/oauth/state-store.ts +0 -93
  487. package/src/parachute/README.md +0 -27
  488. package/src/parachute/create-agent.test.ts +0 -83
  489. package/src/parachute/create-agent.ts +0 -122
  490. package/src/parachute/group-status.test.ts +0 -165
  491. package/src/parachute/group-status.ts +0 -136
  492. package/src/parachute/types.ts +0 -41
  493. package/src/parachute/vault-mcp.test.ts +0 -251
  494. package/src/parachute/vault-mcp.ts +0 -232
  495. package/src/platform-id.test.ts +0 -104
  496. package/src/platform-id.ts +0 -109
  497. package/src/providers/index.ts +0 -6
  498. package/src/providers/provider-container-registry.ts +0 -58
  499. package/src/response-registry.ts +0 -45
  500. package/src/router.ts +0 -530
  501. package/src/secrets/crypto.test.ts +0 -45
  502. package/src/secrets/crypto.ts +0 -55
  503. package/src/secrets/index.ts +0 -461
  504. package/src/secrets/master-key.ts +0 -70
  505. package/src/secrets/secrets.test.ts +0 -651
  506. package/src/session-manager.attachments.test.ts +0 -171
  507. package/src/session-manager.dup-skip.test.ts +0 -173
  508. package/src/session-manager.migrate.test.ts +0 -59
  509. package/src/session-manager.ts +0 -451
  510. package/src/startup-bootstrap.test.ts +0 -226
  511. package/src/startup-bootstrap.ts +0 -207
  512. package/src/state-sqlite.ts +0 -182
  513. package/src/timezone.test.ts +0 -64
  514. package/src/timezone.ts +0 -37
  515. package/src/types.ts +0 -233
  516. package/src/web/auth.test.ts +0 -335
  517. package/src/web/auth.ts +0 -214
  518. package/src/web/discord-validate.test.ts +0 -77
  519. package/src/web/discord-validate.ts +0 -88
  520. package/src/web/hub-discovery.test.ts +0 -98
  521. package/src/web/hub-discovery.ts +0 -69
  522. package/src/web/routes/activity.ts +0 -106
  523. package/src/web/routes/agent-provider.test.ts +0 -282
  524. package/src/web/routes/agent-provider.ts +0 -309
  525. package/src/web/routes/approvals.ts +0 -185
  526. package/src/web/routes/apps.ts +0 -434
  527. package/src/web/routes/channels-mg-detail.test.ts +0 -324
  528. package/src/web/routes/channels-mga-detail.test.ts +0 -472
  529. package/src/web/routes/channels.ts +0 -311
  530. package/src/web/routes/oauth-providers.ts +0 -42
  531. package/src/web/routes/secrets.test.ts +0 -220
  532. package/src/web/routes/secrets.ts +0 -317
  533. package/src/web/routes/sessions.ts +0 -123
  534. package/src/web/routes/settings.test.ts +0 -106
  535. package/src/web/routes/settings.ts +0 -247
  536. package/src/web/routes/setup-status.ts +0 -205
  537. package/src/web/routes/vaults.test.ts +0 -389
  538. package/src/web/routes/vaults.ts +0 -225
  539. package/src/web/server-version.test.ts +0 -16
  540. package/src/web/server.ts +0 -1024
  541. package/src/web/services-manifest.test.ts +0 -148
  542. package/src/web/services-manifest.ts +0 -66
  543. package/src/web/static-serve.test.ts +0 -255
  544. package/src/web/static-serve.ts +0 -104
  545. package/src/web/telegram-validate.test.ts +0 -116
  546. package/src/web/telegram-validate.ts +0 -107
  547. package/src/web/vault-proxy.test.ts +0 -214
  548. package/src/web/vault-proxy.ts +0 -120
  549. package/src/web/wire-channel.ts +0 -181
  550. package/src/webhook-server.ts +0 -134
  551. package/vitest.config.ts +0 -18
  552. package/web/README.md +0 -63
  553. package/web/ui/index.html +0 -13
  554. package/web/ui/package.json +0 -35
  555. package/web/ui/pnpm-lock.yaml +0 -2164
  556. package/web/ui/scripts/verify-base.mjs +0 -31
  557. package/web/ui/src/App.tsx +0 -88
  558. package/web/ui/src/components/ActivityFeed.tsx +0 -444
  559. package/web/ui/src/components/AgentGroupPicker.tsx +0 -263
  560. package/web/ui/src/components/AgentProviderCards.tsx +0 -220
  561. package/web/ui/src/components/CredentialForm.tsx +0 -214
  562. package/web/ui/src/components/ScopeGrants.tsx +0 -74
  563. package/web/ui/src/components/StatusDot.tsx +0 -43
  564. package/web/ui/src/components/VaultPicker.tsx +0 -127
  565. package/web/ui/src/components/setup/AdapterInstallStep.tsx +0 -178
  566. package/web/ui/src/components/setup/AgentGroupStep.tsx +0 -43
  567. package/web/ui/src/components/setup/ChannelPickStep.tsx +0 -74
  568. package/web/ui/src/components/setup/DoneStep.tsx +0 -49
  569. package/web/ui/src/components/setup/PrereqStep.tsx +0 -129
  570. package/web/ui/src/components/setup/TestConnectionStep.tsx +0 -108
  571. package/web/ui/src/components/setup/TestMessageStep.tsx +0 -104
  572. package/web/ui/src/components/setup/WireChannelStep.tsx +0 -166
  573. package/web/ui/src/components/setup/types.ts +0 -105
  574. package/web/ui/src/lib/api.test.ts +0 -410
  575. package/web/ui/src/lib/api.ts +0 -1248
  576. package/web/ui/src/lib/auth.test.ts +0 -352
  577. package/web/ui/src/lib/auth.ts +0 -405
  578. package/web/ui/src/lib/channel-adapters.ts +0 -136
  579. package/web/ui/src/main.tsx +0 -19
  580. package/web/ui/src/routes/ApprovalsList.tsx +0 -294
  581. package/web/ui/src/routes/Apps.tsx +0 -613
  582. package/web/ui/src/routes/ChannelWireDetail.test.tsx +0 -233
  583. package/web/ui/src/routes/ChannelWireDetail.tsx +0 -403
  584. package/web/ui/src/routes/ChannelsList.tsx +0 -158
  585. package/web/ui/src/routes/GroupDetail.test.tsx +0 -206
  586. package/web/ui/src/routes/GroupDetail.tsx +0 -880
  587. package/web/ui/src/routes/GroupList.tsx +0 -187
  588. package/web/ui/src/routes/MessagingGroupDetail.test.tsx +0 -233
  589. package/web/ui/src/routes/MessagingGroupDetail.tsx +0 -306
  590. package/web/ui/src/routes/NewGroupWizard.tsx +0 -390
  591. package/web/ui/src/routes/OAuthCallback.tsx +0 -56
  592. package/web/ui/src/routes/SecretsList.tsx +0 -942
  593. package/web/ui/src/routes/SessionsList.tsx +0 -220
  594. package/web/ui/src/routes/SettingsAgentProvider.tsx +0 -109
  595. package/web/ui/src/routes/SettingsApprovals.tsx +0 -234
  596. package/web/ui/src/routes/SetupWizard.tsx +0 -219
  597. package/web/ui/src/routes/VaultDetail.test.tsx +0 -363
  598. package/web/ui/src/routes/VaultDetail.tsx +0 -960
  599. package/web/ui/src/routes/VaultsList.tsx +0 -295
  600. package/web/ui/src/routes/WireChannelPage.tsx +0 -413
  601. package/web/ui/src/styles.css +0 -608
  602. package/web/ui/src/test/setup.ts +0 -23
  603. package/web/ui/src/vite-env.d.ts +0 -10
  604. package/web/ui/vite.config.ts +0 -34
  605. 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
+ }