@openparachute/agent 0.1.2 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (608) hide show
  1. package/.parachute/module.json +124 -8
  2. package/LICENSE +2 -16
  3. package/README.md +118 -166
  4. package/package.json +35 -42
  5. package/scripts/spawn-agent.ts +371 -0
  6. package/src/_parked/interactive-spawn.test.ts +324 -0
  7. package/src/_parked/interactive-spawn.ts +701 -0
  8. package/src/agent-defs.test.ts +1504 -0
  9. package/src/agent-defs.ts +1702 -0
  10. package/src/agent-mcp-config.test.ts +115 -0
  11. package/src/agent-mcp-config.ts +115 -0
  12. package/src/agents.test.ts +360 -0
  13. package/src/agents.ts +379 -0
  14. package/src/auth.test.ts +46 -0
  15. package/src/auth.ts +140 -0
  16. package/src/backends/attached-queue.test.ts +376 -0
  17. package/src/backends/attached-queue.ts +372 -0
  18. package/src/backends/programmatic.test.ts +1715 -0
  19. package/src/backends/programmatic.ts +927 -0
  20. package/src/backends/registry.test.ts +1494 -0
  21. package/src/backends/registry.ts +1202 -0
  22. package/src/backends/stream-json.test.ts +570 -0
  23. package/src/backends/stream-json.ts +392 -0
  24. package/src/backends/types.ts +223 -0
  25. package/src/bridge.ts +417 -0
  26. package/src/channel-backend-wiring.test.ts +237 -0
  27. package/src/credentials.test.ts +274 -0
  28. package/src/credentials.ts +380 -0
  29. package/src/cron.test.ts +342 -0
  30. package/src/cron.ts +380 -0
  31. package/src/daemon-agent-def-api.test.ts +166 -0
  32. package/src/daemon-agent-defs-api.test.ts +953 -0
  33. package/src/daemon-agent-env-api.test.ts +338 -0
  34. package/src/daemon-attached-queue-store.test.ts +65 -0
  35. package/src/daemon-config-api.test.ts +962 -0
  36. package/src/daemon-jobs-api.test.ts +271 -0
  37. package/src/daemon-vault-chat.test.ts +250 -0
  38. package/src/daemon.test.ts +746 -0
  39. package/src/daemon.ts +3314 -0
  40. package/src/def-vaults.test.ts +136 -0
  41. package/src/def-vaults.ts +165 -0
  42. package/src/delivery-state.test.ts +110 -0
  43. package/src/delivery-state.ts +154 -0
  44. package/src/effective-env.test.ts +114 -0
  45. package/src/effective-env.ts +184 -0
  46. package/src/env-compat.ts +39 -0
  47. package/src/grants.test.ts +638 -0
  48. package/src/grants.ts +675 -0
  49. package/src/hub-jwt.test.ts +161 -0
  50. package/src/hub-jwt.ts +182 -0
  51. package/src/jobs.test.ts +245 -0
  52. package/src/jobs.ts +266 -0
  53. package/src/mcp-http.test.ts +265 -0
  54. package/src/mcp-http.ts +771 -0
  55. package/src/mint-token.test.ts +152 -0
  56. package/src/mint-token.ts +139 -0
  57. package/src/module-manifest.test.ts +158 -0
  58. package/src/oauth-discovery.ts +134 -0
  59. package/src/programmatic-wiring.test.ts +838 -0
  60. package/src/registry.test.ts +227 -0
  61. package/src/registry.ts +228 -0
  62. package/src/resolve-port.test.ts +64 -0
  63. package/src/routing.test.ts +184 -0
  64. package/src/routing.ts +76 -0
  65. package/src/runner.test.ts +506 -0
  66. package/src/runner.ts +255 -0
  67. package/src/sandbox/config.test.ts +150 -0
  68. package/src/sandbox/config.ts +102 -0
  69. package/src/sandbox/egress.test.ts +113 -0
  70. package/src/sandbox/egress.ts +123 -0
  71. package/src/sandbox/index.ts +180 -0
  72. package/src/sandbox/live-seatbelt.test.ts +277 -0
  73. package/src/sandbox/mounts.test.ts +154 -0
  74. package/src/sandbox/mounts.ts +133 -0
  75. package/src/sandbox/sandbox.test.ts +168 -0
  76. package/src/sandbox/types.ts +382 -0
  77. package/src/services-manifest.test.ts +106 -0
  78. package/src/services-manifest.ts +95 -0
  79. package/src/spa-serve.test.ts +116 -0
  80. package/src/spa-serve.ts +116 -0
  81. package/src/spawn-agent-cli.test.ts +172 -0
  82. package/src/spawn-agent.test.ts +1218 -0
  83. package/src/spawn-agent.ts +569 -0
  84. package/src/spawn-deps.test.ts +54 -0
  85. package/src/spawn-deps.ts +166 -0
  86. package/src/telegram/api.ts +153 -0
  87. package/src/terminal-assets.test.ts +50 -0
  88. package/src/terminal-assets.ts +79 -0
  89. package/src/terminal-ui.ts +305 -0
  90. package/src/terminal.test.ts +530 -0
  91. package/src/terminal.ts +458 -0
  92. package/src/transport.ts +270 -0
  93. package/src/transports/http-ui.test.ts +455 -0
  94. package/src/transports/http-ui.ts +201 -0
  95. package/src/transports/telegram.test.ts +174 -0
  96. package/src/transports/telegram.ts +426 -0
  97. package/src/transports/vault.test.ts +2011 -0
  98. package/src/transports/vault.ts +1790 -0
  99. package/src/ui-kit.test.ts +178 -0
  100. package/src/ui-kit.ts +402 -0
  101. package/tsconfig.json +8 -14
  102. package/web/ui/dist/assets/index-C-iWdFFV.css +1 -0
  103. package/web/ui/dist/assets/index-VFETBk0a.js +60 -0
  104. package/web/ui/dist/index.html +15 -0
  105. package/web/ui/tsconfig.json +2 -1
  106. package/.claude/scheduled_tasks.lock +0 -1
  107. package/.claude/settings.json +0 -5
  108. package/.claude/skills/add-atomic-chat-tool/SKILL.md +0 -243
  109. package/.claude/skills/add-atomic-chat-tool/atomic-chat-mcp-stdio.ts +0 -229
  110. package/.claude/skills/add-codex/SKILL.md +0 -161
  111. package/.claude/skills/add-dashboard/SKILL.md +0 -138
  112. package/.claude/skills/add-dashboard/resources/dashboard-pusher.ts +0 -495
  113. package/.claude/skills/add-emacs/SKILL.md +0 -296
  114. package/.claude/skills/add-gcal-tool/SKILL.md +0 -210
  115. package/.claude/skills/add-gchat/REMOVE.md +0 -6
  116. package/.claude/skills/add-gchat/SKILL.md +0 -92
  117. package/.claude/skills/add-gchat/VERIFY.md +0 -3
  118. package/.claude/skills/add-github/REMOVE.md +0 -6
  119. package/.claude/skills/add-github/SKILL.md +0 -148
  120. package/.claude/skills/add-github/VERIFY.md +0 -3
  121. package/.claude/skills/add-gmail-tool/SKILL.md +0 -229
  122. package/.claude/skills/add-imessage/REMOVE.md +0 -6
  123. package/.claude/skills/add-imessage/SKILL.md +0 -113
  124. package/.claude/skills/add-imessage/VERIFY.md +0 -3
  125. package/.claude/skills/add-karpathy-llm-wiki/SKILL.md +0 -110
  126. package/.claude/skills/add-karpathy-llm-wiki/llm-wiki.md +0 -75
  127. package/.claude/skills/add-linear/REMOVE.md +0 -6
  128. package/.claude/skills/add-linear/SKILL.md +0 -168
  129. package/.claude/skills/add-linear/VERIFY.md +0 -3
  130. package/.claude/skills/add-macos-statusbar/SKILL.md +0 -133
  131. package/.claude/skills/add-macos-statusbar/add/src/statusbar.swift +0 -147
  132. package/.claude/skills/add-matrix/REMOVE.md +0 -6
  133. package/.claude/skills/add-matrix/SKILL.md +0 -148
  134. package/.claude/skills/add-matrix/VERIFY.md +0 -3
  135. package/.claude/skills/add-ollama-provider/SKILL.md +0 -179
  136. package/.claude/skills/add-ollama-tool/SKILL.md +0 -193
  137. package/.claude/skills/add-opencode/SKILL.md +0 -229
  138. package/.claude/skills/add-parallel/SKILL.md +0 -290
  139. package/.claude/skills/add-resend/REMOVE.md +0 -6
  140. package/.claude/skills/add-resend/SKILL.md +0 -93
  141. package/.claude/skills/add-resend/VERIFY.md +0 -3
  142. package/.claude/skills/add-signal/REMOVE.md +0 -13
  143. package/.claude/skills/add-signal/SKILL.md +0 -318
  144. package/.claude/skills/add-signal/VERIFY.md +0 -5
  145. package/.claude/skills/add-slack/REMOVE.md +0 -6
  146. package/.claude/skills/add-slack/SKILL.md +0 -112
  147. package/.claude/skills/add-slack/VERIFY.md +0 -3
  148. package/.claude/skills/add-teams/REMOVE.md +0 -6
  149. package/.claude/skills/add-teams/SKILL.md +0 -207
  150. package/.claude/skills/add-teams/VERIFY.md +0 -3
  151. package/.claude/skills/add-vercel/SKILL.md +0 -147
  152. package/.claude/skills/add-vercel/container-skills/vercel-cli/SKILL.md +0 -103
  153. package/.claude/skills/add-webex/REMOVE.md +0 -6
  154. package/.claude/skills/add-webex/SKILL.md +0 -88
  155. package/.claude/skills/add-webex/VERIFY.md +0 -3
  156. package/.claude/skills/add-wechat/REMOVE.md +0 -49
  157. package/.claude/skills/add-wechat/SKILL.md +0 -170
  158. package/.claude/skills/add-wechat/scripts/wire-dm.ts +0 -172
  159. package/.claude/skills/add-whatsapp/SKILL.md +0 -264
  160. package/.claude/skills/add-whatsapp-cloud/REMOVE.md +0 -6
  161. package/.claude/skills/add-whatsapp-cloud/SKILL.md +0 -95
  162. package/.claude/skills/add-whatsapp-cloud/VERIFY.md +0 -3
  163. package/.claude/skills/claw/SKILL.md +0 -131
  164. package/.claude/skills/claw/scripts/claw +0 -374
  165. package/.claude/skills/convert-to-apple-container/SKILL.md +0 -212
  166. package/.claude/skills/customize/SKILL.md +0 -110
  167. package/.claude/skills/debug/SKILL.md +0 -349
  168. package/.claude/skills/get-qodo-rules/SKILL.md +0 -122
  169. package/.claude/skills/get-qodo-rules/references/output-format.md +0 -41
  170. package/.claude/skills/get-qodo-rules/references/pagination.md +0 -33
  171. package/.claude/skills/get-qodo-rules/references/repository-scope.md +0 -26
  172. package/.claude/skills/init-first-agent/SKILL.md +0 -120
  173. package/.claude/skills/init-onecli/SKILL.md +0 -270
  174. package/.claude/skills/manage-channels/SKILL.md +0 -87
  175. package/.claude/skills/manage-mounts/SKILL.md +0 -47
  176. package/.claude/skills/migrate-from-openclaw/MIGRATE_CRONS.md +0 -100
  177. package/.claude/skills/migrate-from-openclaw/SKILL.md +0 -447
  178. package/.claude/skills/migrate-from-openclaw/scripts/discover-openclaw.ts +0 -734
  179. package/.claude/skills/migrate-from-openclaw/scripts/extract-channel-credentials.ts +0 -476
  180. package/.claude/skills/migrate-nanoclaw/SKILL.md +0 -484
  181. package/.claude/skills/migrate-nanoclaw/diagnostics.md +0 -51
  182. package/.claude/skills/qodo-pr-resolver/SKILL.md +0 -326
  183. package/.claude/skills/qodo-pr-resolver/resources/providers.md +0 -329
  184. package/.claude/skills/update-nanoclaw/SKILL.md +0 -243
  185. package/.claude/skills/update-nanoclaw/diagnostics.md +0 -48
  186. package/.claude/skills/update-skills/SKILL.md +0 -130
  187. package/.claude/skills/use-native-credential-proxy/SKILL.md +0 -167
  188. package/.claude/skills/x-integration/SKILL.md +0 -417
  189. package/.claude/skills/x-integration/agent.ts +0 -243
  190. package/.claude/skills/x-integration/host.ts +0 -155
  191. package/.claude/skills/x-integration/lib/browser.ts +0 -148
  192. package/.claude/skills/x-integration/lib/config.ts +0 -62
  193. package/.claude/skills/x-integration/scripts/like.ts +0 -56
  194. package/.claude/skills/x-integration/scripts/post.ts +0 -66
  195. package/.claude/skills/x-integration/scripts/quote.ts +0 -80
  196. package/.claude/skills/x-integration/scripts/reply.ts +0 -74
  197. package/.claude/skills/x-integration/scripts/retweet.ts +0 -62
  198. package/.claude/skills/x-integration/scripts/setup.ts +0 -87
  199. package/.github/CODEOWNERS +0 -10
  200. package/.github/PULL_REQUEST_TEMPLATE.md +0 -18
  201. package/.github/workflows/bump-version.yml +0 -35
  202. package/.github/workflows/ci.yml +0 -39
  203. package/.github/workflows/label-pr.yml +0 -40
  204. package/.github/workflows/update-tokens.yml +0 -43
  205. package/.husky/pre-commit +0 -1
  206. package/.mcp.json +0 -3
  207. package/.nvmrc +0 -1
  208. package/.prettierrc +0 -4
  209. package/CHANGELOG.md +0 -263
  210. package/CLAUDE.md +0 -307
  211. package/CODE_OF_CONDUCT.md +0 -128
  212. package/CONTRIBUTING.md +0 -159
  213. package/CONTRIBUTORS.md +0 -26
  214. package/LICENSE-NANOCLAW-MIT +0 -21
  215. package/README_ja.md +0 -194
  216. package/README_zh.md +0 -194
  217. package/assets/nanoclaw-favicon.png +0 -0
  218. package/assets/nanoclaw-icon.png +0 -0
  219. package/assets/nanoclaw-logo-dark.png +0 -0
  220. package/assets/nanoclaw-logo.png +0 -0
  221. package/assets/nanoclaw-profile.jpeg +0 -0
  222. package/assets/nanoclaw-sales.png +0 -0
  223. package/assets/social-preview.jpg +0 -0
  224. package/config-examples/mount-allowlist.json +0 -25
  225. package/container/.dockerignore +0 -2
  226. package/container/CLAUDE.md +0 -21
  227. package/container/Dockerfile +0 -121
  228. package/container/agent-runner/bun.lock +0 -243
  229. package/container/agent-runner/package.json +0 -22
  230. package/container/agent-runner/scripts/sdk-signal-probe.ts +0 -169
  231. package/container/agent-runner/src/config.ts +0 -55
  232. package/container/agent-runner/src/db/connection.ts +0 -267
  233. package/container/agent-runner/src/db/index.ts +0 -20
  234. package/container/agent-runner/src/db/messages-in.ts +0 -138
  235. package/container/agent-runner/src/db/messages-out.ts +0 -143
  236. package/container/agent-runner/src/db/session-routing.ts +0 -30
  237. package/container/agent-runner/src/db/session-state.test.ts +0 -100
  238. package/container/agent-runner/src/db/session-state.ts +0 -79
  239. package/container/agent-runner/src/destinations.ts +0 -135
  240. package/container/agent-runner/src/formatter.test.ts +0 -167
  241. package/container/agent-runner/src/formatter.ts +0 -260
  242. package/container/agent-runner/src/index.ts +0 -110
  243. package/container/agent-runner/src/integration.test.ts +0 -121
  244. package/container/agent-runner/src/mcp-tools/agents.instructions.md +0 -26
  245. package/container/agent-runner/src/mcp-tools/agents.ts +0 -66
  246. package/container/agent-runner/src/mcp-tools/core.instructions.md +0 -27
  247. package/container/agent-runner/src/mcp-tools/core.ts +0 -262
  248. package/container/agent-runner/src/mcp-tools/index.ts +0 -22
  249. package/container/agent-runner/src/mcp-tools/interactive.instructions.md +0 -22
  250. package/container/agent-runner/src/mcp-tools/interactive.ts +0 -169
  251. package/container/agent-runner/src/mcp-tools/scheduling.instructions.md +0 -40
  252. package/container/agent-runner/src/mcp-tools/scheduling.ts +0 -299
  253. package/container/agent-runner/src/mcp-tools/self-mod.instructions.md +0 -25
  254. package/container/agent-runner/src/mcp-tools/self-mod.ts +0 -120
  255. package/container/agent-runner/src/mcp-tools/server.ts +0 -54
  256. package/container/agent-runner/src/mcp-tools/types.ts +0 -6
  257. package/container/agent-runner/src/poll-loop.test.ts +0 -248
  258. package/container/agent-runner/src/poll-loop.ts +0 -437
  259. package/container/agent-runner/src/providers/claude.ts +0 -379
  260. package/container/agent-runner/src/providers/factory.test.ts +0 -19
  261. package/container/agent-runner/src/providers/factory.ts +0 -13
  262. package/container/agent-runner/src/providers/index.ts +0 -6
  263. package/container/agent-runner/src/providers/mock.ts +0 -77
  264. package/container/agent-runner/src/providers/provider-registry.ts +0 -33
  265. package/container/agent-runner/src/providers/types.ts +0 -82
  266. package/container/agent-runner/src/scheduling/task-script.ts +0 -121
  267. package/container/agent-runner/src/timezone.test.ts +0 -93
  268. package/container/agent-runner/src/timezone.ts +0 -107
  269. package/container/agent-runner/tsconfig.json +0 -14
  270. package/container/build.sh +0 -48
  271. package/container/entrypoint.sh +0 -16
  272. package/container/skills/agent-browser/SKILL.md +0 -159
  273. package/container/skills/frontend-engineer/SKILL.md +0 -157
  274. package/container/skills/self-customize/SKILL.md +0 -87
  275. package/container/skills/slack-formatting/SKILL.md +0 -94
  276. package/container/skills/vercel-cli/SKILL.md +0 -111
  277. package/container/skills/welcome/SKILL.md +0 -85
  278. package/docs/APPLE-CONTAINER-NETWORKING.md +0 -90
  279. package/docs/BRANCH-FORK-MAINTENANCE.md +0 -81
  280. package/docs/README.md +0 -25
  281. package/docs/SDK_DEEP_DIVE.md +0 -643
  282. package/docs/SECURITY.md +0 -162
  283. package/docs/agent-runner-details.md +0 -749
  284. package/docs/api-details.md +0 -365
  285. package/docs/architecture-diagram.html +0 -422
  286. package/docs/architecture-diagram.md +0 -215
  287. package/docs/architecture.md +0 -751
  288. package/docs/audit/2026-04-30-channel-endpoint-audit.md +0 -36
  289. package/docs/build-and-runtime.md +0 -80
  290. package/docs/cross-mount-stress/README.md +0 -112
  291. package/docs/cross-mount-stress/container-writer-retry.mjs +0 -55
  292. package/docs/cross-mount-stress/container-writer-slow.mjs +0 -42
  293. package/docs/cross-mount-stress/container-writer.mjs +0 -47
  294. package/docs/cross-mount-stress/host-writer-retry.mjs +0 -55
  295. package/docs/cross-mount-stress/host-writer-slow.mjs +0 -43
  296. package/docs/cross-mount-stress/host-writer.mjs +0 -47
  297. package/docs/db-central.md +0 -316
  298. package/docs/db-session.md +0 -183
  299. package/docs/db.md +0 -119
  300. package/docs/design/2026-04-29-vault-management-ui.md +0 -231
  301. package/docs/design/2026-04-30-channel-wiring-rework.md +0 -234
  302. package/docs/design/2026-05-01-channel-wiring-approvals-deep-dive.md +0 -272
  303. package/docs/design/2026-05-02-channel-policy-and-approval-routing.md +0 -250
  304. package/docs/docker-sandboxes.md +0 -359
  305. package/docs/isolation-model.md +0 -88
  306. package/docs/ollama.md +0 -79
  307. package/docs/parachute-integration.md +0 -109
  308. package/docs/post-night-rebirth-reflections.md +0 -151
  309. package/eslint.config.js +0 -32
  310. package/pnpm-workspace.yaml +0 -8
  311. package/repo-tokens/README.md +0 -113
  312. package/repo-tokens/action.yml +0 -186
  313. package/repo-tokens/badge.svg +0 -23
  314. package/repo-tokens/examples/green.svg +0 -14
  315. package/repo-tokens/examples/red.svg +0 -14
  316. package/repo-tokens/examples/yellow-green.svg +0 -14
  317. package/repo-tokens/examples/yellow.svg +0 -14
  318. package/scripts/chat.ts +0 -101
  319. package/scripts/cleanup-sessions.sh +0 -150
  320. package/scripts/init-cli-agent.ts +0 -172
  321. package/scripts/init-first-agent.ts +0 -378
  322. package/scripts/parachute.ts +0 -158
  323. package/scripts/run-migrations.ts +0 -105
  324. package/scripts/sanity-live-poll.ts +0 -95
  325. package/scripts/seed-discord.ts +0 -80
  326. package/scripts/test-v2-agent.ts +0 -106
  327. package/scripts/test-v2-channel-e2e.ts +0 -265
  328. package/scripts/test-v2-host.ts +0 -184
  329. package/src/channels/adapter.ts +0 -214
  330. package/src/channels/api-translator.test.ts +0 -306
  331. package/src/channels/api-translator.ts +0 -214
  332. package/src/channels/ask-question.ts +0 -46
  333. package/src/channels/channel-registry.test.ts +0 -421
  334. package/src/channels/channel-registry.ts +0 -313
  335. package/src/channels/chat-sdk-bridge.test.ts +0 -84
  336. package/src/channels/chat-sdk-bridge.ts +0 -652
  337. package/src/channels/cli.ts +0 -276
  338. package/src/channels/discord.ts +0 -90
  339. package/src/channels/index.ts +0 -17
  340. package/src/channels/telegram-markdown-sanitize.test.ts +0 -78
  341. package/src/channels/telegram-markdown-sanitize.ts +0 -55
  342. package/src/channels/telegram-pairing.test.ts +0 -254
  343. package/src/channels/telegram-pairing.ts +0 -339
  344. package/src/channels/telegram.ts +0 -279
  345. package/src/channels/trust-hint.test.ts +0 -48
  346. package/src/channels/trust-hint.ts +0 -75
  347. package/src/claude-md-compose.migrate.test.ts +0 -64
  348. package/src/claude-md-compose.ts +0 -205
  349. package/src/command-gate.ts +0 -63
  350. package/src/config.test.ts +0 -93
  351. package/src/config.ts +0 -128
  352. package/src/container-config.ts +0 -167
  353. package/src/container-runner.test.ts +0 -32
  354. package/src/container-runner.ts +0 -576
  355. package/src/container-runtime.test.ts +0 -269
  356. package/src/container-runtime.ts +0 -167
  357. package/src/db/_bun-sqlite-shim.ts +0 -88
  358. package/src/db/agent-activity.test.ts +0 -155
  359. package/src/db/agent-activity.ts +0 -121
  360. package/src/db/agent-groups.ts +0 -77
  361. package/src/db/connection.migrate.test.ts +0 -176
  362. package/src/db/connection.ts +0 -259
  363. package/src/db/db-v2.test.ts +0 -440
  364. package/src/db/dropped-messages.ts +0 -44
  365. package/src/db/index.ts +0 -40
  366. package/src/db/messaging-groups.ts +0 -252
  367. package/src/db/migrations/001-initial.ts +0 -112
  368. package/src/db/migrations/002-chat-sdk-state.ts +0 -36
  369. package/src/db/migrations/008-dropped-messages.ts +0 -27
  370. package/src/db/migrations/009-drop-pending-credentials.ts +0 -13
  371. package/src/db/migrations/010-engage-modes.ts +0 -103
  372. package/src/db/migrations/011-pending-sender-approvals.ts +0 -40
  373. package/src/db/migrations/012-channel-registration.ts +0 -48
  374. package/src/db/migrations/013-approval-render-metadata.ts +0 -27
  375. package/src/db/migrations/014-secrets.ts +0 -44
  376. package/src/db/migrations/015-secrets-drop-host-pattern.ts +0 -18
  377. package/src/db/migrations/016-secret-assignments.ts +0 -30
  378. package/src/db/migrations/017-agent-activity.ts +0 -40
  379. package/src/db/migrations/018-oauth-app-configs.ts +0 -34
  380. package/src/db/migrations/019-oauth-app-connections.ts +0 -48
  381. package/src/db/migrations/020-agent-app-connections.ts +0 -28
  382. package/src/db/migrations/021-pending-oauth-states.ts +0 -35
  383. package/src/db/migrations/022-app-connections-provider.ts +0 -25
  384. package/src/db/migrations/023-agent-group-secret-mode.test.ts +0 -124
  385. package/src/db/migrations/023-agent-group-secret-mode.ts +0 -65
  386. package/src/db/migrations/024-collapse-approvals.test.ts +0 -249
  387. package/src/db/migrations/024-collapse-approvals.ts +0 -182
  388. package/src/db/migrations/025-secret-mode-check.test.ts +0 -155
  389. package/src/db/migrations/025-secret-mode-check.ts +0 -49
  390. package/src/db/migrations/026-user-dms-bot-id.test.ts +0 -116
  391. package/src/db/migrations/026-user-dms-bot-id.ts +0 -54
  392. package/src/db/migrations/027-provider-credentials.ts +0 -41
  393. package/src/db/migrations/_test-helpers.ts +0 -41
  394. package/src/db/migrations/index.ts +0 -127
  395. package/src/db/migrations/module-agent-to-agent-destinations.ts +0 -84
  396. package/src/db/migrations/module-approvals-pending-approvals.ts +0 -42
  397. package/src/db/migrations/module-approvals-title-options.ts +0 -40
  398. package/src/db/schema.ts +0 -258
  399. package/src/db/session-db.test.ts +0 -93
  400. package/src/db/session-db.ts +0 -325
  401. package/src/db/sessions.ts +0 -241
  402. package/src/delivery.test.ts +0 -148
  403. package/src/delivery.ts +0 -445
  404. package/src/env.ts +0 -74
  405. package/src/group-folder.test.ts +0 -35
  406. package/src/group-folder.ts +0 -44
  407. package/src/group-init.ts +0 -92
  408. package/src/host-core.test.ts +0 -456
  409. package/src/host-sweep.test.ts +0 -146
  410. package/src/host-sweep.ts +0 -287
  411. package/src/index.ts +0 -232
  412. package/src/install-slug.ts +0 -33
  413. package/src/log.test.ts +0 -81
  414. package/src/log.ts +0 -117
  415. package/src/mcp/http.ts +0 -72
  416. package/src/mcp/server.ts +0 -92
  417. package/src/mcp/stdio.ts +0 -51
  418. package/src/mcp/tools/activity.ts +0 -88
  419. package/src/mcp/tools/agent-groups.ts +0 -183
  420. package/src/mcp/tools/approvals.ts +0 -122
  421. package/src/mcp/tools/channels.test.ts +0 -126
  422. package/src/mcp/tools/channels.ts +0 -134
  423. package/src/mcp/tools/index.ts +0 -27
  424. package/src/mcp/tools/oauth.ts +0 -48
  425. package/src/mcp/tools/secrets.ts +0 -169
  426. package/src/mcp/tools/sessions.ts +0 -135
  427. package/src/mcp/types.ts +0 -51
  428. package/src/modules/agent-to-agent/agent-route.test.ts +0 -46
  429. package/src/modules/agent-to-agent/agent-route.ts +0 -223
  430. package/src/modules/agent-to-agent/create-agent.ts +0 -127
  431. package/src/modules/agent-to-agent/db/agent-destinations.ts +0 -135
  432. package/src/modules/agent-to-agent/index.ts +0 -22
  433. package/src/modules/agent-to-agent/write-destinations.ts +0 -59
  434. package/src/modules/approvals/agent.md +0 -45
  435. package/src/modules/approvals/index.ts +0 -21
  436. package/src/modules/approvals/picks.test.ts +0 -291
  437. package/src/modules/approvals/primitive.ts +0 -279
  438. package/src/modules/approvals/project.md +0 -27
  439. package/src/modules/approvals/response-handler.ts +0 -87
  440. package/src/modules/index.ts +0 -24
  441. package/src/modules/interactive/agent.md +0 -21
  442. package/src/modules/interactive/index.ts +0 -69
  443. package/src/modules/interactive/project.md +0 -12
  444. package/src/modules/mount-security/expand-path.test.ts +0 -82
  445. package/src/modules/mount-security/index.ts +0 -459
  446. package/src/modules/mount-security/migrate.test.ts +0 -91
  447. package/src/modules/permissions/access.ts +0 -28
  448. package/src/modules/permissions/channel-approval.test.ts +0 -389
  449. package/src/modules/permissions/channel-approval.ts +0 -188
  450. package/src/modules/permissions/db/agent-group-members.ts +0 -44
  451. package/src/modules/permissions/db/pending-channel-approvals.test.ts +0 -86
  452. package/src/modules/permissions/db/pending-channel-approvals.ts +0 -66
  453. package/src/modules/permissions/db/pending-sender-approvals.ts +0 -60
  454. package/src/modules/permissions/db/user-dms.ts +0 -58
  455. package/src/modules/permissions/db/user-roles.ts +0 -85
  456. package/src/modules/permissions/db/users.ts +0 -38
  457. package/src/modules/permissions/index.ts +0 -421
  458. package/src/modules/permissions/permissions.test.ts +0 -358
  459. package/src/modules/permissions/sender-approval.test.ts +0 -641
  460. package/src/modules/permissions/sender-approval.ts +0 -165
  461. package/src/modules/permissions/user-dm.ts +0 -200
  462. package/src/modules/provider-credentials/db.ts +0 -121
  463. package/src/modules/provider-credentials/index.ts +0 -12
  464. package/src/modules/provider-credentials/spawn.test.ts +0 -206
  465. package/src/modules/provider-credentials/spawn.ts +0 -114
  466. package/src/modules/scheduling/actions.ts +0 -113
  467. package/src/modules/scheduling/db.test.ts +0 -282
  468. package/src/modules/scheduling/db.ts +0 -148
  469. package/src/modules/scheduling/index.ts +0 -34
  470. package/src/modules/scheduling/recurrence.test.ts +0 -98
  471. package/src/modules/scheduling/recurrence.ts +0 -54
  472. package/src/modules/self-mod/agent.md +0 -30
  473. package/src/modules/self-mod/apply.ts +0 -85
  474. package/src/modules/self-mod/index.ts +0 -30
  475. package/src/modules/self-mod/project.md +0 -39
  476. package/src/modules/self-mod/request.ts +0 -91
  477. package/src/modules/typing/index.ts +0 -165
  478. package/src/oauth/agent-app-connections.ts +0 -103
  479. package/src/oauth/app-configs.test.ts +0 -64
  480. package/src/oauth/app-configs.ts +0 -114
  481. package/src/oauth/app-connections.test.ts +0 -109
  482. package/src/oauth/app-connections.ts +0 -178
  483. package/src/oauth/crypto.ts +0 -56
  484. package/src/oauth/flow.ts +0 -104
  485. package/src/oauth/providers/google.test.ts +0 -38
  486. package/src/oauth/providers/google.ts +0 -46
  487. package/src/oauth/providers/index.ts +0 -48
  488. package/src/oauth/state-store.test.ts +0 -54
  489. package/src/oauth/state-store.ts +0 -93
  490. package/src/parachute/README.md +0 -27
  491. package/src/parachute/create-agent.test.ts +0 -83
  492. package/src/parachute/create-agent.ts +0 -122
  493. package/src/parachute/group-status.test.ts +0 -165
  494. package/src/parachute/group-status.ts +0 -136
  495. package/src/parachute/types.ts +0 -41
  496. package/src/parachute/vault-mcp.test.ts +0 -251
  497. package/src/parachute/vault-mcp.ts +0 -232
  498. package/src/platform-id.test.ts +0 -104
  499. package/src/platform-id.ts +0 -109
  500. package/src/providers/index.ts +0 -6
  501. package/src/providers/provider-container-registry.ts +0 -58
  502. package/src/response-registry.ts +0 -45
  503. package/src/router.ts +0 -530
  504. package/src/secrets/crypto.test.ts +0 -45
  505. package/src/secrets/crypto.ts +0 -55
  506. package/src/secrets/index.ts +0 -461
  507. package/src/secrets/master-key.ts +0 -70
  508. package/src/secrets/secrets.test.ts +0 -651
  509. package/src/session-manager.attachments.test.ts +0 -171
  510. package/src/session-manager.dup-skip.test.ts +0 -173
  511. package/src/session-manager.migrate.test.ts +0 -59
  512. package/src/session-manager.ts +0 -451
  513. package/src/startup-bootstrap.test.ts +0 -226
  514. package/src/startup-bootstrap.ts +0 -207
  515. package/src/state-sqlite.ts +0 -182
  516. package/src/timezone.test.ts +0 -64
  517. package/src/timezone.ts +0 -37
  518. package/src/types.ts +0 -233
  519. package/src/web/auth.test.ts +0 -335
  520. package/src/web/auth.ts +0 -214
  521. package/src/web/discord-validate.test.ts +0 -77
  522. package/src/web/discord-validate.ts +0 -88
  523. package/src/web/hub-discovery.test.ts +0 -98
  524. package/src/web/hub-discovery.ts +0 -69
  525. package/src/web/routes/activity.ts +0 -106
  526. package/src/web/routes/agent-provider.test.ts +0 -282
  527. package/src/web/routes/agent-provider.ts +0 -309
  528. package/src/web/routes/approvals.ts +0 -185
  529. package/src/web/routes/apps.ts +0 -434
  530. package/src/web/routes/channels-mg-detail.test.ts +0 -324
  531. package/src/web/routes/channels-mga-detail.test.ts +0 -472
  532. package/src/web/routes/channels.ts +0 -311
  533. package/src/web/routes/oauth-providers.ts +0 -42
  534. package/src/web/routes/secrets.test.ts +0 -220
  535. package/src/web/routes/secrets.ts +0 -317
  536. package/src/web/routes/sessions.ts +0 -123
  537. package/src/web/routes/settings.test.ts +0 -106
  538. package/src/web/routes/settings.ts +0 -247
  539. package/src/web/routes/setup-status.ts +0 -205
  540. package/src/web/routes/vaults.test.ts +0 -389
  541. package/src/web/routes/vaults.ts +0 -225
  542. package/src/web/server-version.test.ts +0 -16
  543. package/src/web/server.ts +0 -1024
  544. package/src/web/services-manifest.test.ts +0 -148
  545. package/src/web/services-manifest.ts +0 -66
  546. package/src/web/static-serve.test.ts +0 -255
  547. package/src/web/static-serve.ts +0 -104
  548. package/src/web/telegram-validate.test.ts +0 -116
  549. package/src/web/telegram-validate.ts +0 -107
  550. package/src/web/vault-proxy.test.ts +0 -214
  551. package/src/web/vault-proxy.ts +0 -120
  552. package/src/web/wire-channel.ts +0 -181
  553. package/src/webhook-server.ts +0 -134
  554. package/vitest.config.ts +0 -18
  555. package/web/README.md +0 -63
  556. package/web/ui/index.html +0 -13
  557. package/web/ui/package.json +0 -35
  558. package/web/ui/pnpm-lock.yaml +0 -2164
  559. package/web/ui/scripts/verify-base.mjs +0 -31
  560. package/web/ui/src/App.tsx +0 -88
  561. package/web/ui/src/components/ActivityFeed.tsx +0 -444
  562. package/web/ui/src/components/AgentGroupPicker.tsx +0 -263
  563. package/web/ui/src/components/AgentProviderCards.tsx +0 -220
  564. package/web/ui/src/components/CredentialForm.tsx +0 -214
  565. package/web/ui/src/components/ScopeGrants.tsx +0 -74
  566. package/web/ui/src/components/StatusDot.tsx +0 -43
  567. package/web/ui/src/components/VaultPicker.tsx +0 -127
  568. package/web/ui/src/components/setup/AdapterInstallStep.tsx +0 -178
  569. package/web/ui/src/components/setup/AgentGroupStep.tsx +0 -43
  570. package/web/ui/src/components/setup/ChannelPickStep.tsx +0 -74
  571. package/web/ui/src/components/setup/DoneStep.tsx +0 -49
  572. package/web/ui/src/components/setup/PrereqStep.tsx +0 -129
  573. package/web/ui/src/components/setup/TestConnectionStep.tsx +0 -108
  574. package/web/ui/src/components/setup/TestMessageStep.tsx +0 -104
  575. package/web/ui/src/components/setup/WireChannelStep.tsx +0 -166
  576. package/web/ui/src/components/setup/types.ts +0 -105
  577. package/web/ui/src/lib/api.test.ts +0 -410
  578. package/web/ui/src/lib/api.ts +0 -1248
  579. package/web/ui/src/lib/auth.test.ts +0 -352
  580. package/web/ui/src/lib/auth.ts +0 -405
  581. package/web/ui/src/lib/channel-adapters.ts +0 -136
  582. package/web/ui/src/main.tsx +0 -19
  583. package/web/ui/src/routes/ApprovalsList.tsx +0 -294
  584. package/web/ui/src/routes/Apps.tsx +0 -613
  585. package/web/ui/src/routes/ChannelWireDetail.test.tsx +0 -233
  586. package/web/ui/src/routes/ChannelWireDetail.tsx +0 -403
  587. package/web/ui/src/routes/ChannelsList.tsx +0 -158
  588. package/web/ui/src/routes/GroupDetail.test.tsx +0 -206
  589. package/web/ui/src/routes/GroupDetail.tsx +0 -880
  590. package/web/ui/src/routes/GroupList.tsx +0 -187
  591. package/web/ui/src/routes/MessagingGroupDetail.test.tsx +0 -233
  592. package/web/ui/src/routes/MessagingGroupDetail.tsx +0 -306
  593. package/web/ui/src/routes/NewGroupWizard.tsx +0 -390
  594. package/web/ui/src/routes/OAuthCallback.tsx +0 -56
  595. package/web/ui/src/routes/SecretsList.tsx +0 -942
  596. package/web/ui/src/routes/SessionsList.tsx +0 -220
  597. package/web/ui/src/routes/SettingsAgentProvider.tsx +0 -109
  598. package/web/ui/src/routes/SettingsApprovals.tsx +0 -234
  599. package/web/ui/src/routes/SetupWizard.tsx +0 -219
  600. package/web/ui/src/routes/VaultDetail.test.tsx +0 -363
  601. package/web/ui/src/routes/VaultDetail.tsx +0 -960
  602. package/web/ui/src/routes/VaultsList.tsx +0 -295
  603. package/web/ui/src/routes/WireChannelPage.tsx +0 -413
  604. package/web/ui/src/styles.css +0 -608
  605. package/web/ui/src/test/setup.ts +0 -23
  606. package/web/ui/src/vite-env.d.ts +0 -10
  607. package/web/ui/vite.config.ts +0 -34
  608. package/web/ui/vitest.config.ts +0 -25
@@ -0,0 +1,1790 @@
1
+ /**
2
+ * vault transport for parachute-agent.
3
+ *
4
+ * A channel backed by `#agent/message` notes in a Parachute vault. The vault
5
+ * becomes the persistence layer + the inter-module event bus; the channel is the
6
+ * adapter that wakes a session on a new note and writes the session's reply back
7
+ * as a note.
8
+ *
9
+ * TAG NAMESPACE (`#agent/*`, module-owned — design
10
+ * `2026-06-17-vault-native-agents.md`). The `#agent` prefix is owned entirely by
11
+ * the agent module: every vault object the module manages hangs off it —
12
+ * `#agent/definition` (the agent def), `#agent/message{,/inbound,/outbound}` (a
13
+ * conversation turn), `#agent/job` (a scheduled trigger). We WRITE and READ only
14
+ * the `#agent/message*` tags — the channel→agent data-model rename CONTRACT phase
15
+ * dropped the legacy `#channel-message*` and interim `#agent-message*` dual-read
16
+ * (no surviving old-tagged data to recognize).
17
+ *
18
+ * ROUTING KEY (`metadata.agent`). Every note this module writes carries the routing
19
+ * key under `metadata.agent` ONLY — the CONTRACT phase of the channel→agent rename
20
+ * dropped the `metadata.channel` dual-write. The vault inbound trigger keys on
21
+ * `has_metadata:["agent"]`. The `noteAgentKey` helper still READS `agent ?? channel`
22
+ * as a tolerance fallback so a stray in-flight note written by an older build during
23
+ * the live cutover still routes — read-only, no longer written.
24
+ *
25
+ * How it differs from telegram / http-ui — the "external party" is the vault:
26
+ * - Inbound (human → session): a vault trigger POSTs the daemon's
27
+ * `/api/vault/inbound` webhook when a new `#agent/message/inbound` note
28
+ * appears; the daemon resolves the channel from `note.metadata.agent` (via
29
+ * `noteAgentKey`) and calls this transport's `ingestInbound(note)`, which
30
+ * `ctx.emit(...)`s → routes to the bridge / MCP session subscribed to that
31
+ * channel and wakes it.
32
+ * - Outbound (session → human): when the session calls the `reply` tool, the
33
+ * bridge POSTs `/api/reply {channel,...}`; the daemon dispatches to this
34
+ * transport's `reply()`, which writes a `#agent/message/outbound` note via
35
+ * the vault REST API (`POST <vaultUrl>/vault/<vault>/api/notes`).
36
+ *
37
+ * Tagging model — two ORTHOGONAL axes (this was a footgun; read carefully).
38
+ * In a Parachute vault a slash in a tag NAME is a namespace convention only —
39
+ * it implies NOTHING about query inheritance. `query-notes { tag: "X" }` matches
40
+ * descendants by the `tags.parent_names` graph, which is declared explicitly via
41
+ * `update-tag`, NOT inferred from the name. So a note tagged ONLY
42
+ * `#agent/message/inbound` is INVISIBLE to a `tag: "#agent/message"` query
43
+ * unless that inheritance was separately declared. We don't want to depend on
44
+ * per-vault schema setup, so every note carries BOTH tags literally:
45
+ * - the parent `#agent/message` — the QUERYABLE membership tag (a UI lists a
46
+ * channel's whole transcript, both directions, with one `tag: "#agent/message"`
47
+ * + `metadata.channel` query, because the parent is literally present);
48
+ * - a directional child — the trigger DISCRIMINATOR (`#agent/message/inbound`
49
+ * on inbound, `#agent/message/outbound` on outbound).
50
+ *
51
+ * Loop avoidance (load-bearing). An outbound reply is itself an `#agent/message`
52
+ * note; if the trigger fired on it the session would wake on its own reply forever.
53
+ * The vault trigger predicate does EXACT tag membership, so it's keyed on the
54
+ * inbound child only — `tags: ["#agent/message/inbound"]` — which an outbound
55
+ * note (parent + `/outbound`) never carries, so a reply can't wake its own session.
56
+ * As belt-and-suspenders, `ingestInbound` also drops any note tagged
57
+ * `#agent/message/outbound` (or `direction: "outbound"`) — so even a mis-wired
58
+ * trigger can never wake us on our own reply.
59
+ */
60
+
61
+ import type {
62
+ Transport,
63
+ TransportContext,
64
+ ReplyArgs,
65
+ ThreadRecord,
66
+ CallbackMetadata,
67
+ InboundAttachment,
68
+ } from "../transport.ts";
69
+
70
+ /** The safe basename of a (possibly path-ful, possibly untrusted) string — the LAST
71
+ * path segment, with traversal markers stripped. Used to derive a display `filename`
72
+ * from an attachment `path`. The backend re-sanitizes before staging; this is just a
73
+ * reasonable default for the surfaced hint. */
74
+ function basenameOf(p: string): string {
75
+ // Split on both slash flavors, take the last non-empty segment, drop `..`.
76
+ const parts = p.split(/[/\\]+/).filter((s) => s.length > 0 && s !== "..");
77
+ return parts.length > 0 ? parts[parts.length - 1]! : "";
78
+ }
79
+
80
+ /** Config for a vault transport instance (from the channel registry entry). */
81
+ export interface VaultTransportConfig {
82
+ /** Vault name (the `<vault>` path segment in the REST URL). */
83
+ vault: string;
84
+ /** REST base origin. Default `http://127.0.0.1:1940`. */
85
+ vaultUrl?: string;
86
+ /** A `vault:<name>:write` hub JWT, presented as Bearer when writing replies. */
87
+ token: string;
88
+ /**
89
+ * Shared secret the inbound webhook must present (validated by the daemon),
90
+ * for the DEPRECATED `?secret=` back-compat path. OPTIONAL — a JWT-only channel
91
+ * (the frictionless-setup default, provisioned by the hub with NO shared
92
+ * secret) configures none, and the webhook handler authenticates it via the
93
+ * hub-JWT path instead. When absent, the `?secret=` fallback can never succeed
94
+ * for this channel (nothing to validate against → 401).
95
+ */
96
+ webhookSecret?: string;
97
+ /** Optional path prefix for written notes. Default `channel`. */
98
+ notePathPrefix?: string;
99
+ }
100
+
101
+ /** The note shape the daemon hands `ingestInbound` (a subset of the trigger payload). */
102
+ export interface InboundNote {
103
+ id: string;
104
+ content?: string;
105
+ /** The note's tags — carries `#agent/message/{inbound,outbound}` for loop avoidance. */
106
+ tags?: string[];
107
+ metadata?: Record<string, unknown>;
108
+ /**
109
+ * The note's attachments, if the trigger payload carried them inline (vault's
110
+ * `send: "json"` webhook includes `note.attachments` — each `{ id, path, mimeType, ... }`).
111
+ * A FAST-PATH: when present + non-empty, `ingestInbound` uses these directly and skips
112
+ * the REST attachment-list fetch. When absent, `ingestInbound` does NOT fetch (the
113
+ * daemon always forwards the inline list when the note has one — Phase 1).
114
+ */
115
+ attachments?: Array<{ id?: string; path?: string; mimeType?: string }>;
116
+ }
117
+
118
+ /**
119
+ * A scheduled-job note as read back from the vault (design
120
+ * `2026-06-17-runner-scheduled-agent-turns.md`). The runner's vault-native job
121
+ * store maps these to/from the `Job` type in `jobs.ts`. `content` is the message
122
+ * to inject; the schedule + bookkeeping live in `metadata` (all string-typed in
123
+ * the vault — `enabled` is "true"/"false"; `nextRunAt` is NEVER persisted, it's
124
+ * recomputed in memory by the runner). The note `id` (or path) addresses it for
125
+ * PATCH/DELETE.
126
+ */
127
+ export interface JobNote {
128
+ /**
129
+ * The operator-facing job id — the SLUG the operator typed (carried in
130
+ * `metadata.jobId`). This is what the UI displays, what addresses the job in the
131
+ * `/api/jobs/:id` routes, and what stamps `runner:<jobId>` provenance. Falls back
132
+ * to `noteId` for a legacy note written without the metadata field.
133
+ */
134
+ id: string;
135
+ /** The vault note id/path — addresses the note for PATCH / DELETE I/O. */
136
+ noteId: string;
137
+ /** The message text to inject as the inbound note when this job fires. */
138
+ message: string;
139
+ /** Target channel (routes the job to its vault transport). */
140
+ channel: string;
141
+ /** 5-field cron expression. */
142
+ cron: string;
143
+ /** IANA timezone, if set. */
144
+ tz?: string;
145
+ /** Whether the runner considers this job. */
146
+ enabled: boolean;
147
+ /** ISO timestamp the job was created. */
148
+ createdAt?: string;
149
+ /** ISO timestamp of the most recent fire. */
150
+ lastRunAt?: string;
151
+ /** "ok" / "error: …" from the most recent fire. */
152
+ lastStatus?: string;
153
+ }
154
+
155
+ /** The metadata payload written for a job note (all string-typed, per the vault). */
156
+ export interface JobNoteMetadata {
157
+ /** The operator-facing slug (so the displayed id survives the vault's note-id assignment). */
158
+ jobId: string;
159
+ /** The routing key — written under `metadata.agent` only (the channel→agent CONTRACT). */
160
+ agent: string;
161
+ cron: string;
162
+ tz?: string;
163
+ /** "true" | "false" — the vault stores metadata as strings. */
164
+ enabled: string;
165
+ createdAt: string;
166
+ lastRunAt?: string;
167
+ lastStatus?: string;
168
+ }
169
+
170
+ /**
171
+ * One message in a channel transcript, as the built-in chat renders it. This is
172
+ * the transport-neutral shape `loadTranscript` produces from the vault notes; the
173
+ * daemon's `GET /api/channels/<ch>/messages` returns `{ messages: ChannelMessage[] }`.
174
+ *
175
+ * `direction` drives the chat's bubble placement: `inbound` (human → session) is
176
+ * "you" (right), `outbound` (session → human) is "them" (left) — mirroring the
177
+ * Telegram/vault transport meaning, NOT the chat's local point of view.
178
+ */
179
+ export interface ChannelMessage {
180
+ /** The vault note id — the chat dedups its poll by this. */
181
+ id: string;
182
+ /** The message body (the note content). */
183
+ text: string;
184
+ /** `inbound` = human→session ("you"); `outbound` = session→human ("them"). */
185
+ direction: "inbound" | "outbound";
186
+ /** Who authored it (metadata.sender), e.g. "operator" / "session" / "aaron". */
187
+ sender: string;
188
+ /** ISO timestamp (metadata.ts) — the transcript is sorted ascending by this. */
189
+ ts: string;
190
+ /** The inbound note id this reply threads to, when present (outbound only). */
191
+ inReplyTo?: string;
192
+ }
193
+
194
+ /**
195
+ * The claim status carried on an `#agent/message/inbound` note for a CHANNEL-backend
196
+ * agent (design 2026-06-18-channel-backend.md "Claim/ack durability"). The vault is
197
+ * the source of truth — the status lives on the note so a claim survives a daemon
198
+ * restart and a handled message is never re-presented.
199
+ *
200
+ * - `pending` — unhandled; waiting for a connected session to claim it.
201
+ * - `in-flight` — claimed by a session (`next-message`); `claimedAt` stamps when.
202
+ * Auto-released back to `pending` after a TTL (the daemon sweep) so a
203
+ * crashed session can't strand the queue.
204
+ * - `handled` — replied to; the outbound note is written. Never re-presented.
205
+ *
206
+ * NOTE: programmatic-backend inbound notes do NOT use this field — their turn runs
207
+ * synchronously in the serial worker; status is meaningful only on the channel path.
208
+ */
209
+ export type InboundStatus = "pending" | "in-flight" | "handled";
210
+
211
+ /**
212
+ * One inbound queue item for an ATTACHED-backend agent — an `#agent/message/inbound`
213
+ * note as the {@link AttachedQueueRegistry} reads it. Carries the claim `status` +
214
+ * `claimedAt` (for the TTL sweep) alongside the message text + threading id.
215
+ */
216
+ export interface InboundQueueNote {
217
+ /** The vault note id — addresses the note for the status PATCH + threads the reply. */
218
+ id: string;
219
+ /** The message text the connected session works on. */
220
+ text: string;
221
+ /** Who authored it (metadata.sender). */
222
+ sender: string;
223
+ /** ISO timestamp (metadata.ts) — the queue is ordered ascending by this (oldest first). */
224
+ ts: string;
225
+ /** The claim status (`pending` when the field is absent — a fresh inbound). */
226
+ status: InboundStatus;
227
+ /** ISO timestamp the note was claimed (set with `in-flight`); used by the TTL sweep. */
228
+ claimedAt?: string;
229
+ /**
230
+ * The note's vault `updated_at` (the last-seen revision). Threaded through so a
231
+ * claim can use it as the `if_updated_at` compare-and-swap precondition (agent#101):
232
+ * two concurrent `claimNext` reads see the SAME `updated_at`; the first claim PATCH
233
+ * advances it, so the second's precondition fails (vault 409) and it re-lists rather
234
+ * than double-claiming. Absent when the vault response omitted it.
235
+ */
236
+ updatedAt?: string;
237
+ }
238
+
239
+ const DEFAULT_VAULT_URL = "http://127.0.0.1:1940";
240
+ const DEFAULT_PATH_PREFIX = "channel";
241
+
242
+ /**
243
+ * Thrown by {@link VaultTransport.setInboundStatus} when a compare-and-swap claim
244
+ * (an `ifUpdatedAt` precondition) FAILED — the note changed since it was read, so
245
+ * another writer won the race (agent#101). The vault returns **409** (`error_type:
246
+ * "conflict"`) for a STALE `if_updated_at`, and **428** (`precondition_required`) when
247
+ * the precondition is absent; we treat both as "lost the claim race" so the caller
248
+ * (the channel queue's `claimNext`) re-lists and tries the next pending message rather
249
+ * than double-claiming. Distinct from a generic write error (any other non-ok status),
250
+ * which still throws a plain Error.
251
+ */
252
+ export class InboundClaimConflictError extends Error {
253
+ constructor(
254
+ readonly id: string,
255
+ readonly status: number,
256
+ ) {
257
+ super(`vault transport: inbound claim ${id} lost the CAS race (${status})`);
258
+ this.name = "InboundClaimConflictError";
259
+ }
260
+ }
261
+ /** Parent tag (NEW, namespaced) — carried LITERALLY on every note WE write; query
262
+ * this + metadata.channel to see BOTH directions of a channel (the slash children
263
+ * are namespace, not inheritance). */
264
+ const AGENT_MESSAGE_TAG = "#agent/message";
265
+ /** Inbound child (NEW) — the vault trigger fires on this exact tag (never matches outbound → no loop). */
266
+ const AGENT_MESSAGE_INBOUND_TAG = "#agent/message/inbound";
267
+ /** Outbound child (NEW) — replies carry this; the trigger's exact-match predicate excludes it. */
268
+ const AGENT_MESSAGE_OUTBOUND_TAG = "#agent/message/outbound";
269
+
270
+ /** Metadata key carrying the channel-queue claim status (design 2026-06-18). */
271
+ const STATUS_META_KEY = "status";
272
+ /** Metadata key carrying the ISO timestamp an inbound was claimed (for the TTL sweep). */
273
+ const CLAIMED_AT_META_KEY = "claimedAt";
274
+
275
+ /** The agent (routing) key carried on a vault note's metadata. Reads the canonical
276
+ * `agent` field, falling back to the legacy `channel` field as a read-only TOLERANCE
277
+ * for any in-flight note written by an older build during the live cutover. New writes
278
+ * carry `agent` only (the channel→agent CONTRACT dropped the `channel` dual-write). */
279
+ export function noteAgentKey(meta: Record<string, unknown> | undefined | null): string | undefined {
280
+ const a = meta?.agent;
281
+ if (typeof a === "string" && a) return a;
282
+ const c = meta?.channel;
283
+ return typeof c === "string" && c ? c : undefined;
284
+ }
285
+
286
+ /**
287
+ * Coerce a raw `status` metadata value to an {@link InboundStatus}. The vault stores
288
+ * metadata as strings; an absent / empty / unrecognized value reads as `pending` (the
289
+ * safe default — a fresh inbound the trigger just created carries no status, and an
290
+ * unknown value shouldn't strand the note). Only the two non-default states need an
291
+ * explicit value.
292
+ */
293
+ function coerceInboundStatus(v: unknown): InboundStatus {
294
+ if (v === "in-flight" || v === "handled") return v;
295
+ return "pending";
296
+ }
297
+
298
+ /**
299
+ * Coerce a raw metadata value (the vault stores metadata as STRINGS) to a finite number,
300
+ * defaulting to 0. Used to roll up a single-threaded thread's cumulative aggregates
301
+ * (`turn_count`, token/cost usage) read back from the prior note.
302
+ */
303
+ function numFromMeta(v: unknown): number {
304
+ const n = typeof v === "number" ? v : typeof v === "string" ? Number(v) : NaN;
305
+ return Number.isFinite(n) ? n : 0;
306
+ }
307
+
308
+ /**
309
+ * Build the `#agent/thread` note BODY — the rolling SUMMARY of the thread (design
310
+ * 2026-06-18: "hold a summary of this thread in the content; maybe another agent
311
+ * facilitates that"). The MODULE writes a useful default, STRUCTURED (`## Summary` /
312
+ * `## Latest turn`) so the `## Summary` section is the slot a future summarizer agent is
313
+ * EARMARKED to own/enrich; the `## Latest turn` block + the metadata roll-up are always
314
+ * module-owned. The same shape serves both modes (multi-threaded = one turn).
315
+ *
316
+ * v1 LIMITATION — the module OVERWRITES the `## Summary` section every turn. This function
317
+ * REGENERATES the whole body from scratch using only the rolled-up aggregates (passed in
318
+ * from `prior.metadata`); it NEVER reads `prior.content`. So a summarizer agent's
319
+ * enrichment of `## Summary` would be CLOBBERED on the next turn. Summarizer-agent
320
+ * enrichment needs a read-prior-content → merge path (preserve a summarizer-owned section
321
+ * across the regenerate), which is DEFERRED. Until then, "may own" means EARMARKED, not
322
+ * PRESERVED.
323
+ */
324
+ function buildThreadSummaryBody(t: {
325
+ name: string;
326
+ mode: string;
327
+ turnCount: number;
328
+ status: "ok" | "error" | "working";
329
+ lastTurnAt: string;
330
+ input: string;
331
+ output: string;
332
+ }): string {
333
+ const turns = t.turnCount === 1 ? "1 turn" : `${t.turnCount} turns`;
334
+ // `## Summary` is EARMARKED for a future summarizer agent — but v1 OVERWRITES it every
335
+ // turn (this body is fully regenerated from metadata; `prior.content` is never read), so
336
+ // it's a module-owned default for now, NOT a preserved slot (see the function doc).
337
+ // The `## Latest turn` block + the metadata roll-up are always module-owned.
338
+ //
339
+ // WORKING (the thread-as-container start-ensure, before the turn finishes): show the input
340
+ // and a clear "awaiting reply" state — NEVER print a fake reply. The thread is visible the
341
+ // moment processing starts; the end-record overwrites this body with the real ok/error
342
+ // reply once the turn completes.
343
+ if (t.status === "working") {
344
+ // No turn has COMPLETED yet, so don't print a (confusing) "0 turns" count — the prior
345
+ // completed count rides in the metadata for queries; the body just says it's working.
346
+ const priorTurns = t.turnCount === 0 ? "first turn" : `${turns} so far`;
347
+ const auto = `${t.mode} thread for ${t.name} — working on the ${t.turnCount === 0 ? "first turn" : "next turn"} (${priorTurns}, awaiting reply).`;
348
+ return (
349
+ `## Summary\n\n${auto}\n\n` +
350
+ `## Latest turn\n\n` +
351
+ `**Input:** ${t.input}\n\n` +
352
+ `**Status:** working — awaiting reply.\n`
353
+ );
354
+ }
355
+ const auto = `${t.mode} thread for ${t.name} — ${turns}, last ${t.status} at ${t.lastTurnAt}.`;
356
+ const turnHeading = t.status === "ok" ? "Reply" : "Error";
357
+ return (
358
+ `## Summary\n\n${auto}\n\n` +
359
+ `## Latest turn\n\n` +
360
+ `**Input:** ${t.input}\n\n` +
361
+ `**${turnHeading}:** ${t.output}\n`
362
+ );
363
+ }
364
+
365
+ /**
366
+ * The module-owned root namespace tag. Declared (with the three children rolling up
367
+ * to it via `parent_names`) so a human `tag:#agent` query expands to EVERYTHING the
368
+ * module owns — definitions, messages, jobs. The module itself never queries by this
369
+ * (it always queries the exact leaf tag); it exists for the nice human rollup, per
370
+ * the design's namespacing decision.
371
+ */
372
+ export const AGENT_ROOT_TAG = "#agent";
373
+
374
+ /**
375
+ * Agent-definition tag — a vault-native agent IS a `#agent/definition` note (design
376
+ * `2026-06-17-vault-native-agents.md`). The note BODY is the system prompt; the note
377
+ * METADATA is the config (name, backend, workspace, isolation, the def-vault binding).
378
+ * The module reads these notes from a def-vault and instantiates each as a live agent.
379
+ */
380
+ export const AGENT_DEFINITION_TAG = "#agent/definition";
381
+
382
+ /**
383
+ * Scheduled-job tag — the runner's vault-native job store (design
384
+ * `2026-06-17-runner-scheduled-agent-turns.md`). A job IS a vault note carrying
385
+ * this parent tag; queryable + durable + surface-renderable, exactly like
386
+ * `#agent/message`. Introduced in Phase 2 as the flat `#agent-job`; moved into the
387
+ * `#agent/*` namespace (`#agent/job`) by the vault-native-agents work (Phase 4a).
388
+ */
389
+ export const AGENT_JOB_TAG = "#agent/job";
390
+ /** Default path prefix under which job notes are written: `Channels/<ch>/jobs/<id>`. */
391
+ const JOB_PATH_PREFIX = "Channels";
392
+
393
+ /**
394
+ * Thread tag — the UNIFIED model: `definition -> thread -> message`. EVERYTHING is a
395
+ * thread; a `#agent/thread` note is the durable, queryable record of one conversation
396
+ * thread, written for BOTH execution-lifecycle modes (the structural unification —
397
+ * "a run was always a thread with one turn"). The note BODY is a rolling SUMMARY of the
398
+ * thread (a future summarizer agent may own/enrich the `## Summary` slot — module-owned
399
+ * in v1); metadata = `{ agent, definition, mode, status, started_at, last_turn_at,
400
+ * turn_count, usage }` (`agent` is the routing key — the channel→agent CONTRACT).
401
+ * The INDEXED string fields (`status`, `definition`, `mode`) make
402
+ * "all failed threads" / "all threads of agent X" / "all multi-threaded threads"
403
+ * operator-queryable. `definition` is a plain note-id string for now (interim — typed
404
+ * link fields are a future vault feature).
405
+ *
406
+ * The MODE difference is the thread's IDENTITY (path leaf) + whether it upserts:
407
+ * - `single-threaded` — exactly ONE thread note per channel, at the DETERMINISTIC stable
408
+ * path `Threads/<safeChannel>/<safeName>` ("named after the definition"), UPSERTED in
409
+ * place across turns (turn_count increments, usage accumulates).
410
+ * - `multi-threaded` — one thread note per fire, at `Threads/<safeChannel>/<uuid>` (today
411
+ * one fire = one thread = one note; turn_count = 1; usage = this turn's). No upsert.
412
+ *
413
+ * The note carries `['#agent/thread']` EXACTLY — NOT a message tag, NOT the inbound
414
+ * child — so it can never wake a session (no loop).
415
+ */
416
+ export const AGENT_THREAD_TAG = "#agent/thread";
417
+ /** Default path prefix under which thread notes are written: `Threads/<ch>/<leaf>`. */
418
+ const THREAD_PATH_PREFIX = "Threads";
419
+
420
+ /**
421
+ * The tag schema this module manages in any vault it's connected to.
422
+ *
423
+ * This is the declarative complement to the "tag both parent + child" fail-safe
424
+ * in `reply()` / inbound writes. A slash in a Parachute tag NAME is namespace-only
425
+ * — it carries NO query inheritance. Inheritance is the `parent_names` graph,
426
+ * declared via the vault's tag-schema API. We declare the full `#agent/*`
427
+ * namespace rollup (design `2026-06-17-vault-native-agents.md`):
428
+ * - `#agent/definition` → parent `#agent`
429
+ * - `#agent/message` → parent `#agent`
430
+ * - `#agent/message/inbound` → parent `#agent/message`
431
+ * - `#agent/message/outbound` → parent `#agent/message`
432
+ * - `#agent/job` → parent `#agent`
433
+ * so a human `tag:#agent` query rolls up to EVERYTHING the module owns, and
434
+ * `tag:#agent/message` rolls up to both directions — without the module's own
435
+ * exact-leaf queries depending on per-vault schema.
436
+ *
437
+ * The channel→agent rename CONTRACT dropped the prior `#agent-message*` (interim) and
438
+ * `#channel-message*` (legacy) schema entries — there's no surviving old-tagged data
439
+ * to keep their inheritance declared for.
440
+ *
441
+ * This matches the vault's "clients bring their own tag schema" principle: the
442
+ * WRITING module provisions its own tag schema at connect-time. It's MODULE-OWNED
443
+ * DATA (not inline calls) so it's the seam for a future module-protocol
444
+ * "tag schemas this module manages" declaration — changing this constant changes
445
+ * exactly what `ensureSchema()` provisions.
446
+ *
447
+ * `ensureSchema()` upserts each entry; the "tag both" floor in the note writes
448
+ * stays as the fail-safe so the channel works even if this declaration never lands.
449
+ */
450
+ export const AGENT_VAULT_TAG_SCHEMA: ReadonlyArray<{
451
+ name: string;
452
+ description?: string;
453
+ parent_names?: string[];
454
+ /**
455
+ * Indexed metadata field declarations (the vault's `update-tag` `fields` shape) —
456
+ * `{ <field>: { type, indexed } }`. Declared so the field gets a generated column +
457
+ * index, making it queryable via metadata operator objects. Used by `#agent/thread`
458
+ * (status/definition/mode) so an operator can query "all failed threads" / "all threads
459
+ * of agent X" / "all multi-threaded threads".
460
+ */
461
+ fields?: Record<string, { type: "string" | "boolean" | "integer"; indexed?: boolean }>;
462
+ }> = [
463
+ {
464
+ name: AGENT_ROOT_TAG,
465
+ description: "The agent module's namespace root — rolls up definitions, messages, and jobs.",
466
+ },
467
+ {
468
+ name: AGENT_DEFINITION_TAG,
469
+ parent_names: [AGENT_ROOT_TAG],
470
+ description: "A vault-native agent definition — body is the system prompt, metadata is the config.",
471
+ },
472
+ {
473
+ name: AGENT_MESSAGE_TAG,
474
+ parent_names: [AGENT_ROOT_TAG],
475
+ description: "A message in a Parachute channel (parent of /inbound + /outbound).",
476
+ // Declare the canonical `agent` routing key indexed so agent-keyed queries are
477
+ // indexed. (Transcript filtering itself stays client-side / index-free.)
478
+ fields: {
479
+ agent: { type: "string", indexed: true },
480
+ },
481
+ },
482
+ {
483
+ name: AGENT_MESSAGE_INBOUND_TAG,
484
+ parent_names: [AGENT_MESSAGE_TAG],
485
+ description: "Human→session message; the vault trigger fires on this.",
486
+ },
487
+ {
488
+ name: AGENT_MESSAGE_OUTBOUND_TAG,
489
+ parent_names: [AGENT_MESSAGE_TAG],
490
+ description: "Session→human reply.",
491
+ },
492
+ {
493
+ name: AGENT_JOB_TAG,
494
+ parent_names: [AGENT_ROOT_TAG],
495
+ description: "A scheduled job — the runner injects this note's message on its cron schedule.",
496
+ // Indexed query axes so an operator/agent can find jobs by target + state (mirrors
497
+ // the #agent/thread axes). All stored as strings (the vault stores metadata as
498
+ // strings; `enabled` is "true"/"false"):
499
+ // - agent → "all jobs targeting agent X"
500
+ // - enabled → "active jobs" (enabled:"true") vs paused ("false")
501
+ // - lastStatus → "jobs whose last run errored"
502
+ // The full field set is `JobNoteMetadata` in src/jobs.ts (design
503
+ // 2026-06-17-runner-scheduled-agent-turns); the schema is permissive, so the other
504
+ // job fields (jobId/cron/tz/createdAt/lastRunAt) ride as undeclared metadata.
505
+ fields: {
506
+ // The canonical `agent` routing key, indexed for "all jobs targeting agent X".
507
+ agent: { type: "string", indexed: true },
508
+ enabled: { type: "string", indexed: true },
509
+ lastStatus: { type: "string", indexed: true },
510
+ },
511
+ },
512
+ {
513
+ name: AGENT_THREAD_TAG,
514
+ parent_names: [AGENT_ROOT_TAG],
515
+ description:
516
+ "A thread record (definition -> thread -> message) — body is a rolling summary, metadata is the thread state. Written for BOTH modes.",
517
+ // The three indexed query axes carry over from the run record VERBATIM — an operator
518
+ // can query threads by outcome / agent / mode:
519
+ // - status → "all failed threads" (status:error)
520
+ // - definition → "all threads of agent X" (the def note id)
521
+ // - mode → "all multi-threaded threads"
522
+ fields: {
523
+ // The canonical `agent` routing key, indexed (mirrors #agent/message + #agent/job).
524
+ agent: { type: "string", indexed: true },
525
+ status: { type: "string", indexed: true },
526
+ definition: { type: "string", indexed: true },
527
+ mode: { type: "string", indexed: true },
528
+ },
529
+ },
530
+ ];
531
+
532
+ /**
533
+ * The vault trigger the hub registers to wake this channel on inbound notes.
534
+ *
535
+ * This is MODULE-OWNED DATA: the channel owns the shape of the trigger it needs,
536
+ * rather than the hub hardcoding it. The hub fetches this template (via
537
+ * `GET /.parachute/config` → `triggerTemplate`), substitutes the channel name
538
+ * into the placeholders, fills the webhook origin + the `action.auth.bearer`
539
+ * (an `agent:send` hub JWT, per the keystone vault PR's `action.auth.bearer`
540
+ * support), and registers it through the vault's runtime trigger-registration API.
541
+ *
542
+ * Placeholders the hub substitutes:
543
+ * - `<channel>` in `name` → the channel name (e.g. `channel_inbound_eng`);
544
+ * - `<hub-origin>` in `action.webhook` → the hub's public origin.
545
+ * The hub also injects `action.auth.bearer` (not in the template — it's a secret
546
+ * the hub mints).
547
+ *
548
+ * The predicate matches a NEW inbound note (`#agent/message/inbound`) that
549
+ * carries an `agent` metadata field (the routing key, post channel→agent CONTRACT)
550
+ * and hasn't been rendered yet. Loop avoidance is by the inbound CHILD tag: an
551
+ * outbound (reply) note carries `#agent/message/outbound`, never the inbound child,
552
+ * so it never fires this. (The trigger `name` and the `channel_inbound_rendered_at`
553
+ * marker are internal plumbing — kept STABLE so re-registration updates the existing
554
+ * trigger in place rather than orphaning one.)
555
+ */
556
+ export const AGENT_VAULT_TRIGGER_TEMPLATE = {
557
+ name: "channel_inbound_<channel>", // hub substitutes the channel name
558
+ events: ["created"],
559
+ when: {
560
+ tags: ["#agent/message/inbound"],
561
+ has_metadata: ["agent"],
562
+ missing_metadata: ["channel_inbound_rendered_at"],
563
+ },
564
+ action: {
565
+ webhook: "<hub-origin>/agent/api/vault/inbound", // hub fills origin + the auth.bearer
566
+ send: "json",
567
+ },
568
+ } as const;
569
+
570
+ /**
571
+ * The vault trigger that keeps vault-native agent DEFINITIONS in sync (design
572
+ * `2026-06-17-vault-native-agents.md`, Phase 4a). On a `#agent/definition` note
573
+ * created/updated/deleted, the hub POSTs the def-reload webhook; the daemon reloads
574
+ * that ONE agent (created/updated → re-instantiate; deleted → deregister). MODULE-
575
+ * OWNED DATA — the module declares the trigger it needs; the hub fills the origin +
576
+ * the `action.auth.bearer` (a minted `agent:send` token, the same auth as the inbound
577
+ * trigger). One trigger per def-vault (no per-note placeholder — the predicate is the
578
+ * whole `#agent/definition` tag). A poll fallback covers vaults without trigger support.
579
+ */
580
+ export const AGENT_DEF_VAULT_TRIGGER_TEMPLATE = {
581
+ name: "agent_def_reload",
582
+ events: ["created", "updated", "deleted"],
583
+ when: {
584
+ tags: ["#agent/definition"],
585
+ },
586
+ action: {
587
+ webhook: "<hub-origin>/agent/api/vault/agent-def", // hub fills origin + the auth.bearer
588
+ send: "json",
589
+ },
590
+ } as const;
591
+
592
+ export class VaultTransport implements Transport {
593
+ readonly kind = "vault";
594
+
595
+ private ctx: TransportContext | undefined;
596
+ private readonly vault: string;
597
+ private readonly vaultUrl: string;
598
+ private readonly token: string;
599
+ /**
600
+ * Shared secret the daemon validates on the inbound webhook (read by the
601
+ * daemon), for the DEPRECATED `?secret=` path only. Optional — absent on a
602
+ * JWT-only channel, in which case the `?secret=` fallback can never authorize
603
+ * this channel (the daemon treats an absent/empty configured secret as
604
+ * never-matching). The hub-JWT path doesn't read it at all.
605
+ */
606
+ readonly webhookSecret?: string;
607
+ private readonly pathPrefix: string;
608
+
609
+ constructor(config: VaultTransportConfig) {
610
+ if (!config.vault) {
611
+ throw new Error("VaultTransport: config.vault (vault name) is required");
612
+ }
613
+ if (!config.token) {
614
+ throw new Error("VaultTransport: config.token (vault:<name>:write JWT) is required");
615
+ }
616
+ // webhookSecret is OPTIONAL — a JWT-only channel (the frictionless-setup
617
+ // default) needs none. The webhook is authenticated via the hub-JWT path;
618
+ // the `?secret=` fallback simply can't succeed for a channel with no secret.
619
+ this.vault = config.vault;
620
+ this.vaultUrl = (config.vaultUrl ?? DEFAULT_VAULT_URL).replace(/\/$/, "");
621
+ this.token = config.token;
622
+ this.webhookSecret = config.webhookSecret;
623
+ this.pathPrefix = (config.notePathPrefix ?? DEFAULT_PATH_PREFIX).replace(/\/$/, "");
624
+ }
625
+
626
+ /**
627
+ * Stable identity of the backing vault (origin + name) — NOT the transport
628
+ * instance. Many channels each construct their OWN VaultTransport pointing at the
629
+ * SAME vault; callers that must query a vault once (e.g. the job-store's `listAll`)
630
+ * dedup by THIS key, not by object identity, or the same notes come back once per
631
+ * channel that shares the vault.
632
+ */
633
+ vaultKey(): string {
634
+ return `${this.vaultUrl}::${this.vault}`;
635
+ }
636
+
637
+ async start(ctx: TransportContext): Promise<void> {
638
+ this.ctx = ctx;
639
+ // Declare the tag schema this module manages in the connected vault. Strictly
640
+ // best-effort: `ensureSchema` swallows all of its own errors, so an unreachable
641
+ // vault or a failing PUT can NEVER block (or reject out of) `start()`. The
642
+ // "tag both parent + child" floor in the note writes is the fail-safe, so the
643
+ // channel works even if this declaration never lands. Fire-and-forget — no
644
+ // reason to delay the channel coming up on a schema upsert.
645
+ void this.ensureSchema();
646
+ }
647
+
648
+ // -------------------------------------------------------------------------
649
+ // Schema declaration — provision this module's tag inheritance at connect-time.
650
+ // -------------------------------------------------------------------------
651
+
652
+ /**
653
+ * Idempotently upsert `AGENT_VAULT_TAG_SCHEMA` into the connected vault via
654
+ * the vault's tag-schema REST API. The vault route is
655
+ * PUT /vault/<vault>/api/tags/:name
656
+ * where `:name` is matched by `subpath.match(/^\/([^/]+)$/)` then
657
+ * `decodeURIComponent`'d (parachute-vault `src/routes.ts` handleTags, the
658
+ * "Routes with tag name" block + `routing.ts` `apiPath.startsWith("/tags")`).
659
+ * Because the route matches a SINGLE path segment (`[^/]+`, no literal slash)
660
+ * and decodes it, the tag name — which contains BOTH `#` and `/`
661
+ * (`#agent/message/inbound`) — must be `encodeURIComponent`'d so the `#`
662
+ * becomes `%23` and the `/` becomes `%2F`; the route then decodes that back to
663
+ * the literal name. A bare `/` in the URL would fail the `[^/]+` match → 404,
664
+ * silently dropping the declaration. The PUT body is `{ description?, parent_names? }`.
665
+ *
666
+ * Best-effort + non-fatal by contract: every failure is caught and `console.warn`'d,
667
+ * never thrown — the tag-both write floor is the fallback.
668
+ */
669
+ async ensureSchema(): Promise<void> {
670
+ for (const entry of AGENT_VAULT_TAG_SCHEMA) {
671
+ try {
672
+ // Single-segment, percent-encoded name: `#agent/message/inbound` →
673
+ // `%23agent%2Fmessage%2Finbound`. The vault decodes it back to the literal.
674
+ const url = `${this.vaultUrl}/vault/${this.vault}/api/tags/${encodeURIComponent(entry.name)}`;
675
+ const body: {
676
+ description?: string;
677
+ parent_names?: string[];
678
+ fields?: Record<string, { type: "string" | "boolean" | "integer"; indexed?: boolean }>;
679
+ } = {};
680
+ if (entry.description !== undefined) body.description = entry.description;
681
+ if (entry.parent_names !== undefined) body.parent_names = entry.parent_names;
682
+ if (entry.fields !== undefined) body.fields = entry.fields;
683
+
684
+ const res = await fetch(url, {
685
+ method: "PUT",
686
+ headers: {
687
+ "content-type": "application/json",
688
+ authorization: `Bearer ${this.token}`,
689
+ },
690
+ body: JSON.stringify(body),
691
+ });
692
+ if (!res.ok) {
693
+ const detail = await res.text().catch(() => "");
694
+ console.warn(
695
+ `vault transport: tag-schema upsert for ${entry.name} failed (${res.status}) ${detail}`.trim(),
696
+ );
697
+ }
698
+ } catch (err) {
699
+ // Vault unreachable / fetch rejected — non-fatal, the tag-both floor covers us.
700
+ console.warn(
701
+ `vault transport: tag-schema upsert for ${entry.name} errored: ${(err as Error).message}`,
702
+ );
703
+ }
704
+ }
705
+ }
706
+
707
+ async stop(): Promise<void> {
708
+ // Nothing to release — inbound arrives via the daemon's webhook, not a poll.
709
+ }
710
+
711
+ /** The channel name this transport is bound to (after start). */
712
+ private get channel(): string {
713
+ if (!this.ctx) throw new Error("vault transport: not started");
714
+ return this.ctx.channel;
715
+ }
716
+
717
+ // -------------------------------------------------------------------------
718
+ // Outbound — the session → vault direction. Write an OUTBOUND note.
719
+ // -------------------------------------------------------------------------
720
+
721
+ async reply(args: ReplyArgs): Promise<{ sent: string[] }> {
722
+ const channel = this.channel;
723
+ const ts = new Date().toISOString();
724
+ const id = crypto.randomUUID();
725
+ // Sanitize the channel segment so an operator-configured name with a slash
726
+ // can't reshape the vault path hierarchy (the channel/prefix are operator
727
+ // config, not external input, but keep the path a flat, predictable slug).
728
+ const safeChannel = channel.replace(/[^a-zA-Z0-9_-]/g, "-");
729
+ const path = `${this.pathPrefix}/${safeChannel}/${id}`;
730
+
731
+ const metadata: Record<string, string> = {
732
+ // The routing key — written under `metadata.agent` ONLY (the channel→agent
733
+ // CONTRACT dropped the `channel` dual-write). `noteAgentKey` still reads
734
+ // `agent ?? channel` as a tolerance fallback for any in-flight straggler.
735
+ agent: channel,
736
+ // `direction` stays as a human/UI convenience field. The loop-avoidance
737
+ // source of truth is now the `#agent/message/outbound` TAG below — the
738
+ // trigger fires on the inbound child tag only, so this note never wakes us.
739
+ direction: "outbound",
740
+ sender: "session",
741
+ ts,
742
+ };
743
+ // Thread the reply to the inbound note id when the bridge passes it through.
744
+ const inReplyTo = args.meta?.in_reply_to;
745
+ if (inReplyTo) metadata.in_reply_to = inReplyTo;
746
+ // The explicit definition→thread→message link: stamp the outbound note with its thread
747
+ // id (the programmatic worker passes the per-turn thread id through `meta.thread`). For a
748
+ // multi-threaded turn this IS the per-fire `#agent/thread` note's leaf; for a
749
+ // single-threaded turn it's a per-turn correlation id. INBOUND-note stamping is deferred
750
+ // (those notes are written externally, before the turn knows its thread).
751
+ const threadId = args.meta?.thread;
752
+ if (threadId) metadata.thread = threadId;
753
+
754
+ const res = await fetch(`${this.vaultUrl}/vault/${this.vault}/api/notes`, {
755
+ method: "POST",
756
+ headers: {
757
+ "content-type": "application/json",
758
+ authorization: `Bearer ${this.token}`,
759
+ },
760
+ body: JSON.stringify({
761
+ content: args.text ?? "",
762
+ path,
763
+ // Parent (queryable membership) + directional child (trigger discriminator).
764
+ // Both literal — the slash child is NOT queryable under the parent on its own.
765
+ tags: [AGENT_MESSAGE_TAG, AGENT_MESSAGE_OUTBOUND_TAG],
766
+ metadata,
767
+ }),
768
+ });
769
+
770
+ if (!res.ok) {
771
+ const detail = await res.text().catch(() => "");
772
+ throw new Error(
773
+ `vault transport: write reply failed (${res.status}) ${detail}`.trim(),
774
+ );
775
+ }
776
+
777
+ // The vault returns the created note; surface its id. Fall back to the id we
778
+ // proposed in the path if the response shape is unexpected.
779
+ let noteId: string = id;
780
+ try {
781
+ const created = (await res.json()) as { id?: string; note?: { id?: string } };
782
+ noteId = created?.id ?? created?.note?.id ?? id;
783
+ } catch {
784
+ // Non-JSON / empty body — keep the proposed id.
785
+ }
786
+ return { sent: [noteId] };
787
+ }
788
+
789
+ /**
790
+ * The DETERMINISTIC path of a single-threaded agent's ONE thread note —
791
+ * `Threads/<safeChannel>/<safeName>` (named after the def). The single shared
792
+ * source of truth for that path so {@link writeThread} (the upsert) and
793
+ * {@link readThreadSession} (the pre-turn session read) can never disagree on
794
+ * where the note lives. Sanitizes both segments to a flat, predictable slug.
795
+ *
796
+ * COLLISION NOTE: two single-threaded agents whose names collapse to the SAME safeName
797
+ * on the same channel would share this note. Acceptable because the registry enforces
798
+ * ONE agent per channel (byChannel index), so the collision can't arise in practice.
799
+ */
800
+ private singleThreadedPath(channel: string, name: string): string {
801
+ const safeChannel = channel.replace(/[^a-zA-Z0-9_-]/g, "-");
802
+ const safeName = (name ?? channel).replace(/[^a-zA-Z0-9_-]/g, "-");
803
+ return `${THREAD_PATH_PREFIX}/${safeChannel}/${safeName}`;
804
+ }
805
+
806
+ /**
807
+ * Materialize a `#agent/thread` note for ONE completed turn — the UNIFIED model
808
+ * (`definition -> thread -> message`). Written for BOTH execution-lifecycle modes
809
+ * (the structural unification): EVERYTHING is a thread, a "run" was always a thread
810
+ * with one turn. The note BODY is a rolling SUMMARY of the thread; the metadata is the
811
+ * thread state. The INDEXED fields (`status`/`definition`/`mode`) make threads
812
+ * operator-queryable. This note carries `['#agent/thread']` EXACTLY — NOT a
813
+ * `#agent/message`, NO inbound child — so it can never wake a session (no loop).
814
+ *
815
+ * The MODE governs the thread's IDENTITY + whether it upserts:
816
+ * - `single-threaded` — ONE thread note per channel at the DETERMINISTIC stable path
817
+ * `Threads/<safeChannel>/<safeName>` (named after the definition). It UPSERTS in
818
+ * place across turns: we READ the existing note first, then write the rolled-up
819
+ * aggregates (`turn_count` incremented, cumulative `usage`, original `started_at`).
820
+ * - `multi-threaded` — one thread note PER FIRE at `Threads/<safeChannel>/<uuid>`
821
+ * (today one fire = one thread; turn_count = 1; usage = this turn's). NO upsert.
822
+ *
823
+ * SAFETY of the read-modify-write for single-threaded: the drain is SERIAL per channel
824
+ * AND single-threaded is one-thread-per-channel today, so there's no concurrent writer
825
+ * to lose an update against. WHEN CONTINUATION brings concurrent threads per channel,
826
+ * switch to re-deriving aggregates from the `#agent/message` children or a vault
827
+ * atomic-merge, to avoid lost-update.
828
+ *
829
+ * Best-effort caller-side: a throw is surfaced to the registry, which logs it (a missing
830
+ * thread note never re-runs the turn — same "don't retry" posture as outbound).
831
+ */
832
+ async writeThread(thread: ThreadRecord): Promise<{ sent: string[] }> {
833
+ const safeChannel = thread.channel.replace(/[^a-zA-Z0-9_-]/g, "-");
834
+ const singleThreaded = thread.mode === "single-threaded";
835
+
836
+ // IDENTITY by mode (HARD CONSTRAINT 3 — the path leaf IS the thread's identity; no
837
+ // ambiguous `thread_id` metadata field). single-threaded: a DETERMINISTIC leaf named
838
+ // after the def (the agent/spec name, sanitized) so the SAME note upserts across turns.
839
+ // multi-threaded: a fresh uuid per fire (one fire = one thread = one note today).
840
+ // COLLISION NOTE: two single-threaded agents whose names collapse to the SAME safeName
841
+ // on the same channel would upsert each other's thread note. Acceptable because the
842
+ // registry enforces ONE agent per channel (byChannel index), so the collision can't
843
+ // arise in practice.
844
+ // Multi-threaded leaf: a per-FIRE id. Reuse the caller's `threadId` when given (a
845
+ // re-record of the same turn — e.g. the outbound-failure status flip — targets the
846
+ // SAME per-fire note instead of minting a duplicate); else mint a fresh one. Single-
847
+ // threaded uses the DETERMINISTIC path (named after the def) so the one-per-channel
848
+ // note upserts — computed via {@link singleThreadedPath} so writeThread and
849
+ // readThreadSession agree on exactly where the note lives.
850
+ const path = singleThreaded
851
+ ? this.singleThreadedPath(thread.channel, thread.name ?? thread.channel)
852
+ : `${THREAD_PATH_PREFIX}/${safeChannel}/${thread.threadId ?? crypto.randomUUID()}`;
853
+
854
+ // For single-threaded UPSERT, read the existing thread note (by its deterministic
855
+ // path) to roll up the aggregates. SAFE because the drain is serial per channel and
856
+ // single-threaded is one-thread-per-channel today (see the method doc) — there's no
857
+ // concurrent writer to lose an update against.
858
+ // WHEN CONTINUATION brings concurrent threads per channel, switch to re-deriving
859
+ // aggregates from the #agent/message children or a vault atomic-merge, to avoid
860
+ // lost-update.
861
+ let priorTurnCount = 0;
862
+ let priorInputTokens = 0;
863
+ let priorOutputTokens = 0;
864
+ let priorCostUsd = 0;
865
+ let priorStartedAt: string | undefined;
866
+ let priorLastTurnAt: string | undefined;
867
+ let priorSession: string | undefined;
868
+ if (singleThreaded) {
869
+ const prior = await this.readThreadNote(path);
870
+ if (prior) {
871
+ priorTurnCount = numFromMeta(prior.metadata?.turn_count);
872
+ priorInputTokens = numFromMeta(prior.metadata?.input_tokens);
873
+ priorOutputTokens = numFromMeta(prior.metadata?.output_tokens);
874
+ priorCostUsd = numFromMeta(prior.metadata?.total_cost_usd);
875
+ if (typeof prior.metadata?.started_at === "string" && prior.metadata.started_at) {
876
+ priorStartedAt = prior.metadata.started_at;
877
+ }
878
+ if (typeof prior.metadata?.last_turn_at === "string" && prior.metadata.last_turn_at) {
879
+ priorLastTurnAt = prior.metadata.last_turn_at;
880
+ }
881
+ // The persisted Claude session UUID — captured so a write that carries NO
882
+ // session (a start-phase working-ensure) PRESERVES it across the upsert rather
883
+ // than dropping continuity (the thread≡session record).
884
+ if (typeof prior.metadata?.session === "string" && prior.metadata.session) {
885
+ priorSession = prior.metadata.session;
886
+ }
887
+ }
888
+ }
889
+
890
+ // ── THREAD-AS-CONTAINER turn_count discipline (the no-double-count invariant) ─────────
891
+ // `phase: "start"` is the WORKING-ENSURE written BEFORE the turn — NO turn has completed
892
+ // yet, so it must NOT advance turn_count: single-threaded writes `turn_count = prior`
893
+ // (UNCHANGED), multi-threaded writes 0 (the per-fire note is being created mid-turn).
894
+ // `phase: "end"` (or absent — back-compat) is the FINAL record AFTER the turn, which is
895
+ // where the turn is COUNTED: single-threaded increments `prior + 1` (UNLESS `sameTurn`,
896
+ // the ok→error outbound-failure re-record, which keeps the already-counted value), and
897
+ // multi-threaded is 1 (one fire = one thread = one turn). So across the start+end pair a
898
+ // turn is counted EXACTLY ONCE (on `end`) — never double-counted.
899
+ const isStart = thread.phase === "start";
900
+ let turnCount: number;
901
+ if (isStart) {
902
+ turnCount = singleThreaded ? priorTurnCount : 0;
903
+ } else if (singleThreaded) {
904
+ turnCount = thread.sameTurn ? priorTurnCount : priorTurnCount + 1;
905
+ } else {
906
+ turnCount = 1;
907
+ }
908
+ // `started_at` is set ONCE on create (preserve the prior on upsert). `last_turn_at`
909
+ // advances only when a turn COMPLETES (the `end` write); a `start` working-ensure leaves
910
+ // it at the prior value (single) or empty (multi-create — no turn has completed yet).
911
+ const startedAt = priorStartedAt ?? thread.started_at;
912
+ const lastTurnAt = isStart ? (priorLastTurnAt ?? "") : thread.ended_at;
913
+
914
+ // Cumulative usage: single-threaded SUMS this turn into the prior totals; multi-threaded
915
+ // carries just this turn's (one fire = one thread).
916
+ const inputTokens =
917
+ (singleThreaded ? priorInputTokens : 0) + (thread.usage?.inputTokens ?? 0);
918
+ const outputTokens =
919
+ (singleThreaded ? priorOutputTokens : 0) + (thread.usage?.outputTokens ?? 0);
920
+ const costUsd = (singleThreaded ? priorCostUsd : 0) + (thread.usage?.totalCostUsd ?? 0);
921
+
922
+ // Indexed string fields (queryable) + the thread-state observability fields. The
923
+ // vault stores metadata as strings; numbers are stringified.
924
+ const metadata: Record<string, string> = {
925
+ // The routing key — `metadata.agent` ONLY (the channel→agent CONTRACT).
926
+ agent: thread.channel,
927
+ mode: thread.mode,
928
+ status: thread.status,
929
+ started_at: startedAt,
930
+ turn_count: String(turnCount),
931
+ };
932
+ // `last_turn_at` is only meaningful once a turn has COMPLETED. A `start` working-ensure on
933
+ // a brand-new thread (no prior turn) has no last-turn time yet → omit it rather than
934
+ // stamp an empty string (which would index as a present-but-blank value).
935
+ if (lastTurnAt) metadata.last_turn_at = lastTurnAt;
936
+ if (thread.definition) metadata.definition = thread.definition;
937
+ // The thread≡session record: persist the Claude session UUID onto the note so the
938
+ // NEXT turn can `--resume` it. Prefer the session this write carries; else (a write
939
+ // with no session, e.g. a start-phase working-ensure) PRESERVE the prior single-
940
+ // threaded note's session so an upsert never drops continuity. Multi-threaded carries
941
+ // its own per-fire session each write (no preserve — each fire is a fresh thread).
942
+ const session = thread.session ?? (singleThreaded ? priorSession : undefined);
943
+ if (session) metadata.session = session;
944
+ // Usage is always present once a turn carried it OR we accumulated any — emit the
945
+ // running totals so a query sees cumulative cost for the thread.
946
+ if (singleThreaded || thread.usage) {
947
+ if (inputTokens) metadata.input_tokens = String(inputTokens);
948
+ if (outputTokens) metadata.output_tokens = String(outputTokens);
949
+ // Round the accumulated cost to 9 decimals before serializing — summing floats
950
+ // (e.g. 0.1 + 0.2) accrues IEEE-754 drift, so a naive String() yields
951
+ // "0.30000000000000004". 9 decimals covers sub-cent costs without losing precision.
952
+ if (costUsd) metadata.total_cost_usd = String(Math.round(costUsd * 1e9) / 1e9);
953
+ }
954
+
955
+ const body = buildThreadSummaryBody({
956
+ name: thread.name ?? thread.channel,
957
+ mode: thread.mode,
958
+ turnCount,
959
+ status: thread.status,
960
+ lastTurnAt,
961
+ input: thread.input,
962
+ output: thread.output,
963
+ });
964
+
965
+ // Upsert by path via PATCH + `if_missing: "create"` (vault#309) — NOT POST. POST
966
+ // /api/notes 409s `path_conflict` on an existing path (it does not upsert), so a
967
+ // single-threaded thread note would create on turn 1 and 409 on every turn after.
968
+ // PATCH-by-path is the real upsert: the vault resolves the (decoded) path, UPDATES it
969
+ // when present (single-threaded turn 2+: content replaced, metadata merged) or CREATES
970
+ // it when missing (turn 1, and every multi-threaded fresh-uuid fire). `force: true`
971
+ // satisfies the vault's 428 mutation precondition (mirrors `setInboundStatus`). The
972
+ // path is one URL segment (percent-encoded `/`); the route `decodeURIComponent`s it.
973
+ // The `tags` array is consumed ONLY by the create branch. VERIFIED against the vault
974
+ // (`routes.ts`): the PATCH UPDATE branch reads `tags.add` / `tags.remove` (the delta
975
+ // shape), NOT a plain `tags` array — so sending `tags: [AGENT_THREAD_TAG]` here is
976
+ // INERT on update (the note's existing tag is preserved untouched) and only takes
977
+ // effect on the if_missing:create branch. So the single tag is set once at create and
978
+ // preserved across every subsequent upsert (HARD CONSTRAINT 4 — loop-safe single tag).
979
+ const url = `${this.vaultUrl}/vault/${this.vault}/api/notes/${encodeURIComponent(path)}`;
980
+ const res = await fetch(url, {
981
+ method: "PATCH",
982
+ headers: {
983
+ "content-type": "application/json",
984
+ authorization: `Bearer ${this.token}`,
985
+ },
986
+ body: JSON.stringify({
987
+ content: body,
988
+ path,
989
+ tags: [AGENT_THREAD_TAG],
990
+ metadata,
991
+ if_missing: "create",
992
+ force: true,
993
+ }),
994
+ });
995
+
996
+ if (!res.ok) {
997
+ const detail = await res.text().catch(() => "");
998
+ throw new Error(`vault transport: write thread note failed (${res.status}) ${detail}`.trim());
999
+ }
1000
+
1001
+ let noteId: string = path;
1002
+ try {
1003
+ const created = (await res.json()) as { id?: string; note?: { id?: string } };
1004
+ noteId = created?.id ?? created?.note?.id ?? path;
1005
+ } catch {
1006
+ // Non-JSON / empty body — keep the path as the addressable id.
1007
+ }
1008
+ return { sent: [noteId] };
1009
+ }
1010
+
1011
+ /**
1012
+ * Read a single thread note by its deterministic PATH (the single-threaded upsert
1013
+ * read-back). The vault's `GET .../api/notes/<id-or-path>` resolves a note by id OR
1014
+ * path; we percent-encode the path's `/` so it's one URL segment. Returns the note
1015
+ * (metadata + content) or undefined when it doesn't exist yet (a 404 on the first
1016
+ * turn) or the vault is unreachable — the caller treats "no prior" as turn_count 0.
1017
+ * Throws on an UNEXPECTED non-ok response (not 404) so a misconfig surfaces.
1018
+ */
1019
+ private async readThreadNote(
1020
+ path: string,
1021
+ ): Promise<{ metadata?: Record<string, unknown>; content?: string } | undefined> {
1022
+ const url = `${this.vaultUrl}/vault/${this.vault}/api/notes/${encodeURIComponent(path)}`;
1023
+ let res: Response;
1024
+ try {
1025
+ res = await fetch(url, { headers: { authorization: `Bearer ${this.token}` } });
1026
+ } catch (err) {
1027
+ // Vault unreachable — treat as "no prior" (we'll create fresh; aggregates reset).
1028
+ // SURFACE it: a flaky vault silently resetting a thread's turn_count/usage is a
1029
+ // data-quality bug we want visible in logs. Still return undefined so the upsert
1030
+ // proceeds (don't strand the queue on a transient network blip).
1031
+ console.warn(
1032
+ `parachute-agent: readThreadNote network error — thread aggregates reset for ${path}: ${(err as Error).message}`,
1033
+ );
1034
+ return undefined;
1035
+ }
1036
+ if (res.status === 404) return undefined; // first turn — note doesn't exist yet.
1037
+ if (!res.ok) {
1038
+ const detail = await res.text().catch(() => "");
1039
+ throw new Error(`vault transport: read thread note failed (${res.status}) ${detail}`.trim());
1040
+ }
1041
+ try {
1042
+ const parsed = (await res.json()) as unknown;
1043
+ // Tolerate a bare note object OR a `{ note: {...} }` envelope OR a 1-element array.
1044
+ if (Array.isArray(parsed)) {
1045
+ return parsed[0] as { metadata?: Record<string, unknown>; content?: string } | undefined;
1046
+ }
1047
+ const obj = parsed as { note?: unknown; metadata?: unknown; content?: unknown };
1048
+ if (obj.note && typeof obj.note === "object") {
1049
+ return obj.note as { metadata?: Record<string, unknown>; content?: string };
1050
+ }
1051
+ return obj as { metadata?: Record<string, unknown>; content?: string };
1052
+ } catch {
1053
+ // Bad JSON — treat as no prior (don't strand the write on a parse hiccup).
1054
+ return undefined;
1055
+ }
1056
+ }
1057
+
1058
+ /** The persisted Claude session UUID for a single-threaded agent's deterministic
1059
+ * thread note, or undefined if none yet (first turn). Read before a turn so the
1060
+ * daemon can --resume it. */
1061
+ async readThreadSession(channel: string, name: string): Promise<string | undefined> {
1062
+ const prior = await this.readThreadNote(this.singleThreadedPath(channel, name));
1063
+ const s = prior?.metadata?.session;
1064
+ return typeof s === "string" && s ? s : undefined;
1065
+ }
1066
+
1067
+ /** Clear a single-threaded agent's persisted session so its next turn starts a
1068
+ * fresh Claude conversation (the per-agent restart). No-op if no thread note yet. */
1069
+ async clearThreadSession(channel: string, name: string): Promise<void> {
1070
+ const path = this.singleThreadedPath(channel, name);
1071
+ const url = `${this.vaultUrl}/vault/${this.vault}/api/notes/${encodeURIComponent(path)}`;
1072
+ const res = await fetch(url, {
1073
+ method: "PATCH",
1074
+ headers: { "content-type": "application/json", authorization: `Bearer ${this.token}` },
1075
+ body: JSON.stringify({ metadata: { session: "" }, force: true }),
1076
+ });
1077
+ if (res.status === 404) return; // no thread yet = already fresh
1078
+ if (!res.ok) {
1079
+ const detail = await res.text().catch(() => "");
1080
+ throw new Error(`vault transport: clear thread session failed (${res.status}) ${detail}`.trim());
1081
+ }
1082
+ }
1083
+
1084
+ // react / edit / download: vault has no reactions; v1 is reply-only. Omitted.
1085
+
1086
+ // -------------------------------------------------------------------------
1087
+ // Transcript — read the durable store the chat + Telegram + any vault surface
1088
+ // all share. The chat polls this; on send it writes an inbound note (below).
1089
+ // -------------------------------------------------------------------------
1090
+
1091
+ /**
1092
+ * Read this channel's whole transcript (both directions) from the vault and
1093
+ * map it to `ChannelMessage[]`, sorted ascending by `ts`.
1094
+ *
1095
+ * The query is the canonical "list a channel's transcript" shape from the
1096
+ * tagging model: the parent message tag (carried literally on every note) + a
1097
+ * routing-key filter (`noteAgentKey(meta) == <this channel>`). Because the parent
1098
+ * is on every note, this returns BOTH inbound and outbound — the slash children are
1099
+ * namespace, not query inheritance, so we never key off them here.
1100
+ *
1101
+ * GET <vaultUrl>/vault/<vault>/api/notes
1102
+ * ?tag=%23agent%2Fmessage (the `#` + `/` MUST be percent-encoded)
1103
+ * &include_content=true (we need the bodies)
1104
+ * &limit=<n> (default 200)
1105
+ *
1106
+ * The vault returns a bare JSON array of note objects ({id, content, tags,
1107
+ * metadata, ...}). Direction comes from `metadata.direction`, falling back to
1108
+ * the inbound/outbound CHILD tag if the metadata field is missing. On a non-ok
1109
+ * vault response we throw with a clear message — the daemon route maps it to an
1110
+ * error the chat surfaces (no silent empty transcript).
1111
+ */
1112
+ async loadTranscript(opts?: { limit?: number }): Promise<ChannelMessage[]> {
1113
+ const channel = this.channel;
1114
+ const limit = opts?.limit ?? 200;
1115
+ // Query by the parent TAG only and filter to this channel CLIENT-SIDE. We do
1116
+ // NOT use the `?metadata={channel:{eq:...}}` operator filter: an operator
1117
+ // query on `channel` requires that field to be declared `indexed: true` in the
1118
+ // vault's tag schema, which we can't assume (the vault returns HTTP 400
1119
+ // FIELD_NOT_INDEXED otherwise). Tagging-both + client-side filter is the
1120
+ // module's index-free floor (same philosophy as the tag-both write) — it works
1121
+ // on any vault with no per-vault schema setup. (Declaring the channel field
1122
+ // indexed is a future scale optimization, not a requirement.)
1123
+ //
1124
+ // Because the tag query returns notes across ALL channels, OVERFETCH so this
1125
+ // channel's recent history isn't crowded out by other channels' interleaved
1126
+ // notes, then keep the most recent `limit` for this channel below.
1127
+ const fetchLimit = Math.min(Math.max(limit * 4, 500), 2000);
1128
+
1129
+ type RawNote = {
1130
+ id?: string;
1131
+ content?: string;
1132
+ tags?: string[];
1133
+ metadata?: Record<string, unknown>;
1134
+ };
1135
+
1136
+ // Fetch one parent tag's notes; throws with a clear message on a non-ok vault
1137
+ // response or bad JSON (the daemon maps it to a surfaced error — no silent
1138
+ // empty transcript).
1139
+ const fetchByTag = async (tag: string): Promise<RawNote[]> => {
1140
+ const params = new URLSearchParams();
1141
+ params.set("tag", tag); // URLSearchParams encodes `#` → `%23`
1142
+ params.set("include_content", "true");
1143
+ params.set("limit", String(fetchLimit));
1144
+ const url = `${this.vaultUrl}/vault/${this.vault}/api/notes?${params.toString()}`;
1145
+ const res = await fetch(url, { headers: { authorization: `Bearer ${this.token}` } });
1146
+ if (!res.ok) {
1147
+ const detail = await res.text().catch(() => "");
1148
+ throw new Error(
1149
+ `vault transport: load transcript failed (${res.status}) ${detail}`.trim(),
1150
+ );
1151
+ }
1152
+ try {
1153
+ const parsed = (await res.json()) as unknown;
1154
+ // The structured-query route returns a bare array; tolerate a `{notes:[]}`
1155
+ // envelope too in case a future shape wraps it.
1156
+ return Array.isArray(parsed)
1157
+ ? (parsed as RawNote[])
1158
+ : ((parsed as { notes?: RawNote[] })?.notes ?? []);
1159
+ } catch (err) {
1160
+ throw new Error(
1161
+ `vault transport: load transcript — bad JSON from vault: ${(err as Error).message}`,
1162
+ );
1163
+ }
1164
+ };
1165
+
1166
+ // Query the single `#agent/message` parent tag (the channel→agent CONTRACT
1167
+ // dropped the legacy `#channel-message` / interim `#agent-message` union).
1168
+ const notes = await fetchByTag(AGENT_MESSAGE_TAG);
1169
+
1170
+ const messages: ChannelMessage[] = [];
1171
+ for (const note of notes) {
1172
+ if (typeof note.id !== "string" || !note.id) continue;
1173
+ const meta = note.metadata ?? {};
1174
+ // Client-side routing-key filter (see the index-free note above): keep only
1175
+ // notes whose routing key matches this channel (`noteAgentKey` reads `agent`).
1176
+ if (noteAgentKey(meta) !== channel) continue;
1177
+ const tags = note.tags ?? [];
1178
+ // Direction: prefer the explicit metadata field; fall back to the outbound child tag.
1179
+ let direction: "inbound" | "outbound";
1180
+ if (meta.direction === "inbound" || meta.direction === "outbound") {
1181
+ direction = meta.direction;
1182
+ } else if (tags.includes(AGENT_MESSAGE_OUTBOUND_TAG)) {
1183
+ direction = "outbound";
1184
+ } else {
1185
+ // Default to inbound (a human message) when neither signal is present —
1186
+ // it renders as "you", the safe default for an unlabeled note.
1187
+ direction = "inbound";
1188
+ }
1189
+ const msg: ChannelMessage = {
1190
+ id: note.id,
1191
+ text: typeof note.content === "string" ? note.content : "",
1192
+ direction,
1193
+ sender: typeof meta.sender === "string" ? meta.sender : "",
1194
+ ts: typeof meta.ts === "string" ? meta.ts : "",
1195
+ };
1196
+ if (typeof meta.in_reply_to === "string") msg.inReplyTo = meta.in_reply_to;
1197
+ messages.push(msg);
1198
+ }
1199
+ // Ascending by ts; notes with no ts sort first (stable, deterministic).
1200
+ messages.sort((a, b) => (a.ts < b.ts ? -1 : a.ts > b.ts ? 1 : 0));
1201
+ // Keep the most recent `limit` for this channel (we overfetched the tag).
1202
+ return messages.length > limit ? messages.slice(messages.length - limit) : messages;
1203
+ }
1204
+
1205
+ /**
1206
+ * Write a human→session INBOUND note — the chat's "send". This mirrors
1207
+ * `reply()` exactly except the tags + direction: the inbound CHILD tag
1208
+ * (`#agent/message/inbound`) is what the vault trigger fires on, so writing
1209
+ * this note WAKES the subscribed session via the existing vault trigger. We do
1210
+ * NOT also `ctx.emit` — that would double-wake (one wake from the trigger, one
1211
+ * from here). The trigger is the single wake path; this is purely the write.
1212
+ *
1213
+ * Returns the created note id so the chat can dedup its optimistic local echo
1214
+ * against the same id when the note round-trips through the next poll.
1215
+ */
1216
+ async writeInbound(
1217
+ text: string,
1218
+ sender?: string,
1219
+ /**
1220
+ * Extra metadata to STAMP onto the inbound note (e.g. the agent-to-agent callback
1221
+ * contract). Merged AFTER the base fields but BEFORE the non-overridable invariants
1222
+ * (`agent`/`direction` always win — an inbound note must route + be inbound). A caller
1223
+ * must NEVER pass `reply_to` here for a CALLBACK note (the terminal-callback loop guard);
1224
+ * see {@link writeCallback}.
1225
+ */
1226
+ extraMeta?: Record<string, string>,
1227
+ ): Promise<{ id: string }> {
1228
+ const channel = this.channel;
1229
+ const ts = new Date().toISOString();
1230
+ const id = crypto.randomUUID();
1231
+ const safeChannel = channel.replace(/[^a-zA-Z0-9_-]/g, "-");
1232
+ const path = `${this.pathPrefix}/${safeChannel}/${id}`;
1233
+
1234
+ const metadata: Record<string, string> = {
1235
+ // Caller-supplied extra fields first, so the invariants below cannot be clobbered.
1236
+ ...(extraMeta ?? {}),
1237
+ // The routing key under `metadata.agent` ONLY (the channel→agent CONTRACT
1238
+ // dropped the `channel` dual-write). This is the inbound path the vault trigger
1239
+ // fires on — the trigger keys on `has_metadata:["agent"]` to match it.
1240
+ agent: channel,
1241
+ direction: "inbound",
1242
+ sender: sender ?? "operator",
1243
+ ts,
1244
+ };
1245
+
1246
+ const res = await fetch(`${this.vaultUrl}/vault/${this.vault}/api/notes`, {
1247
+ method: "POST",
1248
+ headers: {
1249
+ "content-type": "application/json",
1250
+ authorization: `Bearer ${this.token}`,
1251
+ },
1252
+ body: JSON.stringify({
1253
+ content: text,
1254
+ path,
1255
+ // Parent (queryable membership) + inbound child (the trigger discriminator
1256
+ // that wakes the session). Both literal — the child alone is invisible to
1257
+ // a `tag:#agent/message` query.
1258
+ tags: [AGENT_MESSAGE_TAG, AGENT_MESSAGE_INBOUND_TAG],
1259
+ metadata,
1260
+ }),
1261
+ });
1262
+
1263
+ if (!res.ok) {
1264
+ const detail = await res.text().catch(() => "");
1265
+ throw new Error(
1266
+ `vault transport: write inbound failed (${res.status}) ${detail}`.trim(),
1267
+ );
1268
+ }
1269
+
1270
+ let noteId: string = id;
1271
+ try {
1272
+ const created = (await res.json()) as { id?: string; note?: { id?: string } };
1273
+ noteId = created?.id ?? created?.note?.id ?? id;
1274
+ } catch {
1275
+ // Non-JSON / empty body — keep the proposed id.
1276
+ }
1277
+ return { id: noteId };
1278
+ }
1279
+
1280
+ /**
1281
+ * Inject an inbound message AUTHORED BY THE RUNNER (a scheduled job firing) —
1282
+ * design `2026-06-17-runner-scheduled-agent-turns.md`. This is the runner's
1283
+ * ONLY seam into the transport: a scheduled job is "an automated human," so
1284
+ * firing it = writing an inbound note exactly like a human typing in chat. The
1285
+ * existing vault trigger → agent-turn → outbound flow does the rest; the runner
1286
+ * never touches the turn.
1287
+ *
1288
+ * Mechanically this is `writeInbound` with runner provenance: BOTH the parent
1289
+ * `#agent/message` (queryable) and the inbound child `#agent/message/inbound`
1290
+ * (the trigger discriminator that wakes the session), `direction: "inbound"`,
1291
+ * and `sender` defaulting to a `runner:<jobId>` marker so the transcript shows
1292
+ * who authored it. We deliberately do NOT stamp `channel_inbound_rendered_at`
1293
+ * (so the trigger fires), and we do NOT `ctx.emit` (the trigger is the single
1294
+ * wake path — emitting too would double-wake). Reuses the channel's existing
1295
+ * `vault:<name>:write` token — the runner mints nothing and adds no authority.
1296
+ *
1297
+ * Returns the created note id (for logging / the "run now" response). Kept a
1298
+ * thin wrapper over `writeInbound` so the inbound write path has ONE
1299
+ * implementation; only the default sender differs.
1300
+ */
1301
+ async injectInbound(opts: { content: string; sender?: string }): Promise<{ id: string }> {
1302
+ return this.writeInbound(opts.content, opts.sender ?? "runner");
1303
+ }
1304
+
1305
+ /**
1306
+ * Write an agent-to-agent CALLBACK as an INBOUND note on THIS channel — the "reply_to"
1307
+ * substrate. A recipient agent's drain, on turn completion, calls this on the SENDER's
1308
+ * channel transport (resolved by the daemon's buildWriteCallback) so the sender is woken
1309
+ * with a completion notification through the NORMAL inbound path: this writes a
1310
+ * `#agent/message/inbound` note (parent + inbound child tags), the vault trigger fires,
1311
+ * webhooks back, and the daemon routes it to the sender's agent — exactly like a human's
1312
+ * chat send. The callback `content` is a brief notification + link; the metadata is the
1313
+ * {@link CallbackMetadata} contract (`source_*` for the orchestrator to PULL the result).
1314
+ *
1315
+ * LOOP GUARD (structural): we stamp the callback metadata but NEVER a `reply_to` — a
1316
+ * callback is terminal, so handling it can't auto-trigger another callback. We defensively
1317
+ * STRIP any `reply_to` from the incoming meta to make that invariant impossible to violate
1318
+ * even if a caller mistakenly supplied one. `sender` is a `callback:<source_channel>`
1319
+ * marker so the transcript shows who/what authored it.
1320
+ *
1321
+ * Reuses {@link writeInbound} (the one inbound-write implementation), passing the callback
1322
+ * fields as its `extraMeta`. Returns the written note id.
1323
+ */
1324
+ async writeCallback(content: string, meta: CallbackMetadata): Promise<{ sent: string[] }> {
1325
+ // Defense-in-depth: never let a `reply_to` ride on a callback note (the terminal-callback
1326
+ // loop guard). The CallbackMetadata type has no reply_to, but we strip explicitly in case
1327
+ // a future caller widens the shape — a callback that carries reply_to would ping-pong.
1328
+ const { reply_to: _stripReplyTo, ...safe } = meta as CallbackMetadata & { reply_to?: string };
1329
+ void _stripReplyTo;
1330
+ const extraMeta: Record<string, string> = {
1331
+ callback: safe.callback,
1332
+ status: safe.status,
1333
+ source_channel: safe.source_channel,
1334
+ source_thread: safe.source_thread,
1335
+ delegation_depth: safe.delegation_depth,
1336
+ ...(safe.source_message ? { source_message: safe.source_message } : {}),
1337
+ ...(safe.correlation_id ? { correlation_id: safe.correlation_id } : {}),
1338
+ };
1339
+ const { id } = await this.writeInbound(content, `callback:${safe.source_channel}`, extraMeta);
1340
+ return { sent: [id] };
1341
+ }
1342
+
1343
+ // -------------------------------------------------------------------------
1344
+ // Channel-queue inbound notes — the durable queue a CHANNEL-backend agent's
1345
+ // connected session pulls from (design 2026-06-18-channel-backend.md). The
1346
+ // inbound `#agent/message/inbound` notes themselves ARE the queue; the claim
1347
+ // `status` (pending | in-flight | handled) lives on each note so the vault is
1348
+ // the source of truth (restart-safe). These methods own the vault I/O (URL +
1349
+ // token + encoding) so the AttachedQueueRegistry stays storage-agnostic — the
1350
+ // same separation jobs.ts has from the job-note I/O. The channel's existing
1351
+ // `vault:<name>:write` token covers GET + the status PATCH; no new mint.
1352
+ // -------------------------------------------------------------------------
1353
+
1354
+ /**
1355
+ * List THIS channel's INBOUND queue notes (the `#agent/message/inbound` notes),
1356
+ * ascending by `ts` (oldest first), carrying the claim `status`/`claimedAt`/`updatedAt`.
1357
+ * The query is index-free, mirroring {@link loadTranscript}: query by the inbound
1358
+ * CHILD tag (we want inbound only — outbound replies are not queue items) and
1359
+ * filter to this channel CLIENT-SIDE on `metadata.channel` (we don't assume a
1360
+ * `channel` index). A note with NO `status` field reads as `pending` (a fresh
1361
+ * inbound the trigger just created). Throws on a non-ok vault response so the
1362
+ * caller surfaces a clear error rather than a silently-empty queue.
1363
+ *
1364
+ * QUEUE-CAP TRUNCATION FIX (agent#103). Over time `handled` notes accumulate; the
1365
+ * tag query is capped (the vault limit), so once enough `handled` notes precede the
1366
+ * still-`pending` ones, the pending notes fall OUTSIDE the cap and are never claimed
1367
+ * (a silently-stuck queue). The vault's `status` metadata isn't indexed (we can't
1368
+ * assume a per-vault schema), so we can't filter `status:pending` server-side. So we
1369
+ * EXCLUDE `handled` notes CLIENT-SIDE — the live queue is only `pending` + `in-flight`
1370
+ * — and additionally REQUEST the cap descending (newest first) so when the raw note
1371
+ * count itself exceeds the cap, it's the OLDEST `handled` notes that get dropped, never
1372
+ * a recent `pending`. The two together keep the actionable queue (pending + in-flight)
1373
+ * intact regardless of how many `handled` notes have piled up. (Declaring `status`
1374
+ * indexed for a true server-side `status != handled` filter is a future scale
1375
+ * optimization, not a correctness requirement.)
1376
+ */
1377
+ async listInboundQueue(opts?: { limit?: number }): Promise<InboundQueueNote[]> {
1378
+ const channel = this.channel;
1379
+ const limit = opts?.limit ?? 200;
1380
+ // Overfetch (the tag query spans all channels) then keep this channel's items.
1381
+ const fetchLimit = Math.min(Math.max(limit * 4, 500), 2000);
1382
+ const params = new URLSearchParams();
1383
+ params.set("tag", AGENT_MESSAGE_INBOUND_TAG); // → %23agent%2Fmessage%2Finbound
1384
+ params.set("include_content", "true");
1385
+ params.set("limit", String(fetchLimit));
1386
+ // NEWEST-first at the vault (default order_by is `updated_at`) so a hard cap drops
1387
+ // the OLDEST notes (the long-settled `handled` ones), never a recent pending. We
1388
+ // re-sort ascending below for the queue. The vault param is `sort` (asc|desc).
1389
+ params.set("sort", "desc");
1390
+ const url = `${this.vaultUrl}/vault/${this.vault}/api/notes?${params.toString()}`;
1391
+ const res = await fetch(url, { headers: { authorization: `Bearer ${this.token}` } });
1392
+ if (!res.ok) {
1393
+ const detail = await res.text().catch(() => "");
1394
+ throw new Error(`vault transport: list inbound queue failed (${res.status}) ${detail}`.trim());
1395
+ }
1396
+ type RawNote = {
1397
+ id?: string;
1398
+ content?: string;
1399
+ metadata?: Record<string, unknown>;
1400
+ updated_at?: string;
1401
+ updatedAt?: string;
1402
+ };
1403
+ let notes: RawNote[];
1404
+ try {
1405
+ const parsed = (await res.json()) as unknown;
1406
+ notes = Array.isArray(parsed)
1407
+ ? (parsed as RawNote[])
1408
+ : ((parsed as { notes?: RawNote[] })?.notes ?? []);
1409
+ } catch (err) {
1410
+ throw new Error(
1411
+ `vault transport: list inbound queue — bad JSON from vault: ${(err as Error).message}`,
1412
+ );
1413
+ }
1414
+ const out: InboundQueueNote[] = [];
1415
+ for (const note of notes) {
1416
+ if (typeof note.id !== "string" || !note.id) continue;
1417
+ const meta = note.metadata ?? {};
1418
+ if (noteAgentKey(meta) !== channel) continue; // client-side filter (index-free); noteAgentKey reads `agent` (channel fallback for stragglers).
1419
+ const status = coerceInboundStatus(meta[STATUS_META_KEY]);
1420
+ // Drop `handled` notes — they are not queue items (#103). Only pending + in-flight
1421
+ // make up the actionable queue; counting/returning handled would let them crowd
1422
+ // the live queue out of the cap.
1423
+ if (status === "handled") continue;
1424
+ const updatedAt =
1425
+ typeof note.updated_at === "string"
1426
+ ? note.updated_at
1427
+ : typeof note.updatedAt === "string"
1428
+ ? note.updatedAt
1429
+ : undefined;
1430
+ out.push({
1431
+ id: note.id,
1432
+ text: typeof note.content === "string" ? note.content : "",
1433
+ sender: typeof meta.sender === "string" ? meta.sender : "",
1434
+ ts: typeof meta.ts === "string" ? meta.ts : "",
1435
+ status,
1436
+ ...(typeof meta[CLAIMED_AT_META_KEY] === "string"
1437
+ ? { claimedAt: meta[CLAIMED_AT_META_KEY] as string }
1438
+ : {}),
1439
+ ...(updatedAt ? { updatedAt } : {}),
1440
+ });
1441
+ }
1442
+ // Ascending by ts; blank-ts notes sort first (stable, deterministic).
1443
+ out.sort((a, b) => (a.ts < b.ts ? -1 : a.ts > b.ts ? 1 : 0));
1444
+ return out;
1445
+ }
1446
+
1447
+ /**
1448
+ * PATCH an inbound note's claim status (+ optionally `claimedAt`), by note id.
1449
+ * Sends ONLY the changed metadata; the vault MERGES it, so the channel/direction/
1450
+ * sender/ts are preserved. Passing `claimedAt: null` CLEARS the field (written as
1451
+ * an empty string) — used on release/handled so a stale claim timestamp doesn't
1452
+ * linger.
1453
+ *
1454
+ * COMPARE-AND-SWAP (agent#101). When `ifUpdatedAt` is given, the PATCH carries
1455
+ * `if_updated_at` (the note's last-seen `updated_at`) as the vault's optimistic-
1456
+ * concurrency precondition instead of `force: true` — so a CLAIM only lands if the
1457
+ * note hasn't changed since it was read. A STALE precondition (another session
1458
+ * already claimed it) makes the vault return **409** (`conflict`); an ABSENT one (if
1459
+ * the note carried no `updated_at` to send) would 428 — either way we throw
1460
+ * {@link InboundClaimConflictError} so the caller re-lists and skips to the next
1461
+ * pending message rather than double-claiming. When `ifUpdatedAt` is OMITTED (the
1462
+ * release/handled/sweep paths, which are last-write-wins by design) the PATCH uses
1463
+ * `force: true` as before. Any OTHER non-ok status throws a plain Error.
1464
+ */
1465
+ async setInboundStatus(
1466
+ id: string,
1467
+ status: InboundStatus,
1468
+ claimedAt?: string | null,
1469
+ ifUpdatedAt?: string,
1470
+ ): Promise<void> {
1471
+ const metadata: Record<string, string> = { [STATUS_META_KEY]: status };
1472
+ if (claimedAt !== undefined) {
1473
+ metadata[CLAIMED_AT_META_KEY] = claimedAt === null ? "" : claimedAt;
1474
+ }
1475
+ const url = `${this.vaultUrl}/vault/${this.vault}/api/notes/${encodeURIComponent(id)}`;
1476
+ // CAS when an `ifUpdatedAt` precondition is supplied; otherwise last-write-wins via
1477
+ // `force` (the prior behavior, kept for release/handled/sweep).
1478
+ const body =
1479
+ ifUpdatedAt !== undefined
1480
+ ? { metadata, if_updated_at: ifUpdatedAt }
1481
+ : { metadata, force: true };
1482
+ const res = await fetch(url, {
1483
+ method: "PATCH",
1484
+ headers: { "content-type": "application/json", authorization: `Bearer ${this.token}` },
1485
+ body: JSON.stringify(body),
1486
+ });
1487
+ if (!res.ok) {
1488
+ // 409 (stale precondition) / 428 (precondition required) on a CAS attempt = the
1489
+ // claim race was lost → a typed conflict the caller re-lists on.
1490
+ if (ifUpdatedAt !== undefined && (res.status === 409 || res.status === 428)) {
1491
+ throw new InboundClaimConflictError(id, res.status);
1492
+ }
1493
+ const detail = await res.text().catch(() => "");
1494
+ throw new Error(
1495
+ `vault transport: set inbound status ${id} failed (${res.status}) ${detail}`.trim(),
1496
+ );
1497
+ }
1498
+ }
1499
+
1500
+ // -------------------------------------------------------------------------
1501
+ // Scheduled-job notes — the runner's VAULT-NATIVE job store (design
1502
+ // 2026-06-17). A job IS a `#agent/job` note in THIS channel's vault. These
1503
+ // methods own the vault I/O (URL + token + encoding) so jobs.ts stays a thin,
1504
+ // storage-agnostic facade — token handling lives in ONE place (the transport),
1505
+ // mirroring loadTranscript / writeInbound. The channel's existing
1506
+ // `vault:<name>:write` token covers all of GET/POST/PATCH/DELETE — no new mint.
1507
+ // -------------------------------------------------------------------------
1508
+
1509
+ /**
1510
+ * List the scheduled-job notes in THIS channel's vault. Queries by the parent
1511
+ * `#agent/job` tag (URLSearchParams encodes `#`→`%23`, `/`→`%2F`) and returns ALL job
1512
+ * notes in the vault — the CALLER filters by `metadata.channel` (same index-free
1513
+ * pattern as loadTranscript; we don't assume a `channel` index exists). Throws
1514
+ * on a non-ok vault response so the API surfaces a clear error rather than a
1515
+ * silently-empty list.
1516
+ */
1517
+ async listJobNotes(opts?: { limit?: number }): Promise<JobNote[]> {
1518
+ const limit = opts?.limit ?? 500;
1519
+ const params = new URLSearchParams();
1520
+ params.set("tag", AGENT_JOB_TAG); // → %23agent%2Fjob
1521
+ params.set("include_content", "true");
1522
+ params.set("limit", String(limit));
1523
+ const url = `${this.vaultUrl}/vault/${this.vault}/api/notes?${params.toString()}`;
1524
+ const res = await fetch(url, { headers: { authorization: `Bearer ${this.token}` } });
1525
+ if (!res.ok) {
1526
+ const detail = await res.text().catch(() => "");
1527
+ throw new Error(`vault transport: list jobs failed (${res.status}) ${detail}`.trim());
1528
+ }
1529
+ let notes: Array<{ id?: string; content?: string; metadata?: Record<string, unknown> }>;
1530
+ try {
1531
+ const parsed = (await res.json()) as unknown;
1532
+ notes = Array.isArray(parsed)
1533
+ ? (parsed as typeof notes)
1534
+ : ((parsed as { notes?: typeof notes })?.notes ?? []);
1535
+ } catch (err) {
1536
+ throw new Error(`vault transport: list jobs — bad JSON from vault: ${(err as Error).message}`);
1537
+ }
1538
+ const jobs: JobNote[] = [];
1539
+ for (const note of notes) {
1540
+ if (typeof note.id !== "string" || !note.id) continue;
1541
+ const m = note.metadata ?? {};
1542
+ const channel = noteAgentKey(m) ?? ""; // routing key via noteAgentKey (`agent`, channel fallback for stragglers).
1543
+ const cron = typeof m.cron === "string" ? m.cron : "";
1544
+ if (!channel || !cron) continue; // not a well-formed job note; skip.
1545
+ // The operator-facing id is the slug in `metadata.jobId`; fall back to the
1546
+ // note id for a note written before that field existed.
1547
+ const slug = typeof m.jobId === "string" && m.jobId ? m.jobId : note.id;
1548
+ const job: JobNote = {
1549
+ id: slug,
1550
+ noteId: note.id,
1551
+ message: typeof note.content === "string" ? note.content : "",
1552
+ channel,
1553
+ cron,
1554
+ // The vault stores metadata as strings; "false" (and only "false") disables.
1555
+ enabled: String(m.enabled) !== "false",
1556
+ };
1557
+ if (typeof m.tz === "string" && m.tz) job.tz = m.tz;
1558
+ if (typeof m.createdAt === "string") job.createdAt = m.createdAt;
1559
+ if (typeof m.lastRunAt === "string") job.lastRunAt = m.lastRunAt;
1560
+ if (typeof m.lastStatus === "string") job.lastStatus = m.lastStatus;
1561
+ jobs.push(job);
1562
+ }
1563
+ return jobs;
1564
+ }
1565
+
1566
+ /**
1567
+ * Create OR replace a job note at a deterministic path (`Channels/<ch>/jobs/<id>`)
1568
+ * so an upsert by the same job id overwrites in place. The vault upserts by path
1569
+ * on POST. Returns the created/updated note id. `nextRunAt` is NEVER written
1570
+ * (recomputed in memory by the runner).
1571
+ */
1572
+ async upsertJobNote(job: {
1573
+ id: string;
1574
+ message: string;
1575
+ channel: string;
1576
+ cron: string;
1577
+ tz?: string;
1578
+ enabled: boolean;
1579
+ createdAt: string;
1580
+ lastRunAt?: string;
1581
+ lastStatus?: string;
1582
+ }): Promise<{ id: string }> {
1583
+ const safeId = job.id.replace(/[^a-zA-Z0-9_-]/g, "-");
1584
+ const safeChannel = job.channel.replace(/[^a-zA-Z0-9_-]/g, "-");
1585
+ const path = `${JOB_PATH_PREFIX}/${safeChannel}/jobs/${safeId}`;
1586
+ const metadata: JobNoteMetadata = {
1587
+ jobId: job.id, // the operator-facing slug, so it survives the vault's note-id assignment.
1588
+ // The routing key under `metadata.agent` ONLY (the channel→agent CONTRACT).
1589
+ agent: job.channel,
1590
+ cron: job.cron,
1591
+ enabled: job.enabled ? "true" : "false",
1592
+ createdAt: job.createdAt,
1593
+ };
1594
+ if (job.tz) metadata.tz = job.tz;
1595
+ if (job.lastRunAt) metadata.lastRunAt = job.lastRunAt;
1596
+ if (job.lastStatus) metadata.lastStatus = job.lastStatus;
1597
+
1598
+ const res = await fetch(`${this.vaultUrl}/vault/${this.vault}/api/notes`, {
1599
+ method: "POST",
1600
+ headers: { "content-type": "application/json", authorization: `Bearer ${this.token}` },
1601
+ body: JSON.stringify({ content: job.message, path, tags: [AGENT_JOB_TAG], metadata }),
1602
+ });
1603
+ if (!res.ok) {
1604
+ const detail = await res.text().catch(() => "");
1605
+ throw new Error(`vault transport: write job failed (${res.status}) ${detail}`.trim());
1606
+ }
1607
+ let noteId = path;
1608
+ try {
1609
+ const created = (await res.json()) as { id?: string; note?: { id?: string } };
1610
+ noteId = created?.id ?? created?.note?.id ?? path;
1611
+ } catch {
1612
+ // Non-JSON / empty body — keep the path as the addressable id.
1613
+ }
1614
+ return { id: noteId };
1615
+ }
1616
+
1617
+ /**
1618
+ * PATCH a job note's bookkeeping metadata (lastRunAt / lastStatus) after a fire,
1619
+ * by note id. We send ONLY the changed metadata fields; the vault merges them.
1620
+ * Best-effort on the runner's side (a failed status-write is logged, not fatal),
1621
+ * so this throws and the caller decides — the runner swallows it.
1622
+ */
1623
+ async patchJobNote(
1624
+ id: string,
1625
+ fields: { lastRunAt?: string; lastStatus?: string; enabled?: boolean },
1626
+ ): Promise<void> {
1627
+ const metadata: Record<string, string> = {};
1628
+ if (fields.lastRunAt !== undefined) metadata.lastRunAt = fields.lastRunAt;
1629
+ if (fields.lastStatus !== undefined) metadata.lastStatus = fields.lastStatus;
1630
+ if (fields.enabled !== undefined) metadata.enabled = fields.enabled ? "true" : "false";
1631
+ const res = await fetch(
1632
+ `${this.vaultUrl}/vault/${this.vault}/api/notes/${encodeURIComponent(id)}`,
1633
+ {
1634
+ method: "PATCH",
1635
+ headers: { "content-type": "application/json", authorization: `Bearer ${this.token}` },
1636
+ // `force: true` satisfies the vault's mutation precondition (428 without
1637
+ // `if_updated_at`/`force`). Safe: lastRunAt/lastStatus/enabled are the
1638
+ // runner's OWN bookkeeping fields, no content in the body, and the vault
1639
+ // MERGES metadata so the job's cron/message/etc. are preserved. (Without
1640
+ // this the runner's status-write silently 428'd.)
1641
+ body: JSON.stringify({ metadata, force: true }),
1642
+ },
1643
+ );
1644
+ if (!res.ok) {
1645
+ const detail = await res.text().catch(() => "");
1646
+ throw new Error(`vault transport: patch job failed (${res.status}) ${detail}`.trim());
1647
+ }
1648
+ }
1649
+
1650
+ /** Delete a job note by id. Throws on a non-ok vault response. */
1651
+ async deleteJobNote(id: string): Promise<void> {
1652
+ const res = await fetch(
1653
+ `${this.vaultUrl}/vault/${this.vault}/api/notes/${encodeURIComponent(id)}`,
1654
+ { method: "DELETE", headers: { authorization: `Bearer ${this.token}` } },
1655
+ );
1656
+ if (!res.ok) {
1657
+ const detail = await res.text().catch(() => "");
1658
+ throw new Error(`vault transport: delete job failed (${res.status}) ${detail}`.trim());
1659
+ }
1660
+ }
1661
+
1662
+ // -------------------------------------------------------------------------
1663
+ // Inbound — the daemon's webhook hands us a new inbound note to deliver.
1664
+ // -------------------------------------------------------------------------
1665
+
1666
+ /**
1667
+ * Fetch the attachment list for an inbound note from the vault REST API
1668
+ * (`GET <vaultUrl>/vault/<vault>/api/notes/<id>/attachments`, Bearer the channel's
1669
+ * existing vault token). Returns the surfaced {@link InboundAttachment} refs (one per
1670
+ * vault attachment that carries a usable `path`), or `[]` on ANY failure (best-effort —
1671
+ * a missing/unreachable attachment list must NEVER drop the inbound message; the turn
1672
+ * still runs with the text). The note id is percent-encoded as one path segment.
1673
+ *
1674
+ * Phase 1: the bytes are NOT fetched here — the programmatic backend stages them from
1675
+ * `<vaultUrl>/.../api/storage/<path>` into the agent's private workspace. This method
1676
+ * only surfaces the refs (path/mimeType/filename).
1677
+ */
1678
+ async fetchInboundAttachments(noteId: string): Promise<InboundAttachment[]> {
1679
+ const url = `${this.vaultUrl}/vault/${this.vault}/api/notes/${encodeURIComponent(noteId)}/attachments`;
1680
+ let res: Response;
1681
+ try {
1682
+ res = await fetch(url, { headers: { authorization: `Bearer ${this.token}` } });
1683
+ } catch (err) {
1684
+ console.warn(
1685
+ `parachute-agent: fetch attachments for inbound note ${noteId} errored (proceeding ` +
1686
+ `with text only): ${(err as Error).message}`,
1687
+ );
1688
+ return [];
1689
+ }
1690
+ if (!res.ok) {
1691
+ const detail = await res.text().catch(() => "");
1692
+ console.warn(
1693
+ `parachute-agent: fetch attachments for inbound note ${noteId} failed (${res.status}) ` +
1694
+ `${detail} — proceeding with text only`.trim(),
1695
+ );
1696
+ return [];
1697
+ }
1698
+ let raw: unknown;
1699
+ try {
1700
+ raw = await res.json();
1701
+ } catch (err) {
1702
+ console.warn(
1703
+ `parachute-agent: fetch attachments for inbound note ${noteId} — bad JSON (proceeding ` +
1704
+ `with text only): ${(err as Error).message}`,
1705
+ );
1706
+ return [];
1707
+ }
1708
+ // Tolerate a bare array OR an `{ attachments: [...] }` envelope.
1709
+ const list: Array<{ path?: unknown; mimeType?: unknown; mime_type?: unknown }> = Array.isArray(raw)
1710
+ ? (raw as typeof list)
1711
+ : (((raw as { attachments?: unknown }).attachments as typeof list | undefined) ?? []);
1712
+ const out: InboundAttachment[] = [];
1713
+ for (const a of list) {
1714
+ const path = typeof a.path === "string" ? a.path : "";
1715
+ if (!path) continue; // no storage path → nothing to fetch later; skip.
1716
+ const mimeType =
1717
+ typeof a.mimeType === "string"
1718
+ ? a.mimeType
1719
+ : typeof a.mime_type === "string"
1720
+ ? a.mime_type
1721
+ : "application/octet-stream";
1722
+ out.push({ path, mimeType, filename: basenameOf(path) });
1723
+ }
1724
+ return out;
1725
+ }
1726
+
1727
+ /**
1728
+ * Deliver an inbound `#agent/message/inbound` note onto this channel: emit it
1729
+ * so the subscribed bridge / MCP session wakes. Called by the daemon's
1730
+ * `/api/vault/inbound` webhook after it has resolved the channel.
1731
+ *
1732
+ * Belt-and-suspenders over the trigger predicate: a note tagged outbound
1733
+ * (`#agent/message/outbound`) OR explicitly `direction: "outbound"` is IGNORED —
1734
+ * we never wake on our own reply, even if a mis-wired trigger delivers one.
1735
+ *
1736
+ * ATTACHMENTS (Phase 1). When the note carries attachments inline (the vault
1737
+ * `send: "json"` trigger payload includes `note.attachments`), we fetch the
1738
+ * authoritative attachment list (REST) and surface the refs on the emitted
1739
+ * {@link InboundMessage.attachments} so the programmatic backend can stage the
1740
+ * bytes for the turn. The fetch is best-effort: a failure logs + the message is
1741
+ * still emitted with the text (never dropped). When the note has NO attachments
1742
+ * inline, NO fetch happens and emit is SYNCHRONOUS (today's behavior unchanged) —
1743
+ * the only async path is the attachments-present case.
1744
+ */
1745
+ async ingestInbound(note: InboundNote): Promise<void> {
1746
+ if (!this.ctx) throw new Error("vault transport: not started");
1747
+ const meta = note.metadata ?? {};
1748
+ const tags = note.tags ?? [];
1749
+ if (tags.includes(AGENT_MESSAGE_OUTBOUND_TAG) || meta.direction === "outbound") {
1750
+ return; // our own reply — never wake on it.
1751
+ }
1752
+ // Flatten the note's metadata into the inbound meta (string-valued), then
1753
+ // stamp our own provenance fields. `source`/`note_id`/`direction` are set
1754
+ // explicitly so they win over anything in the note's metadata.
1755
+ const flatMeta: Record<string, string> = {};
1756
+ for (const [k, v] of Object.entries(meta)) {
1757
+ flatMeta[k] = typeof v === "string" ? v : String(v);
1758
+ }
1759
+
1760
+ // Only reach for the attachment list when the inline payload signals there ARE
1761
+ // attachments — so the no-attachment path emits WITHOUT a network round-trip (and
1762
+ // stays synchronous-before-await, preserving the existing fire-and-forget callers).
1763
+ const hasInline =
1764
+ Array.isArray(note.attachments) &&
1765
+ note.attachments.some((a) => typeof a?.path === "string" && a.path.length > 0);
1766
+ let attachments: InboundAttachment[] = [];
1767
+ if (hasInline) {
1768
+ attachments = await this.fetchInboundAttachments(note.id);
1769
+ }
1770
+
1771
+ this.ctx.emit({
1772
+ // `channel` here is the in-memory InboundMessage.channel TS field (NOT serialized
1773
+ // note metadata) — left as the channel name. The routing key rides in `meta.agent`.
1774
+ channel: this.ctx.channel,
1775
+ content: note.content ?? "",
1776
+ meta: {
1777
+ ...flatMeta,
1778
+ // The routing key on the in-memory event meta under `agent` ONLY (the
1779
+ // channel→agent CONTRACT dropped the `channel` dual-write).
1780
+ agent: this.ctx.channel,
1781
+ source: "vault",
1782
+ note_id: note.id,
1783
+ sender: typeof meta.sender === "string" ? meta.sender : "",
1784
+ direction: "inbound",
1785
+ },
1786
+ source: "vault",
1787
+ ...(attachments.length > 0 ? { attachments } : {}),
1788
+ });
1789
+ }
1790
+ }