@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,1202 @@
1
+ /**
2
+ * The daemon-level PROGRAMMATIC-AGENT registry + per-channel serial queue — the
3
+ * wiring that makes the {@link ProgrammaticBackend} usable end-to-end (design
4
+ * 2026-06-16-pluggable-agent-backend.md, the wiring follow-up to PR #73).
5
+ *
6
+ * A programmatic agent has NO resident process (unlike the interactive tmux
7
+ * backend). It is just: a registered handle (workspace + spec + persisted
8
+ * session_id) and a per-channel serial worker. An inbound message for a registered
9
+ * channel is ENQUEUED here; the worker drains the queue ONE turn at a time, FIFO,
10
+ * running a single `claude -p --resume <sid>` turn per message and posting the
11
+ * reply back as an outbound `#agent/message/outbound` note.
12
+ *
13
+ * ── The serial-queue contract (HARD requirement — reviewer contract) ─────────────
14
+ * Each agent processes turns ONE AT A TIME, FIFO. There is NEVER two concurrent
15
+ * `claude -p` for the same channel/session — that would FORK the conversation (two
16
+ * turns resuming the same session_id, racing the session-id store). New inbound
17
+ * while a turn runs is queued; the worker drains in arrival order. This is enforced
18
+ * structurally: a single in-flight promise chain per agent (`#draining`), not a
19
+ * lock the caller must remember to take.
20
+ *
21
+ * ── Outbound (design step 5) ─────────────────────────────────────────────────────
22
+ * On a `deliver()` result that is `ok: true` AND `reply` is non-empty, the worker
23
+ * writes an outbound note via the injected {@link WriteOutbound} callback — which
24
+ * the daemon wires to the channel transport's `reply()` (the SAME vault-transport
25
+ * outbound path the interactive `reply` tool uses, so it's durable + shows in the
26
+ * chat UI). An EMPTY reply writes NO note (reviewer contract — `reply` can be `""`).
27
+ * On `ok: false` the error is logged and the turn is DROPPED (no infinite loop, no
28
+ * retry — a failed turn's session id is already persisted by the backend, so the
29
+ * next message resumes the conversation). The outbound write goes through `reply()`,
30
+ * which tags the note `#agent/message/outbound` — the vault inbound trigger keys on
31
+ * `#agent/message/inbound` only, so writing the reply CANNOT re-trigger the inbound
32
+ * webhook (no loop).
33
+ *
34
+ * ── Thread note (the UNIFIED model: definition -> thread -> message) ───────────────
35
+ * BOTH execution-lifecycle modes now MATERIALIZE a `#agent/thread` note (the structural
36
+ * unification — everything is a thread; a "run" was always a thread with one turn). It is
37
+ * the PRIMARY record of the turn, written BEFORE the additive outbound (the c34db03
38
+ * ordering, now uniform) so the record survives an outbound failure. The MODE governs the
39
+ * thread's identity (resolved transport-side): `single-threaded` upserts ONE thread note
40
+ * per channel (named after the def, rolling turn_count + cumulative usage),
41
+ * `multi-threaded` writes one thread note per fire. The thread note carries
42
+ * `['#agent/thread']` EXACTLY — never a message tag — so it can never wake a session.
43
+ */
44
+
45
+ import type { AgentSpec, AgentMode } from "../sandbox/types.ts";
46
+ import { normalizeChannel } from "../sandbox/types.ts";
47
+ import type { AgentBackend, AgentHandle, InterimTurnEvent, TurnSession } from "./types.ts";
48
+ import type { InboundAttachment } from "../transport.ts";
49
+
50
+ /**
51
+ * The streaming-view sink (design 2026-06-16 build item #1): the daemon wires this
52
+ * to push a turn's interim progress (assistant text chunks + tool_use) to the
53
+ * channel's live chat subscribers (the per-channel turn-event SSE). The registry's
54
+ * worker calls it per channel as the turn runs, plus a synthesized `done`/`error`
55
+ * lifecycle event so the live view can finalize cleanly even on an empty/failed
56
+ * turn. ADDITIVE: when omitted the worker behaves exactly as before (no live view).
57
+ */
58
+ export type TurnEventSink = (channel: string, event: TurnLifecycleEvent) => void;
59
+
60
+ /**
61
+ * A per-channel turn event the daemon fans out to the live chat. It's the backend's
62
+ * {@link InterimTurnEvent} (text / tool / init) PLUS two registry-synthesized
63
+ * lifecycle events that bracket every turn so the UI never gets stuck "working":
64
+ * - `done` — the turn finished; `reply` is the final outbound text (empty when the
65
+ * turn produced no text). The UI finalizes the live bubble.
66
+ * - `error` — the turn failed; `error` is the reason. The UI resolves the live view
67
+ * to an error state rather than leaving a hung spinner.
68
+ */
69
+ export type TurnLifecycleEvent =
70
+ | InterimTurnEvent
71
+ | { kind: "done"; reply: string }
72
+ | { kind: "error"; error: string };
73
+
74
+ /**
75
+ * Write an outbound reply for a channel — the seam the registry posts a turn's
76
+ * reply through. The daemon wires this to the channel transport's `reply()` (a
77
+ * VaultTransport writes a `#agent/message/outbound` note). `inReplyTo` threads the
78
+ * reply to the inbound note id when one is known.
79
+ *
80
+ * RETURN: optionally the written outbound note's id (`{ id }`) — the agent-to-agent
81
+ * callback uses it as the `source_message` an orchestrator pulls the full reply from.
82
+ * Returning `void` (or `{}`) is fine — the callback then just omits `source_message`
83
+ * (the sender still learns the turn finished; it has the `source_thread` to pull from).
84
+ * Kept BACK-COMPAT: every existing `async () => {}` recorder still satisfies this (`void`
85
+ * is a member of the union). A write failure is still surfaced as a throw (the registry's
86
+ * retry/record logic depends on the throw, not the return).
87
+ */
88
+ export type WriteOutbound = (
89
+ channel: string,
90
+ reply: string,
91
+ inReplyTo?: string,
92
+ /**
93
+ * The per-turn thread id this reply belongs to — the explicit definition→thread→message
94
+ * link the outbound note carries (stamped into `metadata.thread`). For multi-threaded it
95
+ * IS the per-fire thread note's leaf (an exact link); for single-threaded it's a per-turn
96
+ * correlation id (the note's stable deterministic leaf is the def name — single-threaded
97
+ * outbound→note linkage by the stable path is a follow-up). INBOUND-note stamping is
98
+ * deferred (those notes are externally written; see the PR notes).
99
+ */
100
+ threadId?: string,
101
+ ) => Promise<{ id?: string } | void>;
102
+
103
+ /**
104
+ * One turn's input to materializing a `#agent/thread` note (the UNIFIED model
105
+ * `definition -> thread -> message`) — the data the registry hands {@link WriteThread}.
106
+ * BOTH execution-lifecycle modes materialize a thread note (the structural unification:
107
+ * everything is a thread; a "run" was always a thread with one turn). Mirrors {@link
108
+ * ThreadRecord} in transport.ts; kept local here so the registry doesn't import the
109
+ * transport layer.
110
+ */
111
+ export interface ThreadNote {
112
+ channel: string;
113
+ /**
114
+ * The agent/def name — single-threaded's thread is "named after the definition" (the
115
+ * transport sanitizes it into the deterministic upsert path). Falls back to the channel.
116
+ */
117
+ name?: string;
118
+ /** The `#agent/definition` note id (provenance; plain id string). */
119
+ definition?: string;
120
+ /** The mode the turn ran under — governs thread identity + whether the note upserts. */
121
+ mode: AgentMode;
122
+ /**
123
+ * Outcome / lifecycle state after THIS write — `working` (the start-ensure, written
124
+ * BEFORE the turn: input shown, no reply yet), `ok` (success), or `error` (failed).
125
+ * `working` is only valid alongside `phase: "start"`.
126
+ */
127
+ status: "ok" | "error" | "working";
128
+ /** The inbound text handed to the turn (the `-p` prompt). */
129
+ input: string;
130
+ /** The reply on success, the failure reason on error, or "" while `working`. */
131
+ output: string;
132
+ /**
133
+ * The Claude session UUID for this turn — the transport persists it to the thread
134
+ * note's `metadata.session` (the thread≡session record), so the NEXT turn can
135
+ * `--resume` it. Set ONLY on the `end` record, and ONLY from the session claude
136
+ * actually ECHOED (`result.sessionId`, captured from the init/result event). A turn
137
+ * that never established a session (claude exited before creating one) persists NONE
138
+ * — and a single-threaded prior session is preserved by the transport — so the next
139
+ * turn resolves a fresh create and SELF-HEALS rather than `--resume`ing a phantom id
140
+ * (which would brick the channel: "No conversation found" is non-transient → no retry).
141
+ * NOT set on the `start`-ensure (it runs before claude, so no session exists yet).
142
+ */
143
+ session?: string;
144
+ /** ISO start/end of the turn (a start-ensure does not advance the thread's last_turn_at). */
145
+ started_at: string;
146
+ ended_at: string;
147
+ /** Optional token/cost usage for observability. */
148
+ usage?: { inputTokens?: number; outputTokens?: number; totalCostUsd?: number };
149
+ /**
150
+ * MULTI-threaded only: a stable per-TURN thread id (the per-fire note's leaf). The same
151
+ * id on a re-record (the outbound-failure status flip) reuses the SAME note instead of
152
+ * minting a duplicate. Single-threaded ignores it (deterministic name leaf).
153
+ */
154
+ threadId?: string;
155
+ /**
156
+ * Re-record of the SAME turn — single-threaded keeps `turn_count` (the turn was already
157
+ * counted by the first record); no effect on multi-threaded.
158
+ */
159
+ sameTurn?: boolean;
160
+ /**
161
+ * The lifecycle PHASE of this write (thread-as-container). `"start"` = the WORKING-ENSURE
162
+ * before the turn (status `working`, turn_count UNCHANGED — no turn completed yet);
163
+ * `"end"` (DEFAULT when absent) = the final record after the turn (turn_count increments
164
+ * on the `end` write). So a turn is counted EXACTLY ONCE — on `end`, never on `start`.
165
+ */
166
+ phase?: "start" | "end";
167
+ }
168
+
169
+ /**
170
+ * Materialize a `#agent/thread` note for a completed turn — the seam the registry posts a
171
+ * thread note through. The daemon wires this to the channel transport's `writeThread()` (a
172
+ * VaultTransport writes a `#agent/thread` note). Called for BOTH modes now (the structural
173
+ * unification — every turn materializes a thread note): single-threaded upserts one note
174
+ * per channel, multi-threaded writes one per fire. A write failure is the implementation's
175
+ * to surface (the registry logs whatever it throws); it never re-runs the turn. Optional on
176
+ * the registry — when unwired (no vault-backed channel), a turn still runs, just no note.
177
+ */
178
+ export type WriteThread = (thread: ThreadNote) => Promise<void>;
179
+
180
+ /**
181
+ * A callback delivered back to a SENDER's channel when a turn it requested finishes —
182
+ * the agent-to-agent request/response substrate ("reply_to"). The daemon wires this to
183
+ * write a NEW `#agent/message/inbound` note to the `channel` (so it wakes the sender
184
+ * through the normal inbound path), carrying the {@link CallbackMeta} contract.
185
+ *
186
+ * The content is a brief NOTIFICATION + a LINK to the result (NOT the full reply
187
+ * duplicated) — the orchestrator reads `source_message`/`source_thread` off the metadata
188
+ * and PULLS the full result if it wants (the user's explicit choice: summary + link,
189
+ * orchestrator pulls — cleaner + a better security boundary than fan-out duplication).
190
+ *
191
+ * LOOP SAFETY (load-bearing): the callback note this writes carries the INBOUND tags so
192
+ * it routes, but the daemon's wiring MUST NOT put a `reply_to` on it — a callback is
193
+ * TERMINAL, so handling one can never auto-trigger another callback (no ping-pong).
194
+ *
195
+ * A write failure is the implementation's to surface (the registry logs whatever it
196
+ * throws); a callback NEVER re-runs the turn. Optional on the registry — when unwired (no
197
+ * vault-backed channels), reply_to is silently inert.
198
+ */
199
+ export type WriteCallback = (channel: string, content: string, meta: CallbackMeta) => Promise<void>;
200
+
201
+ /**
202
+ * The METADATA CONTRACT a callback inbound note carries (design
203
+ * 2026-06-20-agent-callbacks.md). The daemon's {@link WriteCallback} wiring stamps these
204
+ * onto the new `#agent/message/inbound` note's `metadata` (the vault stores them as
205
+ * strings). The orchestrator reads `source_message` / `source_thread` to PULL the full
206
+ * result. Deliberately a SUMMARY + LINK, never the duplicated reply body.
207
+ */
208
+ export interface CallbackMeta {
209
+ /**
210
+ * `"true"` — the marker that distinguishes a callback inbound from an ordinary one, so
211
+ * an orchestrator's turn can tell "a sub-task finished" from "a new request arrived".
212
+ */
213
+ callback: "true";
214
+ /** The terminal outcome of the requested turn — `ok` (succeeded) or `error` (failed). */
215
+ status: "ok" | "error";
216
+ /** The channel/def whose turn just finished (the recipient) — provenance for the sender. */
217
+ source_channel: string;
218
+ /**
219
+ * The per-turn thread id the drain minted. RESOLVABILITY DIFFERS BY MODE:
220
+ * - multi-threaded: this IS the per-fire note's leaf — the orchestrator can pull the
221
+ * thread note at `Threads/<channel>/<source_thread>`.
222
+ * - single-threaded: this is a per-turn CORRELATION id, NOT the note leaf (the
223
+ * single-threaded note lives at the deterministic `Threads/<channel>/<name>`), so it
224
+ * is NOT directly resolvable. Use `source_message` as the reliable pull-link for a
225
+ * single-threaded recipient. Making this a resolvable thread id for both modes
226
+ * (widen the writeThread seam to return the written note id) is tracked as a
227
+ * follow-up (parachute-agent#124).
228
+ */
229
+ source_thread: string;
230
+ /**
231
+ * The recipient's OUTBOUND reply note id, when the turn produced (and delivered) a
232
+ * reply. The orchestrator pulls the full reply text from here. ABSENT when there was no
233
+ * reply (an error turn, or an empty/tool-only turn) — the callback still fires so the
234
+ * orchestrator learns the turn is done; it just has no reply note to pull.
235
+ */
236
+ source_message?: string;
237
+ /** The sender's opaque correlation id, echoed verbatim when one was set. Omitted otherwise. */
238
+ correlation_id?: string;
239
+ /**
240
+ * The depth of THIS callback = the incoming message's depth + 1. The sender's turn,
241
+ * woken by this callback, inherits it; if that turn delegates onward, the chain's depth
242
+ * keeps climbing toward {@link MAX_DELEGATION_DEPTH}.
243
+ */
244
+ delegation_depth: string;
245
+ }
246
+
247
+ /**
248
+ * The hard ceiling on delegation HOPS — the depth loop guard (design
249
+ * 2026-06-20-agent-callbacks.md §loop-safety). An inbound message arriving at or past this
250
+ * depth delivers NO callback (logged), which BOUNDS any chain even if the no-`reply_to`-on-
251
+ * callback rule were somehow circumvented. 8 is generous for real orchestration trees
252
+ * (an orchestrator → workers → sub-workers fan-out is 2-3 deep) while still finite.
253
+ */
254
+ export const MAX_DELEGATION_DEPTH = 8;
255
+
256
+ /**
257
+ * Cap on the per-channel PENDING-INBOUND queue (the agent#121 pre-registration buffer).
258
+ * A buffer that grows without bound is a memory-leak / DoS footgun if a channel's agent
259
+ * never comes up; past the cap the OLDEST pending message is dropped (FIFO eviction) with
260
+ * a loud log — bounded loss is better than unbounded growth, and the durable inbound notes
261
+ * still exist in the vault for the agent to re-read once it's live.
262
+ */
263
+ export const PENDING_INBOUND_CAP = 50;
264
+
265
+ /** How many times the outbound write is RETRIED on a transient failure (agent — PR #3
266
+ * FIX 1) before giving up. Total attempts = 1 + this. */
267
+ export const OUTBOUND_MAX_RETRIES = 2;
268
+ /** Base backoff (ms) between outbound retries — grows linearly (attempt 1 → BASE, 2 → 2×BASE). */
269
+ export const OUTBOUND_RETRY_BASE_MS = 250;
270
+
271
+ /**
272
+ * Classify an outbound-write error as TRANSIENT (worth retrying) vs PERMANENT (a real
273
+ * rejection). The VaultTransport's `reply()` throws `Error` whose message embeds the
274
+ * HTTP status as `(NNN)` for a non-ok vault response, or a raw network/fetch rejection
275
+ * (no status) when the vault is unreachable. So:
276
+ * - a parseable 5xx (502/503/504/…) → TRANSIENT (a vault blip; retry).
277
+ * - NO parseable status (a network error, DNS, connection refused) → TRANSIENT.
278
+ * - a parseable 4xx (400/401/403/409/…) → PERMANENT (a real rejection — auth, bad
279
+ * request; retrying just re-fails). Do NOT retry these.
280
+ * This keeps the retry to the case the audit flagged (a transient vault 5xx silently
281
+ * losing the reply) without papering over a genuine 4xx rejection.
282
+ */
283
+ export function isTransientOutboundError(err: unknown): boolean {
284
+ const msg = (err as Error)?.message ?? "";
285
+ const m = msg.match(/\((\d{3})\)/);
286
+ if (!m) return true; // no HTTP status → a network/connection error → transient.
287
+ const status = Number(m[1]);
288
+ return status >= 500 && status <= 599; // 5xx transient; 4xx permanent.
289
+ }
290
+
291
+ /** Sleep helper for the outbound retry backoff (injectable-free; small + bounded). */
292
+ function delay(ms: number): Promise<void> {
293
+ return new Promise((resolve) => setTimeout(resolve, ms));
294
+ }
295
+
296
+ /** A queued inbound message awaiting its serial turn. */
297
+ export interface QueuedMessage {
298
+ /** The inbound text handed to the `claude -p` turn as the prompt. */
299
+ content: string;
300
+ /** The inbound note id (if known), threaded into the outbound reply's `in_reply_to`. */
301
+ inReplyTo?: string;
302
+ // ── AGENT-TO-AGENT CALLBACK ROUTING ("reply_to") ───────────────────────────────
303
+ // These ride from the inbound note's metadata (a SENDING agent stamps them when it
304
+ // writes an inbound note to THIS channel via the vault), through `contextFor.emit`
305
+ // (daemon.ts, which flattens note.metadata into `meta`), onto this queue item, so the
306
+ // drain can deliver a CALLBACK to the originating channel when this turn finishes.
307
+ /**
308
+ * The SENDER's channel name — where to deliver a callback when this turn completes
309
+ * (BOTH ok and error). A single-threaded agent's channel ↔ thread is 1:1, so an
310
+ * orchestrator knows its own channel = its def name and stamps it here when it writes
311
+ * the inbound note to the recipient. Absent → NO callback (a normal, non-orchestrated
312
+ * turn). A callback note itself NEVER carries `reply_to` (it's terminal — that is the
313
+ * primary loop guard; see {@link MAX_DELEGATION_DEPTH}).
314
+ */
315
+ replyTo?: string;
316
+ /**
317
+ * An OPAQUE id the sender uses to match a callback to the request it fired (it may
318
+ * have N sub-tasks in flight). Echoed verbatim onto the callback metadata; the daemon
319
+ * never interprets it. Absent → omitted from the callback.
320
+ */
321
+ correlationId?: string;
322
+ /**
323
+ * How many delegation HOPS deep this message is (0 = a top-level human/runner turn).
324
+ * Incremented on each callback hop; bounds runaway chains. A message arriving at or
325
+ * past {@link MAX_DELEGATION_DEPTH} delivers NO callback (the depth loop guard). The
326
+ * vault stores metadata as STRINGS, so daemon.ts coerces `metadata.delegation_depth`
327
+ * to a finite integer before it lands here; a missing/garbage value reads as 0.
328
+ */
329
+ delegationDepth?: number;
330
+ /**
331
+ * Files attached to this inbound message (Phase 1: inbound file attachments → the
332
+ * programmatic turn). Threaded transport → daemon → `deliver`; the programmatic
333
+ * backend stages each into the agent's private session workspace so the turn can
334
+ * `Read` it. Absent/empty → no attachments (today's behavior unchanged).
335
+ */
336
+ attachments?: InboundAttachment[];
337
+ }
338
+
339
+ /** A registered programmatic agent's live status (surfaced in /health + the list). */
340
+ export type ProgrammaticAgentState = "idle" | "working" | "queued";
341
+
342
+ /**
343
+ * One registered programmatic agent. Holds the backend handle, its serial queue +
344
+ * in-flight worker, and a tiny bit of observable state (`working` + queue depth).
345
+ */
346
+ export interface ProgrammaticAgentHandle {
347
+ /** The agent slug (the spec name). */
348
+ name: string;
349
+ /** The wake channel this agent serves (the first channel of its spec). */
350
+ channel: string;
351
+ /** The spec the agent was registered from (carries the workspace/sandbox policy). */
352
+ spec: AgentSpec;
353
+ /** The backend's opaque handle (passed to `deliver`/`stop`/`status`). */
354
+ backendHandle: AgentHandle;
355
+ }
356
+
357
+ /**
358
+ * The daemon's registry of programmatic agents + their per-channel serial queues.
359
+ *
360
+ * Keyed by CHANNEL (the wake channel) so inbound routing — which only knows the
361
+ * channel — is an O(1) lookup. A second index by NAME backs the lifecycle ops
362
+ * (`deregister`, mutual-exclusion check). One instance per daemon, constructed at
363
+ * boot; injectable deps (`backend`, `writeOutbound`) so tests drive it with a fake
364
+ * backend + a recorder, no real `claude -p` or vault.
365
+ */
366
+ export class ProgrammaticAgentRegistry {
367
+ /** channel → handle (the inbound-routing index). */
368
+ private readonly byChannel = new Map<string, ProgrammaticAgentHandle>();
369
+ /** name → channel (the lifecycle index; an agent has exactly one wake channel). */
370
+ private readonly nameToChannel = new Map<string, string>();
371
+ /** channel → FIFO queue of pending messages. */
372
+ private readonly queues = new Map<string, QueuedMessage[]>();
373
+ /** channel → the in-flight drain promise (its presence == a worker is running). */
374
+ private readonly draining = new Map<string, Promise<void>>();
375
+ /**
376
+ * channel → FIFO queue of PENDING-INBOUND messages that arrived BEFORE a live
377
+ * programmatic agent was registered for the channel (the agent#121 fix). The daemon
378
+ * OWNS these — it must never drop an inbound it can't yet process (the vault trigger
379
+ * acks success on the daemon's 200 and never retries, so a drop is permanent). On
380
+ * {@link register} the channel's pending queue is DRAINED into the normal {@link
381
+ * enqueue} path, so the queued turns run in arrival order once the agent is live.
382
+ * IN-MEMORY only (v1): a daemon restart loses pending, which is fine — the durable
383
+ * inbound notes still exist in the vault and `loadAll` + the 60s def-poll reconverge.
384
+ */
385
+ private readonly pending = new Map<string, QueuedMessage[]>();
386
+ /**
387
+ * Channels EXPECTED to gain a live programmatic agent (a def maps here / the
388
+ * instantiate path has started bringing one up) — the gate for {@link queuePending}.
389
+ * Only an EXPECTED channel queues a pre-registration inbound; a genuinely unknown
390
+ * channel (nothing maps to it) is logged + dropped (there's nothing to deliver to).
391
+ * Marked by {@link expectChannel} (the def-instantiation path) and by {@link register}
392
+ * itself; cleared by {@link unexpectChannel} (deregister/teardown).
393
+ */
394
+ private readonly expectedChannels = new Set<string>();
395
+
396
+ private readonly backend: AgentBackend;
397
+ private readonly writeOutbound: WriteOutbound;
398
+ /** Optional thread-note sink — materialize an `#agent/thread` note (BOTH modes). */
399
+ private readonly writeThread?: WriteThread;
400
+ /**
401
+ * Optional callback sink — deliver an agent-to-agent callback to a sender's channel on
402
+ * turn completion (the "reply_to" substrate). Unwired → reply_to is silently inert.
403
+ */
404
+ private readonly writeCallback?: WriteCallback;
405
+ /** Optional streaming-view sink — push interim + lifecycle turn events per channel. */
406
+ private readonly onTurnEvent?: TurnEventSink;
407
+ /**
408
+ * Optional pre-turn session read — the persisted Claude session UUID for a
409
+ * single-threaded agent's thread note (the daemon wires this to the channel
410
+ * transport's `readThreadSession`). Read in {@link drain} so a single-threaded turn
411
+ * 2+ `--resume`s its prior conversation. Unwired (or no prior) → every turn creates a
412
+ * fresh session. Multi-threaded NEVER consults it (each fire is a fresh thread).
413
+ */
414
+ private readonly readSession?: (channel: string, name: string) => Promise<string | undefined>;
415
+ /**
416
+ * Optional session CLEAR — wipe a single-threaded agent's persisted thread-note session
417
+ * so its next turn starts a FRESH claude conversation (the per-agent restart). The daemon
418
+ * wires this to the channel transport's `clearThreadSession`. Called by {@link resetSession}.
419
+ * Unwired → reset is a clean no-op beyond returning that the agent exists.
420
+ */
421
+ private readonly clearSession?: (channel: string, name: string) => Promise<void>;
422
+ /** Base backoff (ms) between outbound retries (FIX 1). Injectable so tests run fast. */
423
+ private readonly outboundRetryBaseMs: number;
424
+
425
+ constructor(deps: {
426
+ backend: AgentBackend;
427
+ writeOutbound: WriteOutbound;
428
+ writeThread?: WriteThread;
429
+ writeCallback?: WriteCallback;
430
+ onTurnEvent?: TurnEventSink;
431
+ /** Read the persisted thread-note session UUID (single-threaded resume). */
432
+ readSession?: (channel: string, name: string) => Promise<string | undefined>;
433
+ /** Clear the persisted thread-note session (the per-agent restart / reset). */
434
+ clearSession?: (channel: string, name: string) => Promise<void>;
435
+ /** Override the outbound-retry backoff base (ms). Default {@link OUTBOUND_RETRY_BASE_MS}. */
436
+ outboundRetryBaseMs?: number;
437
+ }) {
438
+ this.backend = deps.backend;
439
+ this.writeOutbound = deps.writeOutbound;
440
+ if (deps.writeThread) this.writeThread = deps.writeThread;
441
+ if (deps.writeCallback) this.writeCallback = deps.writeCallback;
442
+ if (deps.onTurnEvent) this.onTurnEvent = deps.onTurnEvent;
443
+ if (deps.readSession) this.readSession = deps.readSession;
444
+ if (deps.clearSession) this.clearSession = deps.clearSession;
445
+ this.outboundRetryBaseMs = deps.outboundRetryBaseMs ?? OUTBOUND_RETRY_BASE_MS;
446
+ }
447
+
448
+ /**
449
+ * Emit a turn event to the streaming-view sink, swallowing any throw — a live-view
450
+ * push must NEVER break the serial worker (the durable note path is what matters).
451
+ * A no-op when no sink is wired.
452
+ */
453
+ private emitTurnEvent(channel: string, event: TurnLifecycleEvent): void {
454
+ if (!this.onTurnEvent) return;
455
+ try {
456
+ this.onTurnEvent(channel, event);
457
+ } catch {
458
+ // A dead-stream / sink fault must not strand the queue.
459
+ }
460
+ }
461
+
462
+ /** The wake channel for a spec (its first channel, normalized). */
463
+ private static channelOf(spec: AgentSpec): string {
464
+ if (spec.channels.length === 0) {
465
+ throw new Error(`programmatic registry: spec "${spec.name}" declares no channels`);
466
+ }
467
+ return normalizeChannel(spec.channels[0]!).name;
468
+ }
469
+
470
+ /** Is a programmatic agent registered for this channel? (the inbound-routing check) */
471
+ hasChannel(channel: string): boolean {
472
+ return this.byChannel.has(channel);
473
+ }
474
+
475
+ /** Is a programmatic agent registered under this name? (the mutual-exclusion check) */
476
+ hasName(name: string): boolean {
477
+ return this.nameToChannel.has(name);
478
+ }
479
+
480
+ /** The registered handle for a channel, or undefined. */
481
+ getByChannel(channel: string): ProgrammaticAgentHandle | undefined {
482
+ return this.byChannel.get(channel);
483
+ }
484
+
485
+ /** The registered handle for a name, or undefined. */
486
+ getByName(name: string): ProgrammaticAgentHandle | undefined {
487
+ const channel = this.nameToChannel.get(name);
488
+ return channel === undefined ? undefined : this.byChannel.get(channel);
489
+ }
490
+
491
+ /** All registered handles (for /health + the GET /api/agents list). */
492
+ list(): ProgrammaticAgentHandle[] {
493
+ return [...this.byChannel.values()];
494
+ }
495
+
496
+ /**
497
+ * The live status of an agent: `working` while a turn is in flight, `queued`
498
+ * (with the pending count) when messages are waiting, else `idle`. Used by
499
+ * /health + the agents list to render `programmatic · idle|working|queued:N`.
500
+ */
501
+ statusOf(channel: string): { state: ProgrammaticAgentState; queued: number } {
502
+ const queued = this.queues.get(channel)?.length ?? 0;
503
+ if (this.draining.has(channel)) {
504
+ // A worker is in flight. If there are messages waiting BEHIND the in-flight
505
+ // one, report queued:N (N = waiting, not counting the in-flight turn); else
506
+ // working. `queued` is the queue length, which excludes the message currently
507
+ // being processed (it's shifted off before the turn runs).
508
+ return queued > 0 ? { state: "queued", queued } : { state: "working", queued: 0 };
509
+ }
510
+ return { state: "idle", queued: 0 };
511
+ }
512
+
513
+ /**
514
+ * Register a programmatic agent from its spec — lightweight: validate, build the
515
+ * backend handle (no resident process), index it by channel + name. The caller
516
+ * (the spawn path) has already set up the workspace + .mcp.json + credentials +
517
+ * spec.json. Idempotent-replace: re-registering the same name swaps the handle
518
+ * (the boot re-register + a re-spawn both land here).
519
+ *
520
+ * Returns the registered handle. Throws if the spec declares no channels (a
521
+ * programmatic agent must have a wake channel to route inbound to).
522
+ */
523
+ async register(spec: AgentSpec): Promise<ProgrammaticAgentHandle> {
524
+ const channel = ProgrammaticAgentRegistry.channelOf(spec);
525
+ // Replace any prior registration for this name (a re-spawn / boot re-register).
526
+ const priorChannel = this.nameToChannel.get(spec.name);
527
+ if (priorChannel !== undefined && priorChannel !== channel) {
528
+ // The name moved to a different wake channel — drop the old channel index.
529
+ // An in-flight drain on the old channel self-terminates (it re-reads
530
+ // `byChannel`, now empty for that channel); we drop its `draining` flag too so
531
+ // the entry doesn't leak until that promise happens to settle. Also drop the old
532
+ // channel's EXPECTED mark + any stranded pending buffer — nothing routes there now,
533
+ // so a residual mark/buffer would leak (reviewer nit; defense-in-depth — the normal
534
+ // flow only ever expects the NEW channel before this register).
535
+ this.byChannel.delete(priorChannel);
536
+ this.queues.delete(priorChannel);
537
+ this.draining.delete(priorChannel);
538
+ this.expectedChannels.delete(priorChannel);
539
+ this.pending.delete(priorChannel);
540
+ }
541
+ const backendHandle = await this.backend.start(spec);
542
+ const handle: ProgrammaticAgentHandle = {
543
+ name: spec.name,
544
+ channel,
545
+ spec,
546
+ backendHandle,
547
+ };
548
+ this.byChannel.set(channel, handle);
549
+ this.nameToChannel.set(spec.name, channel);
550
+ // The channel now has a live agent — it's no longer merely "expected" (the gate that
551
+ // let pre-registration inbound queue pending); the live byChannel index is the truth now.
552
+ this.expectedChannels.delete(channel);
553
+ // REPLAY-ON-REGISTER (agent#121): drain any inbound that arrived BEFORE this agent was
554
+ // live — they were buffered in the pending queue (never dropped). Feed them through the
555
+ // NORMAL enqueue path, in arrival order (FIFO), so the queued turns run exactly as if
556
+ // they'd arrived after registration. enqueue() requires the channel to be in byChannel,
557
+ // which it now is. Do this AFTER the indexes are set so enqueue routes correctly.
558
+ this.drainPending(channel);
559
+ return handle;
560
+ }
561
+
562
+ /**
563
+ * Mark a channel as EXPECTED to gain a live programmatic agent — the gate that lets an
564
+ * inbound arriving BEFORE registration be QUEUED PENDING instead of dropped (agent#121).
565
+ * The def-instantiation path calls this BEFORE it brings the channel + agent up, so the
566
+ * narrow desync window (channel live, agent not yet registered) buffers rather than loses.
567
+ * Idempotent. {@link register} also marks-then-clears it; {@link unexpectChannel} clears it
568
+ * on teardown.
569
+ */
570
+ expectChannel(channel: string): void {
571
+ this.expectedChannels.add(channel);
572
+ }
573
+
574
+ /**
575
+ * Drop a channel's EXPECTED mark + any buffered pending inbound — called on teardown
576
+ * (deregister) of an agent that will NOT come back, so a stale def can't leave inbound
577
+ * stranded in the pending buffer forever. (deregister of a still-expected agent that WILL
578
+ * re-register should NOT call this — only a genuine removal.)
579
+ */
580
+ unexpectChannel(channel: string): void {
581
+ this.expectedChannels.delete(channel);
582
+ this.pending.delete(channel);
583
+ }
584
+
585
+ /**
586
+ * QUEUE an inbound that arrived before a live programmatic agent exists for the channel
587
+ * (agent#121). Returns:
588
+ * - `"queued"` — the channel is EXPECTED (a def maps here / instantiation in flight); the
589
+ * message is buffered (FIFO, capped at {@link PENDING_INBOUND_CAP}) and
590
+ * will replay on {@link register}. The daemon now OWNS it (never dropped).
591
+ * - `"unknown"` — nothing maps to this channel (not expected, not registered): there is
592
+ * nothing to deliver to, so the caller logs + drops (still 200 — the vault
593
+ * must not retry into a permanent `_pending_at` stall).
594
+ *
595
+ * NOTE: a channel with a LIVE agent never reaches here — {@link enqueue} handles it. This is
596
+ * strictly the pre-registration / desync buffer.
597
+ */
598
+ queuePending(channel: string, msg: QueuedMessage): "queued" | "unknown" {
599
+ if (!this.expectedChannels.has(channel)) return "unknown";
600
+ const queue = this.pending.get(channel) ?? [];
601
+ queue.push(msg);
602
+ // Bounded buffer: past the cap, evict the OLDEST (FIFO) so we keep the most recent
603
+ // context and never grow unbounded. Loud log — a capped pending queue means an agent
604
+ // isn't coming up in time (a real operational signal), and the dropped message is still
605
+ // durable in the vault for the agent to re-read once live.
606
+ if (queue.length > PENDING_INBOUND_CAP) {
607
+ queue.shift();
608
+ console.warn(
609
+ `parachute-agent: pending-inbound queue for channel "${channel}" hit the cap ` +
610
+ `(${PENDING_INBOUND_CAP}) — dropped the oldest buffered message (still durable in ` +
611
+ `the vault). The programmatic agent for this channel is not coming up in time.`,
612
+ );
613
+ }
614
+ this.pending.set(channel, queue);
615
+ return "queued";
616
+ }
617
+
618
+ /**
619
+ * Drain a channel's PENDING-INBOUND buffer into the live serial queue — called by
620
+ * {@link register} once the agent is live. FIFO: the oldest pending inbound is enqueued
621
+ * first, so the buffered turns run in arrival order. A no-op when the buffer is empty.
622
+ */
623
+ private drainPending(channel: string): void {
624
+ const buffered = this.pending.get(channel);
625
+ if (!buffered || buffered.length === 0) return;
626
+ this.pending.delete(channel);
627
+ console.log(
628
+ `parachute-agent: replaying ${buffered.length} pending inbound message(s) for ` +
629
+ `channel "${channel}" now that its programmatic agent is registered.`,
630
+ );
631
+ for (const msg of buffered) {
632
+ // enqueue() routes to the serial worker (the channel is now in byChannel). FIFO order
633
+ // is preserved by iterating the buffer oldest-first.
634
+ this.enqueue(channel, msg);
635
+ }
636
+ }
637
+
638
+ /** How many inbound are buffered pending for a channel (tests + /health observability). */
639
+ pendingCount(channel: string): number {
640
+ return this.pending.get(channel)?.length ?? 0;
641
+ }
642
+
643
+ /** Is a channel currently marked EXPECTED (the pending-queue gate)? (tests) */
644
+ isExpected(channel: string): boolean {
645
+ return this.expectedChannels.has(channel);
646
+ }
647
+
648
+ /**
649
+ * Deregister a programmatic agent by NAME — drop its indexes + queue and clear
650
+ * its backend session (so a future re-spawn starts a fresh conversation). An
651
+ * in-flight turn is NOT cancelled (a `claude -p` turn is a fire-once subprocess;
652
+ * we just stop routing new inbound to it). Returns whether one was registered.
653
+ */
654
+ async deregister(name: string): Promise<boolean> {
655
+ const channel = this.nameToChannel.get(name);
656
+ if (channel === undefined) return false;
657
+ const handle = this.byChannel.get(channel);
658
+ this.byChannel.delete(channel);
659
+ this.nameToChannel.delete(name);
660
+ this.queues.delete(channel);
661
+ // Clear the EXPECTED mark + any buffered pending inbound for this channel too —
662
+ // the agent is gone, so a pending message has nothing to drain into and would
663
+ // strand forever (and the next register would replay stale messages). The daemon's
664
+ // teardown wrapper also calls unexpectChannel, but clearing it here makes direct
665
+ // registry callers safe too (the reviewer's latent-footgun nit).
666
+ this.expectedChannels.delete(channel);
667
+ this.pending.delete(channel);
668
+ // Tear down the backend handle (the programmatic `stop` is a no-op — there's no
669
+ // process to kill, and the session now lives on the durable thread note, not a
670
+ // backend store). Deregister deliberately does NOT clear the thread-note session:
671
+ // re-registering the same agent should resume its conversation. Wiping continuity is
672
+ // an explicit RESET (`resetSession`), not a side effect of teardown.
673
+ if (handle) {
674
+ try {
675
+ await this.backend.stop(handle.backendHandle);
676
+ } catch (err) {
677
+ console.error(
678
+ `parachute-agent: programmatic backend.stop for "${name}" failed (continuing): ${(err as Error).message}`,
679
+ );
680
+ }
681
+ }
682
+ return true;
683
+ }
684
+
685
+ /**
686
+ * Reset a programmatic agent's conversation — clear the persisted session on its
687
+ * `#agent/thread` note (via the wired `clearSession` → the transport's
688
+ * `clearThreadSession`) so the next message starts a FRESH claude conversation, WITHOUT
689
+ * deregistering it. This is what the per-session restart endpoint maps to for a
690
+ * programmatic agent (the interactive restart's "kill + re-spawn" has no analog — there's
691
+ * no process; continuity is the thread-note session, not a backend store). With the next
692
+ * turn's `readSession` finding no session, it resolves a fresh `--session-id` create.
693
+ * Best-effort: a clear failure is logged, never thrown. Returns whether an agent was
694
+ * registered under that name.
695
+ */
696
+ async resetSession(name: string): Promise<boolean> {
697
+ const handle = this.getByName(name);
698
+ if (!handle) return false;
699
+ try {
700
+ await this.clearSession?.(handle.channel, handle.spec.name);
701
+ } catch (err) {
702
+ console.error(
703
+ `parachute-agent: programmatic session reset for "${name}" failed: ${(err as Error).message}`,
704
+ );
705
+ }
706
+ return true;
707
+ }
708
+
709
+ /**
710
+ * ENQUEUE an inbound message for the channel's programmatic agent and ensure the
711
+ * serial worker is draining. A no-op (returns false) when no programmatic agent
712
+ * is registered for the channel — the caller falls back to the normal push path.
713
+ *
714
+ * The worker is a single in-flight promise chain per channel (`#draining`): if one
715
+ * is already running, this just appends to the queue and the running worker picks
716
+ * it up; otherwise it starts a new drain. Concurrency is impossible by
717
+ * construction — there is at most ONE drain promise per channel at a time, and the
718
+ * drain processes the queue strictly in order.
719
+ */
720
+ enqueue(channel: string, msg: QueuedMessage): boolean {
721
+ if (!this.byChannel.has(channel)) return false;
722
+ const queue = this.queues.get(channel) ?? [];
723
+ queue.push(msg);
724
+ this.queues.set(channel, queue);
725
+ // Start the worker if it isn't already running. The drain promise's PRESENCE in
726
+ // `draining` is the "a worker is running" flag — set it synchronously before any
727
+ // await so a second enqueue in the same tick can't start a second worker.
728
+ if (!this.draining.has(channel)) {
729
+ const p = this.drain(channel).finally(() => {
730
+ this.draining.delete(channel);
731
+ });
732
+ this.draining.set(channel, p);
733
+ }
734
+ return true;
735
+ }
736
+
737
+ /**
738
+ * Drain a channel's queue ONE turn at a time, FIFO, until empty. Each iteration
739
+ * shifts the oldest message, runs ONE `deliver()` turn, and posts a non-empty
740
+ * `ok` reply as an outbound note. Never two concurrent turns — the loop awaits each
741
+ * `deliver()` before shifting the next. Re-checks the queue after each turn so a
742
+ * message enqueued mid-turn is drained in the same run (no missed wake).
743
+ *
744
+ * Failure handling: a `deliver` that returns `{ ok: false }` is LOGGED and dropped
745
+ * (no retry, no loop — the design's "do NOT infinite-loop" contract). A throw from
746
+ * `deliver` (it shouldn't — the contract is failure-as-value) is caught so one bad
747
+ * turn can't kill the worker / strand the rest of the queue. An outbound-write
748
+ * failure is logged; the turn still counts as drained (the reply is durable-or-not
749
+ * at the transport's discretion; we don't re-run the turn, which would fork).
750
+ */
751
+ private async drain(channel: string): Promise<void> {
752
+ for (;;) {
753
+ const queue = this.queues.get(channel);
754
+ if (!queue || queue.length === 0) return;
755
+ const handle = this.byChannel.get(channel);
756
+ if (!handle) return; // deregistered mid-drain — stop.
757
+ const msg = queue.shift()!;
758
+
759
+ // The UNIFIED model (the structural unification): BOTH modes materialize a
760
+ // `#agent/thread` note — everything is a thread; a "run" was always a thread with one
761
+ // turn. The MODE difference is the thread's identity (resolved transport-side):
762
+ // single-threaded upserts ONE note per channel (rolling turn_count + usage),
763
+ // multi-threaded writes one note per fire. Read the mode off the spec so the
764
+ // thread note carries it (it's the indexed query axis + governs the upsert).
765
+ const startedAt = new Date().toISOString();
766
+ // A stable per-TURN thread id, passed to every recordThread for this turn. For
767
+ // multi-threaded it's the per-fire note's leaf, so a re-record (the outbound-failure
768
+ // status flip below) updates the SAME note instead of minting a duplicate; single-
769
+ // threaded ignores it (deterministic name leaf). One uuid per turn.
770
+ const turnThreadId = crypto.randomUUID();
771
+
772
+ // RESOLVE THE SESSION (the thread≡session record — the daemon owns the uuid). A
773
+ // single-threaded agent RESUMES the session persisted on its deterministic thread
774
+ // note (when one exists); the first turn (no prior) and EVERY multi-threaded fire
775
+ // CREATE a fresh session with a new uuid (`--session-id`). The backend just runs the
776
+ // turn with this {@link TurnSession}; it reads no session store.
777
+ const multiThreaded = (handle.spec.mode ?? "single-threaded") === "multi-threaded";
778
+ let resumeId: string | undefined;
779
+ if (!multiThreaded && this.readSession) {
780
+ resumeId = await this.readSession(handle.channel, handle.spec.name);
781
+ }
782
+ const turnSession: TurnSession = resumeId
783
+ ? { id: resumeId, resume: true }
784
+ : { id: crypto.randomUUID(), resume: false };
785
+
786
+ // ── THREAD-AS-CONTAINER (the user's model: definition -> thread -> message). ENSURE
787
+ // the thread note in a `working` state BEFORE the turn runs, so the thread is visible
788
+ // the moment processing starts (status `working` → `ok`/`error`), not only as a
789
+ // by-product of a completed turn. The SAME per-turn thread id ties this start-ensure
790
+ // to the end-record below: single-threaded UPSERTS its deterministic note (and the
791
+ // end-record overwrites it `working` → `ok`/`error`); multi-threaded CREATES the
792
+ // per-fire note (and the end-record updates the SAME note via `turnThreadId`).
793
+ //
794
+ // turn_count is NOT touched here. `phase: "start"` tells the transport to write
795
+ // `turn_count = prior` (UNCHANGED — no turn has completed) and NOT advance
796
+ // `last_turn_at`. The turn is counted EXACTLY ONCE, on the `end` record below — so
797
+ // start+end never double-count. Best-effort: a start-ensure write failure is logged
798
+ // (inside recordThread) and the turn STILL runs — a missing/stale working note must
799
+ // never strand the queue or skip the turn.
800
+ await this.recordThread(handle, msg, "working", "", startedAt, undefined, {
801
+ threadId: turnThreadId,
802
+ phase: "start",
803
+ // NO session on the start-ensure: it runs BEFORE claude, so claude may never
804
+ // establish a session this turn. Persisting `turnSession.id` here would brick the
805
+ // next turn (it'd `--resume` an id for a conversation that never existed →
806
+ // non-transient "No conversation found" → no retry). We persist a session ONLY on
807
+ // the `end` record, and ONLY the id claude actually echoed (FIX 2). For a
808
+ // single-threaded resume turn the prior session is preserved by writeThread anyway.
809
+ });
810
+
811
+ let result;
812
+ try {
813
+ // Forward each interim event to the streaming-view sink (keyed by channel)
814
+ // as the turn runs — the "watch it work" live progress. The sink swallows
815
+ // its own throws (emitTurnEvent), so a dead live stream can't break the turn.
816
+ result = await this.backend.deliver(
817
+ handle.backendHandle,
818
+ msg.content,
819
+ turnSession,
820
+ (e) => this.emitTurnEvent(channel, e),
821
+ // Phase 1: inbound attachments → the programmatic backend stages them into the
822
+ // agent's private workspace so the turn can Read them. Absent/empty → no staging.
823
+ msg.attachments,
824
+ );
825
+ } catch (err) {
826
+ // The backend contract is failure-as-VALUE, never a throw — but defend so a
827
+ // surprise throw can't kill the worker and strand the queue. Resolve the live
828
+ // view to an error state (no stuck "working" spinner).
829
+ const reason = (err as Error).message;
830
+ console.error(
831
+ `parachute-agent: programmatic turn for channel "${channel}" threw ` +
832
+ `(should be a value): ${reason}`,
833
+ );
834
+ // BOTH modes materialize a thread note even on a (defensive-catch) failure — the
835
+ // thread note captures the turn outcome, so a failed turn is still a queryable
836
+ // `status:error` (single-threaded upserts the rolling thread; multi-threaded writes
837
+ // a per-fire note).
838
+ await this.recordThread(handle, msg, "error", reason, startedAt, undefined, {
839
+ threadId: turnThreadId,
840
+ phase: "end",
841
+ // No `result` (the backend threw) → NO session to persist. We never write a
842
+ // session claude didn't echo (FIX 2): persisting an unestablished uuid would
843
+ // brick the next turn's `--resume`. A single-threaded prior session is preserved
844
+ // by writeThread; otherwise the next turn self-heals with a fresh create.
845
+ });
846
+ this.emitTurnEvent(channel, { kind: "error", error: reason });
847
+ // Post a user-facing failure note so the channel shows SOMETHING (not a silent
848
+ // no-reply) — best-effort.
849
+ await this.postFailureNote(channel, msg.inReplyTo, turnThreadId, reason);
850
+ // CALLBACK on the failure too — an orchestrator MUST learn its sub-task failed, not
851
+ // hang waiting forever. No outbound note was produced, so no `source_message`.
852
+ await this.maybeDeliverCallback(handle, msg, turnThreadId, "error");
853
+ continue;
854
+ }
855
+
856
+ if (!result.ok) {
857
+ // Logged + dropped — no retry, no loop. The backend already persisted the
858
+ // session id (a turn can fail after establishing a session), so the next
859
+ // message resumes the conversation. Resolve the live view to an error state.
860
+ console.warn(
861
+ `parachute-agent: programmatic turn for channel "${channel}" failed: ${result.error}`,
862
+ );
863
+ // BOTH modes record the failed turn (status:error) on the thread note so a failure
864
+ // always leaves a queryable trace (single-threaded upserts the rolling thread,
865
+ // marking it errored; multi-threaded writes a per-fire status:error note).
866
+ await this.recordThread(handle, msg, "error", result.error, startedAt, undefined, {
867
+ threadId: turnThreadId,
868
+ phase: "end",
869
+ // Persist ONLY the session claude ECHOED (FIX 2). A turn can fail AFTER
870
+ // establishing a session (claude emitted it in the init/result event) → resume
871
+ // it next turn. A turn that failed BEFORE establishing one echoes none →
872
+ // `result.sessionId` is undefined → we persist nothing → the next turn
873
+ // self-heals with a fresh create (no brick). NEVER fall back to `turnSession.id`.
874
+ ...(result.sessionId ? { session: result.sessionId } : {}),
875
+ });
876
+ this.emitTurnEvent(channel, { kind: "error", error: result.error });
877
+ // Post a user-facing failure note so the channel shows SOMETHING (not a silent
878
+ // no-reply) — best-effort.
879
+ await this.postFailureNote(channel, msg.inReplyTo, turnThreadId, result.error);
880
+ // CALLBACK on the failure-as-value too (status:error) — the orchestrator learns the
881
+ // sub-task failed and can react. No delivered reply, so no `source_message`.
882
+ await this.maybeDeliverCallback(handle, msg, turnThreadId, "error");
883
+ continue;
884
+ }
885
+
886
+ // The THREAD NOTE comes FIRST — it is the PRIMARY record of the turn (status:ok now
887
+ // that the turn succeeded), so it must survive even if the ADDITIVE outbound transcript
888
+ // write below fails (that path `continue`s past here). Writing it before the outbound
889
+ // (the c34db03 ordering — now applied UNIFORMLY to both modes) guarantees the turn's
890
+ // record survives an outbound failure: single-threaded upserts the rolling thread,
891
+ // multi-threaded writes the per-fire note. Best-effort: a thread-note failure is
892
+ // logged + the turn still resolves (we never re-run a `claude -p` turn — that would
893
+ // burn quota for a duplicate).
894
+ await this.recordThread(handle, msg, "ok", result.reply ?? "", startedAt, result.usage, {
895
+ threadId: turnThreadId,
896
+ phase: "end",
897
+ // Persist the session claude ECHOED (FIX 2) so the next turn `--resume`s this
898
+ // conversation — the thread≡session record. A successful turn always echoes an id;
899
+ // the guard keeps the "only an established session" invariant uniform.
900
+ ...(result.sessionId ? { session: result.sessionId } : {}),
901
+ });
902
+
903
+ // The outbound reply — the channel-transcript delivery (the chat bubble). It is
904
+ // ADDITIVE to the primary thread-note record already written above (for BOTH modes).
905
+ // Empty reply → NO note (reviewer contract — `reply` can be ""): a turn that produced
906
+ // no text (e.g. tool-only work) leaves the chat clean.
907
+ //
908
+ // `sourceMessage` — the delivered outbound note id, captured for the callback's
909
+ // `source_message` so an orchestrator can PULL the full reply text. Stays undefined
910
+ // for an empty/tool-only turn (no note) — the callback still fires (status:ok), it
911
+ // just has no reply note to point at (the orchestrator pulls from `source_thread`).
912
+ let sourceMessage: string | undefined;
913
+ if (result.reply && result.reply.length > 0) {
914
+ const delivered = await this.deliverOutboundWithRetry(
915
+ channel,
916
+ result.reply,
917
+ msg.inReplyTo,
918
+ turnThreadId,
919
+ );
920
+ if (delivered.ok) sourceMessage = delivered.noteId;
921
+ if (!delivered.ok) {
922
+ // FIX 1 (PR #3) — the SCARY one. The reply was PRODUCED but, after the bounded
923
+ // retry, still NOT persisted to the transcript (a persistent vault 5xx / network
924
+ // fault, or a real 4xx rejection). We must NOT leave a clean `status:ok` record
925
+ // claiming the reply landed when it didn't:
926
+ // 1. RE-RECORD the thread note as `status:error` so the durable thread record
927
+ // reflects the UN-DELIVERED reply (overwrites the optimistic `ok` upsert for
928
+ // single-threaded; writes/overwrites the per-fire note for multi-threaded).
929
+ // 2. Resolve the live view to ERROR (not `done`) so the UI doesn't drop the
930
+ // in-progress bubble + poll for a note that isn't there (PR #83 nit).
931
+ // We do NOT re-run the `claude -p` turn (that forks/burns quota) — the reply text
932
+ // is preserved IN the error thread note's output for an operator to recover.
933
+ console.error(
934
+ `parachute-agent: programmatic outbound write for channel "${channel}" failed ` +
935
+ `after ${OUTBOUND_MAX_RETRIES} retries: ${delivered.error}`,
936
+ );
937
+ // RE-RECORD the SAME turn as status:error — reuse the per-turn thread id +
938
+ // `sameTurn` so this updates the note the `ok` record above just wrote (one
939
+ // note, no turn_count double-count) rather than minting a duplicate / advancing
940
+ // the count (the FIX-1 re-record bug the reviewer caught).
941
+ await this.recordThread(
942
+ handle,
943
+ msg,
944
+ "error",
945
+ `reply produced but NOT delivered (outbound write failed: ${delivered.error}). ` +
946
+ `Undelivered reply text: ${result.reply}`,
947
+ startedAt,
948
+ result.usage,
949
+ {
950
+ threadId: turnThreadId,
951
+ sameTurn: true,
952
+ phase: "end",
953
+ // The turn DID establish a session (it produced a reply) — keep the ECHOED id
954
+ // on the note so the next turn resumes, even though the outbound transcript
955
+ // write failed. Only claude's echoed id (FIX 2), never the passed uuid.
956
+ ...(result.sessionId ? { session: result.sessionId } : {}),
957
+ },
958
+ );
959
+ this.emitTurnEvent(channel, {
960
+ kind: "error",
961
+ error: `reply produced but not saved: ${delivered.error}`,
962
+ });
963
+ // CALLBACK as status:error — the reply was produced but NOT delivered, so the
964
+ // turn did not truly succeed; the orchestrator must learn that. No `source_message`
965
+ // (the outbound note never landed); the undelivered text lives in the error thread
966
+ // note for an operator to recover.
967
+ await this.maybeDeliverCallback(handle, msg, turnThreadId, "error");
968
+ continue;
969
+ }
970
+ }
971
+
972
+ // Resolve the live view: `done` carries the final reply text (empty when the
973
+ // turn produced none) so the UI finalizes the in-progress bubble — the durable
974
+ // note (written above) is what actually persists; this just ends the spinner.
975
+ // Reached only when the outbound write SUCCEEDED, or there was no reply to write
976
+ // (empty/tool-only turn → clean resolve, no note expected).
977
+ this.emitTurnEvent(channel, { kind: "done", reply: result.reply ?? "" });
978
+ // CALLBACK on success — the turn finished cleanly (status:ok). `sourceMessage` is the
979
+ // delivered reply note (when there was one) the orchestrator pulls the full result from.
980
+ await this.maybeDeliverCallback(handle, msg, turnThreadId, "ok", sourceMessage);
981
+ }
982
+ }
983
+
984
+ /**
985
+ * Deliver an agent-to-agent CALLBACK to the originating channel when a requested turn
986
+ * finishes — the request/response substrate ("reply_to"). Called at EVERY terminal point
987
+ * of the drain (success, failure-as-value, defensive-catch throw, AND outbound-delivery
988
+ * failure), because an orchestrator must learn the outcome whether the sub-task succeeded
989
+ * OR failed — a hung orchestrator waiting on a dropped failure is the worst outcome.
990
+ *
991
+ * GUARDS (in order; each is a hard precondition — failing any one is a clean no-op):
992
+ * 1. No {@link WriteCallback} wired → reply_to is inert (a daemon with no vault channels).
993
+ * 2. No `replyTo` on the originating message → an ordinary, non-orchestrated turn. This
994
+ * is THE common case + the first loop guard: a CALLBACK note is written WITHOUT a
995
+ * `reply_to` (see the daemon's wiring), so handling a callback can NEVER itself emit a
996
+ * callback — no ping-pong, structurally.
997
+ * 3. `delegationDepth >= MAX_DELEGATION_DEPTH` → the DEPTH loop guard. Even if guard 2
998
+ * were somehow defeated, this bounds any chain to a finite hop count. We LOG loudly
999
+ * (a hit is a real signal — a delegation tree ran away or a cycle formed) and drop the
1000
+ * callback. The turn itself already ran + recorded; only the onward notification stops.
1001
+ *
1002
+ * The callback CONTENT is a brief NOTIFICATION + a LINK (never the duplicated reply) — the
1003
+ * orchestrator reads `source_thread`/`source_message` off the metadata and PULLS the full
1004
+ * result (the user's explicit choice). METADATA is the {@link CallbackMeta} contract, with
1005
+ * `delegation_depth` = incoming + 1 so the chain's depth climbs each hop.
1006
+ *
1007
+ * Best-effort like the other sinks: a write failure is LOGGED, never thrown — a callback
1008
+ * failure must NOT strand the per-channel drain or re-run the (already-completed) turn.
1009
+ */
1010
+ private async maybeDeliverCallback(
1011
+ handle: ProgrammaticAgentHandle,
1012
+ msg: QueuedMessage,
1013
+ turnThreadId: string,
1014
+ status: "ok" | "error",
1015
+ sourceMessage?: string,
1016
+ ): Promise<void> {
1017
+ // Guard 1 + 2: no sink, or this wasn't a delegated request → nothing to call back.
1018
+ if (!this.writeCallback) return;
1019
+ if (!msg.replyTo) return;
1020
+
1021
+ // Guard 3 (DEPTH): bound the delegation chain. The INCOMING depth (default 0) is how
1022
+ // deep this message already is; the callback we'd write is one hop deeper. If the
1023
+ // incoming message is already at/over the ceiling, stop — do not deliver.
1024
+ const incomingDepth = msg.delegationDepth ?? 0;
1025
+ if (incomingDepth >= MAX_DELEGATION_DEPTH) {
1026
+ console.warn(
1027
+ `parachute-agent: delegation depth ${incomingDepth} >= MAX (${MAX_DELEGATION_DEPTH}) for ` +
1028
+ `channel "${handle.channel}" → NOT delivering a callback to "${msg.replyTo}" (loop guard). ` +
1029
+ `A delegation chain ran away or a cycle formed; the turn ran + recorded normally.`,
1030
+ );
1031
+ return;
1032
+ }
1033
+
1034
+ // The brief notification + link. NOT the full reply (the orchestrator pulls it).
1035
+ const verb = status === "ok" ? "finished (ok)" : "finished with an error";
1036
+ const content =
1037
+ `[callback] ${handle.channel} ${verb} — see source_message / source_thread in this ` +
1038
+ `note's metadata to pull the full result.`;
1039
+
1040
+ // The metadata contract. delegation_depth = incoming + 1 (this hop). correlation_id +
1041
+ // source_message are echoed/included only when present. The daemon's WriteCallback
1042
+ // wiring writes this as a `#agent/message/inbound` note to `msg.replyTo` and — CRUCIALLY
1043
+ // — does NOT stamp a `reply_to` on it (the terminal-callback loop guard).
1044
+ const meta: CallbackMeta = {
1045
+ callback: "true",
1046
+ status,
1047
+ source_channel: handle.channel,
1048
+ source_thread: turnThreadId,
1049
+ ...(sourceMessage ? { source_message: sourceMessage } : {}),
1050
+ ...(msg.correlationId ? { correlation_id: msg.correlationId } : {}),
1051
+ delegation_depth: String(incomingDepth + 1),
1052
+ };
1053
+
1054
+ try {
1055
+ await this.writeCallback(msg.replyTo, content, meta);
1056
+ } catch (err) {
1057
+ console.error(
1058
+ `parachute-agent: delivering callback to channel "${msg.replyTo}" failed ` +
1059
+ `(continuing — the turn already completed + recorded): ${(err as Error).message}`,
1060
+ );
1061
+ }
1062
+ }
1063
+
1064
+ /**
1065
+ * Materialize the `#agent/thread` note for a completed turn — the UNIFIED model, written
1066
+ * for BOTH modes (the structural unification). A no-op when no {@link WriteThread} sink is
1067
+ * wired (a channel with no durable store). The timing is captured by the caller
1068
+ * (`startedAt` before the turn; `ended_at` is now). The MODE rides on the note so the
1069
+ * transport resolves the thread identity (single-threaded upserts one note per channel,
1070
+ * multi-threaded writes one per fire) + the upsert aggregation. The thread `name` is the
1071
+ * agent name (single-threaded's thread is "named after the definition"). Best-effort: a
1072
+ * write failure is LOGGED, never thrown out — a missing thread note must not strand the
1073
+ * queue, and the turn is never re-run (it would burn quota for a duplicate `claude -p`).
1074
+ */
1075
+ private async recordThread(
1076
+ handle: ProgrammaticAgentHandle,
1077
+ msg: QueuedMessage,
1078
+ status: "ok" | "error" | "working",
1079
+ output: string,
1080
+ startedAt: string,
1081
+ usage: ThreadNote["usage"],
1082
+ opts: { threadId?: string; sameTurn?: boolean; phase?: "start" | "end"; session?: string } = {},
1083
+ ): Promise<void> {
1084
+ if (!this.writeThread) return;
1085
+ const thread: ThreadNote = {
1086
+ channel: handle.channel,
1087
+ name: handle.spec.name,
1088
+ ...(handle.spec.definition ? { definition: handle.spec.definition } : {}),
1089
+ mode: handle.spec.mode ?? "single-threaded",
1090
+ status,
1091
+ input: msg.content,
1092
+ output,
1093
+ started_at: startedAt,
1094
+ ended_at: new Date().toISOString(),
1095
+ ...(usage ? { usage } : {}),
1096
+ // The Claude session UUID for this turn — persisted to the thread note so the next
1097
+ // turn `--resume`s it (the thread≡session record). The transport preserves a prior
1098
+ // single-threaded session when a write carries none.
1099
+ ...(opts.session ? { session: opts.session } : {}),
1100
+ // The per-turn thread id (stable across an ok→error re-record) + the same-turn flag,
1101
+ // so a re-record updates the SAME note without minting a duplicate (multi) or
1102
+ // double-counting turn_count (single).
1103
+ ...(opts.threadId ? { threadId: opts.threadId } : {}),
1104
+ ...(opts.sameTurn ? { sameTurn: true } : {}),
1105
+ // The lifecycle phase — `start` (working-ensure, no turn counted) vs `end` (final
1106
+ // record, turn counted). Absent → `end` at the transport (back-compat).
1107
+ ...(opts.phase ? { phase: opts.phase } : {}),
1108
+ };
1109
+ try {
1110
+ await this.writeThread(thread);
1111
+ } catch (err) {
1112
+ console.error(
1113
+ `parachute-agent: writing #agent/thread note for channel "${handle.channel}" failed ` +
1114
+ `(continuing): ${(err as Error).message}`,
1115
+ );
1116
+ }
1117
+ }
1118
+
1119
+ /**
1120
+ * Deliver the outbound reply with a BOUNDED retry on a TRANSIENT failure (FIX 1, PR
1121
+ * #3). A vault 5xx / network blip during the outbound write used to silently lose the
1122
+ * reply (the turn resolved, the thread note said "ok", but the chat bubble never
1123
+ * landed). We retry up to {@link OUTBOUND_MAX_RETRIES} times with a small linear
1124
+ * backoff on a transient error ({@link isTransientOutboundError}: a 5xx or a
1125
+ * no-status network error). A PERMANENT error (a 4xx — a real rejection) does NOT
1126
+ * retry. Returns `{ ok: true }` once the write lands, or `{ ok: false, error }` after
1127
+ * exhausting the retries / on a permanent failure — the caller then records the turn
1128
+ * as un-delivered + surfaces it (never claims a clean success). We NEVER re-run the
1129
+ * `claude -p` turn here (that would fork the conversation / burn quota); only the
1130
+ * idempotent outbound WRITE is retried.
1131
+ */
1132
+ private async deliverOutboundWithRetry(
1133
+ channel: string,
1134
+ reply: string,
1135
+ inReplyTo?: string,
1136
+ threadId?: string,
1137
+ ): Promise<{ ok: true; noteId?: string } | { ok: false; error: string }> {
1138
+ let lastError = "";
1139
+ for (let attempt = 0; attempt <= OUTBOUND_MAX_RETRIES; attempt++) {
1140
+ try {
1141
+ // Capture the written note id (when the seam returns one) so the caller can
1142
+ // point a callback's `source_message` at the delivered reply. A void return →
1143
+ // no id (the callback then omits source_message — still fires).
1144
+ const written = await this.writeOutbound(channel, reply, inReplyTo, threadId);
1145
+ return { ok: true, ...(written && written.id ? { noteId: written.id } : {}) };
1146
+ } catch (err) {
1147
+ lastError = (err as Error).message;
1148
+ const transient = isTransientOutboundError(err);
1149
+ const more = attempt < OUTBOUND_MAX_RETRIES;
1150
+ if (!transient || !more) {
1151
+ // A permanent (4xx) error never retries; a transient one that exhausted the
1152
+ // budget falls through to the failure return below.
1153
+ if (!transient) {
1154
+ console.warn(
1155
+ `parachute-agent: outbound write for channel "${channel}" failed with a ` +
1156
+ `non-transient error (not retrying): ${lastError}`,
1157
+ );
1158
+ }
1159
+ return { ok: false, error: lastError };
1160
+ }
1161
+ // Transient + retries remain — back off (linear) and try again.
1162
+ console.warn(
1163
+ `parachute-agent: outbound write for channel "${channel}" transient failure ` +
1164
+ `(attempt ${attempt + 1}/${OUTBOUND_MAX_RETRIES + 1}), retrying: ${lastError}`,
1165
+ );
1166
+ await delay(this.outboundRetryBaseMs * (attempt + 1));
1167
+ }
1168
+ }
1169
+ return { ok: false, error: lastError };
1170
+ }
1171
+
1172
+ /**
1173
+ * Post a brief, user-facing FAILURE note to the channel when a turn doesn't complete
1174
+ * (the backend's transient-retry is exhausted, or a non-transient error). A silent
1175
+ * no-reply reads as "nothing came through" — this makes the failure visible in the
1176
+ * transcript, with the reason. Best-effort: a failed failure-note write is logged,
1177
+ * never thrown (it must not break the drain). Reuses the bounded outbound-write retry.
1178
+ */
1179
+ private async postFailureNote(
1180
+ channel: string,
1181
+ inReplyTo: string | undefined,
1182
+ threadId: string,
1183
+ reason: string,
1184
+ ): Promise<void> {
1185
+ const short = reason.length > 240 ? `${reason.slice(0, 240)}…` : reason;
1186
+ const text =
1187
+ `⚠️ I couldn't complete that — the turn failed: ${short}\n\n` +
1188
+ `This is often temporary; please try again in a moment.`;
1189
+ try {
1190
+ const delivered = await this.deliverOutboundWithRetry(channel, text, inReplyTo, threadId);
1191
+ if (!delivered.ok) {
1192
+ console.error(
1193
+ `parachute-agent: failure note for channel "${channel}" not delivered: ${delivered.error}`,
1194
+ );
1195
+ }
1196
+ } catch (err) {
1197
+ console.error(
1198
+ `parachute-agent: posting failure note for channel "${channel}" threw (continuing): ${(err as Error).message}`,
1199
+ );
1200
+ }
1201
+ }
1202
+ }