@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
package/src/daemon.ts ADDED
@@ -0,0 +1,3314 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * parachute-agent daemon — the transport-agnostic orchestrator.
4
+ *
5
+ * Runs as a long-lived HTTP server (launchd, systemd, or manual). It loads a
6
+ * channel registry (name → transport), starts each transport, and routes
7
+ * inbound traffic to the bridges subscribed to that channel. Bridges connect
8
+ * via SSE (`/events?channel=<name>`) for inbound and POST outbound to the HTTP
9
+ * API with a `channel` field.
10
+ *
11
+ * Telegram is one transport behind the registry; the daemon core touches no
12
+ * platform API directly.
13
+ *
14
+ * Port resolution (see `resolvePort`): the hub supervisor's injected `PORT`
15
+ * wins, then the `PARACHUTE_AGENT_PORT` override (legacy `PARACHUTE_CHANNEL_PORT`
16
+ * still honored), then the compiled-in canonical default 1941. The daemon binds
17
+ * AND self-registers the resolved port, so the supervisor's probe/proxy and the
18
+ * bound port never disagree (agent#41).
19
+ */
20
+
21
+ import { mkdirSync, readFileSync, existsSync, readdirSync } from "fs";
22
+ import { join } from "path";
23
+ import { homedir } from "os";
24
+ import { timingSafeEqual } from "node:crypto";
25
+ import { upsertService, listVaultNames } from "./services-manifest.ts";
26
+
27
+ /** Constant-time webhook-secret compare. Length check first (a length mismatch
28
+ * is never equal); timingSafeEqual on equal-length buffers avoids the
29
+ * short-circuit timing leak of `===`. Empty configured/presented → never match. */
30
+ function webhookSecretMatches(presented: string, configured: string): boolean {
31
+ if (!presented || !configured || presented.length !== configured.length) return false;
32
+ return timingSafeEqual(Buffer.from(presented), Buffer.from(configured));
33
+ }
34
+ import type {
35
+ Transport,
36
+ TransportContext,
37
+ InboundMessage,
38
+ ReplyArgs,
39
+ ReactArgs,
40
+ EditArgs,
41
+ PermissionArgs,
42
+ DownloadArgs,
43
+ } from "./transport.ts";
44
+ import { ChannelConfigError } from "./transport.ts";
45
+ import {
46
+ loadRegistry,
47
+ instantiateTransport,
48
+ upsertChannelEntry,
49
+ removeChannelEntry,
50
+ defaultStateDir,
51
+ type Channel,
52
+ type ChannelEntry,
53
+ } from "./registry.ts";
54
+ import { VaultTransport, AGENT_VAULT_TRIGGER_TEMPLATE, noteAgentKey } from "./transports/vault.ts";
55
+ import {
56
+ AgentDefRegistry,
57
+ AgentDefWriteError,
58
+ type DefVaultBinding,
59
+ type InstantiateDeps,
60
+ } from "./agent-defs.ts";
61
+ import {
62
+ resolveDefVaults,
63
+ readDefVaultsFile,
64
+ writeDefVaultsFile,
65
+ DEFAULT_DEF_VAULT_URL,
66
+ DEFAULT_HUB_ORIGIN,
67
+ } from "./def-vaults.ts";
68
+ import { mintScopedToken, vaultScope } from "./mint-token.ts";
69
+ import { GrantsClient } from "./grants.ts";
70
+ import { resolveEffectiveEnv } from "./effective-env.ts";
71
+ import { VaultJobStore, validateJob, vaultTransportFor, type Job } from "./jobs.ts";
72
+ import { Runner, realTickDriver } from "./runner.ts";
73
+ import { nextRunAfter } from "./cron.ts";
74
+ import {
75
+ setDefaultClaudeCredential,
76
+ setChannelClaudeCredential,
77
+ removeChannelClaudeCredential,
78
+ describeClaudeCredentials,
79
+ setChannelEnvVar,
80
+ removeChannelEnvVar,
81
+ describeChannelEnv,
82
+ DenylistedEnvError,
83
+ } from "./credentials.ts";
84
+ import { ClientRegistry, sseFrame } from "./routing.ts";
85
+ import { DeliveryState } from "./delivery-state.ts";
86
+ import {
87
+ requireScope,
88
+ extractToken,
89
+ json as authJson,
90
+ SCOPE_READ,
91
+ SCOPE_WRITE,
92
+ SCOPE_SEND,
93
+ SCOPE_ADMIN,
94
+ SCOPE_TERMINAL,
95
+ } from "./auth.ts";
96
+ import {
97
+ createTerminalWsHandlers,
98
+ type TerminalWsData,
99
+ } from "./terminal.ts";
100
+ import { TERMINAL_UI_HTML } from "./terminal-ui.ts";
101
+ import { serveTerminalAsset } from "./terminal-assets.ts";
102
+ import { isSpaPath, serveSpa, spaDistDir } from "./spa-serve.ts";
103
+ import {
104
+ buildSpecFromBody,
105
+ setupProgrammaticSpawn,
106
+ SpawnRequestError,
107
+ AGENT_NAME_SLUG,
108
+ type AgentInfo,
109
+ } from "./agents.ts";
110
+ import { SpawnDepsError, sessionsDir as defaultSessionsDir, resolveSpawnDeps } from "./spawn-deps.ts";
111
+ import {
112
+ ProgrammaticBackend,
113
+ realProgrammaticSpawn,
114
+ type ProgrammaticBackendDeps,
115
+ } from "./backends/programmatic.ts";
116
+ import {
117
+ ProgrammaticAgentRegistry,
118
+ type WriteOutbound,
119
+ type WriteThread,
120
+ type WriteCallback,
121
+ type QueuedMessage,
122
+ type TurnEventSink,
123
+ } from "./backends/registry.ts";
124
+ import {
125
+ AttachedQueueRegistry,
126
+ type AttachedQueueStore,
127
+ } from "./backends/attached-queue.ts";
128
+ import { readPersistedSpec, sessionWorkspace } from "./spawn-agent.ts";
129
+ import { normalizeChannel } from "./sandbox/types.ts";
130
+ import { CredentialNotConfiguredError } from "./credentials.ts";
131
+ import { MintError } from "./mint-token.ts";
132
+ import { validateHubJwt, getHubOrigin } from "./hub-jwt.ts";
133
+ import {
134
+ handleProtectedResource,
135
+ handleAuthorizationServer,
136
+ mcpWwwAuthenticate,
137
+ } from "./oauth-discovery.ts";
138
+ import {
139
+ handleMcp,
140
+ pushToChannel as mcpPushToChannel,
141
+ pushPermissionVerdict as mcpPushPermissionVerdict,
142
+ mcpSessionCount,
143
+ assertMcpSdkStreamContract,
144
+ } from "./mcp-http.ts";
145
+
146
+ // Re-export the shared auth surface so existing importers of the daemon module
147
+ // keep working; the canonical home is now `auth.ts` (shared with http-ui.ts).
148
+ export { requireScope, SCOPE_READ, SCOPE_WRITE, SCOPE_SEND } from "./auth.ts";
149
+
150
+ // ---------------------------------------------------------------------------
151
+ // Config
152
+ // ---------------------------------------------------------------------------
153
+
154
+ const STATE_DIR = defaultStateDir();
155
+ const INBOX_DIR = join(STATE_DIR, "inbox");
156
+
157
+ /**
158
+ * Resolve the HTTP port the daemon binds (and self-registers in services.json),
159
+ * honoring sources in priority order:
160
+ *
161
+ * 1. `PORT` — the hub supervisor injects this from the module's services.json
162
+ * `entry.port` (the canonical pattern vault/scribe follow). It is the port
163
+ * the supervisor ALSO probes for readiness and proxies `/agent/*` to, so
164
+ * the daemon MUST bind it or the supervisor reports `started_but_unbound`
165
+ * and the proxy routes to a dead port (agent#41).
166
+ * 2. `PARACHUTE_AGENT_PORT` — manual override for a daemon run outside the
167
+ * supervisor. Falls back to the legacy `PARACHUTE_CHANNEL_PORT` (the
168
+ * pre-rename env var; still honored during the channel→agent transition).
169
+ * 3. `1941` — the compiled-in canonical default.
170
+ *
171
+ * Pre-#41 the daemon read only `PARACHUTE_CHANNEL_PORT`, so it ignored the
172
+ * supervisor's `PORT` and bound 1941 regardless — the supervisor's injected
173
+ * port and the bound port could disagree, stranding the proxy. Honoring `PORT`
174
+ * first closes that gap.
175
+ *
176
+ * Read at call time (not at import) so tests can drive each tier deterministically.
177
+ *
178
+ * Uses `||` (not `??`) for the fall-through so an EMPTY-string env value falls
179
+ * through rather than being treated as "set": `PORT=""` with `??` would yield
180
+ * `parseInt("")` = NaN and bind port 0 / garbage. `||` skips the empty string
181
+ * to the next tier — matches vault's defensive `parseInt(...) || ... || DEFAULT`.
182
+ * The final `1941` literal also guards a non-numeric value (`PORT="abc"` →
183
+ * `parseInt` NaN → falsy → falls through to the default).
184
+ */
185
+ export function resolvePort(env: NodeJS.ProcessEnv = process.env): number {
186
+ return (
187
+ parseInt(env.PORT ?? "", 10) ||
188
+ parseInt(env.PARACHUTE_AGENT_PORT ?? "", 10) ||
189
+ parseInt(env.PARACHUTE_CHANNEL_PORT ?? "", 10) ||
190
+ 1941
191
+ );
192
+ }
193
+
194
+ const PORT = resolvePort();
195
+
196
+ /** Channel a bridge subscribes to when `?channel=` is omitted (back-compat). */
197
+ const DEFAULT_CHANNEL = "telegram";
198
+
199
+ /** Package version + install dir, for services.json self-registration. */
200
+ const PKG_VERSION = ((): string => {
201
+ try {
202
+ return JSON.parse(readFileSync(join(import.meta.dir, "..", "package.json"), "utf8")).version ?? "0.0.0";
203
+ } catch {
204
+ return "0.0.0";
205
+ }
206
+ })();
207
+ const INSTALL_DIR = join(import.meta.dir, "..");
208
+
209
+ /**
210
+ * The argv the hub supervisor should spawn to (re)start this module — written
211
+ * into our services.json row so `parachute restart agent` / reboot-survival /
212
+ * adopt all have a command to run. Without it the supervisor knows the port but
213
+ * not how to start the process, so a manually-run `bun src/daemon.ts` daemon
214
+ * can't be supervised (agent#34).
215
+ *
216
+ * Sourced from our own `.parachute/module.json` `startCmd` (the canonical
217
+ * declaration the hub already prefers when it can read the install dir),
218
+ * falling back to the package.json `bin` name when the manifest is unreadable.
219
+ * The bin (`parachute-agent` → `src/daemon.ts`) runs the daemon directly and
220
+ * ignores extra argv, so the literal command is stable regardless of any
221
+ * subcommand the hub's first-party fallback might carry.
222
+ */
223
+ export function resolveStartCmd(installDir: string): string[] {
224
+ try {
225
+ const manifest = JSON.parse(
226
+ readFileSync(join(installDir, ".parachute", "module.json"), "utf8"),
227
+ ) as { startCmd?: unknown };
228
+ if (
229
+ Array.isArray(manifest.startCmd) &&
230
+ manifest.startCmd.length > 0 &&
231
+ manifest.startCmd.every((a) => typeof a === "string")
232
+ ) {
233
+ return manifest.startCmd as string[];
234
+ }
235
+ } catch {
236
+ // fall through to the bin-name default
237
+ }
238
+ return ["parachute-agent"];
239
+ }
240
+
241
+ const START_CMD: string[] = resolveStartCmd(INSTALL_DIR);
242
+
243
+ // ---------------------------------------------------------------------------
244
+ // Registry + routing
245
+ // ---------------------------------------------------------------------------
246
+
247
+ /**
248
+ * Extract the agent-to-agent CALLBACK fields ("reply_to") from a flattened inbound `meta`
249
+ * (the vault transport's ingestInbound copies the note's metadata into `meta`, all string-
250
+ * valued). A SENDING agent stamps these on the inbound note it writes to the recipient:
251
+ * - `reply_to` — the sender's channel name; where to deliver the completion
252
+ * callback. Absent → no callback (an ordinary turn).
253
+ * - `correlation_id` — an opaque id the sender matches replies to requests with.
254
+ * - `delegation_depth` — how many hops deep this message is (the loop guard's counter).
255
+ * The vault stores it as a STRING, so coerce to a finite integer
256
+ * here; a missing/garbage value reads as 0 (a top-level turn).
257
+ *
258
+ * Returns ONLY the keys that are present, so spreading it into a {@link QueuedMessage} is a
259
+ * clean no-op when this isn't a delegated request. NOTE we read `reply_to` from metadata —
260
+ * NOT to be confused with the Telegram quote-reply `reply_to` on ReplyArgs (a message-id,
261
+ * a different axis that lives on the outbound side).
262
+ */
263
+ export function callbackFieldsFromMeta(
264
+ meta: Record<string, string> | undefined,
265
+ ): Pick<QueuedMessage, "replyTo" | "correlationId" | "delegationDepth"> {
266
+ if (!meta) return {};
267
+ const out: Pick<QueuedMessage, "replyTo" | "correlationId" | "delegationDepth"> = {};
268
+ if (typeof meta.reply_to === "string" && meta.reply_to) out.replyTo = meta.reply_to;
269
+ if (typeof meta.correlation_id === "string" && meta.correlation_id) {
270
+ out.correlationId = meta.correlation_id;
271
+ }
272
+ // Coerce the string-typed depth to a finite positive integer. Anything else — absent, "",
273
+ // "abc", a negative, OR a literal "0" — is OMITTED here; the drain's `?? 0` fallback
274
+ // (maybeDeliverCallback) treats an absent `delegationDepth` as 0, so a depth-0 message
275
+ // still gets to call back (the ceiling, not the floor, is what stops a runaway chain).
276
+ // We only bother storing a value when it's a meaningful positive depth.
277
+ const depth = Number(meta.delegation_depth);
278
+ if (Number.isFinite(depth) && depth > 0) out.delegationDepth = Math.floor(depth);
279
+ return out;
280
+ }
281
+
282
+ /** Build the per-channel context a transport routes through. Exported for tests
283
+ * (the inbound-routing fork lives here). */
284
+ export function contextFor(
285
+ registry: ClientRegistry,
286
+ channel: string,
287
+ deliveryState: DeliveryState,
288
+ programmatic?: ProgrammaticAgentRegistry,
289
+ attachedQueue?: AttachedQueueRegistry,
290
+ ): TransportContext {
291
+ return {
292
+ channel,
293
+ emit(msg: InboundMessage): void {
294
+ // ── DAEMON ROUTING FORK (design 2026-06-18-channel-backend.md, the load-bearing
295
+ // change). Route inbound by the agent's BACKEND:
296
+ //
297
+ // backend: attached → the AttachedQueueRegistry path. The inbound
298
+ // `#agent/message/inbound` note IS the queue item (durable in the vault,
299
+ // status:pending by default). There is NO `claude -p`, NO serial worker, and
300
+ // NO live push — a connected Claude Code session PULLS it via the channel MCP
301
+ // surface. So an attached inbound is a NO-OP here beyond its own durability:
302
+ // we MUST NOT enqueue to the programmatic worker (that would run a turn the
303
+ // attached model deliberately doesn't), and we don't advance the delivery
304
+ // high-water-mark (there's no live subscriber to deliver to; the durable note
305
+ // queue + claim status is the durability, not replay). Checked FIRST so an
306
+ // attached agent NEVER falls through to the programmatic enqueue below.
307
+ if (attachedQueue?.hasChannel(channel)) {
308
+ return;
309
+ }
310
+ // PROGRAMMATIC ROUTING (design 2026-06-16 step 3). If a programmatic agent is
311
+ // registered for this channel, the inbound becomes one on-demand `claude -p`
312
+ // turn — ENQUEUE it (the per-channel serial worker drains it) and do NOT also
313
+ // push to SSE/MCP: a programmatic agent has no live subscriber, so a fan-out
314
+ // would reach no one AND the delivery high-water-mark must NOT advance (there's
315
+ // nothing to deliver to; the queue is the durability). The note's id rides in
316
+ // `meta.note_id` so the reply threads to it.
317
+ if (programmatic?.hasChannel(channel)) {
318
+ programmatic.enqueue(channel, {
319
+ content: msg.content,
320
+ ...(msg.meta?.note_id ? { inReplyTo: msg.meta.note_id } : {}),
321
+ // AGENT-TO-AGENT CALLBACK ROUTING ("reply_to") — pull the callback fields a
322
+ // SENDING agent stamped on this inbound note's metadata (flattened into `meta` by
323
+ // the vault transport's ingestInbound). When `reply_to` is present, the drain
324
+ // delivers a callback to that channel on turn completion. See callbackFieldsFromMeta.
325
+ ...callbackFieldsFromMeta(msg.meta),
326
+ // Phase 1: carry inbound file attachments through to the turn (the programmatic
327
+ // backend stages them into the agent's private workspace so the turn can Read them).
328
+ ...(msg.attachments && msg.attachments.length > 0 ? { attachments: msg.attachments } : {}),
329
+ });
330
+ return;
331
+ }
332
+ // PENDING-INBOUND BUFFER (agent#121). No LIVE programmatic agent for this channel —
333
+ // but if the channel is EXPECTED to gain one (a def maps here; instantiation may be
334
+ // in flight, or a brief channel/agent desync), we must OWN the message, not drop it:
335
+ // the vault trigger acks success on our 200 and NEVER retries, so a silent drop is a
336
+ // PERMANENT loss (0 turns, 0 threads, no reply — the bug). Buffer it; `register()`
337
+ // replays the buffer in order once the agent is live. A genuinely UNKNOWN channel
338
+ // (nothing maps to it) returns "unknown": nothing to deliver to, so we log + fall
339
+ // through to the push path (which reaches no one) and still 200. We do NOT advance the
340
+ // delivery high-water-mark here (no real delivery happened; the durable note + the
341
+ // pending buffer / replay is the durability).
342
+ if (programmatic) {
343
+ const outcome = programmatic.queuePending(channel, {
344
+ content: msg.content,
345
+ ...(msg.meta?.note_id ? { inReplyTo: msg.meta.note_id } : {}),
346
+ // Carry the callback fields through the PENDING buffer too — a delegated request
347
+ // that arrives before its recipient agent is live must still trigger a callback
348
+ // once the buffered turn runs on register() (the agent#121 replay path).
349
+ ...callbackFieldsFromMeta(msg.meta),
350
+ // Phase 1: carry inbound attachments through the pending buffer too, so a turn
351
+ // that runs on register() still stages them.
352
+ ...(msg.attachments && msg.attachments.length > 0 ? { attachments: msg.attachments } : {}),
353
+ });
354
+ if (outcome === "queued") return;
355
+ // outcome === "unknown" — not an expected programmatic channel. It may still be a
356
+ // genuine push/bridge channel (telegram, a connected session), so fall through to
357
+ // the normal SSE/MCP push below rather than dropping outright. If THAT also reaches
358
+ // no one (0 subscribers), the message is logged-as-undelivered by leaving the
359
+ // high-water-mark behind (the existing no-silent-loss behavior), and for a truly
360
+ // orphaned channel there is, by definition, nothing more we can do.
361
+ }
362
+ // Route on the bound `channel`, NOT msg.channel — the transport's own
363
+ // channel is authoritative. This makes it impossible for a transport to
364
+ // emit onto another channel (closing a silent cross-channel-leak footgun)
365
+ // even if a future transport sets msg.channel incorrectly.
366
+ const sseDelivered = registry.routeToChannel(channel, "message", {
367
+ content: msg.content,
368
+ meta: msg.meta,
369
+ source: msg.source,
370
+ });
371
+ // ALSO wake any HTTP MCP sessions on this channel — a session connected
372
+ // over /mcp/<channel> (vs. the stdio bridge over /events) receives the
373
+ // same inbound as a server-pushed notifications/claude/agent. Additive:
374
+ // the SSE path above is untouched.
375
+ const mcpDelivered = mcpPushToChannel(channel, msg.content, msg.meta);
376
+
377
+ // Advance the per-channel delivery high-water-mark ONLY on a real delivery
378
+ // (≥1 live subscriber across SSE bridges + MCP sessions). If nobody was
379
+ // listening (delivered === 0) we deliberately leave the mark BEHIND so this
380
+ // message replays the next time a session (re)connects — the spine of the
381
+ // no-silent-loss fix. The note's ts rides in `meta.ts` (ingestInbound
382
+ // flattens the note metadata, which carries the vault-written `ts`).
383
+ const delivered = sseDelivered + mcpDelivered;
384
+ const ts = msg.meta?.ts;
385
+ if (delivered > 0 && typeof ts === "string" && ts) {
386
+ deliveryState.advance(channel, ts);
387
+ }
388
+ },
389
+ emitPermissionVerdict(v): void {
390
+ registry.routeToChannel(channel, "permission_verdict", v);
391
+ mcpPushPermissionVerdict(channel, v);
392
+ },
393
+ };
394
+ }
395
+
396
+ /**
397
+ * Instantiate one channel entry, start its transport, and register it in the
398
+ * LIVE channels map — the single per-channel "bring a channel up" path. Boot
399
+ * (`main`) and the config-management hot-add both go through here so they can't
400
+ * drift. If a channel with the same name is already live, its old transport is
401
+ * stopped first (config-API replace semantics).
402
+ *
403
+ * `start()` is awaited so a hot-add only reports success once the transport is
404
+ * actually receiving (e.g. the vault transport has fired its schema upsert). At
405
+ * boot a throw is logged per-channel and doesn't abort the others; the config
406
+ * API surfaces the throw to the caller as a 500.
407
+ */
408
+ async function addChannelLive(
409
+ channels: Map<string, Channel>,
410
+ registry: ClientRegistry,
411
+ entry: ChannelEntry,
412
+ deliveryState: DeliveryState,
413
+ programmatic?: ProgrammaticAgentRegistry,
414
+ attachedQueue?: AttachedQueueRegistry,
415
+ ): Promise<Channel> {
416
+ const existing = channels.get(entry.name);
417
+ if (existing) {
418
+ // Replace: stop the old transport before swapping it out so it releases any
419
+ // resources (pollers, SSE clients) before the new one starts.
420
+ try {
421
+ await existing.transport.stop();
422
+ } catch (err) {
423
+ console.error(`parachute-agent: stopping old transport for "${entry.name}" failed (continuing):`, err);
424
+ }
425
+ channels.delete(entry.name);
426
+ }
427
+ const transport = instantiateTransport(entry);
428
+ const channel: Channel = { name: entry.name, transport, entry };
429
+ channels.set(entry.name, channel);
430
+ await transport.start(contextFor(registry, entry.name, deliveryState, programmatic, attachedQueue));
431
+ return channel;
432
+ }
433
+
434
+ /**
435
+ * Stop a live channel's transport and remove it from the map. Idempotent — a
436
+ * missing name is a no-op returning false. The transport's `stop()` is awaited
437
+ * so it releases resources before we drop the reference.
438
+ */
439
+ async function removeChannelLive(
440
+ channels: Map<string, Channel>,
441
+ name: string,
442
+ ): Promise<boolean> {
443
+ const channel = channels.get(name);
444
+ if (!channel) return false;
445
+ try {
446
+ await channel.transport.stop();
447
+ } catch (err) {
448
+ console.error(`parachute-agent: stopping transport for "${name}" failed (continuing):`, err);
449
+ }
450
+ channels.delete(name);
451
+ return true;
452
+ }
453
+
454
+ // ---------------------------------------------------------------------------
455
+ // Vault-native agent definitions (design 2026-06-17-vault-native-agents, Phase 4a)
456
+ // ---------------------------------------------------------------------------
457
+
458
+ /**
459
+ * Build the vault `ChannelEntry` for a vault-native agent's wake channel, from its
460
+ * def-vault binding. The agent's conversation lives in its def-vault, so the channel
461
+ * is a `vault` transport pointed at the SAME vault + token the def registry reads
462
+ * from (own-vault scoping — 4a). This is the exact `ChannelEntry` shape the existing
463
+ * create-agent flow + boot persist; we just synthesize it from the binding instead
464
+ * of from channels.json (the note IS the definition).
465
+ */
466
+ export function defVaultChannelEntry(name: string, binding: DefVaultBinding): ChannelEntry {
467
+ return {
468
+ name,
469
+ transport: "vault",
470
+ config: {
471
+ vault: binding.vault,
472
+ ...(binding.vaultUrl ? { vaultUrl: binding.vaultUrl } : {}),
473
+ token: binding.token,
474
+ },
475
+ };
476
+ }
477
+
478
+ /**
479
+ * Build the {@link InstantiateDeps} the {@link AgentDefRegistry} drives, wired to the
480
+ * SAME machinery the create-agent flow + boot use — so a vault-defined agent comes up
481
+ * byte-for-byte like a UI-created one, only its SOURCE differs (a note, not a form):
482
+ * - ensureChannel → `addChannelLive` with a vault `ChannelEntry` from the binding;
483
+ * - setupAndRegister → `setupProgrammaticSpawn` (persist spec.json) + `programmatic.register`;
484
+ * - deregister → `programmatic.deregister`;
485
+ * - removeChannel → `removeChannelLive`.
486
+ *
487
+ * `setupProgrammaticSpawn` resolves the Claude credential early — a missing one
488
+ * throws `CredentialNotConfiguredError`, which the registry catches + stamps the
489
+ * note `status: error` (the agent can't run turns without auth; the note surfaces
490
+ * the gap rather than registering a dead agent). Secrets stay local throughout.
491
+ */
492
+ export function buildInstantiateDeps(
493
+ channels: Map<string, Channel>,
494
+ registry: ClientRegistry,
495
+ deliveryState: DeliveryState,
496
+ programmatic: ProgrammaticAgentRegistry,
497
+ attachedQueue: AttachedQueueRegistry,
498
+ ): InstantiateDeps {
499
+ return {
500
+ ensureChannel: async (name, binding) => {
501
+ // EXPECT-BEFORE-LIVE (agent#121). Mark this channel EXPECTED to gain a programmatic
502
+ // agent BEFORE we bring the channel transport live — closing the desync window: once
503
+ // the channel is live the vault trigger can fire an inbound, but the agent isn't
504
+ // `register()`ed until `setupAndRegister` runs (a later step). An inbound landing in
505
+ // that window now QUEUES PENDING (owned, replayed on register) instead of dropping.
506
+ // Harmless for a `channel`-backend agent — its inbound is handled by the attachedQueue
507
+ // routing fork first, so the expected mark is never consulted for it. The mark is
508
+ // cleared on register (the live index takes over) or on teardown (unexpectChannel).
509
+ programmatic.expectChannel(normalizeChannel(name).name);
510
+ await addChannelLive(
511
+ channels,
512
+ registry,
513
+ defVaultChannelEntry(name, binding),
514
+ deliveryState,
515
+ programmatic,
516
+ attachedQueue,
517
+ );
518
+ },
519
+ setupAndRegister: async (spec) => {
520
+ // ── BACKEND FORK (design 2026-06-18-channel-backend.md). An `attached` agent
521
+ // does NOT register with the programmatic registry (no `claude -p`, no serial
522
+ // worker) — it registers with the AttachedQueueRegistry, whose store is the
523
+ // agent's live VaultTransport (the durable inbound-note queue). A `programmatic`
524
+ // agent takes the existing path (persist spec.json + register the serial worker).
525
+ //
526
+ // DUAL-READ: a spec carrying the legacy backend value `"channel"` (un-normalized,
527
+ // e.g. read straight from an old spec.json) is treated as `"attached"` here too —
528
+ // belt-and-suspenders on top of the parse-path normalization in agent-defs.ts.
529
+ if (spec.backend === "attached" || (spec.backend as string) === "channel") {
530
+ const store = attachedQueueStoreFor(channels, spec.channels[0]);
531
+ if (!store) {
532
+ throw new Error(
533
+ `cannot register attached-backend agent "${spec.name}": its wake channel is not a ` +
534
+ `live vault transport (the queue needs the vault as its durable store)`,
535
+ );
536
+ }
537
+ attachedQueue.register(spec, store);
538
+ return;
539
+ }
540
+ // Persist spec.json (so boot re-register + per-turn deliver find the workspace)
541
+ // then register — the same two steps the web programmatic spawn runs.
542
+ setupProgrammaticSpawn(spec);
543
+ await programmatic.register({ ...spec, backend: "programmatic" });
544
+ },
545
+ // Deregister covers BOTH registries — an agent lives in exactly one, and
546
+ // deregister is a no-op (returns false) where it isn't registered. OR the two so
547
+ // a reload/delete tears the agent down regardless of its backend.
548
+ deregister: async (name) => {
549
+ // Capture the wake channel BEFORE deregister drops the indexes, so we can clear the
550
+ // EXPECTED mark + any stranded pending buffer for a genuinely-removed agent (agent#121
551
+ // teardown — a deleted def must not leave its channel marked expected forever).
552
+ const wakeChannel = programmatic.getByName(name)?.channel;
553
+ const fromProgrammatic = await programmatic.deregister(name);
554
+ const fromChannel = attachedQueue.deregister(name);
555
+ if (wakeChannel) programmatic.unexpectChannel(wakeChannel);
556
+ return fromProgrammatic || fromChannel;
557
+ },
558
+ removeChannel: async (name) => removeChannelLive(channels, name),
559
+ };
560
+ }
561
+
562
+ /**
563
+ * The real "add a def-vault" implementation behind `POST /api/agent-vaults`: mint the
564
+ * vault's `vault:<name>:write` token (attenuated to the operator bearer, the SAME path
565
+ * `resolveDefVaults` mints the default with), persist it into `agent-vaults.json`
566
+ * (0600 — it carries a token), then `addVault` + `loadAll` for THAT vault so its defs
567
+ * come up LIVE immediately (no restart). Re-resolves the manager bearer + hub origin at
568
+ * request time (dynamic-read discipline — a credential set after boot is picked up).
569
+ * Returns the non-secret view; throws on a missing operator token, a mint refusal, or
570
+ * a duplicate vault. No-ops cleanly when no registry is wired.
571
+ */
572
+ function defaultAddDefVault(
573
+ agentDefs: AgentDefRegistry | undefined,
574
+ ): (args: { vault: string; url?: string }) => Promise<{ vault: string; url: string; tokenPresent: boolean }> {
575
+ return async ({ vault, url }) => {
576
+ if (!agentDefs) {
577
+ throw new Error("no def-vault registry configured (the vault-native agent path is idle)");
578
+ }
579
+ if (agentDefs.hasVault(vault)) {
580
+ throw new Error(`def-vault "${vault}" is already configured`);
581
+ }
582
+ const vaultUrl = url && url.length > 0 ? url : DEFAULT_DEF_VAULT_URL;
583
+ // Resolve the operator bearer + hub origin at request time (a credential set after
584
+ // boot is picked up). A missing operator token → can't mint a child token.
585
+ let managerBearer: string;
586
+ try {
587
+ managerBearer = resolveSpawnDeps().managerBearer;
588
+ } catch {
589
+ throw new Error(
590
+ "cannot mint the def-vault token — no operator token (the hub isn't provisioned yet)",
591
+ );
592
+ }
593
+ if (!managerBearer) {
594
+ throw new Error(
595
+ "cannot mint the def-vault token — no operator token (the hub isn't provisioned yet)",
596
+ );
597
+ }
598
+ const minted = await mintScopedToken(
599
+ { scope: vaultScope(vault, "write") },
600
+ { hubOrigin: getHubOrigin() || DEFAULT_HUB_ORIGIN, managerBearer },
601
+ );
602
+ const binding: DefVaultBinding = { vault, vaultUrl, token: minted.token };
603
+ // Persist into agent-vaults.json (merge: keep existing entries, append this one).
604
+ // Source the existing set from the LIVE registry bindings (which carry the real
605
+ // boot-minted tokens) — NOT a tokenless reconstruction from vaultNames(), which
606
+ // would clobber a boot-minted default's token to empty on disk and 401 next boot.
607
+ // Prefer the on-disk file when present (it's the durable record); fall back to the
608
+ // live bindings when no file has been written yet.
609
+ const stateDir = defaultStateDir();
610
+ const existing = readDefVaultsFile(stateDir)?.vaults ?? agentDefs.liveBindings();
611
+ const merged = [...existing.filter((v) => v.vault !== vault), binding];
612
+ writeDefVaultsFile({ vaults: merged }, stateDir);
613
+ // Bring the vault up LIVE: register it + load its defs now (the immediate path).
614
+ // NOTE: loadAll() reloads ALL configured def-vaults, not just the one just added —
615
+ // a slight over-read, acceptable at the current handful-of-vaults scale.
616
+ agentDefs.addVault(binding);
617
+ await agentDefs.loadAll();
618
+ return { vault, url: vaultUrl, tokenPresent: true };
619
+ };
620
+ }
621
+
622
+ /**
623
+ * Build a {@link AttachedQueueStore} for a channel name from its live VaultTransport —
624
+ * the durable inbound-note queue an ATTACHED-backend agent's connected session pulls
625
+ * from (design 2026-06-18). Returns null when the channel isn't a live vault transport
626
+ * (an attached agent's queue REQUIRES the vault as its source of truth). The store is a
627
+ * thin adapter over the transport's `listInboundQueue` / `setInboundStatus` / `reply`
628
+ * — the same `reply()` the programmatic worker uses, so the outbound is durable +
629
+ * loop-safe (tagged `#agent/message/outbound`, which the inbound trigger never fires on).
630
+ */
631
+ export function attachedQueueStoreFor(
632
+ channels: Map<string, Channel>,
633
+ channelName: string | { name: string } | undefined,
634
+ ): AttachedQueueStore | null {
635
+ const name = typeof channelName === "string" ? channelName : channelName?.name;
636
+ if (!name) return null;
637
+ const vt = channels.get(name)?.transport;
638
+ if (!(vt instanceof VaultTransport)) return null;
639
+ return {
640
+ listInboundQueue: (opts) => vt.listInboundQueue(opts),
641
+ // Forward ALL FOUR args — the 4th `ifUpdatedAt` is the CAS precondition the
642
+ // single-claim guard (agent#101) depends on. A 3-arg arrow silently dropped it,
643
+ // collapsing every claim to `force:true` (last-write-wins) and DISABLING the CAS in
644
+ // production (the double-claim race PR #116 closed was re-opened for attached agents).
645
+ setInboundStatus: (id, status, claimedAt, ifUpdatedAt) =>
646
+ vt.setInboundStatus(id, status, claimedAt, ifUpdatedAt),
647
+ reply: async (args) => {
648
+ return vt.reply({
649
+ channel: name,
650
+ text: args.text,
651
+ ...(args.inReplyTo ? { meta: { in_reply_to: args.inReplyTo } } : {}),
652
+ });
653
+ },
654
+ };
655
+ }
656
+
657
+ // ---------------------------------------------------------------------------
658
+ // Programmatic-agent backend wiring (design 2026-06-16)
659
+ // ---------------------------------------------------------------------------
660
+
661
+ /**
662
+ * Build the {@link WriteOutbound} the programmatic registry posts a turn's reply
663
+ * through: resolve the channel's transport from the live `channels` map and call its
664
+ * `reply()` — the SAME outbound path the interactive `reply` tool uses, so a
665
+ * programmatic reply is durable + renders in the chat UI exactly like an
666
+ * interactive one. For a VaultTransport this writes a `#agent/message/outbound`
667
+ * note; the vault inbound trigger keys on `#agent/message/inbound`, so writing the
668
+ * reply CANNOT re-trigger the inbound webhook (verified: no loop). `inReplyTo`
669
+ * threads the reply to the inbound note id.
670
+ *
671
+ * A missing transport (channel deregistered between the turn + its reply) throws —
672
+ * the registry's drain logs it and moves on; it never re-runs the turn (which would
673
+ * fork the conversation).
674
+ */
675
+ export function buildWriteOutbound(channels: Map<string, Channel>): WriteOutbound {
676
+ return async (channel, reply, inReplyTo, threadId) => {
677
+ const ch = channels.get(channel);
678
+ if (!ch) {
679
+ throw new Error(`no live transport for channel "${channel}" — cannot post the reply`);
680
+ }
681
+ // Carry the in-reply-to + the per-turn thread id through the transport's `meta` escape
682
+ // hatch. The vault transport stamps `meta.thread` into the outbound note's
683
+ // `metadata.thread` — the explicit definition→thread→message link the outbound note
684
+ // gets (multi-threaded: the per-fire note leaf; single-threaded: a per-turn id).
685
+ const meta: Record<string, string> = {};
686
+ if (inReplyTo) meta.in_reply_to = inReplyTo;
687
+ if (threadId) meta.thread = threadId;
688
+ const sent = await ch.transport.reply({
689
+ channel,
690
+ text: reply,
691
+ ...(Object.keys(meta).length > 0 ? { meta } : {}),
692
+ });
693
+ // Surface the written outbound note id so the agent-to-agent callback can point its
694
+ // `source_message` at it (the orchestrator pulls the full reply from there). `reply()`
695
+ // returns `{ sent: [noteId] }`; the first id is the note. Absent/empty → undefined,
696
+ // and the callback simply omits source_message.
697
+ return { ...(sent?.sent?.[0] ? { id: sent.sent[0] } : {}) };
698
+ };
699
+ }
700
+
701
+ /**
702
+ * Build the {@link WriteThread} the programmatic registry posts each turn's thread note
703
+ * through — the UNIFIED model, called for BOTH modes (the structural unification: every
704
+ * turn materializes a thread note). Resolve the channel's transport from the live
705
+ * `channels` map and call its `writeThread()` (a VaultTransport writes a `#agent/thread`
706
+ * note; single-threaded upserts one note per channel, multi-threaded writes one per fire).
707
+ * A transport without a durable store (telegram) has no `writeThread`; we no-op there (the
708
+ * turn still runs — it just leaves no thread note). A missing transport (channel
709
+ * deregistered between the turn + its thread record) throws; the registry logs it and moves
710
+ * on (it never re-runs the turn).
711
+ */
712
+ export function buildWriteThread(channels: Map<string, Channel>): WriteThread {
713
+ return async (thread) => {
714
+ const ch = channels.get(thread.channel);
715
+ if (!ch) {
716
+ throw new Error(
717
+ `no live transport for channel "${thread.channel}" — cannot write the thread note`,
718
+ );
719
+ }
720
+ // Only a transport with a durable store implements writeThread (the VaultTransport).
721
+ if (!ch.transport.writeThread) return;
722
+ await ch.transport.writeThread({
723
+ channel: thread.channel,
724
+ ...(thread.name ? { name: thread.name } : {}),
725
+ ...(thread.definition ? { definition: thread.definition } : {}),
726
+ mode: thread.mode,
727
+ status: thread.status,
728
+ input: thread.input,
729
+ output: thread.output,
730
+ started_at: thread.started_at,
731
+ ended_at: thread.ended_at,
732
+ ...(thread.usage ? { usage: thread.usage } : {}),
733
+ // The Claude session UUID — persisted to the note's `metadata.session` (thread≡session
734
+ // record) so the next turn `--resume`s it (read back via `readThreadSession`).
735
+ ...(thread.session ? { session: thread.session } : {}),
736
+ // Forward the per-turn thread id + same-turn flag + lifecycle phase to the transport.
737
+ // These are LOAD-BEARING (not optional decoration):
738
+ // - threadId — multi-threaded targets the SAME per-fire note across the start-ensure,
739
+ // the end-record, AND the outbound-failure re-record (else each mints a duplicate).
740
+ // - sameTurn — the outbound-failure re-record keeps turn_count (no double-count).
741
+ // - phase — `start` (working-ensure: turn_count UNCHANGED) vs `end` (turn counted).
742
+ ...(thread.threadId ? { threadId: thread.threadId } : {}),
743
+ ...(thread.sameTurn ? { sameTurn: true } : {}),
744
+ ...(thread.phase ? { phase: thread.phase } : {}),
745
+ });
746
+ };
747
+ }
748
+
749
+ /**
750
+ * Build the {@link WriteCallback} the programmatic registry delivers an agent-to-agent
751
+ * completion callback through (the "reply_to" substrate). Resolve the SENDER's (`reply_to`)
752
+ * channel transport from the live `channels` map and write a CALLBACK inbound note there
753
+ * (`writeCallback` → a `#agent/message/inbound` note + the {@link CallbackMetadata}
754
+ * contract). The vault trigger on that note wakes the sender's agent through the normal
755
+ * inbound path — so an orchestrator is resumed by its own channel exactly as if a human
756
+ * had messaged it, and the per-channel serial drain handles N returning callbacks FIFO.
757
+ *
758
+ * UNKNOWN / not-live reply_to channel (reuses the #122 own-it-don't-strand posture): if the
759
+ * channel has no live VaultTransport, we LOG and return WITHOUT throwing — a callback that
760
+ * can't be delivered must not crash the recipient's drain or strand its queue. (We don't
761
+ * throw — unlike buildWriteOutbound/buildWriteThread, where a missing transport IS an error
762
+ * worth surfacing — because a callback is best-effort orchestration sugar: the recipient's
763
+ * turn already ran + recorded; only the onward notification is lost, and the sender can still
764
+ * poll the recipient's thread/transcript out-of-band.)
765
+ *
766
+ * LOOP SAFETY: `writeCallback` writes the inbound WITHOUT a `reply_to` (terminal callback),
767
+ * so the woken sender's turn cannot auto-emit another callback. Verified end-to-end:
768
+ * callback note → vault trigger → /api/vault/inbound → contextFor.emit → the sender's drain;
769
+ * `callbackFieldsFromMeta` finds no `reply_to`, so `maybeDeliverCallback` no-ops there.
770
+ */
771
+ export function buildWriteCallback(channels: Map<string, Channel>): WriteCallback {
772
+ return async (channel, content, meta) => {
773
+ const ch = channels.get(channel);
774
+ const vt = ch?.transport instanceof VaultTransport ? ch.transport : undefined;
775
+ if (!vt || !vt.writeCallback) {
776
+ // Own-it-don't-strand: no live vault transport for the reply_to channel. The sender
777
+ // may have been torn down, or never been a vault-backed channel. Log + drop — the
778
+ // recipient turn already completed + recorded; we never throw (which would surface as
779
+ // an error in the recipient's drain).
780
+ console.warn(
781
+ `parachute-agent: callback for source "${meta.source_channel}" could not be delivered ` +
782
+ `— reply_to channel "${channel}" has no live vault transport (dropping the callback; ` +
783
+ `the turn itself completed + recorded normally).`,
784
+ );
785
+ return;
786
+ }
787
+ // `meta` is the registry's CallbackMeta; the transport's CallbackMetadata is the
788
+ // structurally-identical local mirror (the transport layer doesn't import the backend
789
+ // layer), so it passes without a cast.
790
+ await vt.writeCallback(content, meta);
791
+ };
792
+ }
793
+
794
+ /**
795
+ * Build the {@link ProgrammaticAgentRegistry}'s pre-turn session read — the thread≡session
796
+ * record. Resolve the channel's transport from the live `channels` map and read the
797
+ * persisted Claude session UUID off its deterministic `#agent/thread` note (only the
798
+ * VaultTransport implements `readThreadSession`; telegram/http-ui omit it → undefined →
799
+ * the turn creates a fresh session). The registry calls this BEFORE a single-threaded turn
800
+ * so the turn `--resume`s its prior conversation. Mirrors {@link buildWriteThread}.
801
+ */
802
+ export function buildReadSession(
803
+ channels: Map<string, Channel>,
804
+ ): (channel: string, name: string) => Promise<string | undefined> {
805
+ return async (channel, name) => {
806
+ const ch = channels.get(channel);
807
+ if (!ch?.transport.readThreadSession) return undefined;
808
+ return ch.transport.readThreadSession(channel, name);
809
+ };
810
+ }
811
+
812
+ /**
813
+ * Build the {@link ProgrammaticAgentRegistry}'s session CLEAR — the per-agent restart /
814
+ * reset. Resolve the channel's transport and wipe the persisted session on its
815
+ * deterministic `#agent/thread` note (only the VaultTransport implements
816
+ * `clearThreadSession`; telegram/http-ui omit it → a clean no-op). `resetSession` calls
817
+ * this so the agent's NEXT turn finds no session and starts a fresh claude conversation.
818
+ * Mirrors {@link buildReadSession}.
819
+ */
820
+ export function buildClearSession(
821
+ channels: Map<string, Channel>,
822
+ ): (channel: string, name: string) => Promise<void> {
823
+ return async (channel, name) => {
824
+ const ch = channels.get(channel);
825
+ if (!ch?.transport.clearThreadSession) return;
826
+ await ch.transport.clearThreadSession(channel, name);
827
+ };
828
+ }
829
+
830
+ /**
831
+ * Build the REAL programmatic-agent registry — the {@link ProgrammaticBackend}
832
+ * wired to the env-resolved spawn deps, plus the outbound-write + thread-note +
833
+ * session-read seams over the live `channels`. The session UUID lives on the durable
834
+ * `#agent/thread` note (`metadata.session`) — read pre-turn via `readSession`
835
+ * ({@link buildReadSession}) and persisted post-turn via `writeThread` — there is no
836
+ * separate session store. Lazily defaulted by `createFetchHandler` and constructed
837
+ * explicitly by `main` (so the same instance the routes use is the one the transports'
838
+ * `contextFor` enqueues onto).
839
+ *
840
+ * Best-effort on the backend deps: if the operator token / hub origin can't be
841
+ * resolved yet, the backend still constructs (its mint happens per-turn and will
842
+ * surface the error there as a `{ ok: false }` — not at boot), so a daemon with no
843
+ * hub provisioned yet still starts and can register programmatic agents.
844
+ */
845
+ export function createDefaultProgrammaticRegistry(
846
+ channels: Map<string, Channel>,
847
+ onTurnEvent?: TurnEventSink,
848
+ ): ProgrammaticAgentRegistry {
849
+ // Resolve the spawn deps lazily/defensively — a missing operator token must not
850
+ // crash boot (the interactive path resolves per-spawn too). We read what we can
851
+ // and let the per-turn mint surface any gap as a failure-value.
852
+ let backendDeps: ProgrammaticBackendDeps;
853
+ try {
854
+ const deps = resolveSpawnDeps();
855
+ backendDeps = {
856
+ hubOrigin: deps.hubOrigin,
857
+ managerBearer: deps.managerBearer,
858
+ ...(deps.vaultUrl ? { vaultUrl: deps.vaultUrl } : {}),
859
+ sessionsDir: deps.sessionsDir,
860
+ runtimeReadOnly: deps.runtimeReadOnly,
861
+ spawnFn: realProgrammaticSpawn(),
862
+ ...(deps.claudeBin ? { claudeBin: deps.claudeBin } : {}),
863
+ // 4b: the hub grants client — reuses the manager bearer (same operator token
864
+ // the vault-token mint uses). Lets each `claude -p` turn inject the agent's
865
+ // APPROVED cross-resource grants (other-vault MCP, service env/MCP). design
866
+ // 2026-06-17-agent-connectors-4b.md.
867
+ grants: new GrantsClient({ hubOrigin: deps.hubOrigin, managerBearer: deps.managerBearer }),
868
+ };
869
+ } catch {
870
+ // No operator token yet — construct with placeholders; a per-turn mint will
871
+ // fail cleanly (as a value) until the hub is provisioned. The registry + queue
872
+ // still work; only the actual `claude -p` turn needs the credential.
873
+ backendDeps = {
874
+ hubOrigin: "",
875
+ managerBearer: "",
876
+ sessionsDir: defaultSessionsDir(),
877
+ runtimeReadOnly: [],
878
+ spawnFn: realProgrammaticSpawn(),
879
+ };
880
+ }
881
+ const backend = new ProgrammaticBackend(backendDeps);
882
+ return new ProgrammaticAgentRegistry({
883
+ backend,
884
+ writeOutbound: buildWriteOutbound(channels),
885
+ writeThread: buildWriteThread(channels),
886
+ writeCallback: buildWriteCallback(channels),
887
+ readSession: buildReadSession(channels),
888
+ clearSession: buildClearSession(channels),
889
+ ...(onTurnEvent ? { onTurnEvent } : {}),
890
+ });
891
+ }
892
+
893
+ /**
894
+ * Build the {@link TurnEventSink} that pushes a programmatic turn's live progress
895
+ * (interim assistant text + tool_use, plus the registry's done/error lifecycle
896
+ * events) to the channel's turn-event SSE subscribers — the chat UI's "watch it
897
+ * work" view (design 2026-06-16 build item #1).
898
+ *
899
+ * Transport choice (documented in the PR): a DEDICATED per-channel SSE stream
900
+ * (`/api/channels/<ch>/turn-events`) over the existing {@link ClientRegistry},
901
+ * NOT the durable-message poll. Rationale — the chat already POLLs vault channels
902
+ * for their DURABLE transcript (the `#agent/message` notes, the record of truth);
903
+ * turn progress is EPHEMERAL and chunk-frequent, so polling would be coarse + would
904
+ * surface partial state as if durable. An SSE stream is the clean real-time fit and
905
+ * reuses the registry/`sseFrame` infra already in the daemon. The durable path is
906
+ * untouched: the final `result` still becomes the `#agent/message/outbound` note,
907
+ * and the live stream is purely additive progress that the UI finalizes against it.
908
+ *
909
+ * Keyed by channel; fans out to every subscriber on that channel. A 0-subscriber
910
+ * turn is a clean no-op (the events drop; the durable note still lands) — there is
911
+ * no high-water-mark / replay for live progress (it's ephemeral by design).
912
+ */
913
+ export function buildTurnEventSink(turnEvents: ClientRegistry): TurnEventSink {
914
+ return (channel, event) => {
915
+ // routeToChannel swallows dead-stream enqueues (drops the client); a 0-subscriber
916
+ // channel returns 0 delivered — both are fine, progress is best-effort.
917
+ turnEvents.routeToChannel(channel, "turn", event);
918
+ };
919
+ }
920
+
921
+ /**
922
+ * Map the registered programmatic agents to the {@link AgentInfo} shape the
923
+ * `/api/agents` list returns — `backend: "programmatic"` + a live `status`
924
+ * (`idle` | `working` | `queued:N`) in place of the interactive `attached`/
925
+ * `mcp_sessions` liveness (design 2026-06-16 step 6). No tmux session, so `session`
926
+ * is the conventional `<name>-agent` label for display continuity and `attached` is
927
+ * always false.
928
+ */
929
+ export function listProgrammaticAgents(programmatic: ProgrammaticAgentRegistry): AgentInfo[] {
930
+ const dir = defaultSessionsDir();
931
+ return programmatic
932
+ .list()
933
+ .map((h) => {
934
+ const s = programmatic.statusOf(h.channel);
935
+ const status = s.state === "queued" ? `queued:${s.queued}` : s.state;
936
+ const workspace = sessionWorkspace(dir, h.name);
937
+ const hasPrompt = typeof h.spec.systemPrompt === "string" && h.spec.systemPrompt.length > 0;
938
+ // Surface the working dir only when set AND still present on disk (a deleted
939
+ // dir post-spawn shouldn't show a dead-path badge — mirrors `hasWorkspace`).
940
+ const hasWorkingDir =
941
+ typeof h.spec.workspace === "string" && h.spec.workspace.length > 0 && existsSync(h.spec.workspace);
942
+ return {
943
+ name: h.name,
944
+ session: `${h.name}-agent`,
945
+ workspace,
946
+ hasWorkspace: existsSync(join(workspace, "spec.json")),
947
+ backend: "programmatic" as const,
948
+ status,
949
+ ...(hasPrompt ? { systemPromptMode: h.spec.systemPromptMode ?? "append" } : {}),
950
+ ...(hasWorkingDir ? { workingDir: h.spec.workspace } : {}),
951
+ };
952
+ })
953
+ .sort((a, b) => a.name.localeCompare(b.name));
954
+ }
955
+
956
+ /**
957
+ * Map the registered CHANNEL-backend agents to the {@link AgentInfo} shape the
958
+ * `/api/agents` list returns (#102 — the v2 API layer stops rejecting `channel`).
959
+ * A channel agent has no tmux session + no daemon-run turn: its turns are handled by
960
+ * a Claude Code session the operator connects to the channel's MCP endpoint, and the
961
+ * inbound notes accumulate as a durable queue. So `attached` is always false, the
962
+ * `session` label is the conventional `<name>-agent` for display continuity, and the
963
+ * live `status` is `queued:N` (N = pending inbound waiting for the connected session)
964
+ * or `idle`. The pending counts are read from the queue in parallel (one vault read
965
+ * each) — best-effort: a queue read failure degrades that agent's status to `idle`,
966
+ * never failing the whole list. NEVER surfaces a token/secret.
967
+ */
968
+ export async function listAttachedAgents(attachedQueue: AttachedQueueRegistry): Promise<AgentInfo[]> {
969
+ const dir = defaultSessionsDir();
970
+ const records = attachedQueue.list();
971
+ return Promise.all(
972
+ records.map(async (rec) => {
973
+ let status = "idle";
974
+ try {
975
+ const view = await attachedQueue.pending(rec.channel);
976
+ status = view.count > 0 ? `queued:${view.count}` : "idle";
977
+ } catch {
978
+ // A queue read failure shouldn't sink the list — show idle, not an error.
979
+ }
980
+ const workspace = sessionWorkspace(dir, rec.name);
981
+ const info: AgentInfo = {
982
+ name: rec.name,
983
+ session: `${rec.name}-agent`,
984
+ workspace,
985
+ hasWorkspace: existsSync(join(workspace, "spec.json")),
986
+ backend: "attached",
987
+ status,
988
+ channel: rec.channel,
989
+ ...(rec.systemPrompt ? { systemPromptMode: "append" as const } : {}),
990
+ ...(rec.vault ? { vault: rec.vault } : {}),
991
+ };
992
+ return info;
993
+ }),
994
+ ).then((infos) => infos.sort((a, b) => a.name.localeCompare(b.name)));
995
+ }
996
+
997
+ /**
998
+ * BOOT RE-REGISTER (design 2026-06-16 step 2). Scan the per-session workspaces under
999
+ * the sessions dir, read each `spec.json`, and re-register every spec whose
1000
+ * `backend === "programmatic"` into the live registry — so a programmatic agent,
1001
+ * which has no resident process to survive a restart, resumes routing inbound to an
1002
+ * on-demand turn after a daemon restart. The session UUID lives on the `#agent/thread`
1003
+ * note (`metadata.session`), so that next turn reads it back + `--resume`s the prior
1004
+ * conversation, so no message is lost in the restart window beyond the normal
1005
+ * inbound-trigger durability.
1006
+ *
1007
+ * INTERACTIVE specs are SKIPPED — their tmux sessions survive a daemon restart on
1008
+ * their own (or are restarted via the supervisor), and re-registering them here
1009
+ * would be wrong (they aren't programmatic). Best-effort: an unreadable spec / a
1010
+ * register failure is logged per-agent and never aborts boot. Returns the count
1011
+ * re-registered. `sessionsDirPath` is injectable for tests.
1012
+ *
1013
+ * ORPHAN GUARD (agent#75 — defense-in-depth). A spec dir is durable cruft: it can
1014
+ * outlive the channel it was spawned for (a deleted agent whose workspace wasn't
1015
+ * swept, a crash mid-spawn, a leaked test fixture, a hand-copied dir). Re-registering
1016
+ * a programmatic agent whose wake channel ISN'T in the live channels config would
1017
+ * resurrect a PHANTOM agent — one with nothing to receive for (no live channel feeds
1018
+ * it inbound), confusing the operator and the agent list. So we re-register ONLY a
1019
+ * spec whose wake channel STILL EXISTS in `channels` (the live channels.json-derived
1020
+ * map); a spec for a missing channel is SKIPPED with a one-line notice, making any
1021
+ * orphaned/leaked spec dir inert. The wake channel is keyed exactly as the registry
1022
+ * keys it (`normalizeChannel(spec.channels[0]).name` — see `ProgrammaticAgentRegistry`).
1023
+ * A spec with an EMPTY channels array is also skipped (it has no wake channel to key /
1024
+ * route on — re-registering it would throw at the registry's channelOf).
1025
+ */
1026
+ export async function reregisterProgrammaticAgents(
1027
+ programmatic: ProgrammaticAgentRegistry,
1028
+ channels: Map<string, Channel>,
1029
+ sessionsDirPath: string = defaultSessionsDir(),
1030
+ ): Promise<number> {
1031
+ let entries: string[];
1032
+ try {
1033
+ entries = readdirSync(sessionsDirPath, { withFileTypes: true })
1034
+ .filter((d) => d.isDirectory())
1035
+ .map((d) => d.name);
1036
+ } catch {
1037
+ // No sessions dir yet (first boot) — nothing to re-register.
1038
+ return 0;
1039
+ }
1040
+ let count = 0;
1041
+ for (const name of entries) {
1042
+ const workspace = sessionWorkspace(sessionsDirPath, name);
1043
+ const spec = readPersistedSpec(workspace);
1044
+ // Re-register ONLY specs that explicitly persisted `backend: "programmatic"`.
1045
+ // A spec with no `backend` field (pre-field, was interactive) or the retired
1046
+ // `backend: "interactive"` value is SKIPPED — the interactive backend was retired
1047
+ // 2026-06-19 (design 2026-06-19-retire-interactive-backend.md), so a stale
1048
+ // interactive spec on disk is inert: never migrated to programmatic, never launched.
1049
+ if (!spec || spec.backend !== "programmatic") continue;
1050
+ // ORPHAN GUARD: a spec with no wake channel, or whose wake channel isn't a live
1051
+ // channel, has nothing to receive for — skip it so a leaked/stale spec dir can't
1052
+ // resurrect a phantom agent. Keyed exactly as the registry keys the channel.
1053
+ const wakeChannel = spec.channels[0]
1054
+ ? normalizeChannel(spec.channels[0]).name
1055
+ : undefined;
1056
+ if (!wakeChannel) {
1057
+ console.log(
1058
+ `parachute-agent: skipping re-register of "${spec.name}" — spec declares no channel.`,
1059
+ );
1060
+ continue;
1061
+ }
1062
+ if (!channels.has(wakeChannel)) {
1063
+ console.log(
1064
+ `parachute-agent: skipping re-register of "${spec.name}" — channel "${wakeChannel}" not configured.`,
1065
+ );
1066
+ continue;
1067
+ }
1068
+ try {
1069
+ await programmatic.register(spec);
1070
+ count++;
1071
+ console.log(`parachute-agent: re-registered programmatic agent "${spec.name}" (channel ${wakeChannel}).`);
1072
+ } catch (err) {
1073
+ console.error(
1074
+ `parachute-agent: failed to re-register programmatic agent "${name}" from spec.json: ${(err as Error).message}`,
1075
+ );
1076
+ }
1077
+ }
1078
+ if (count > 0) {
1079
+ console.log(`parachute-agent: re-registered ${count} programmatic agent(s) from persisted specs.`);
1080
+ }
1081
+ return count;
1082
+ }
1083
+
1084
+ // ---------------------------------------------------------------------------
1085
+ // HTTP server
1086
+ // ---------------------------------------------------------------------------
1087
+
1088
+ function json(data: unknown, status = 200): Response {
1089
+ return new Response(JSON.stringify(data), {
1090
+ status,
1091
+ headers: { "content-type": "application/json" },
1092
+ });
1093
+ }
1094
+
1095
+ /**
1096
+ * 302-redirect a retired server-rendered page to the v2 SPA (Phase 4c). The
1097
+ * Location is RELATIVE so the browser resolves it against the request URL,
1098
+ * working both daemon-direct (`/ui` → `/app/...`) and hub-proxied (`/agent/ui`
1099
+ * → `/agent/app/...`) without the daemon needing to know its public mount
1100
+ * (the hub strips the `/agent` prefix before the daemon ever sees the path).
1101
+ *
1102
+ * From a single-segment page like `/ui` or `/agents`, a relative `app/` target
1103
+ * resolves to `/app/` (and `app/chat` → `/app/chat`); the SPA's BrowserRouter
1104
+ * (basename `/app` or `/agent/app`) then renders the matching route.
1105
+ */
1106
+ function redirect(location: string): Response {
1107
+ return new Response(null, { status: 302, headers: { Location: location } });
1108
+ }
1109
+
1110
+ // ---------------------------------------------------------------------------
1111
+ // Auth gates
1112
+ //
1113
+ // Both layers share `requireScope` from `auth.ts` (validate a hub-issued JWT
1114
+ // against the hub's JWKS via scope-guard, assert a scope). It accepts the token
1115
+ // from an `Authorization: Bearer` header OR a `?token=` query param.
1116
+ //
1117
+ // Layer 1 — bridge / session↔channel. The session↔channel connection is
1118
+ // authenticated with hub-issued JWTs, exactly like a vault MCP client. A
1119
+ // launched session has full machine access, so we do NOT rely on loopback trust
1120
+ // — any session on any machine presents a hub token (`aud: "agent"`, scopes
1121
+ // `agent:read`/`agent:write`) as a Bearer header and the daemon validates
1122
+ // it against the hub's JWKS. Scope split: subscribing to inbound events is
1123
+ // `agent:read`; sending anything out (reply/react/edit/permission/download)
1124
+ // is `agent:write`.
1125
+ //
1126
+ // Layer 2 — human / chat UI — gates the http-ui transport's `send` (POST,
1127
+ // `agent:send`) + `/ui/events` SSE (`?token=` query, `agent:read`) inside
1128
+ // `http-ui.ts`'s ingestHttp using the same `requireScope`.
1129
+ //
1130
+ // Discovery + the page itself (/health, /.parachute/config[/schema], /ui) stay
1131
+ // OPEN — non-sensitive, and /ui must load to bootstrap its token fetch.
1132
+ // ---------------------------------------------------------------------------
1133
+
1134
+ /**
1135
+ * Decide whether a terminal WebSocket upgrade is authorized + which tmux session
1136
+ * it targets. Pure over its inputs (no `server.upgrade`, no pty) so the auth +
1137
+ * routing layer is unit-testable without a live hub or a real socket — the same
1138
+ * shape the HTTP gate tests rely on.
1139
+ *
1140
+ * Auth: OPERATOR-GATED on `agent:admin` (`SCOPE_TERMINAL`). The token rides in
1141
+ * as a `?token=` query param (browsers can't set Authorization on
1142
+ * `new WebSocket()`), so `allowQueryParam: true`. The no-token path
1143
+ * short-circuits to 401 before any JWKS fetch (testable offline).
1144
+ *
1145
+ * The path segment is an AGENT name — the tmux session is `<name>-agent`. An agent
1146
+ * has its OWN name (chosen at spawn), which is NOT necessarily a configured
1147
+ * channel (the 1:1 channel↔session assumption from the launch-session.sh era no
1148
+ * longer holds — an operator can name an agent anything). So we DON'T require the
1149
+ * name to be a known channel; we slug-guard it (it lands UNESCAPED in a tmux `-t`
1150
+ * target) and let the attach handle a non-existent session — `tmux attach` to a
1151
+ * missing session fails cleanly and the relay closes 1000 ("session ended"), no
1152
+ * reconnect loop. Operator-only behind agent:admin, so there's no enumeration
1153
+ * concern. (`channels` is no longer consulted; kept in the signature for the
1154
+ * stable call shape.)
1155
+ *
1156
+ * Returns either `{ ok: true, ... }` with the tmux session name (`<name>-agent`)
1157
+ * + the client's requested geometry, or `{ ok: false, response }` carrying the
1158
+ * deny Response the caller returns as-is.
1159
+ */
1160
+ export async function authorizeTerminalUpgrade(
1161
+ req: Request,
1162
+ url: URL,
1163
+ _channels: Map<string, Channel>,
1164
+ agentName: string,
1165
+ ): Promise<
1166
+ | { ok: true; channel: string; session: string; cols: number; rows: number }
1167
+ | { ok: false; response: Response }
1168
+ > {
1169
+ // Slug-guard: the name lands unescaped in a tmux `-t <session>` target and the
1170
+ // session string `<name>-agent`. Reject anything that isn't a strict slug.
1171
+ if (!AGENT_NAME_SLUG.test(agentName)) {
1172
+ return {
1173
+ ok: false,
1174
+ response: authJson(
1175
+ { error: `invalid agent name "${agentName}" (alphanumeric, dash, underscore only)` },
1176
+ 400,
1177
+ ),
1178
+ };
1179
+ }
1180
+ // Operator-grade gate. allowQueryParam: true — the only way a browser
1181
+ // WebSocket can present the token (no Authorization header on `new WebSocket`).
1182
+ const denied = await requireScope(req, url, SCOPE_TERMINAL, true);
1183
+ if (denied) return { ok: false, response: denied };
1184
+
1185
+ // tmux session name convention: `<name>-agent`. Attach a viewer pty to THIS
1186
+ // session; the session itself is created by the spawn path.
1187
+ const session = `${agentName}-agent`;
1188
+ const cols = clampQueryDim(url.searchParams.get("cols"), 80);
1189
+ const rows = clampQueryDim(url.searchParams.get("rows"), 24);
1190
+ return { ok: true, channel: agentName, session, cols, rows };
1191
+ }
1192
+
1193
+ /** Is this request a WebSocket upgrade? (case-insensitive `Upgrade: websocket`). */
1194
+ export function isWebSocketUpgrade(req: Request): boolean {
1195
+ return (req.headers.get("upgrade") ?? "").toLowerCase() === "websocket";
1196
+ }
1197
+
1198
+ /**
1199
+ * Coerce an untrusted JSON object into a `Record<string,string>` for a def note's
1200
+ * extra metadata bag — every value stringified (the vault stores metadata as strings).
1201
+ * Non-object input yields an empty map. Used by the agent-def write routes so a caller
1202
+ * passing `{ workspace: "/x", filesystem: "workspace" }` lands as string metadata.
1203
+ */
1204
+ function coerceStringMap(v: unknown): Record<string, string> {
1205
+ const out: Record<string, string> = {};
1206
+ if (!v || typeof v !== "object" || Array.isArray(v)) return out;
1207
+ for (const [k, val] of Object.entries(v as Record<string, unknown>)) {
1208
+ if (val === undefined || val === null) continue;
1209
+ out[k] = typeof val === "string" ? val : String(val);
1210
+ }
1211
+ return out;
1212
+ }
1213
+
1214
+ /** Parse + clamp a `?cols=`/`?rows=` query dim to [1, 9999], with a fallback. */
1215
+ function clampQueryDim(raw: string | null, fallback: number): number {
1216
+ const n = raw === null ? NaN : parseInt(raw, 10);
1217
+ if (!Number.isFinite(n) || n < 1) return fallback;
1218
+ return n > 9999 ? 9999 : n;
1219
+ }
1220
+
1221
+ /**
1222
+ * Build the daemon's HTTP fetch handler over a channel registry + client
1223
+ * registry. Extracted as a factory so tests can exercise routing + the auth
1224
+ * gate on an ephemeral `Bun.serve` without booting the real daemon (and without
1225
+ * a live hub — the no-token 401 path short-circuits before JWKS).
1226
+ *
1227
+ * `server` is the `Bun.serve` instance (passed as `fetch`'s 2nd arg at runtime),
1228
+ * needed for `server.upgrade()` on the terminal WS route. It's optional so the
1229
+ * existing tests (which call the handler with one arg) keep working — a terminal
1230
+ * upgrade request with no server falls through to the normal 426-style refusal.
1231
+ */
1232
+ export function createFetchHandler(
1233
+ channels: Map<string, Channel>,
1234
+ registry: ClientRegistry,
1235
+ opts?: {
1236
+ deliveryState?: DeliveryState;
1237
+ programmatic?: ProgrammaticAgentRegistry;
1238
+ /**
1239
+ * The ATTACHED-backend queue registry (design 2026-06-18-channel-backend.md) — the
1240
+ * durable inbound-note queue + claim tracker a connected Claude Code session pulls
1241
+ * from via the channel MCP surface (`next-message` / `pending` / `reply` /
1242
+ * `release`). `main` passes the boot instance (the SAME one the transports'
1243
+ * `contextFor` routing fork checks); tests inject a fake-store-backed instance.
1244
+ * Optional — when absent, the channel MCP tools no-op (no attached agents).
1245
+ */
1246
+ attachedQueue?: AttachedQueueRegistry;
1247
+ /**
1248
+ * The per-channel turn-event SSE registry (the streaming view, design build
1249
+ * item #1). The `/api/channels/<ch>/turn-events` SSE route registers subscribers
1250
+ * here; the programmatic registry's turn-event sink fans out to them. `main`
1251
+ * passes the boot instance (the SAME one the lazily-defaulted programmatic
1252
+ * registry pushes to); tests inject one to assert the live-progress fan-out.
1253
+ */
1254
+ turnEvents?: ClientRegistry;
1255
+ /**
1256
+ * The vault-native scheduled-job store (runner, design 2026-06-17). The
1257
+ * `/api/jobs*` routes read/write through it. `main` passes the boot instance
1258
+ * (shared with the runner); tests inject one (or let it default lazily) to
1259
+ * exercise the routes against a fake-vault transport.
1260
+ */
1261
+ jobStore?: VaultJobStore;
1262
+ /**
1263
+ * The runner — used by `POST /api/jobs/:id/run` (fire now). `main` passes the
1264
+ * boot instance; tests inject a fake. Optional: if absent, the run-now route
1265
+ * fires inline via the job store + the channel's `injectInbound` (so the route
1266
+ * still works in a plain createFetchHandler).
1267
+ */
1268
+ runner?: Runner;
1269
+ /**
1270
+ * The vault-native agent-def registry (design 2026-06-17-vault-native-agents,
1271
+ * Phase 4a). The `POST /api/vault/agent-def` reload webhook drives it. `main`
1272
+ * passes the boot instance; tests inject one. Optional — when absent, the reload
1273
+ * route is a clean no-op ack (a daemon with no def-vaults configured).
1274
+ */
1275
+ agentDefs?: AgentDefRegistry;
1276
+ /**
1277
+ * Add a def-vault to the live registry — the `POST /api/agent-vaults` body of work
1278
+ * (mint the vault's write token, persist `agent-vaults.json`, `addVault` + `loadAll`
1279
+ * for it). Injected so tests exercise the route WITHOUT a live hub mint or a real
1280
+ * vault; `main` leaves it unset and the route uses the real mint
1281
+ * (`mintScopedToken`) + the persisted-file path. Returns the resulting binding's
1282
+ * non-secret view (name + url + token-present).
1283
+ */
1284
+ addDefVault?: (args: { vault: string; url?: string }) => Promise<{
1285
+ vault: string;
1286
+ url: string;
1287
+ tokenPresent: boolean;
1288
+ }>;
1289
+ },
1290
+ ): (req: Request, server?: { upgrade: (req: Request, opts: { data: TerminalWsData }) => boolean }) => Promise<Response> {
1291
+ // The per-channel turn-event SSE registry — subscribers of the live "watch it
1292
+ // work" stream. Defaulted to a fresh instance so a plain createFetchHandler still
1293
+ // serves the route; `main` shares its boot instance so the lazily-defaulted
1294
+ // programmatic registry below pushes to the SAME subscribers the route registers.
1295
+ const turnEvents: ClientRegistry = opts?.turnEvents ?? new ClientRegistry();
1296
+
1297
+ // The programmatic-agent registry (design 2026-06-16) — inbound for a registered
1298
+ // channel routes to an on-demand `claude -p` turn instead of a push. `main`
1299
+ // constructs the real one (with the real backend + the outbound-write wiring);
1300
+ // tests inject a fake-backed instance. Defaulted lazily to the real registry so a
1301
+ // plain `createFetchHandler(channels, registry)` still wires programmatic agents —
1302
+ // and threads the turn-event sink so its turns stream to this handler's `turnEvents`.
1303
+ const programmatic: ProgrammaticAgentRegistry =
1304
+ opts?.programmatic ?? createDefaultProgrammaticRegistry(channels, buildTurnEventSink(turnEvents));
1305
+
1306
+ // The CHANNEL-backend queue registry (design 2026-06-18). `main` shares its boot
1307
+ // instance (the SAME one the transports' `contextFor` routing fork checks + the
1308
+ // channel MCP surface dispatches to). Defaulted to a fresh instance so a plain
1309
+ // createFetchHandler still serves the channel MCP tools (it just has no channel
1310
+ // agents registered until one is instantiated). Tests inject a fake-store-backed one.
1311
+ const attachedQueue: AttachedQueueRegistry = opts?.attachedQueue ?? new AttachedQueueRegistry();
1312
+
1313
+ // Per-channel delivery high-water-mark store (durable infra). `contextFor.emit`
1314
+ // advances it on a real delivery; the daemon's `main` passes the boot-time
1315
+ // instance, tests get a throwaway whose default mark is "now". (The deaf-on-restart
1316
+ // backlog replay that used to READ this mark was retired with the interactive
1317
+ // backend — design 2026-06-19-retire-interactive-backend.md.)
1318
+ const deliveryState: DeliveryState = opts?.deliveryState ?? new DeliveryState();
1319
+
1320
+ // The vault-native scheduled-job store (runner, design 2026-06-17). Defaulted to
1321
+ // a fresh store over the live channels so a plain createFetchHandler serves the
1322
+ // /api/jobs routes; `main` shares its boot instance with the runner so the routes
1323
+ // and the scheduler operate on the same vault.
1324
+ const jobStore: VaultJobStore = opts?.jobStore ?? new VaultJobStore(channels);
1325
+
1326
+ // The vault-native agent-def registry (Phase 4a). Optional — when absent the
1327
+ // reload webhook is a no-op ack (a daemon with no def-vaults). `main` passes the
1328
+ // boot instance so the route reloads the same set the boot instantiated.
1329
+ const agentDefs: AgentDefRegistry | undefined = opts?.agentDefs;
1330
+
1331
+ // Add-a-def-vault (the `POST /api/agent-vaults` body of work). Defaulted to the real
1332
+ // mint + persist path so a plain createFetchHandler serves the route; tests inject a
1333
+ // stub so the route is exercised WITHOUT a live hub mint or a real vault. Returns the
1334
+ // resulting binding's non-secret view (name + url + token-present).
1335
+ const addDefVault = opts?.addDefVault ?? defaultAddDefVault(agentDefs);
1336
+
1337
+ /** Resolve the transport for a channel name, or null on miss. */
1338
+ function transportFor(channel: string | undefined): Transport | null {
1339
+ if (!channel) return null;
1340
+ return channels.get(channel)?.transport ?? null;
1341
+ }
1342
+
1343
+ function channelError(channel: string | undefined): Response {
1344
+ if (!channel) {
1345
+ return json({ error: "missing 'channel' field in request body" }, 400);
1346
+ }
1347
+ return json(
1348
+ {
1349
+ error: `unknown channel "${channel}" — known channels: ${[...channels.keys()].join(", ") || "(none)"}`,
1350
+ },
1351
+ 400,
1352
+ );
1353
+ }
1354
+
1355
+ function methodMissing(channel: string, method: string): Response {
1356
+ const kind = channels.get(channel)?.transport.kind ?? "unknown";
1357
+ return json(
1358
+ { error: `transport "${kind}" for channel "${channel}" does not support ${method}` },
1359
+ 400,
1360
+ );
1361
+ }
1362
+
1363
+ // Idempotency for the vault inbound webhook: a small bounded set of recently-
1364
+ // seen note ids so a duplicate trigger delivery doesn't double-wake the
1365
+ // session. Bounded by eviction (oldest-out) so it can't grow unbounded.
1366
+ const seenInboundNoteIds = new Set<string>();
1367
+ const SEEN_INBOUND_CAP = 2048;
1368
+ function markSeen(noteId: string): boolean {
1369
+ if (seenInboundNoteIds.has(noteId)) return false; // already processed
1370
+ seenInboundNoteIds.add(noteId);
1371
+ if (seenInboundNoteIds.size > SEEN_INBOUND_CAP) {
1372
+ // Evict the oldest insertion (Set preserves insertion order).
1373
+ const oldest = seenInboundNoteIds.values().next().value;
1374
+ if (oldest !== undefined) seenInboundNoteIds.delete(oldest);
1375
+ }
1376
+ return true;
1377
+ }
1378
+
1379
+ return async function fetch(req, server) {
1380
+ const url = new URL(req.url);
1381
+
1382
+ // -------------------------------------------------------------------
1383
+ // Terminal WebSocket upgrade — `/terminal/<agent>` (design §5).
1384
+ //
1385
+ // The in-page xterm.js terminal attaches to the channel's tmux session
1386
+ // (`<channel>-agent`) via Bun's native pty. Externally this is
1387
+ // `<hub>/agent/terminal/<channel>`; the hub strips `/agent` (stripPrefix)
1388
+ // and forwards the `Upgrade: websocket` over its Bun-native WS bridge (which
1389
+ // honors agent's `websocket: true` declaration), so the daemon sees the
1390
+ // bare `/terminal/<channel>` upgrade here. OPERATOR-GATED on agent:admin
1391
+ // (the most dangerous capability), token via `?token=`. Must run BEFORE the
1392
+ // generic routing so the upgrade isn't 404'd.
1393
+ const termMatch = url.pathname.match(/^\/terminal\/([^/]+)$/);
1394
+ if (termMatch && isWebSocketUpgrade(req)) {
1395
+ const channelName = decodeURIComponent(termMatch[1]!);
1396
+ const decision = await authorizeTerminalUpgrade(req, url, channels, channelName);
1397
+ if (!decision.ok) return decision.response;
1398
+ if (!server?.upgrade) {
1399
+ // No server handle (e.g. a unit test calling the handler directly, or a
1400
+ // build where Bun.serve didn't pass it) — the upgrade can't happen here.
1401
+ return authJson(
1402
+ { error: "websocket upgrade unavailable on this server" },
1403
+ 503,
1404
+ );
1405
+ }
1406
+ const data: TerminalWsData = {
1407
+ session: decision.session,
1408
+ channel: decision.channel,
1409
+ cols: decision.cols,
1410
+ rows: decision.rows,
1411
+ };
1412
+ const upgraded = server.upgrade(req, { data });
1413
+ if (upgraded) {
1414
+ // Bun's contract: return undefined from fetch after a successful upgrade
1415
+ // — the socket now belongs to the websocket handlers.
1416
+ return undefined as unknown as Response;
1417
+ }
1418
+ return authJson({ error: "websocket upgrade failed" }, 400);
1419
+ }
1420
+
1421
+ // Terminal renderer assets (xterm.js + addon-fit + css) served SAME-ORIGIN
1422
+ // (design §5; replaces the CDN load that broke behind strict networks/CSP).
1423
+ // Public like the page itself — these are vendored static JS/CSS, no secrets.
1424
+ // Must run BEFORE the `/terminal/<channel>` page match (this is a 2-segment
1425
+ // path the single-segment termMatch wouldn't catch, but keep it explicit).
1426
+ const assetMatch = url.pathname.match(/^\/terminal\/assets\/([^/]+)$/);
1427
+ if (req.method === "GET" && assetMatch) {
1428
+ const served = serveTerminalAsset(decodeURIComponent(assetMatch[1]!));
1429
+ return served ?? json({ error: "not found" }, 404);
1430
+ }
1431
+
1432
+ // Terminal view (the xterm.js page) — `/terminal` or `/terminal/<channel>`
1433
+ // as a plain GET (no upgrade) serves the page; the page then opens the WS to
1434
+ // `/terminal/<channel>`. Loads OPEN (like /ui and /admin) so it can bootstrap
1435
+ // its hub-minted agent:admin token fetch; the WS upgrade above is what's
1436
+ // gated. Served by the daemon (spans every channel via a picker).
1437
+ if (req.method === "GET" && (url.pathname === "/terminal" || termMatch)) {
1438
+ return new Response(TERMINAL_UI_HTML, {
1439
+ headers: { "content-type": "text/html; charset=utf-8" },
1440
+ });
1441
+ }
1442
+
1443
+ // Retired server-rendered pages (Phase 4c) — the v2 SPA now covers Home /
1444
+ // Agents / Config (the Agents view) and Schedules (the agent detail). Each
1445
+ // page route 302s to the SPA app root so operator bookmarks keep working.
1446
+ // The relative `app/` Location resolves daemon-direct AND hub-proxied (see
1447
+ // `redirect`). The SPA itself is served by `serveSpa` at `/app` below; ALL
1448
+ // the data-plane routes (`/api/*`, `/ui/events`, …) are untouched.
1449
+ if (
1450
+ req.method === "GET" &&
1451
+ (url.pathname === "/agents" || url.pathname === "/jobs" || url.pathname === "/home")
1452
+ ) {
1453
+ return redirect("app/");
1454
+ }
1455
+
1456
+ // Bare root — historically a 404 (no page lived here). Send it to the SPA
1457
+ // app root too, so a bookmark on the module root lands somewhere useful.
1458
+ // Relative `app/` → `/app/` direct, `/agent/app/` proxied.
1459
+ if (req.method === "GET" && url.pathname === "/") {
1460
+ return redirect("app/");
1461
+ }
1462
+
1463
+ // Agent UI v2 SPA (the agent-centric React surface) — served at the NEW
1464
+ // `/app` mount, reachable at `<hub>/agent/app/` over the hub proxy. Coexists
1465
+ // with the daemon-rendered HTML pages above (the design's incremental
1466
+ // migration; the HTML retires in a later phase). Serves `index.html` for the
1467
+ // SPA route(s) + `dist/assets/*` for assets; a missing `dist/` → 503 with a
1468
+ // "run build" hint (dev-checkout case). Loads OPEN (like /ui, /admin, /agents)
1469
+ // so it can bootstrap its hub-minted `agent:admin` token; the `/api/*` calls
1470
+ // it makes are what `requireScope` gates. Bundle path is anchored to the
1471
+ // install dir so a `bun src/daemon.ts` from any cwd finds web/ui/dist/.
1472
+ if (req.method === "GET" && isSpaPath(url.pathname)) {
1473
+ return serveSpa(spaDistDir(INSTALL_DIR), url.pathname);
1474
+ }
1475
+
1476
+ // Health check — per-channel client counts. Programmatic agents (design
1477
+ // 2026-06-16 step 6) are listed separately with their backend + live status
1478
+ // (`programmatic · idle|working|queued:N`) instead of `mcp_sessions` — a
1479
+ // programmatic agent has no live subscriber, so SSE/MCP counts don't describe it.
1480
+ if (url.pathname === "/health") {
1481
+ return json({
1482
+ status: "ok",
1483
+ channels: [...channels.values()].map((c) => ({
1484
+ name: c.name,
1485
+ kind: c.transport.kind,
1486
+ clients: registry.countForChannel(c.name),
1487
+ mcp_sessions: mcpSessionCount(c.name),
1488
+ })),
1489
+ total_clients: registry.size,
1490
+ programmatic_agents: programmatic.list().map((h) => {
1491
+ const s = programmatic.statusOf(h.channel);
1492
+ return {
1493
+ name: h.name,
1494
+ channel: h.channel,
1495
+ backend: "programmatic",
1496
+ status: s.state === "queued" ? `queued:${s.queued}` : s.state,
1497
+ };
1498
+ }),
1499
+ });
1500
+ }
1501
+
1502
+ // Self-describing config (runner pattern) — read-only, no secrets.
1503
+ //
1504
+ // `triggerTemplate` is MODULE-OWNED DATA: the prescribed vault trigger this
1505
+ // channel needs the hub to register on its behalf (PR 3). The hub GETs this,
1506
+ // substitutes the channel name into the `<channel>` placeholders, fills the
1507
+ // `<hub-origin>` in `action.webhook`, and injects `action.auth.bearer` (a
1508
+ // minted agent:send JWT) — so the channel owns its own trigger shape rather
1509
+ // than the hub hardcoding it.
1510
+ if (req.method === "GET" && url.pathname === "/.parachute/config") {
1511
+ return json({
1512
+ channels: [...channels.values()].map((c) => ({
1513
+ name: c.name,
1514
+ transport: c.transport.kind,
1515
+ })),
1516
+ triggerTemplate: AGENT_VAULT_TRIGGER_TEMPLATE,
1517
+ });
1518
+ }
1519
+
1520
+ if (req.method === "GET" && url.pathname === "/.parachute/config/schema") {
1521
+ return json({
1522
+ title: "parachute-agent config",
1523
+ description: "Named channels, each bound to a transport.",
1524
+ type: "object",
1525
+ properties: {
1526
+ channels: {
1527
+ type: "array",
1528
+ items: {
1529
+ type: "object",
1530
+ properties: {
1531
+ name: { type: "string", description: "Unique channel name bridges subscribe to." },
1532
+ transport: {
1533
+ type: "string",
1534
+ enum: ["telegram", "http-ui", "vault"],
1535
+ description: "Transport kind backing this channel.",
1536
+ },
1537
+ config: {
1538
+ type: "object",
1539
+ description: "Transport-specific config (secrets live here, not returned by /config).",
1540
+ },
1541
+ },
1542
+ required: ["name", "transport"],
1543
+ },
1544
+ },
1545
+ },
1546
+ required: ["channels"],
1547
+ });
1548
+ }
1549
+
1550
+ // ---------------------------------------------------------------------
1551
+ // Channel config-management API — the hub writes channels.json + hot-adds
1552
+ // the channel to the LIVE daemon, so a frictionless setup never hand-edits a
1553
+ // file or restarts the daemon. Gated on a hub JWT with `agent:admin`.
1554
+ //
1555
+ // POST /api/channels { name, transport, config } → write + hot-add
1556
+ // GET /api/channels → list (name + transport + vault; NO secrets)
1557
+ // DELETE /api/channels/:name → stop + unregister + remove from channels.json
1558
+ //
1559
+ // Externally hub strips `/agent`, so these are `<hub>/agent/api/channels`.
1560
+ // ---------------------------------------------------------------------
1561
+ if (url.pathname === "/api/channels" && (req.method === "GET" || req.method === "POST")) {
1562
+ const denied = await requireScope(req, url, SCOPE_ADMIN);
1563
+ if (denied) return denied;
1564
+
1565
+ if (req.method === "GET") {
1566
+ // List configured channels — surface ONLY name + transport + vault (for a
1567
+ // vault transport). NEVER the token/secret: this is an admin read, but the
1568
+ // file holds credentials we don't echo back.
1569
+ return json({
1570
+ channels: [...channels.values()].map((c) => {
1571
+ const out: { name: string; transport: string; vault?: string } = {
1572
+ name: c.name,
1573
+ transport: c.transport.kind,
1574
+ };
1575
+ const v = (c.entry.config as { vault?: unknown } | undefined)?.vault;
1576
+ if (typeof v === "string") out.vault = v;
1577
+ return out;
1578
+ }),
1579
+ });
1580
+ }
1581
+
1582
+ // POST — create/replace a channel.
1583
+ let cfgBody: { name?: unknown; transport?: unknown; config?: unknown };
1584
+ try {
1585
+ cfgBody = (await req.json()) as typeof cfgBody;
1586
+ } catch {
1587
+ return json({ error: "invalid JSON body" }, 400);
1588
+ }
1589
+ if (typeof cfgBody.name !== "string" || cfgBody.name.length === 0) {
1590
+ return json({ error: "body.name (string) is required" }, 400);
1591
+ }
1592
+ if (typeof cfgBody.transport !== "string" || cfgBody.transport.length === 0) {
1593
+ return json({ error: "body.transport (string) is required" }, 400);
1594
+ }
1595
+ const entry: ChannelEntry = {
1596
+ name: cfgBody.name,
1597
+ transport: cfgBody.transport,
1598
+ config:
1599
+ cfgBody.config && typeof cfgBody.config === "object"
1600
+ ? (cfgBody.config as Record<string, unknown>)
1601
+ : undefined,
1602
+ };
1603
+ // Validate the entry by instantiating it FIRST (constructor throws on a
1604
+ // missing required field — e.g. a vault channel with no token). We do this
1605
+ // before writing channels.json so a bad request never persists a broken
1606
+ // entry. `addChannelLive` re-instantiates; the throwaway here is the gate.
1607
+ try {
1608
+ instantiateTransport(entry);
1609
+ } catch (err) {
1610
+ return json({ error: `invalid channel config: ${(err as Error).message}` }, 400);
1611
+ }
1612
+ // Persist FIRST (chmod 600 — holds a token), then hot-add to the live
1613
+ // daemon. If the hot-add throws, the file is already written, so a daemon
1614
+ // restart would still pick it up; we surface the error AND a restart hint.
1615
+ try {
1616
+ // Resolve the state dir at request time (defaultStateDir reads the env)
1617
+ // so the persisted file always lands where the daemon would next read it,
1618
+ // even if the env was set after module load (and so it's testable).
1619
+ upsertChannelEntry(entry, defaultStateDir());
1620
+ } catch (err) {
1621
+ return json({ error: `failed to write channels.json: ${(err as Error).message}` }, 500);
1622
+ }
1623
+ try {
1624
+ await addChannelLive(channels, registry, entry, deliveryState, programmatic, attachedQueue);
1625
+ } catch (err) {
1626
+ return json(
1627
+ {
1628
+ ok: true,
1629
+ name: entry.name,
1630
+ transport: entry.transport,
1631
+ live: false,
1632
+ restart_needed: true,
1633
+ error: `channel persisted but hot-add failed: ${(err as Error).message}`,
1634
+ },
1635
+ 200,
1636
+ );
1637
+ }
1638
+ return json({ ok: true, name: entry.name, transport: entry.transport, live: true });
1639
+ }
1640
+
1641
+ const delMatch = url.pathname.match(/^\/api\/channels\/([^/]+)$/);
1642
+ if (delMatch && req.method === "DELETE") {
1643
+ const denied = await requireScope(req, url, SCOPE_ADMIN);
1644
+ if (denied) return denied;
1645
+ const name = decodeURIComponent(delMatch[1]!);
1646
+ const wasLive = await removeChannelLive(channels, name);
1647
+ // Always rewrite channels.json (idempotent) so the file matches the live
1648
+ // state even if the channel was only on disk (added before a restart).
1649
+ try {
1650
+ removeChannelEntry(name, defaultStateDir());
1651
+ } catch (err) {
1652
+ return json({ error: `failed to update channels.json: ${(err as Error).message}` }, 500);
1653
+ }
1654
+ if (!wasLive) {
1655
+ // Not in the live map. Either never live, or removed from disk only.
1656
+ return json({ ok: true, name, removed: false }, 200);
1657
+ }
1658
+ return json({ ok: true, name, removed: true });
1659
+ }
1660
+
1661
+ // ---------------------------------------------------------------------
1662
+ // Scheduled-jobs API — the runner (design 2026-06-17). A job is "an
1663
+ // automated human": send message M to a vault agent A on cron S. Storage is
1664
+ // VAULT-NATIVE (`#agent/job` notes in the target channel's vault); these
1665
+ // routes read/write through the shared `jobStore`. ALL gated on
1666
+ // `agent:admin` (operator-only, like /api/channels). The runner does the
1667
+ // injecting; these routes just CRUD the durable job notes (+ fire-now).
1668
+ //
1669
+ // GET /api/jobs → list (across the live vault channels)
1670
+ // POST /api/jobs { id, channel, message, schedule, enabled? } → create
1671
+ // DELETE /api/jobs/:id → delete the job note
1672
+ // POST /api/jobs/:id/run → fire now (inject the inbound message immediately)
1673
+ //
1674
+ // Externally hub strips `/agent`, so these are `<hub>/agent/api/jobs`.
1675
+ // ---------------------------------------------------------------------
1676
+ if (url.pathname === "/api/jobs" && (req.method === "GET" || req.method === "POST")) {
1677
+ const denied = await requireScope(req, url, SCOPE_ADMIN);
1678
+ if (denied) return denied;
1679
+
1680
+ if (req.method === "GET") {
1681
+ // List across every live vault channel. A vault read failure surfaces as a
1682
+ // 502 (not a silently-empty list that looks like "no jobs").
1683
+ try {
1684
+ const jobs = await jobStore.listAll();
1685
+ // `nextRunAt` is computed-in-memory (the stored note never carries it —
1686
+ // see the Job docblock), so the persisted list lacks it and the UI's
1687
+ // "Next run" column would always be "—". Derive it here for ENABLED jobs
1688
+ // (a disabled job isn't scheduled → no next run). Per-job guard: a bad tz
1689
+ // (a RangeError out of nextRunAfter) must not 502 the whole list.
1690
+ const now = new Date();
1691
+ const withNext = jobs.map((j) => {
1692
+ if (!j.enabled) return j;
1693
+ try {
1694
+ const next = nextRunAfter(j.schedule.cron, j.schedule.tz, now);
1695
+ return next ? { ...j, nextRunAt: next.toISOString() } : j;
1696
+ } catch {
1697
+ return j;
1698
+ }
1699
+ });
1700
+ return json({ jobs: withNext });
1701
+ } catch (err) {
1702
+ return json({ error: `failed to list jobs: ${(err as Error).message}` }, 502);
1703
+ }
1704
+ }
1705
+
1706
+ // POST — create/replace a job.
1707
+ let body: { id?: unknown; channel?: unknown; message?: unknown; schedule?: unknown; enabled?: unknown };
1708
+ try {
1709
+ body = (await req.json()) as typeof body;
1710
+ } catch {
1711
+ return json({ error: "invalid JSON body" }, 400);
1712
+ }
1713
+ // Validate against the LIVE channels: known + vault-backed + parseable cron.
1714
+ const validation = validateJob(body, (name) => {
1715
+ if (!channels.has(name)) return null;
1716
+ return channels.get(name)!.transport instanceof VaultTransport;
1717
+ });
1718
+ if (!validation.ok) return json({ error: validation.error }, 400);
1719
+
1720
+ const job: Job = {
1721
+ id: body.id as string,
1722
+ channel: body.channel as string,
1723
+ message: (body.message as string).trim(),
1724
+ schedule: body.schedule as Job["schedule"],
1725
+ enabled: body.enabled === undefined ? true : Boolean(body.enabled),
1726
+ createdAt: new Date().toISOString(),
1727
+ };
1728
+ try {
1729
+ const saved = await jobStore.upsert(job);
1730
+ return json({ ok: true, job: saved });
1731
+ } catch (err) {
1732
+ return json({ error: `failed to write job: ${(err as Error).message}` }, 502);
1733
+ }
1734
+ }
1735
+
1736
+ // POST /api/jobs/:id/run — fire now (inject the message immediately).
1737
+ {
1738
+ const runMatch = url.pathname.match(/^\/api\/jobs\/([^/]+)\/run$/);
1739
+ if (runMatch && req.method === "POST") {
1740
+ const denied = await requireScope(req, url, SCOPE_ADMIN);
1741
+ if (denied) return denied;
1742
+ const id = decodeURIComponent(runMatch[1]!);
1743
+ try {
1744
+ // Prefer the shared runner (records bookkeeping consistently with a
1745
+ // scheduled fire). Fall back to an inline fire via the job store + the
1746
+ // channel's injectInbound when no runner is wired (plain handler/tests).
1747
+ if (opts?.runner) {
1748
+ const status = await opts.runner.runNow(id);
1749
+ return json({ ok: true, id, status });
1750
+ }
1751
+ const jobs = await jobStore.listAll();
1752
+ const job = jobs.find((j) => j.id === id);
1753
+ if (!job) return json({ error: `unknown job "${id}"` }, 404);
1754
+ const transport = vaultTransportFor(channels, job.channel);
1755
+ if (!transport) {
1756
+ return json({ error: `job "${id}" targets a non-vault channel "${job.channel}"` }, 400);
1757
+ }
1758
+ await transport.injectInbound({ content: job.message, sender: `runner:${job.id}` });
1759
+ return json({ ok: true, id, status: "ok" });
1760
+ } catch (err) {
1761
+ return json({ error: `failed to run job: ${(err as Error).message}` }, 502);
1762
+ }
1763
+ }
1764
+ }
1765
+
1766
+ // DELETE /api/jobs/:id — remove the job note. We must resolve which channel's
1767
+ // vault holds it; list once to find the job's channel, then delete there.
1768
+ {
1769
+ const jobDelMatch = url.pathname.match(/^\/api\/jobs\/([^/]+)$/);
1770
+ if (jobDelMatch && req.method === "DELETE") {
1771
+ const denied = await requireScope(req, url, SCOPE_ADMIN);
1772
+ if (denied) return denied;
1773
+ const id = decodeURIComponent(jobDelMatch[1]!);
1774
+ try {
1775
+ const jobs = await jobStore.listAll();
1776
+ const job = jobs.find((j) => j.id === id);
1777
+ if (!job || !job.noteId) return json({ ok: true, id, removed: false }, 200);
1778
+ await jobStore.remove(job.noteId, job.channel);
1779
+ return json({ ok: true, id, removed: true });
1780
+ } catch (err) {
1781
+ return json({ error: `failed to delete job: ${(err as Error).message}` }, 502);
1782
+ }
1783
+ }
1784
+ }
1785
+
1786
+ // ---------------------------------------------------------------------
1787
+ // Claude OAuth credential store (design §6) — the per-channel secret a
1788
+ // launched agent session runs on (`CLAUDE_CODE_OAUTH_TOKEN`). Same
1789
+ // `agent:admin` gate + 0600 file-store + redaction-on-read posture as the
1790
+ // channel config API above. The token comes from `claude setup-token`.
1791
+ //
1792
+ // GET /api/credentials/claude → { defaultSet, channels:[names] } (NO secret)
1793
+ // POST /api/credentials/claude { token } → set the default/operator token
1794
+ // POST /api/credentials/claude/:channel { token } → set a per-channel override
1795
+ // DELETE /api/credentials/claude/:channel → remove an override (falls back to default)
1796
+ //
1797
+ // Externally hub strips `/agent`, so these are `<hub>/agent/api/credentials/claude`.
1798
+ // ---------------------------------------------------------------------
1799
+ if (url.pathname === "/api/credentials/claude" && (req.method === "GET" || req.method === "POST")) {
1800
+ const denied = await requireScope(req, url, SCOPE_ADMIN);
1801
+ if (denied) return denied;
1802
+
1803
+ if (req.method === "GET") {
1804
+ // Inspect WITHOUT leaking the secret: whether a default is set + which
1805
+ // channels carry an override (names only).
1806
+ return json(describeClaudeCredentials(defaultStateDir()));
1807
+ }
1808
+
1809
+ // POST — set the default / operator-level token.
1810
+ let credBody: { token?: unknown };
1811
+ try {
1812
+ credBody = (await req.json()) as typeof credBody;
1813
+ } catch {
1814
+ return json({ error: "invalid JSON body" }, 400);
1815
+ }
1816
+ if (typeof credBody.token !== "string" || credBody.token.length === 0) {
1817
+ return json({ error: "body.token (non-empty string) is required" }, 400);
1818
+ }
1819
+ try {
1820
+ setDefaultClaudeCredential(credBody.token, defaultStateDir());
1821
+ } catch (err) {
1822
+ return json({ error: `failed to write credentials.json: ${(err as Error).message}` }, 500);
1823
+ }
1824
+ // Echo back only the fact of the write — never the token.
1825
+ return json({ ok: true, scope: "default" });
1826
+ }
1827
+
1828
+ const credMatch = url.pathname.match(/^\/api\/credentials\/claude\/([^/]+)$/);
1829
+ if (credMatch && (req.method === "POST" || req.method === "DELETE")) {
1830
+ const denied = await requireScope(req, url, SCOPE_ADMIN);
1831
+ if (denied) return denied;
1832
+ const channel = decodeURIComponent(credMatch[1]!);
1833
+
1834
+ if (req.method === "DELETE") {
1835
+ let removed: boolean;
1836
+ try {
1837
+ removed = removeChannelClaudeCredential(channel, defaultStateDir());
1838
+ } catch (err) {
1839
+ return json({ error: `failed to update credentials.json: ${(err as Error).message}` }, 500);
1840
+ }
1841
+ return json({ ok: true, channel, removed });
1842
+ }
1843
+
1844
+ // POST — set a per-channel override.
1845
+ let credBody: { token?: unknown };
1846
+ try {
1847
+ credBody = (await req.json()) as typeof credBody;
1848
+ } catch {
1849
+ return json({ error: "invalid JSON body" }, 400);
1850
+ }
1851
+ if (typeof credBody.token !== "string" || credBody.token.length === 0) {
1852
+ return json({ error: "body.token (non-empty string) is required" }, 400);
1853
+ }
1854
+ try {
1855
+ setChannelClaudeCredential(channel, credBody.token, defaultStateDir());
1856
+ } catch (err) {
1857
+ return json({ error: `failed to write credentials.json: ${(err as Error).message}` }, 500);
1858
+ }
1859
+ return json({ ok: true, scope: "channel", channel });
1860
+ }
1861
+
1862
+ // ---------------------------------------------------------------------
1863
+ // Generic per-channel ENV-VAR store (GH_TOKEN / CLOUDFLARE_API_TOKEN / …) —
1864
+ // the secrets a launched agent's `gh`/`git`/build tooling needs. Same
1865
+ // `agent:admin` gate + 0600 file-store + redaction-on-read posture as the
1866
+ // Claude credential API above. A blank/omitted `channel` targets the
1867
+ // operator-level DEFAULT layer; a channel name targets that channel's override.
1868
+ // Denylisted names (the Claude-auth trio) are REJECTED with a 400 — they'd break
1869
+ // the managed subscription-billing guarantee.
1870
+ //
1871
+ // GET /api/credentials/env → { default:[names], channels:{ch:[names]} } (NO values)
1872
+ // POST /api/credentials/env { channel?, name, value } → set
1873
+ // DELETE /api/credentials/env { channel?, name } (or ?channel=&name=) → remove
1874
+ //
1875
+ // Externally hub strips `/agent`, so these are `<hub>/agent/api/credentials/env`.
1876
+ // ---------------------------------------------------------------------
1877
+ if (
1878
+ url.pathname === "/api/credentials/env" &&
1879
+ (req.method === "GET" || req.method === "POST" || req.method === "DELETE")
1880
+ ) {
1881
+ const denied = await requireScope(req, url, SCOPE_ADMIN);
1882
+ if (denied) return denied;
1883
+
1884
+ if (req.method === "GET") {
1885
+ // Inspect WITHOUT leaking values: names per channel + the default layer.
1886
+ return json(describeChannelEnv(defaultStateDir()));
1887
+ }
1888
+
1889
+ let envBody: { channel?: unknown; name?: unknown; value?: unknown };
1890
+ try {
1891
+ envBody = (await req.json()) as typeof envBody;
1892
+ } catch {
1893
+ return json({ error: "invalid JSON body" }, 400);
1894
+ }
1895
+ // `channel` is optional — blank/absent/empty means the operator-level default.
1896
+ const channelRaw = typeof envBody.channel === "string" ? envBody.channel : "";
1897
+ const channel = channelRaw.length > 0 ? channelRaw : null;
1898
+ if (typeof envBody.name !== "string" || envBody.name.length === 0) {
1899
+ return json({ error: "body.name (non-empty string) is required" }, 400);
1900
+ }
1901
+ const name = envBody.name;
1902
+
1903
+ if (req.method === "DELETE") {
1904
+ let removed: boolean;
1905
+ try {
1906
+ removed = removeChannelEnvVar(channel, name, defaultStateDir());
1907
+ } catch (err) {
1908
+ return json({ error: `failed to update credentials.json: ${(err as Error).message}` }, 500);
1909
+ }
1910
+ return json({ ok: true, scope: channel ? "channel" : "default", ...(channel ? { channel } : {}), name, removed });
1911
+ }
1912
+
1913
+ // POST — set the var.
1914
+ if (typeof envBody.value !== "string" || envBody.value.length === 0) {
1915
+ return json({ error: "body.value (non-empty string) is required" }, 400);
1916
+ }
1917
+ try {
1918
+ setChannelEnvVar(channel, name, envBody.value, defaultStateDir());
1919
+ } catch (err) {
1920
+ // A denylisted name (ANTHROPIC_API_KEY/CLAUDE_API_KEY/CLAUDE_CODE_OAUTH_TOKEN)
1921
+ // or a malformed name is the operator's mistake → 400 with the clear reason.
1922
+ if (err instanceof DenylistedEnvError) return json({ error: err.message }, 400);
1923
+ if ((err as Error).message?.startsWith("credentials:")) {
1924
+ return json({ error: (err as Error).message }, 400);
1925
+ }
1926
+ return json({ error: `failed to write credentials.json: ${(err as Error).message}` }, 500);
1927
+ }
1928
+ // Echo back only the fact of the write — never the value.
1929
+ return json({ ok: true, scope: channel ? "channel" : "default", ...(channel ? { channel } : {}), name });
1930
+ }
1931
+
1932
+ // ---------------------------------------------------------------------
1933
+ // Agent management API (the web spawn/list/kill surface, design §4/§5).
1934
+ // Operator-gated on `agent:admin`. The interactive (tmux) backend was retired
1935
+ // 2026-06-19 (design 2026-06-19-retire-interactive-backend.md): there is no
1936
+ // tmux session to list/spawn/kill anymore. The two live backends are
1937
+ // PROGRAMMATIC (daemon-run `claude -p` turns) + CHANNEL (a Claude Code session
1938
+ // the operator connects handles the turn; vault-native — defined as an
1939
+ // #agent/definition note, not via this POST).
1940
+ //
1941
+ // GET /api/agents → list registered programmatic + channel agents
1942
+ // POST /api/agents { name, channels, vault?, ... } → register a programmatic agent
1943
+ // DELETE /api/agents/:name → deregister the agent
1944
+ //
1945
+ // Externally hub strips `/agent`, so these are `<hub>/agent/api/agents`.
1946
+ // ---------------------------------------------------------------------
1947
+ if (url.pathname === "/api/agents" && (req.method === "GET" || req.method === "POST")) {
1948
+ const denied = await requireScope(req, url, SCOPE_ADMIN);
1949
+ if (denied) return denied;
1950
+
1951
+ if (req.method === "GET") {
1952
+ try {
1953
+ // The list merges registered PROGRAMMATIC agents (design 2026-06-16 step 6)
1954
+ // + registered CHANNEL-backend agents (#102). Neither has a tmux session, so
1955
+ // each carries its `backend` + a live `status` (idle|working|queued:N); a
1956
+ // channel agent also surfaces its wake `channel` + backing `vault`.
1957
+ const programmaticInfos = listProgrammaticAgents(programmatic);
1958
+ const channelInfos = await listAttachedAgents(attachedQueue);
1959
+ return json({ agents: [...programmaticInfos, ...channelInfos] });
1960
+ } catch (err) {
1961
+ return json({ error: `failed to list agents: ${(err as Error).message}` }, 500);
1962
+ }
1963
+ }
1964
+
1965
+ // POST — register a programmatic agent from a spec. `buildSpecFromBody` accepts
1966
+ // only `backend: "programmatic"` (the default); a `channel` agent is vault-native
1967
+ // and an `interactive` backend is retired — both rejected with a clear 400.
1968
+ let spawnBody: unknown;
1969
+ try {
1970
+ spawnBody = await req.json();
1971
+ } catch {
1972
+ return json({ error: "invalid JSON body" }, 400);
1973
+ }
1974
+ let spec;
1975
+ try {
1976
+ spec = buildSpecFromBody(spawnBody);
1977
+ } catch (err) {
1978
+ if (err instanceof SpawnRequestError) return json({ error: err.message }, 400);
1979
+ throw err;
1980
+ }
1981
+
1982
+ // CHANNEL EXCLUSION: a channel routes inbound to at most one agent. Refuse a
1983
+ // spawn for a DIFFERENT programmatic agent onto an already-occupied wake channel
1984
+ // (re-spawning the SAME name onto its OWN channel is the idempotent-replace path).
1985
+ const wakeChannel = normalizeChannel(spec.channels[0]!).name;
1986
+ if (programmatic.hasChannel(wakeChannel) && programmatic.getByChannel(wakeChannel)?.name !== spec.name) {
1987
+ return json(
1988
+ {
1989
+ error: `programmatic agent "${programmatic.getByChannel(wakeChannel)?.name}" already ` +
1990
+ `serves channel "${wakeChannel}". Kill it first, or pick a different channel.`,
1991
+ },
1992
+ 409,
1993
+ );
1994
+ }
1995
+
1996
+ // PROGRAMMATIC spawn — no tmux. Validate + persist spec.json (the no-tmux
1997
+ // setup), then register in the live registry (so inbound for the channel
1998
+ // enqueues). Boot re-registers from the persisted spec on the next restart.
1999
+ try {
2000
+ const setup = setupProgrammaticSpawn(spec);
2001
+ await programmatic.register({ ...spec, backend: "programmatic" });
2002
+ return json(setup);
2003
+ } catch (err) {
2004
+ if (err instanceof SpawnRequestError) return json({ error: err.message }, 400);
2005
+ if (err instanceof CredentialNotConfiguredError) return json({ error: err.message }, 400);
2006
+ return json({ error: (err as Error).message }, 400);
2007
+ }
2008
+ }
2009
+
2010
+ // PER-SESSION restart — POST /api/agents/:name/restart (agent:admin). For a
2011
+ // programmatic agent this RESETS the conversation (clears the persisted session id
2012
+ // so the next message starts fresh; the agent stays registered) — there is no
2013
+ // resident process to restart. Must match BEFORE the single-segment DELETE below.
2014
+ const restartMatch = url.pathname.match(/^\/api\/agents\/([^/]+)\/restart$/);
2015
+ if (restartMatch && req.method === "POST") {
2016
+ const denied = await requireScope(req, url, SCOPE_ADMIN);
2017
+ if (denied) return denied;
2018
+ const name = decodeURIComponent(restartMatch[1]!);
2019
+ if (programmatic.hasName(name)) {
2020
+ await programmatic.resetSession(name);
2021
+ return json({
2022
+ ok: true,
2023
+ name,
2024
+ backend: "programmatic",
2025
+ session_reset: true,
2026
+ note: "programmatic agent — conversation reset (next message starts a fresh session); no process to restart.",
2027
+ });
2028
+ }
2029
+ // No programmatic agent by that name — nothing to restart (a channel agent has
2030
+ // no daemon-run turn to reset; the interactive backend is retired).
2031
+ return json(
2032
+ { error: `no programmatic agent named "${name}" to restart` },
2033
+ 404,
2034
+ );
2035
+ }
2036
+
2037
+ const agentMatch = url.pathname.match(/^\/api\/agents\/([^/]+)$/);
2038
+ if (agentMatch && req.method === "DELETE") {
2039
+ const denied = await requireScope(req, url, SCOPE_ADMIN);
2040
+ if (denied) return denied;
2041
+ const name = decodeURIComponent(agentMatch[1]!);
2042
+ // PROGRAMMATIC delete — deregister (drop the channel/name indexes + queue,
2043
+ // clear the backend session). No tmux to kill (the interactive backend retired).
2044
+ if (programmatic.hasName(name)) {
2045
+ const deregistered = await programmatic.deregister(name);
2046
+ return json({ ok: true, name, backend: "programmatic", killed: deregistered });
2047
+ }
2048
+ // No live agent by that name (interactive tmux sessions are no longer managed
2049
+ // here) — a no-op success so a delete of an already-gone agent is idempotent.
2050
+ return json({ ok: true, name, killed: false });
2051
+ }
2052
+
2053
+ // Installed vault instances (for the agents page's vault picker) — derived
2054
+ // from the vault module's registered `/vault/<name>` paths in services.json.
2055
+ // No secrets; agent:admin-gated to match the rest of the agents surface.
2056
+ if (url.pathname === "/api/vaults" && req.method === "GET") {
2057
+ const denied = await requireScope(req, url, SCOPE_ADMIN);
2058
+ if (denied) return denied;
2059
+ return json({ vaults: listVaultNames() });
2060
+ }
2061
+
2062
+ // ---------------------------------------------------------------------
2063
+ // EFFECTIVE ENV — the env-var NAMES an agent's `claude -p` turn runs with
2064
+ // (operability: see-what-env-a-turn-runs-with). NAMES ONLY, never values —
2065
+ // the same redaction posture as GET /api/credentials/env (describeChannelEnv).
2066
+ // Composed from three tagged sources, in precedence order channel > default >
2067
+ // grant (mirrors resolveChannelEnv + buildAgentChildEnv's spawn-time merge):
2068
+ // - "default" — the operator-level env.default layer
2069
+ // - "channel" — the per-agent override layer (env.channels[<agent>])
2070
+ // - "grant:<service>" — service env vars an APPROVED grant WOULD inject at spawn,
2071
+ // derived from the def's already-resolved connections via
2072
+ // serviceEnvVar() — NO grant material is fetched.
2073
+ // A lower-precedence entry shadowed by a higher one is marked overridden:true.
2074
+ // RESILIENT: the env-store layers always resolve (a local file read); a missing
2075
+ // def (agent not vault-native / idle registry) returns the env layers + a note,
2076
+ // never a 500. admin-gated to match the rest of the agents surface.
2077
+ //
2078
+ // GET /api/agents/<name>/env → { env: [{ name, source, overridden? }], note? }
2079
+ //
2080
+ // Externally hub strips `/agent`, so this is `<hub>/agent/api/agents/<name>/env`.
2081
+ // Safe to add AFTER the single-segment `/api/agents/<name>` DELETE + `/restart`
2082
+ // routes above: the `\/env$` suffix + GET-only method never collide with them.
2083
+ // ---------------------------------------------------------------------
2084
+ const envMatch = url.pathname.match(/^\/api\/agents\/([^/]+)\/env$/);
2085
+ if (envMatch && req.method === "GET") {
2086
+ const denied = await requireScope(req, url, SCOPE_ADMIN);
2087
+ if (denied) return denied;
2088
+ const name = decodeURIComponent(envMatch[1]!);
2089
+ // Find the agent's live def by name (agent ≡ channel). Its `connections` carry the
2090
+ // hub-resolved grant status (resolved at instantiate, NOT a live material fetch), so
2091
+ // the grant-env names derive without any secret fetch. Absent → env-store layers only.
2092
+ const def = agentDefs?.listDetailed().find((d) => d.name === name);
2093
+ return json(
2094
+ resolveEffectiveEnv(name, {
2095
+ ...(def ? { connections: def.connections } : {}),
2096
+ hasDef: Boolean(def),
2097
+ }),
2098
+ );
2099
+ }
2100
+
2101
+ // ---------------------------------------------------------------------
2102
+ // Vault-native agent DEFINITIONS — the v2 API layer (design
2103
+ // 2026-06-18-agent-ui-v2-and-reactivity.md Part 2 Phase 1). A `#agent/definition`
2104
+ // note IS the agent (body = system prompt, metadata = config); these routes
2105
+ // list + create + edit + delete them in a configured def-vault, reloading the
2106
+ // changed note into a LIVE agent IMMEDIATELY (the per-note reload, NOT the 60s
2107
+ // poll). NO secrets surfaced (no tokens). Externally `<hub>/agent/api/agent-defs`.
2108
+ //
2109
+ // GET /api/agent-defs → list (read-scoped) — per def: noteId, name,
2110
+ // backend, mode, vault, status, pending,
2111
+ // systemPromptPreview, wants, channel
2112
+ // GET /api/agent-defs/<noteId> → one def, FULL (read-scoped) — noteId, name,
2113
+ // backend, vault, mode, wants, systemPrompt
2114
+ // (FULL body), status. Pre-fills the edit form.
2115
+ // POST /api/agent-defs { vault, name, backend, systemPrompt, wants?,
2116
+ // metadata? } → write note + reload live (admin)
2117
+ // PATCH /api/agent-defs/<noteId> { systemPrompt?, wants?, metadata? } → edit +
2118
+ // reload (admin)
2119
+ // DELETE /api/agent-defs/<noteId> → delete note + deregister (admin)
2120
+ // ---------------------------------------------------------------------
2121
+ if (url.pathname === "/api/agent-defs" && (req.method === "GET" || req.method === "POST")) {
2122
+ // GET is READ-scoped (a listing, no secrets); POST is admin (it mints/writes).
2123
+ const scope = req.method === "GET" ? SCOPE_READ : SCOPE_ADMIN;
2124
+ const denied = await requireScope(req, url, scope);
2125
+ if (denied) return denied;
2126
+ if (!agentDefs) {
2127
+ // No def-vaults configured — an empty list (GET) / a clear 400 (POST).
2128
+ if (req.method === "GET") return json({ defs: [] });
2129
+ return json({ error: "no def-vaults configured (add one via POST /api/agent-vaults)" }, 400);
2130
+ }
2131
+
2132
+ if (req.method === "GET") {
2133
+ return json({ defs: agentDefs.listDetailed() });
2134
+ }
2135
+
2136
+ // POST — create a new def note + reload it live.
2137
+ let body: { vault?: unknown; name?: unknown; backend?: unknown; systemPrompt?: unknown; wants?: unknown; metadata?: unknown };
2138
+ try {
2139
+ body = (await req.json()) as typeof body;
2140
+ } catch {
2141
+ return json({ error: "invalid JSON body" }, 400);
2142
+ }
2143
+ if (typeof body.vault !== "string" || body.vault.length === 0) {
2144
+ return json({ error: "body.vault (string) is required" }, 400);
2145
+ }
2146
+ if (typeof body.name !== "string" || body.name.length === 0) {
2147
+ return json({ error: "body.name (string) is required" }, 400);
2148
+ }
2149
+ // DUAL-READ the legacy backend value `"channel"` → canonical `"attached"`, so an
2150
+ // API client still passing the pre-rename value is accepted (and persisted as the
2151
+ // canonical value by createDef). The routing key `channel` is a separate concept.
2152
+ const rawBackend = body.backend === undefined ? "programmatic" : body.backend;
2153
+ const backend = rawBackend === "channel" ? "attached" : rawBackend;
2154
+ if (backend !== "programmatic" && backend !== "attached") {
2155
+ return json({ error: 'body.backend must be "programmatic" or "attached"' }, 400);
2156
+ }
2157
+ if (body.systemPrompt !== undefined && typeof body.systemPrompt !== "string") {
2158
+ return json({ error: "body.systemPrompt must be a string" }, 400);
2159
+ }
2160
+ if (body.wants !== undefined && typeof body.wants !== "string") {
2161
+ return json({ error: "body.wants must be a comma-separated string" }, 400);
2162
+ }
2163
+ if (body.metadata !== undefined && (typeof body.metadata !== "object" || body.metadata === null || Array.isArray(body.metadata))) {
2164
+ return json({ error: "body.metadata must be an object of strings" }, 400);
2165
+ }
2166
+ try {
2167
+ const detail = await agentDefs.createDef({
2168
+ vault: body.vault,
2169
+ name: body.name,
2170
+ backend,
2171
+ systemPrompt: typeof body.systemPrompt === "string" ? body.systemPrompt : "",
2172
+ ...(typeof body.wants === "string" ? { wants: body.wants } : {}),
2173
+ ...(body.metadata ? { metadata: coerceStringMap(body.metadata) } : {}),
2174
+ });
2175
+ return json({ ok: true, def: detail }, 201);
2176
+ } catch (err) {
2177
+ if (err instanceof AgentDefWriteError) return json({ error: err.message }, err.status);
2178
+ return json({ error: `failed to create agent def: ${(err as Error).message}` }, 502);
2179
+ }
2180
+ }
2181
+
2182
+ // GET /api/agent-defs/<noteId> — the FULL editable def (the whole system-prompt
2183
+ // body, not the list's ~200-char preview) so the edit form pre-fills correctly.
2184
+ // READ-scoped, mirroring GET /api/agent-defs (a listing, no secrets — the body is
2185
+ // the prompt, never a token). 404 for an unknown id / a note that isn't a live def.
2186
+ const defGetMatch = url.pathname.match(/^\/api\/agent-defs\/(.+)$/);
2187
+ if (defGetMatch && req.method === "GET") {
2188
+ const denied = await requireScope(req, url, SCOPE_READ);
2189
+ if (denied) return denied;
2190
+ const noteId = decodeURIComponent(defGetMatch[1]!);
2191
+ if (!agentDefs) {
2192
+ return json({ error: "no def-vaults configured" }, 400);
2193
+ }
2194
+ try {
2195
+ const full = await agentDefs.getFullDef(noteId);
2196
+ if (!full) return json({ error: `note ${noteId} is not a live agent definition` }, 404);
2197
+ return json({ def: full });
2198
+ } catch (err) {
2199
+ if (err instanceof AgentDefWriteError) return json({ error: err.message }, err.status);
2200
+ return json({ error: `failed to fetch agent def: ${(err as Error).message}` }, 502);
2201
+ }
2202
+ }
2203
+
2204
+ const defMatch = url.pathname.match(/^\/api\/agent-defs\/(.+)$/);
2205
+ if (defMatch && (req.method === "PATCH" || req.method === "DELETE")) {
2206
+ const denied = await requireScope(req, url, SCOPE_ADMIN);
2207
+ if (denied) return denied;
2208
+ const noteId = decodeURIComponent(defMatch[1]!);
2209
+ if (!agentDefs) {
2210
+ return json({ error: "no def-vaults configured" }, 400);
2211
+ }
2212
+
2213
+ if (req.method === "DELETE") {
2214
+ try {
2215
+ const removed = await agentDefs.deleteDef(noteId);
2216
+ // FIX 5 (PR #3) — surface a PARTIAL success: the note delete completed, but if
2217
+ // best-effort grant cleanup failed, say so (the agent's approved hub grants may
2218
+ // be orphaned) rather than reporting a clean full success. The delete itself is
2219
+ // still a 200 (the def IS gone — grant GC is best-effort, not delete-blocking).
2220
+ if (!removed.grantsReconciled) {
2221
+ console.warn(
2222
+ `parachute-agent: deleted agent def "${removed.name}" but grant cleanup failed — ` +
2223
+ `its approved hub grants may be orphaned.`,
2224
+ );
2225
+ }
2226
+ return json({ ok: true, ...removed, removed: true });
2227
+ } catch (err) {
2228
+ if (err instanceof AgentDefWriteError) return json({ error: err.message }, err.status);
2229
+ return json({ error: `failed to delete agent def: ${(err as Error).message}` }, 502);
2230
+ }
2231
+ }
2232
+
2233
+ // PATCH — edit body and/or metadata, reload live.
2234
+ let body: { systemPrompt?: unknown; wants?: unknown; metadata?: unknown };
2235
+ try {
2236
+ body = (await req.json()) as typeof body;
2237
+ } catch {
2238
+ return json({ error: "invalid JSON body" }, 400);
2239
+ }
2240
+ if (body.systemPrompt !== undefined && typeof body.systemPrompt !== "string") {
2241
+ return json({ error: "body.systemPrompt must be a string" }, 400);
2242
+ }
2243
+ if (body.wants !== undefined && typeof body.wants !== "string") {
2244
+ return json({ error: "body.wants must be a comma-separated string" }, 400);
2245
+ }
2246
+ if (body.metadata !== undefined && (typeof body.metadata !== "object" || body.metadata === null || Array.isArray(body.metadata))) {
2247
+ return json({ error: "body.metadata must be an object of strings" }, 400);
2248
+ }
2249
+ try {
2250
+ const detail = await agentDefs.editDef(noteId, {
2251
+ ...(typeof body.systemPrompt === "string" ? { systemPrompt: body.systemPrompt } : {}),
2252
+ ...(typeof body.wants === "string" ? { wants: body.wants } : {}),
2253
+ ...(body.metadata ? { metadata: coerceStringMap(body.metadata) } : {}),
2254
+ });
2255
+ return json({ ok: true, def: detail });
2256
+ } catch (err) {
2257
+ if (err instanceof AgentDefWriteError) return json({ error: err.message }, err.status);
2258
+ return json({ error: `failed to edit agent def: ${(err as Error).message}` }, 502);
2259
+ }
2260
+ }
2261
+
2262
+ // ---------------------------------------------------------------------
2263
+ // Module-level DEF-VAULT list — which vault(s) this module reads
2264
+ // `#agent/definition` notes from (`agent-vaults.json`). Today invisible +
2265
+ // uneditable; the v2 API surfaces + manages it. NO token VALUE surfaced (only
2266
+ // present/absent). Externally `<hub>/agent/api/agent-vaults`. Admin-scoped.
2267
+ //
2268
+ // GET /api/agent-vaults → list { vault, url, tokenPresent } (read)
2269
+ // POST /api/agent-vaults { vault, url? } → mint token + persist + live (admin)
2270
+ // DELETE /api/agent-vaults/<name> → drop from file + deregister its agents (admin)
2271
+ // ---------------------------------------------------------------------
2272
+ if (url.pathname === "/api/agent-vaults" && (req.method === "GET" || req.method === "POST")) {
2273
+ // GET is READ-scoped to mirror GET /api/agent-defs — the listing is non-sensitive
2274
+ // ({vault,url,tokenPresent}); `tokenPresent` is a boolean, NEVER the token value.
2275
+ // POST is admin (it mints a token + writes config).
2276
+ const scope = req.method === "GET" ? SCOPE_READ : SCOPE_ADMIN;
2277
+ const denied = await requireScope(req, url, scope);
2278
+ if (denied) return denied;
2279
+
2280
+ if (req.method === "GET") {
2281
+ // Source of truth: the LIVE registry's bound vaults (a boot-minted binding
2282
+ // shows its token even before the file write lands). NEVER the token value. We
2283
+ // fall back to the persisted file only when no registry is wired (idle path),
2284
+ // so the listing isn't silently empty. The url defaults to the loopback vault.
2285
+ if (agentDefs) {
2286
+ return json({ vaults: agentDefs.vaultStatuses() });
2287
+ }
2288
+ let persisted: DefVaultBinding[] = [];
2289
+ try {
2290
+ persisted = readDefVaultsFile(defaultStateDir())?.vaults ?? [];
2291
+ } catch {
2292
+ persisted = [];
2293
+ }
2294
+ const vaults = persisted
2295
+ .map((v) => ({
2296
+ vault: v.vault,
2297
+ url: v.vaultUrl ?? DEFAULT_DEF_VAULT_URL,
2298
+ tokenPresent: typeof v.token === "string" && v.token.length > 0,
2299
+ }))
2300
+ .sort((a, b) => a.vault.localeCompare(b.vault));
2301
+ return json({ vaults });
2302
+ }
2303
+
2304
+ // POST — add a def-vault (mint token + persist + load its defs live).
2305
+ let body: { vault?: unknown; url?: unknown };
2306
+ try {
2307
+ body = (await req.json()) as typeof body;
2308
+ } catch {
2309
+ return json({ error: "invalid JSON body" }, 400);
2310
+ }
2311
+ if (typeof body.vault !== "string" || body.vault.length === 0) {
2312
+ return json({ error: "body.vault (string) is required" }, 400);
2313
+ }
2314
+ if (!/^[a-zA-Z0-9_-]+$/.test(body.vault)) {
2315
+ return json({ error: `body.vault "${body.vault}" must be a slug (alphanumeric, dash, underscore)` }, 400);
2316
+ }
2317
+ if (body.url !== undefined && typeof body.url !== "string") {
2318
+ return json({ error: "body.url must be a string (the vault REST origin)" }, 400);
2319
+ }
2320
+ try {
2321
+ const added = await addDefVault({
2322
+ vault: body.vault,
2323
+ ...(typeof body.url === "string" && body.url.length > 0 ? { url: body.url } : {}),
2324
+ });
2325
+ return json({ ok: true, vault: added }, 201);
2326
+ } catch (err) {
2327
+ if (err instanceof MintError) {
2328
+ return json({ error: `token mint failed: ${err.message}` }, err.status >= 400 && err.status < 600 ? err.status : 502);
2329
+ }
2330
+ // A duplicate / no-operator-token / no-registry error → 400 (operator-actionable).
2331
+ return json({ error: `failed to add def-vault: ${(err as Error).message}` }, 400);
2332
+ }
2333
+ }
2334
+
2335
+ const vaultDelMatch = url.pathname.match(/^\/api\/agent-vaults\/([^/]+)$/);
2336
+ if (vaultDelMatch && req.method === "DELETE") {
2337
+ const denied = await requireScope(req, url, SCOPE_ADMIN);
2338
+ if (denied) return denied;
2339
+ const name = decodeURIComponent(vaultDelMatch[1]!);
2340
+ if (!agentDefs) {
2341
+ return json({ error: "no def-vaults configured" }, 400);
2342
+ }
2343
+ // GUARD: don't remove the last def-vault — that would orphan the module's whole
2344
+ // vault-native path (no vault to define agents in). Mirror the channels.json
2345
+ // posture: removing the only one is a clear 400, not a silent orphan.
2346
+ const names = agentDefs.vaultNames();
2347
+ if (!names.includes(name)) {
2348
+ return json({ ok: true, vault: name, removed: false }, 200);
2349
+ }
2350
+ if (names.length <= 1) {
2351
+ return json(
2352
+ { error: `cannot remove the only def-vault "${name}" — the vault-native agent path would have no vault to define agents in. Add another first.` },
2353
+ 400,
2354
+ );
2355
+ }
2356
+ // ORDERING (#106 review): persist the file FIRST, then tear down in-memory state.
2357
+ // The prior order (deregister → write → remove) left an INCOHERENT state on a write
2358
+ // failure: agents already torn down but the vault still in the live registry, while
2359
+ // the on-disk file was unchanged — so a restart re-instantiated agents the operator
2360
+ // had just deleted. Writing first means a write failure leaves EVERYTHING untouched
2361
+ // (vault + agents still live, file unchanged); only after the durable write commits
2362
+ // do we deregister the agents and drop the vault from the live registry.
2363
+ try {
2364
+ const stateDir = defaultStateDir();
2365
+ const file = readDefVaultsFile(stateDir);
2366
+ if (file) {
2367
+ writeDefVaultsFile({ vaults: file.vaults.filter((v) => v.vault !== name) }, stateDir);
2368
+ }
2369
+ } catch (err) {
2370
+ return json({ error: `failed to update agent-vaults.json: ${(err as Error).message}` }, 500);
2371
+ }
2372
+ // File is durable without this vault → tear down its live agents + drop it from the
2373
+ // live registry. A deregister failure now leaves the file already-correct, so a
2374
+ // restart converges to the intended (removed) state rather than resurrecting it.
2375
+ try {
2376
+ await agentDefs.deregisterAllForVault(name);
2377
+ } catch (err) {
2378
+ return json({ error: `failed to deregister agents for "${name}": ${(err as Error).message}` }, 502);
2379
+ }
2380
+ agentDefs.removeVault(name);
2381
+ return json({ ok: true, vault: name, removed: true });
2382
+ }
2383
+
2384
+ // ---------------------------------------------------------------------
2385
+ // OAuth discovery for the HTTP MCP surface — RFC 9728 + RFC 8414, in the
2386
+ // PATH-INSERTION form (`.well-known` ABOVE the resource path). This is the
2387
+ // shape a Claude Code HTTP-MCP client probes when adding the channel by URL
2388
+ // (the same shape vault serves). For the resource at `/mcp/<channel>`:
2389
+ //
2390
+ // /.well-known/oauth-protected-resource/mcp/<channel>
2391
+ // /.well-known/oauth-authorization-server/mcp/<channel>
2392
+ //
2393
+ // Both are PUBLIC (no auth) — they have to be reachable before the client
2394
+ // holds a token. Externally they're `<hub>/agent/.well-known/...`; hub's
2395
+ // stripPrefix removes `/agent`, so the daemon matches the bare path and
2396
+ // re-adds the prefix in the advertised URLs via x-forwarded-host.
2397
+ // ---------------------------------------------------------------------
2398
+ if (req.method === "GET") {
2399
+ const prm = url.pathname.match(/^\/\.well-known\/oauth-protected-resource\/mcp\/([^/]+)$/);
2400
+ if (prm) return handleProtectedResource(req, decodeURIComponent(prm[1]!));
2401
+ const asm = url.pathname.match(/^\/\.well-known\/oauth-authorization-server\/mcp\/([^/]+)$/);
2402
+ if (asm) return handleAuthorizationServer(req, decodeURIComponent(asm[1]!));
2403
+ }
2404
+
2405
+ // SSE event stream — bridges subscribe by channel. Bridge-facing: requires
2406
+ // a hub JWT with `agent:read`.
2407
+ if (req.method === "GET" && url.pathname === "/events") {
2408
+ const denied = await requireScope(req, url, SCOPE_READ);
2409
+ if (denied) return denied;
2410
+ let channel = url.searchParams.get("channel") ?? undefined;
2411
+ if (!channel) {
2412
+ channel = DEFAULT_CHANNEL;
2413
+ console.warn(
2414
+ `parachute-agent: /events without ?channel= — defaulting to "${DEFAULT_CHANNEL}". ` +
2415
+ `This back-compat default is deprecated; pass ?channel=<name>.`,
2416
+ );
2417
+ }
2418
+ const subscribedChannel = channel;
2419
+ const clientId = crypto.randomUUID();
2420
+ const stream = new ReadableStream<string>({
2421
+ start(controller) {
2422
+ registry.add(clientId, {
2423
+ channel: subscribedChannel,
2424
+ enqueue: (payload) => controller.enqueue(payload),
2425
+ });
2426
+ controller.enqueue(": connected\n\n");
2427
+ // (The deaf-on-restart BACKLOG REPLAY that used to fire here — replaying the
2428
+ // messages a reconnecting stdio bridge missed while detached — was retired
2429
+ // with the interactive backend: design 2026-06-19-retire-interactive-
2430
+ // backend.md. The live route still pushes new inbound to subscribed clients.)
2431
+ },
2432
+ cancel() {
2433
+ registry.remove(clientId);
2434
+ },
2435
+ });
2436
+ return new Response(stream, {
2437
+ headers: {
2438
+ "content-type": "text/event-stream",
2439
+ "cache-control": "no-cache",
2440
+ connection: "keep-alive",
2441
+ },
2442
+ });
2443
+ }
2444
+
2445
+ // Reply — bridge-facing: requires `agent:write`.
2446
+ if (req.method === "POST" && url.pathname === "/api/reply") {
2447
+ const denied = await requireScope(req, url, SCOPE_WRITE);
2448
+ if (denied) return denied;
2449
+ try {
2450
+ const body = (await req.json()) as {
2451
+ channel?: string;
2452
+ chat_id?: string;
2453
+ text?: string;
2454
+ reply_to?: string;
2455
+ files?: string[];
2456
+ meta?: Record<string, string>;
2457
+ };
2458
+ const transport = transportFor(body.channel);
2459
+ if (!transport) return channelError(body.channel);
2460
+ const result = await transport.reply(toReplyArgs(body));
2461
+ return json({ sent: result.sent });
2462
+ } catch (err) {
2463
+ return errResponse(err);
2464
+ }
2465
+ }
2466
+
2467
+ // React — bridge-facing: requires `agent:write`.
2468
+ if (req.method === "POST" && url.pathname === "/api/react") {
2469
+ const denied = await requireScope(req, url, SCOPE_WRITE);
2470
+ if (denied) return denied;
2471
+ try {
2472
+ const body = (await req.json()) as {
2473
+ channel?: string;
2474
+ chat_id?: string;
2475
+ message_id: string;
2476
+ emoji: string;
2477
+ meta?: Record<string, string>;
2478
+ };
2479
+ const transport = transportFor(body.channel);
2480
+ if (!transport) return channelError(body.channel);
2481
+ if (!transport.react) return methodMissing(body.channel!, "react");
2482
+ const args: ReactArgs = {
2483
+ channel: body.channel!,
2484
+ message_id: body.message_id,
2485
+ emoji: body.emoji,
2486
+ meta: mergeMeta(body),
2487
+ };
2488
+ await transport.react(args);
2489
+ return json({ ok: true });
2490
+ } catch (err) {
2491
+ return errResponse(err);
2492
+ }
2493
+ }
2494
+
2495
+ // Edit message — bridge-facing: requires `agent:write`.
2496
+ if (req.method === "POST" && url.pathname === "/api/edit") {
2497
+ const denied = await requireScope(req, url, SCOPE_WRITE);
2498
+ if (denied) return denied;
2499
+ try {
2500
+ const body = (await req.json()) as {
2501
+ channel?: string;
2502
+ chat_id?: string;
2503
+ message_id: string;
2504
+ text: string;
2505
+ meta?: Record<string, string>;
2506
+ };
2507
+ const transport = transportFor(body.channel);
2508
+ if (!transport) return channelError(body.channel);
2509
+ if (!transport.edit) return methodMissing(body.channel!, "edit");
2510
+ const args: EditArgs = {
2511
+ channel: body.channel!,
2512
+ message_id: body.message_id,
2513
+ text: body.text,
2514
+ meta: mergeMeta(body),
2515
+ };
2516
+ await transport.edit(args);
2517
+ return json({ ok: true });
2518
+ } catch (err) {
2519
+ return errResponse(err);
2520
+ }
2521
+ }
2522
+
2523
+ // Permission prompt — bridge forwards permission_request here.
2524
+ // Bridge-facing: requires `agent:write`.
2525
+ if (req.method === "POST" && url.pathname === "/api/permission") {
2526
+ const denied = await requireScope(req, url, SCOPE_WRITE);
2527
+ if (denied) return denied;
2528
+ try {
2529
+ const body = (await req.json()) as {
2530
+ channel?: string;
2531
+ request_id: string;
2532
+ tool_name: string;
2533
+ description: string;
2534
+ input_preview: string;
2535
+ };
2536
+ const transport = transportFor(body.channel);
2537
+ if (!transport) return channelError(body.channel);
2538
+ if (!transport.sendPermission) return methodMissing(body.channel!, "sendPermission");
2539
+ const args: PermissionArgs = {
2540
+ channel: body.channel!,
2541
+ request_id: body.request_id,
2542
+ tool_name: body.tool_name,
2543
+ description: body.description,
2544
+ input_preview: body.input_preview,
2545
+ };
2546
+ const result = await transport.sendPermission(args);
2547
+ return json({ sent: result.sent });
2548
+ } catch (err) {
2549
+ return errResponse(err);
2550
+ }
2551
+ }
2552
+
2553
+ // Download attachment — bridge-facing: requires `agent:write`.
2554
+ if (req.method === "POST" && url.pathname === "/api/download") {
2555
+ const denied = await requireScope(req, url, SCOPE_WRITE);
2556
+ if (denied) return denied;
2557
+ try {
2558
+ const body = (await req.json()) as { channel?: string; file_id: string };
2559
+ const transport = transportFor(body.channel);
2560
+ if (!transport) return channelError(body.channel);
2561
+ if (!transport.download) return methodMissing(body.channel!, "download");
2562
+ const args: DownloadArgs = { channel: body.channel!, file_id: body.file_id };
2563
+ const result = await transport.download(args);
2564
+ return json({ path: result.path });
2565
+ } catch (err) {
2566
+ return errResponse(err);
2567
+ }
2568
+ }
2569
+
2570
+ // Vault inbound webhook — a vault trigger POSTs here when a new
2571
+ // `#agent/message/inbound` note appears. Resolves the target channel from
2572
+ // `note.metadata.channel`, asserts it's a vault-transport channel, and hands
2573
+ // the note to that transport's `ingestInbound`, which `ctx.emit`s it →
2574
+ // wakes the subscribed bridge / MCP session.
2575
+ //
2576
+ // Auth — two paths, in order:
2577
+ // 1. PREFERRED: `Authorization: Bearer <hub JWT>` (aud:agent, scope
2578
+ // `agent:send` — the trigger is effectively "posting an inbound
2579
+ // message"). The hub registers the trigger with `action.auth.bearer`
2580
+ // set to a minted agent:send token, so a fresh setup never touches a
2581
+ // shared secret. Validated via the same scope-guard path as the bridge.
2582
+ // 2. DEPRECATED back-compat: a shared `?secret=` (or `X-Channel-Webhook-Secret`)
2583
+ // validated against the target channel's vault-transport `webhookSecret`,
2584
+ // for existing manual setups whose triggers still ride the secret in the
2585
+ // URL. Logs a one-line deprecation warning when used.
2586
+ // A request with NEITHER → 401. We keep the uniform-401 (no channel
2587
+ // enumeration) behavior on both paths.
2588
+ if (req.method === "POST" && url.pathname === "/api/vault/inbound") {
2589
+ let body: {
2590
+ trigger?: string;
2591
+ event?: string;
2592
+ note?: {
2593
+ id?: string;
2594
+ path?: string;
2595
+ content?: string;
2596
+ tags?: string[];
2597
+ metadata?: Record<string, unknown>;
2598
+ // The vault `send: "json"` trigger payload includes the note's attachments
2599
+ // inline (each `{ id, path, mimeType, ... }`) — the has-attachments signal +
2600
+ // fast-path the transport uses to surface inbound files (Phase 1).
2601
+ attachments?: Array<{ id?: string; path?: string; mimeType?: string }>;
2602
+ };
2603
+ };
2604
+ try {
2605
+ body = (await req.json()) as typeof body;
2606
+ } catch {
2607
+ return json({ error: "invalid JSON body" }, 400);
2608
+ }
2609
+ const note = body.note;
2610
+ if (!note || typeof note.id !== "string" || !note.id) {
2611
+ return json({ error: "body must include note.id" }, 400);
2612
+ }
2613
+ // Dual-read the routing key: the NEW `agent` field, falling back to the legacy
2614
+ // `channel` field (the expand-phase dual-read) — a note written by either an
2615
+ // agent-speaking or a legacy channel-speaking writer routes.
2616
+ const channelName = noteAgentKey(note.metadata);
2617
+ if (!channelName) {
2618
+ return json(
2619
+ { error: "note.metadata.agent (or legacy channel) is required to route the message" },
2620
+ 400,
2621
+ );
2622
+ }
2623
+ const ch = channels.get(channelName);
2624
+ const vt = ch?.transport instanceof VaultTransport ? ch.transport : undefined;
2625
+
2626
+ // Branch on Authorization-header PRESENCE, not token truthiness. A
2627
+ // whitespace-only `Authorization: Bearer ` (which extractBearer trims to
2628
+ // empty/falsy) must NOT fall through to the `?secret=` path — that would let
2629
+ // a caller who knows the secret but lacks a valid JWT force the secret path.
2630
+ // Any Authorization header at all → JWT path, full stop; a malformed/empty
2631
+ // token fails hard via requireScope's 401. The deprecated `?secret=`
2632
+ // fallback runs ONLY when there is no Authorization header.
2633
+ const authHeader = req.headers.get("authorization");
2634
+ if (authHeader !== null) {
2635
+ // JWT path — validate the hub token, require agent:send. This is a
2636
+ // tailnet-reachable webhook, so we keep it uniform-401: any auth failure
2637
+ // (missing/malformed/expired token OR insufficient scope OR unknown
2638
+ // channel) collapses to the SAME 401, so it can't be probed for valid
2639
+ // scopes or channel names. (requireScope would otherwise distinguish 401
2640
+ // vs 403 — fine for the operator-facing config API, but this endpoint
2641
+ // stays opaque.)
2642
+ const denied = await requireScope(req, url, SCOPE_SEND);
2643
+ if (denied || !vt) {
2644
+ return json({ error: "unauthorized" }, 401);
2645
+ }
2646
+ } else {
2647
+ // DEPRECATED shared-secret fallback — only reachable with NO Authorization
2648
+ // header. The secret is per-channel, so resolve the channel first, then
2649
+ // constant-time compare. Uniform 401 for an unknown vault channel, a
2650
+ // channel with no configured secret (nothing to validate against), OR a
2651
+ // bad secret — never reveal which (no channel enumeration on this
2652
+ // tailnet-reachable endpoint). webhookSecretMatches treats an empty/absent
2653
+ // configured secret as never-matching, so a JWT-only channel (no secret)
2654
+ // can't be opened by a `?secret=` request.
2655
+ const presented =
2656
+ url.searchParams.get("secret") ?? req.headers.get("x-channel-webhook-secret") ?? "";
2657
+ if (!vt || !webhookSecretMatches(presented, vt.webhookSecret ?? "")) {
2658
+ return json({ error: "unauthorized" }, 401);
2659
+ }
2660
+ console.warn(
2661
+ `parachute-agent: /api/vault/inbound authenticated via DEPRECATED ?secret= shared secret ` +
2662
+ `for channel "${channelName}". Migrate to a hub-JWT trigger (action.auth.bearer, scope agent:send).`,
2663
+ );
2664
+ }
2665
+ // Idempotency: a duplicate trigger delivery for the same note must not
2666
+ // double-wake. First-seen → process; already-seen → ack without emitting.
2667
+ if (markSeen(note.id)) {
2668
+ // Await — ingestInbound is async when the note carries attachments (it fetches
2669
+ // the attachment list before emitting). The `note.attachments` inline list from
2670
+ // the trigger payload is forwarded as the has-attachments signal (Phase 1).
2671
+ await vt.ingestInbound({
2672
+ id: note.id,
2673
+ content: note.content,
2674
+ tags: note.tags,
2675
+ metadata: note.metadata,
2676
+ ...(note.attachments ? { attachments: note.attachments } : {}),
2677
+ });
2678
+ }
2679
+ // Never write back to the note — the v1 trigger handles its own
2680
+ // created/rendered_at markers vault-side.
2681
+ return json({ ok: true });
2682
+ }
2683
+
2684
+ // ---------------------------------------------------------------------
2685
+ // Vault-native agent-def RELOAD webhook — POST /api/vault/agent-def
2686
+ // (design 2026-06-17-vault-native-agents, Phase 4a). A vault trigger on
2687
+ // `#agent/definition` created/updated/deleted POSTs here; we reload that one
2688
+ // agent (per-note granularity). Mirrors /api/vault/inbound's auth (hub JWT,
2689
+ // scope agent:send — the trigger is a vault→module action) and its uniform-401.
2690
+ // Body: { event?, vault?, note: { id, ... } }. `vault` names the source
2691
+ // def-vault (the hub fills it / it defaults to the single configured one when
2692
+ // exactly one is bound). Externally `<hub>/agent/api/vault/agent-def`.
2693
+ // ---------------------------------------------------------------------
2694
+ if (req.method === "POST" && url.pathname === "/api/vault/agent-def") {
2695
+ const denied = await requireScope(req, url, SCOPE_SEND);
2696
+ if (denied) return json({ error: "unauthorized" }, 401);
2697
+ if (!agentDefs) {
2698
+ // No def-vaults configured — nothing to reload. Clean ack (the trigger
2699
+ // shouldn't have fired, but don't error a benign delivery).
2700
+ return json({ ok: true, reloaded: "skipped" });
2701
+ }
2702
+ let body: {
2703
+ event?: "created" | "updated" | "deleted";
2704
+ vault?: string;
2705
+ note?: { id?: string; path?: string; metadata?: Record<string, unknown> };
2706
+ };
2707
+ try {
2708
+ body = (await req.json()) as typeof body;
2709
+ } catch {
2710
+ return json({ error: "invalid JSON body" }, 400);
2711
+ }
2712
+ const noteId =
2713
+ typeof body.note?.id === "string" && body.note.id
2714
+ ? body.note.id
2715
+ : typeof body.note?.path === "string"
2716
+ ? body.note.path
2717
+ : undefined;
2718
+ if (!noteId) {
2719
+ return json({ error: "body must include note.id" }, 400);
2720
+ }
2721
+ // Resolve the source vault: the explicit `vault` field, else the sole
2722
+ // configured def-vault (the single-vault default — unambiguous), else 400.
2723
+ let vault = typeof body.vault === "string" && body.vault ? body.vault : undefined;
2724
+ if (!vault) {
2725
+ const names = agentDefs.list();
2726
+ const distinct = new Set([...names.map((d) => d.vault)]);
2727
+ // Fall back to the lone bound vault even with zero live defs yet.
2728
+ if (agentDefs.vaultCount === 1) {
2729
+ vault = agentDefs.soleVaultName();
2730
+ } else if (distinct.size === 1) {
2731
+ vault = [...distinct][0];
2732
+ }
2733
+ }
2734
+ if (!vault) {
2735
+ return json({ error: "body.vault is required (multiple def-vaults configured)" }, 400);
2736
+ }
2737
+ // Coerce `event` to the declared union (it's an untrusted webhook body) — any
2738
+ // unrecognized value becomes `undefined` (a hint only; reload() re-reads ground
2739
+ // truth regardless, but keep the runtime honest with the type contract).
2740
+ const event =
2741
+ body.event === "created" || body.event === "updated" || body.event === "deleted"
2742
+ ? body.event
2743
+ : undefined;
2744
+ const result = await agentDefs.reload(vault, noteId, event);
2745
+ return json({ ok: true, reloaded: result });
2746
+ }
2747
+
2748
+ // Turn-event SSE — GET /api/channels/<ch>/turn-events (chat-facing; gated on
2749
+ // `agent:read`, same scope as the transcript poll + /ui/events). The streaming
2750
+ // view (design 2026-06-16 build item #1): the chat subscribes here to watch a
2751
+ // PROGRAMMATIC turn work in real time — interim assistant text + tool_use, then a
2752
+ // done/error lifecycle event. EPHEMERAL by design: no backlog/replay (the durable
2753
+ // record is the `#agent/message/outbound` note the turn still writes). A channel
2754
+ // with no programmatic agent simply never receives a `turn` frame (the stream
2755
+ // stays open + idle). Open to any live channel — unknown channel still opens the
2756
+ // stream (it just never emits), matching the low-stakes ephemeral contract.
2757
+ // Externally `<hub>/agent/api/channels/<ch>/turn-events`.
2758
+ {
2759
+ const turnMatch = url.pathname.match(/^\/api\/channels\/([^/]+)\/turn-events$/);
2760
+ if (req.method === "GET" && turnMatch) {
2761
+ // allowQueryParam=true: this SSE is consumed by a browser EventSource, which
2762
+ // cannot set an Authorization header — it authenticates via ?token=. Without
2763
+ // this the live-streaming view 401s in the browser and never connects. (The
2764
+ // stdio-bridge /events SSE uses a Bearer header, so it doesn't need this.)
2765
+ const denied = await requireScope(req, url, SCOPE_READ, true);
2766
+ if (denied) return denied;
2767
+ const channelName = decodeURIComponent(turnMatch[1]!);
2768
+ const clientId = crypto.randomUUID();
2769
+ const stream = new ReadableStream<string>({
2770
+ start(controller) {
2771
+ turnEvents.add(clientId, {
2772
+ channel: channelName,
2773
+ enqueue: (payload) => controller.enqueue(payload),
2774
+ });
2775
+ controller.enqueue(": connected\n\n");
2776
+ },
2777
+ cancel() {
2778
+ turnEvents.remove(clientId);
2779
+ },
2780
+ });
2781
+ return new Response(stream, {
2782
+ headers: {
2783
+ "content-type": "text/event-stream",
2784
+ "cache-control": "no-cache",
2785
+ connection: "keep-alive",
2786
+ },
2787
+ });
2788
+ }
2789
+ }
2790
+
2791
+ // Transcript read — GET /api/channels/<ch>/messages (chat-facing; gated on
2792
+ // `agent:read`, same as /ui/events). The built-in chat polls this to render
2793
+ // a channel's durable history and pick up replies + messages from other
2794
+ // clients (Telegram, other browsers). Behavior by transport:
2795
+ // - vault → loadTranscript() against the channel's vault (the daemon does
2796
+ // the vault I/O with the channel's stored vault token — the chat's
2797
+ // agent:read token never touches the vault).
2798
+ // - http-ui → that transport's traffic is ephemeral (SSE-only, no buffer),
2799
+ // so there's no durable transcript to replay → { messages: [] }.
2800
+ // - other (telegram) → no transcript surface here → { messages: [] }.
2801
+ // 404 for an unknown channel. Externally `<hub>/agent/api/channels/<ch>/messages`.
2802
+ {
2803
+ const msgMatch = url.pathname.match(/^\/api\/channels\/([^/]+)\/messages$/);
2804
+ if (req.method === "GET" && msgMatch) {
2805
+ const denied = await requireScope(req, url, SCOPE_READ);
2806
+ if (denied) return denied;
2807
+ const channelName = decodeURIComponent(msgMatch[1]!);
2808
+ const ch = channels.get(channelName);
2809
+ if (!ch) {
2810
+ return json(
2811
+ {
2812
+ error: `unknown channel "${channelName}" — known channels: ${[...channels.keys()].join(", ") || "(none)"}`,
2813
+ },
2814
+ 404,
2815
+ );
2816
+ }
2817
+ if (ch.transport instanceof VaultTransport) {
2818
+ try {
2819
+ const messages = await ch.transport.loadTranscript();
2820
+ return json({ messages });
2821
+ } catch (err) {
2822
+ // The vault read failed (unreachable / bad token / 5xx). Surface a
2823
+ // 502 so the chat shows "couldn't load history" rather than a silent
2824
+ // empty transcript that looks like "no messages yet".
2825
+ return json({ error: String(err) }, 502);
2826
+ }
2827
+ }
2828
+ // http-ui + telegram: no durable transcript to replay here.
2829
+ return json({ messages: [] });
2830
+ }
2831
+ }
2832
+
2833
+ // Send for a VAULT channel — POST /api/channels/<ch>/send (chat-facing; gated
2834
+ // on `agent:send`, same scope http-ui's send uses). The daemon owns this for
2835
+ // vault transports because the http-ui transport's ingestHttp only matches its
2836
+ // OWN channel name; a vault channel needs the daemon to dispatch. For a vault
2837
+ // channel the daemon writes a `#agent/message/inbound` note via the channel's
2838
+ // stored vault token — which WAKES the session through the existing vault
2839
+ // trigger (we do NOT also emit; that would double-wake). http-ui channels fall
2840
+ // through to their transport's ingestHttp (unchanged), so this guard handles
2841
+ // ONLY vault channels and passes everything else on.
2842
+ {
2843
+ const sendMatch = url.pathname.match(/^\/api\/channels\/([^/]+)\/send$/);
2844
+ if (req.method === "POST" && sendMatch) {
2845
+ const channelName = decodeURIComponent(sendMatch[1]!);
2846
+ const ch = channels.get(channelName);
2847
+ // Only intercept VAULT channels; let http-ui keep its ingestHttp send path
2848
+ // (and an unknown channel falls through to the final 404, matching prior
2849
+ // behavior — http-ui's ingestHttp also only answered for a live channel).
2850
+ if (ch && ch.transport instanceof VaultTransport) {
2851
+ const denied = await requireScope(req, url, SCOPE_SEND);
2852
+ if (denied) return denied;
2853
+ let text: string;
2854
+ try {
2855
+ const body = (await req.json()) as { text?: unknown };
2856
+ if (typeof body.text !== "string" || body.text.length === 0) {
2857
+ return json({ error: "body must be { text: <non-empty string> }" }, 400);
2858
+ }
2859
+ text = body.text;
2860
+ } catch {
2861
+ return json({ error: "invalid JSON body" }, 400);
2862
+ }
2863
+ try {
2864
+ // Writing the inbound note IS the wake (via the vault trigger) — the
2865
+ // transport deliberately does not emit. Return { ok, id } so the chat
2866
+ // can reconcile its optimistic echo against the real note id on the
2867
+ // next poll.
2868
+ const { id } = await ch.transport.writeInbound(text, "operator");
2869
+ return json({ ok: true, id });
2870
+ } catch (err) {
2871
+ return errResponse(err);
2872
+ }
2873
+ }
2874
+ }
2875
+ }
2876
+
2877
+ // Retired built-in chat page (Phase 4c) — the SPA Chat view replaces it.
2878
+ // EXACT `/ui` only (NOT a prefix): `/ui/events` is the message SSE the SPA
2879
+ // Chat depends on and is owned by the http-ui transport's `ingestHttp` (run
2880
+ // at the bottom of this handler) — it MUST keep routing. Redirect to the SPA
2881
+ // Chat route: relative `app/chat` → `/app/chat` direct / `/agent/app/chat`
2882
+ // proxied, which the SPA BrowserRouter (basename `/app`|`/agent/app`) renders
2883
+ // as the `/chat` route (`web/ui/src/App.tsx`).
2884
+ if (req.method === "GET" && url.pathname === "/ui") {
2885
+ return redirect("app/chat");
2886
+ }
2887
+
2888
+ // Retired config/admin page (Phase 4c) — def-vaults + the unified create
2889
+ // flow live in the SPA now. 302 to the SPA app root. `configUiUrl` in
2890
+ // module.json points at `/agent/app/` so the hub frames the SPA directly.
2891
+ if (req.method === "GET" && url.pathname === "/admin") {
2892
+ return redirect("app/");
2893
+ }
2894
+
2895
+ // Stateful HTTP MCP — a session connects directly over HTTP (URL + OAuth,
2896
+ // no stdio bridge): POST/GET/DELETE /mcp/<channel>. Externally this is
2897
+ // `<hub>/agent/mcp/<channel>`; hub's stripPrefix removes `/agent`, so the
2898
+ // daemon sees `/mcp/<channel>`. A session needs `agent:read` to connect +
2899
+ // receive the wake; the reply/react/edit tools additionally require
2900
+ // `agent:write`, enforced inside the tool handlers from the connection's
2901
+ // own scopes. This endpoint is ADDITIVE — the stdio bridge over /events is
2902
+ // unchanged.
2903
+ const mcpMatch = url.pathname.match(/^\/mcp\/([^/]+)$/);
2904
+ if (mcpMatch) {
2905
+ const channel = decodeURIComponent(mcpMatch[1]!);
2906
+ const transport = transportFor(channel);
2907
+ if (!transport) {
2908
+ return json(
2909
+ {
2910
+ error: `unknown channel "${channel}" — known channels: ${[...channels.keys()].join(", ") || "(none)"}`,
2911
+ },
2912
+ 404,
2913
+ );
2914
+ }
2915
+ // Gate on agent:read — short-circuits to 401 pre-JWKS when no token is
2916
+ // presented (testable without a live hub, same as the other endpoints).
2917
+ // On a 401 (no/invalid bearer), decorate with the RFC 9728
2918
+ // `WWW-Authenticate` challenge so a Claude Code HTTP-MCP client knows
2919
+ // where to discover OAuth (mirrors vault's withMcpChallenge). The other
2920
+ // endpoints (/events, /api/*) stay plain 401 — only the /mcp path drives
2921
+ // a spec OAuth client, so only it carries the challenge.
2922
+ const denied = await requireScope(req, url, SCOPE_READ);
2923
+ if (denied) {
2924
+ if (denied.status === 401) {
2925
+ const headers = new Headers(denied.headers);
2926
+ headers.set("WWW-Authenticate", mcpWwwAuthenticate(req, channel));
2927
+ return new Response(await denied.text(), { status: 401, headers });
2928
+ }
2929
+ return denied;
2930
+ }
2931
+ // Re-validate to surface the caller's scopes for the write-tool checks.
2932
+ // (requireScope already proved the token valid + carrying agent:read;
2933
+ // this second pass hits the warm JWKS cache.) A token present but missing
2934
+ // here would have been rejected above, so claims must resolve.
2935
+ let scopes: string[] = [];
2936
+ try {
2937
+ const token = extractToken(req, url);
2938
+ if (token) scopes = (await validateHubJwt(token)).scopes;
2939
+ } catch {
2940
+ // Unreachable in practice (requireScope passed); fall back to read-only.
2941
+ scopes = [SCOPE_READ];
2942
+ }
2943
+ return handleMcp(req, channel, transport, scopes, attachedQueue);
2944
+ }
2945
+
2946
+ // Give each transport a chance to handle a route the daemon didn't. Runs
2947
+ // after the daemon's own built-in routes and before the final 404. A
2948
+ // transport returns a Response if it owns the path, or null to pass.
2949
+ for (const ch of channels.values()) {
2950
+ const res = await ch.transport.ingestHttp?.(req, url);
2951
+ if (res) return res;
2952
+ }
2953
+
2954
+ return json({ error: "not found" }, 404);
2955
+ };
2956
+ }
2957
+
2958
+ // ---------------------------------------------------------------------------
2959
+ // Request helpers (module-scope; hoisted, referenced from inside the factory)
2960
+ // ---------------------------------------------------------------------------
2961
+
2962
+ /**
2963
+ * Map a thrown error to a response: ChannelConfigError → 400 (operator must fix
2964
+ * config), anything else → 500 (runtime fault). Lets callers distinguish the two.
2965
+ */
2966
+ function errResponse(err: unknown): Response {
2967
+ if (err instanceof ChannelConfigError) return json({ error: err.message }, 400);
2968
+ return json({ error: String(err) }, 500);
2969
+ }
2970
+
2971
+ /**
2972
+ * Build the meta map for outbound calls. Telegram addressing historically came
2973
+ * in as a top-level `chat_id`; preserve that by folding it into `meta.chat_id`
2974
+ * while letting an explicit `meta` object take precedence/extend.
2975
+ */
2976
+ function mergeMeta(body: { chat_id?: string; meta?: Record<string, string> }): Record<string, string> {
2977
+ const meta: Record<string, string> = { ...(body.meta ?? {}) };
2978
+ if (body.chat_id !== undefined && meta.chat_id === undefined) meta.chat_id = body.chat_id;
2979
+ return meta;
2980
+ }
2981
+
2982
+ function toReplyArgs(body: {
2983
+ channel?: string;
2984
+ chat_id?: string;
2985
+ text?: string;
2986
+ reply_to?: string;
2987
+ files?: string[];
2988
+ meta?: Record<string, string>;
2989
+ }): ReplyArgs {
2990
+ return {
2991
+ channel: body.channel!,
2992
+ text: body.text,
2993
+ files: body.files,
2994
+ reply_to: body.reply_to,
2995
+ meta: mergeMeta(body),
2996
+ };
2997
+ }
2998
+
2999
+ // ---------------------------------------------------------------------------
3000
+ // Boot — load the registry, bind Bun.serve, start every transport.
3001
+ //
3002
+ // Gated on `import.meta.main` so importing this module (e.g. from a test that
3003
+ // only wants `createFetchHandler` / `requireScope`) does NOT load the registry,
3004
+ // bind a port, or `process.exit` on a missing config.
3005
+ // ---------------------------------------------------------------------------
3006
+
3007
+ function main(): void {
3008
+ mkdirSync(STATE_DIR, { recursive: true });
3009
+ mkdirSync(INBOX_DIR, { recursive: true });
3010
+
3011
+ // Verify the one MCP SDK internal our HTTP-MCP delivery accounting reads
3012
+ // (`_streamMapping['_GET_stream']`, see assertMcpSdkStreamContract). A screaming
3013
+ // boot error on SDK drift beats discovering it as silent message loss later.
3014
+ assertMcpSdkStreamContract();
3015
+
3016
+ let channels: Map<string, Channel>;
3017
+ try {
3018
+ channels = loadRegistry({ stateDir: STATE_DIR });
3019
+ } catch (err) {
3020
+ console.error(`parachute-agent: failed to load channel registry: ${err}`);
3021
+ process.exit(1);
3022
+ }
3023
+
3024
+ if (channels.size === 0) {
3025
+ // Zero channels is a valid STARTING state, not a fatal error. The daemon must
3026
+ // stay up and serve its HTTP surface so an operator can create the first agent
3027
+ // (the /agent/admin + create-agent UI POST to this very daemon — exiting here is
3028
+ // a chicken-and-egg: you couldn't define the first channel), and so future
3029
+ // vault-defined agents can appear into a running module. Channels added live
3030
+ // (via the API/UI, or hot-added) are picked up immediately. So: warn + idle.
3031
+ console.warn(
3032
+ `parachute-agent: no channels configured yet — starting idle.\n` +
3033
+ ` Create an agent via the admin UI at /agent/app/ (or add ${join(STATE_DIR, "channels.json")}).\n` +
3034
+ ` The daemon stays up; channels added live are picked up immediately.`,
3035
+ );
3036
+ }
3037
+
3038
+ const registry = new ClientRegistry();
3039
+
3040
+ // Per-channel delivery high-water-mark store, constructed ONCE at boot with the
3041
+ // daemon's boot time as the default mark — so a channel with no persisted mark
3042
+ // replays only messages that arrive AFTER this start (the deaf-window case),
3043
+ // never its whole vault history. Persisted marks (from a prior run) survive the
3044
+ // restart and replay exactly the gap. Shared by `contextFor.emit` (advance) and
3045
+ // both connect-hook replays (MCP session + SSE bridge).
3046
+ const deliveryState = new DeliveryState({
3047
+ stateDir: STATE_DIR,
3048
+ defaultMark: new Date().toISOString(),
3049
+ });
3050
+
3051
+ // The per-channel turn-event SSE registry (the streaming view, design build item
3052
+ // #1), constructed ONCE at boot and shared by the fetch handler's
3053
+ // `/api/channels/<ch>/turn-events` route (subscriber registration) and the
3054
+ // programmatic registry's turn-event sink (live-progress fan-out) — so a turn's
3055
+ // interim events reach the chat subscribers the route registered.
3056
+ const turnEvents = new ClientRegistry();
3057
+
3058
+ // The PROGRAMMATIC-agent registry (design 2026-06-16), constructed ONCE at boot
3059
+ // and shared by the fetch handler (the /api/agents + /health routes), the
3060
+ // transports' `contextFor` (inbound enqueue), and the boot re-register below — so
3061
+ // the SAME instance the routes operate on is the one inbound enqueues onto. Built
3062
+ // here (not lazily in createFetchHandler) precisely so the transports started
3063
+ // below route inbound to it. Threaded with the turn-event sink so each turn streams
3064
+ // its interim progress to `turnEvents` (the chat's live view).
3065
+ const programmatic = createDefaultProgrammaticRegistry(channels, buildTurnEventSink(turnEvents));
3066
+
3067
+ // The ATTACHED-backend queue registry (design 2026-06-18-channel-backend.md),
3068
+ // constructed ONCE at boot and shared by the fetch handler (the channel MCP surface),
3069
+ // the transports' `contextFor` (the routing fork — an attached inbound is NOT enqueued
3070
+ // to the programmatic worker), the agent-def instantiate path (a `backend:attached`
3071
+ // def registers here, not with programmatic), and the periodic sweep below. The
3072
+ // durable queue + claim state lives on the inbound notes in each channel's vault, so
3073
+ // this registry holds no per-message state of its own — it's the claim/peek/reply
3074
+ // surface over those notes.
3075
+ const attachedQueue = new AttachedQueueRegistry();
3076
+
3077
+ // The terminal WS handler set (pty↔socket relay + backpressure flow control,
3078
+ // src/terminal.ts). One handler object serves every terminal connection;
3079
+ // per-connection state lives on `ws.data`. The fetch handler routes accepted
3080
+ // upgrades into these via `server.upgrade(req, { data })`.
3081
+ const terminalWs = createTerminalWsHandlers();
3082
+
3083
+ // The vault-native scheduled-job store + the runner (design 2026-06-17). The
3084
+ // store reads/writes `#agent/job` notes in each vault channel's vault; the
3085
+ // runner ticks every 30s, loading jobs from the store, firing due ones by
3086
+ // injecting an inbound note onto the job's vault channel (the existing trigger →
3087
+ // agent-turn → outbound flow does the rest). Shared with the fetch handler so
3088
+ // the /api/jobs routes + the scheduler operate on the SAME store, and "Run now"
3089
+ // goes through the runner's bookkeeping path.
3090
+ const jobStore = new VaultJobStore(channels);
3091
+ const runner = new Runner({
3092
+ loadJobs: () => jobStore.listAll(),
3093
+ // Fire = inject an inbound note onto the job's vault channel, exactly like a
3094
+ // human typing in chat. Resolve the channel's vault transport at fire time so
3095
+ // a job whose channel was deleted logs + records an error rather than throwing
3096
+ // the tick. No new authority — uses the channel's existing vault write token.
3097
+ fire: async (job) => {
3098
+ const transport = vaultTransportFor(channels, job.channel);
3099
+ if (!transport) {
3100
+ throw new Error(`channel "${job.channel}" is not a live vault channel`);
3101
+ }
3102
+ await transport.injectInbound({ content: job.message, sender: `runner:${job.id}` });
3103
+ },
3104
+ // Persist bookkeeping (lastRunAt/lastStatus) back onto the job note (addressed
3105
+ // by its vault note id). A job loaded from the store always carries `noteId`.
3106
+ persistFire: async (job) => {
3107
+ if (!job.noteId) return; // nothing to address (shouldn't happen for a loaded job).
3108
+ await jobStore.patch(job.noteId, job.channel, {
3109
+ lastRunAt: job.lastRunAt,
3110
+ lastStatus: job.lastStatus,
3111
+ });
3112
+ },
3113
+ driver: realTickDriver(),
3114
+ });
3115
+
3116
+ // The vault-native agent-def registry (design 2026-06-17-vault-native-agents,
3117
+ // Phase 4a). Reads `#agent/definition` notes from the configured def-vaults and
3118
+ // instantiates each as a live agent (a vault channel + a programmatic agent) via
3119
+ // the SAME machinery the create-agent flow uses (buildInstantiateDeps). Constructed
3120
+ // here (empty) so it's shared with the fetch handler's reload webhook; the boot
3121
+ // resolve below (resolveDefVaults → addVault → loadAll) fills it. ADDITIVE to
3122
+ // channels.json — both paths coexist.
3123
+ const agentDefs = new AgentDefRegistry(
3124
+ buildInstantiateDeps(channels, registry, deliveryState, programmatic, attachedQueue),
3125
+ );
3126
+
3127
+ const fetchHandler = createFetchHandler(channels, registry, { deliveryState, programmatic, attachedQueue, turnEvents, jobStore, runner, agentDefs });
3128
+ const server = Bun.serve<TerminalWsData, never>({
3129
+ port: PORT,
3130
+ hostname: "127.0.0.1",
3131
+ idleTimeout: 0,
3132
+ // `fetch` receives `server` as its 2nd arg at runtime — needed for
3133
+ // `server.upgrade()` on the terminal WS route.
3134
+ fetch: (req, srv) => fetchHandler(req, srv),
3135
+ websocket: terminalWs,
3136
+ });
3137
+
3138
+ console.log(`parachute-agent: daemon listening on http://127.0.0.1:${PORT}`);
3139
+ console.log(`parachute-agent: state dir: ${STATE_DIR}`);
3140
+ console.log(
3141
+ `parachute-agent: ${channels.size} channel(s): ${[...channels.values()]
3142
+ .map((c) => `${c.name}→${c.transport.kind}`)
3143
+ .join(", ")}`,
3144
+ );
3145
+
3146
+ // Self-register into ~/.parachute/services.json so hub lists this module in the
3147
+ // portal and reverse-proxies `<expose>/agent/*` → this loopback daemon.
3148
+ // Best-effort: a failure must not stop the daemon from serving locally. Honors
3149
+ // PARACHUTE_HOME, so sandboxed/e2e daemons never touch the real services.json.
3150
+ try {
3151
+ upsertService({
3152
+ name: "parachute-agent",
3153
+ port: PORT,
3154
+ paths: ["/agent"],
3155
+ health: "/health",
3156
+ version: PKG_VERSION,
3157
+ displayName: "Agent",
3158
+ tagline: "Chat with your Claude Code sessions — a channel per session.",
3159
+ installDir: INSTALL_DIR,
3160
+ // The command the hub supervisor spawns to start/restart/adopt us. Without
3161
+ // this the supervisor knows our port but not how to launch the process, so
3162
+ // `parachute restart agent` 404s and we don't survive reboot (agent#34).
3163
+ startCmd: START_CMD,
3164
+ stripPrefix: true,
3165
+ uiUrl: "/agent/app/", // portal "Open UI" link → the SPA (canonical in module.json, which hub prefers; written here only as a services.json fallback hint)
3166
+ configUiUrl: "/agent/app/", // module-owned config surface (modular-UI P4); hub frames/links it. Canonical in module.json (hub prefers it); this is a services.json fallback hint.
3167
+ // WebSocket support — tells the hub's Bun-native upgrade bridge to forward
3168
+ // `Upgrade: websocket` requests on `/agent/*` to this daemon (the
3169
+ // in-page terminal, design §5.1). DENY-BY-DEFAULT in the hub: without this
3170
+ // the upgrade is refused (426) before it ever reaches us. Declared on
3171
+ // module.json too (the install-time contract); the hub honors either
3172
+ // source. No hub change needed — the hub already reads this field.
3173
+ websocket: true,
3174
+ // The terminal mount, declared as a `uis` sub-unit with audience "surface"
3175
+ // so the hub's audience gate PASSES IT THROUGH (the agent daemon owns
3176
+ // admission end-to-end — operator-grade agent:admin, enforced here). A
3177
+ // `surface` audience is the same pass-through the no-uis-match default
3178
+ // gives, but declaring it explicitly future-proofs against a later `uis`
3179
+ // declaration accidentally gating the terminal at hub-users. Design §5.3.
3180
+ uis: {
3181
+ // The web spawn/list/kill surface — the DEFAULT way to operate (spawn an
3182
+ // agent, scope it, watch it). audience "surface" so the hub passes it
3183
+ // through; agent owns admission end-to-end (operator-grade agent:admin,
3184
+ // enforced on every /api/agents call). Design §4/§5.
3185
+ agents: {
3186
+ displayName: "Agents",
3187
+ tagline: "Spawn, scope, and watch sandboxed Claude Code sessions.",
3188
+ path: "/agent/agents",
3189
+ audience: "surface",
3190
+ },
3191
+ terminal: {
3192
+ displayName: "Terminal",
3193
+ tagline: "Attach to a session's live tmux pane in the browser.",
3194
+ path: "/agent/terminal",
3195
+ audience: "surface",
3196
+ },
3197
+ },
3198
+ });
3199
+ console.log(`parachute-agent: self-registered into services.json (port ${PORT}, mount /agent)`);
3200
+ } catch (err) {
3201
+ console.error(`parachute-agent: services.json self-registration failed (continuing): ${err}`);
3202
+ }
3203
+
3204
+ // Start each channel via the same single-channel add path the config API uses
3205
+ // (`addChannelLive`), so boot and hot-add can't drift. The map already holds
3206
+ // the channels (from `loadRegistry`); addChannelLive replaces-in-place, which
3207
+ // for a freshly-instantiated boot transport means stop()→re-instantiate→start.
3208
+ // Per-channel failures are logged and don't abort the others; the daemon must
3209
+ // still serve the channels that did come up. Pass the programmatic registry so a
3210
+ // channel with a registered programmatic agent routes inbound to its serial queue.
3211
+ for (const channel of [...channels.values()]) {
3212
+ addChannelLive(channels, registry, channel.entry, deliveryState, programmatic, attachedQueue).catch((err) => {
3213
+ console.error(`parachute-agent: transport "${channel.name}" start failed:`, err);
3214
+ });
3215
+ }
3216
+
3217
+ // BOOT RE-REGISTER (design 2026-06-16 step 2). A programmatic agent has NO
3218
+ // resident process, so it doesn't survive a daemon restart as a tmux session
3219
+ // would — but its spec.json (carrying `backend: "programmatic"`) persists. Scan
3220
+ // the per-session workspaces and re-register every programmatic spec so inbound
3221
+ // for its channel resumes routing to an on-demand turn (the session UUID on the
3222
+ // `#agent/thread` note makes the next turn `--resume` the prior conversation — no
3223
+ // deaf problem). Best-
3224
+ // effort: a single bad spec is logged and skipped. The live `channels` map gates
3225
+ // it: only a spec whose wake channel is a configured channel is re-registered, so
3226
+ // a leaked/orphaned spec dir can't resurrect a phantom agent (agent#75).
3227
+ void reregisterProgrammaticAgents(programmatic, channels);
3228
+
3229
+ // Start the runner's scheduled-job tick (design 2026-06-17). Tolerant of an
3230
+ // empty/missing job set (no `#agent/job` notes → idle) and of a daemon with no
3231
+ // vault channels (listAll queries nothing → idle). A job targeting a now-deleted
3232
+ // channel sets lastStatus:error on fire rather than throwing the tick. The tick
3233
+ // is `unref`'d so it never keeps the process alive on its own.
3234
+ runner.start();
3235
+ console.log(`parachute-agent: runner started (scheduled-job tick)`);
3236
+
3237
+ // ATTACHED-BACKEND CLAIM TTL SWEEP (design 2026-06-18-channel-backend.md). A periodic
3238
+ // tick scans every attached-backend agent's in-flight inbound notes and resets any
3239
+ // claimed longer than the claim TTL (15 min) back to `pending` — so a crashed /
3240
+ // abandoned connected session can't strand the queue. Cheap + idempotent (a
3241
+ // channel with no attached agents lists nothing). `unref` so it never holds the
3242
+ // process open; runs at the same 30s cadence as the runner tick.
3243
+ const sweepIntervalMs = parseInt(process.env.PARACHUTE_AGENT_SWEEP_MS ?? "", 10) || 30_000;
3244
+ const channelSweep = setInterval(() => {
3245
+ void attachedQueue.sweepExpired().catch((err) => {
3246
+ console.error(`parachute-agent: attached-queue sweep failed (continuing): ${(err as Error).message}`);
3247
+ });
3248
+ }, sweepIntervalMs);
3249
+ channelSweep.unref?.();
3250
+
3251
+ // VAULT-NATIVE AGENT DEFINITIONS (design 2026-06-17-vault-native-agents, Phase 4a).
3252
+ // Resolve the def-vault bindings (agent-vaults.json, or the minted single-`default`
3253
+ // default), add each to the registry, and instantiate every `#agent/definition`
3254
+ // note in them — each becomes a live agent (a vault channel + a programmatic agent).
3255
+ // Fire-and-forget so a slow/unreachable vault never blocks the daemon from serving;
3256
+ // the reload webhook (POST /api/vault/agent-def) keeps them in sync reactively, and
3257
+ // a poll fallback re-syncs vaults without trigger support. Best-effort throughout —
3258
+ // a def-vault failure is logged and never affects channels.json-defined channels.
3259
+ let agentDefPoll: ReturnType<typeof setInterval> | undefined;
3260
+ void (async () => {
3261
+ let managerBearer: string | null = null;
3262
+ try {
3263
+ managerBearer = resolveSpawnDeps().managerBearer;
3264
+ } catch {
3265
+ // No operator token yet — resolveDefVaults handles the null (idle vault-native
3266
+ // path; channels.json unaffected).
3267
+ }
3268
+ // 4b: wire the hub grants client now the manager bearer is resolved (the registry
3269
+ // was constructed before the operator token was read). With it, each def's `wants:`
3270
+ // connections register as pending grants on instantiate + status derives from the
3271
+ // hub's grant statuses. No bearer → null → the registry falls back to the pure
3272
+ // status (pending if anything is declared) and the vault-native path still runs
3273
+ // own-vault. design 2026-06-17-agent-connectors-4b.md.
3274
+ if (managerBearer) {
3275
+ agentDefs.setGrantsClient(new GrantsClient({ hubOrigin: getHubOrigin(), managerBearer }));
3276
+ }
3277
+ const bindings = await resolveDefVaults({ hubOrigin: getHubOrigin(), managerBearer });
3278
+ for (const b of bindings) agentDefs.addVault(b);
3279
+ if (bindings.length === 0) return; // nothing bound — vault-native path idle.
3280
+ const n = await agentDefs.loadAll();
3281
+ console.log(
3282
+ `parachute-agent: vault-native agent defs — ${n} instantiated from ${bindings.length} def-vault(s).`,
3283
+ );
3284
+ // Poll fallback (every 60s): re-load all defs so a created/updated/deleted note
3285
+ // converges even with no webhook. The created/updated reload webhook is the fast path;
3286
+ // this is the safety net — AND the ONLY automatic path for a DELETE (there is no vault
3287
+ // `deleted` trigger, so a def removed out-of-band converges only here; loadAll's
3288
+ // removed-def diff deregisters the orphaned agent). `unref` so it never holds the
3289
+ // process open. Cheap + idempotent (re-instantiate replaces in place).
3290
+ const interval = parseInt(process.env.PARACHUTE_AGENT_DEF_POLL_MS ?? "", 10) || 60_000;
3291
+ agentDefPoll = setInterval(() => {
3292
+ void agentDefs.loadAll().catch((err) => {
3293
+ console.error(`parachute-agent: agent-def poll failed (continuing): ${(err as Error).message}`);
3294
+ });
3295
+ }, interval);
3296
+ agentDefPoll.unref?.();
3297
+ })().catch((err) => {
3298
+ console.error(`parachute-agent: vault-native agent-def boot failed (continuing): ${(err as Error).message}`);
3299
+ });
3300
+
3301
+ // Graceful shutdown — stop the runner + all transports.
3302
+ async function shutdown(): Promise<void> {
3303
+ runner.stop();
3304
+ clearInterval(channelSweep);
3305
+ if (agentDefPoll) clearInterval(agentDefPoll);
3306
+ await Promise.allSettled([...channels.values()].map((c) => c.transport.stop()));
3307
+ server.stop();
3308
+ process.exit(0);
3309
+ }
3310
+ process.on("SIGINT", shutdown);
3311
+ process.on("SIGTERM", shutdown);
3312
+ }
3313
+
3314
+ if (import.meta.main) main();