@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,927 @@
1
+ /**
2
+ * The PROGRAMMATIC agent backend (design 2026-06-16-pluggable-agent-backend.md).
3
+ *
4
+ * Drives a channel agent by running ONE sandboxed `claude -p` turn per inbound
5
+ * message and capturing the reply — NO idle interactive session, so the whole
6
+ * deaf-on-restart fragility class (no-loss replay #67, per-session restart #68,
7
+ * dev-channels consent gate #70/#71) simply does not exist here. "Wake" is "run the
8
+ * next turn."
9
+ *
10
+ * ── The verified mechanic (spike against claude 2.1.179) ────────────────────────
11
+ * claude -p "<message>" \
12
+ * --output-format stream-json --verbose \
13
+ * --strict-mcp-config --mcp-config <path> \
14
+ * --dangerously-skip-permissions \
15
+ * [--session-id <uuid> | --resume <uuid>]
16
+ *
17
+ * - Runs on the SUBSCRIPTION (`apiKeySource: "none"` in the init event; the
18
+ * rate_limit_event shows the `five_hour` subscription pool) — NOT metered API,
19
+ * as long as no `ANTHROPIC_API_KEY`/`CLAUDE_API_KEY` is in the env. The
20
+ * `total_cost_usd` in the result is an equivalent-cost figure, not a charge.
21
+ * - The DAEMON owns the session uuid (it lives on the `#agent/thread` note's
22
+ * `metadata.session`), NOT a backend-private store. The caller resolves the turn's
23
+ * {@link TurnSession} and hands it in: `--session-id <uuid>` CREATES a session with
24
+ * that uuid (first turn) and `--resume <uuid>` CONTINUES it (subsequent turns) —
25
+ * both restore/establish full conversation continuity. The captured id still comes
26
+ * back on the result so the caller (the registry) can persist it onto the note.
27
+ * - `-p` has NO TUI → no consent gates at all (this backend avoids the #70/#71
28
+ * class by construction). Hence NO `--dangerously-load-development-channels`.
29
+ *
30
+ * ── What's deliberately ABSENT vs the interactive spawn ─────────────────────────
31
+ * - NO channel MCP entry. The daemon mediates messaging in this backend: it
32
+ * hands the agent the inbound text as the `-p` prompt, and turns the returned
33
+ * reply into an outbound `#agent/message/outbound` note itself (the wiring
34
+ * follow-up). The agent's `.mcp.json` carries the VAULT MCP only — so the agent
35
+ * has memory + tools, but inbound/outbound is the daemon's job, not the agent's.
36
+ * - NO `--dangerously-load-development-channels`, NO consent-gate auto-confirm.
37
+ *
38
+ * ── What's REUSED (not reinvented) ──────────────────────────────────────────────
39
+ * - `buildAgentChildEnv` — env scrub + `CLAUDE_CODE_OAUTH_TOKEN` inject + the #68
40
+ * per-channel env injection + the ANTHROPIC_API_KEY/CLAUDE_API_KEY denylist.
41
+ * - `resolveClaudeCredential` + `resolveChannelEnv` (credentials.ts) — the
42
+ * per-channel secret/env stores.
43
+ * - `seedAgentHome` — the per-session writable HOME/config/tmp (stability keystone).
44
+ * - `wrapArgvInSandbox` (spawn-agent.ts) — the SHARED sandbox seam: same egress
45
+ * floor + scoped-read confinement the interactive spawn gets.
46
+ * - `mintScopedToken` + `buildAgentMcpConfigJson` — the vault token mint + the
47
+ * inline MCP config writer.
48
+ *
49
+ * ── Single turn, serial per channel ─────────────────────────────────────────────
50
+ * {@link ProgrammaticBackend.deliver} runs ONE turn. The DAEMON (wiring follow-up)
51
+ * owns per-channel SERIAL processing — never two concurrent `claude -p` for the
52
+ * same channel/session, which would FORK the conversation. This backend does not
53
+ * itself enforce that ordering; it records the latest session id and runs the turn
54
+ * it is handed.
55
+ */
56
+
57
+ import { mkdirSync, writeFileSync } from "node:fs";
58
+ import { join } from "node:path";
59
+ import type { AgentSpec } from "../sandbox/types.ts";
60
+ import type { InboundAttachment } from "../transport.ts";
61
+ import { normalizeChannel } from "../sandbox/types.ts";
62
+ import type { SandboxEngine } from "../sandbox/index.ts";
63
+ import {
64
+ buildAgentChildEnv,
65
+ mergeSandboxLaunchEnv,
66
+ resolveAgentCwd,
67
+ seedAgentHome,
68
+ sessionWorkspace,
69
+ shellJoin,
70
+ wrapArgvInSandbox,
71
+ } from "../spawn-agent.ts";
72
+ import {
73
+ mintScopedToken,
74
+ vaultScope,
75
+ type MintTokenDeps,
76
+ } from "../mint-token.ts";
77
+ import { buildAgentMcpConfigJson, vaultEntryKey } from "../agent-mcp-config.ts";
78
+ import { resolveClaudeCredential, resolveChannelEnv } from "../credentials.ts";
79
+ import { resolveInjectedGrants, type GrantsClient } from "../grants.ts";
80
+ import { parseStreamJsonStream } from "./stream-json.ts";
81
+ import type {
82
+ AgentBackend,
83
+ AgentHandle,
84
+ AgentStatus,
85
+ DeliverResult,
86
+ DeliverUsage,
87
+ InterimSink,
88
+ TurnSession,
89
+ } from "./types.ts";
90
+
91
+ /** Same slug shape `spawnAgent` enforces — a name lands in a path segment. */
92
+ const AGENT_NAME_SLUG = /^[a-z0-9_-]+$/i;
93
+
94
+ export const PROGRAMMATIC_BACKEND_KIND = "programmatic" as const;
95
+
96
+ /** The staging subdir (under the PRIVATE session workspace) inbound files are written into. */
97
+ export const ATTACHMENT_STAGING_DIR = "attachments" as const;
98
+ /**
99
+ * Per-attachment byte ceiling for staging. Matches the vault's own 100MB upload cap
100
+ * (parachute-vault `/api/storage` POST), so we never refuse a file the vault accepted —
101
+ * but caps a runaway/over-large blob from filling the workspace.
102
+ */
103
+ export const ATTACHMENT_MAX_BYTES = 100 * 1024 * 1024;
104
+ /** Max number of attachments staged per turn — a sane bound on fan-out. */
105
+ export const ATTACHMENT_MAX_COUNT = 20;
106
+
107
+ /**
108
+ * Sanitize a (possibly untrusted, possibly path-ful) attachment filename/path to a SAFE
109
+ * BASENAME for staging — NO path traversal, NO directory components. `path`/`filename`
110
+ * come from VAULT DATA (not the operator), so this is the security boundary: we take the
111
+ * LAST path segment, drop any `..`/empty segments, strip NUL + leading dots, and replace
112
+ * every character outside `[A-Za-z0-9._-]` with `_`. The result can ONLY name a file
113
+ * DIRECTLY inside the staging dir — never escape it. Returns `"file"` for a degenerate
114
+ * input so a write target always exists. The caller additionally verifies the joined
115
+ * path stays under the staging dir (defense in depth).
116
+ */
117
+ export function safeAttachmentBasename(name: string): string {
118
+ // Take the final segment across both slash flavors; this alone defeats `../../etc/x`
119
+ // (every `..` and the leading dirs are discarded — only the trailing segment survives).
120
+ const segments = name.split(/[/\\]+/);
121
+ let base = segments.length > 0 ? segments[segments.length - 1]! : "";
122
+ // Strip NUL bytes + control chars, collapse disallowed chars to `_`.
123
+ base = base.replace(/\0/g, "").replace(/[^A-Za-z0-9._-]/g, "_");
124
+ // No leading dots (no `.`, `..`, or hidden-file surprises).
125
+ base = base.replace(/^\.+/, "");
126
+ if (base.length === 0 || base === "." || base === "..") return "file";
127
+ // Bound the length so a pathological name can't blow up the path.
128
+ return base.slice(0, 200);
129
+ }
130
+
131
+ /**
132
+ * The minimal subprocess shape the runner awaits — a slice of `Bun.spawn`'s return
133
+ * (stdout/stderr streams + `exited`). Tests inject a fake that emits canned
134
+ * stream-json so no real `claude` is ever spawned.
135
+ */
136
+ export interface SpawnedProc {
137
+ stdout: ReadableStream<Uint8Array> | null;
138
+ stderr: ReadableStream<Uint8Array> | null;
139
+ exited: Promise<number>;
140
+ }
141
+ export type ProgrammaticSpawnFn = (
142
+ argv: string[],
143
+ opts: { env: Record<string, string | undefined>; cwd: string },
144
+ ) => SpawnedProc;
145
+
146
+ /** Wiring the programmatic backend resolves its launch side-effects from. */
147
+ export interface ProgrammaticBackendDeps {
148
+ /** Hub origin + manager bearer for minting the vault token (§4.3). */
149
+ hubOrigin: string;
150
+ managerBearer: string;
151
+ /** Vault base URL (if the spec binds a vault). Defaults to hubOrigin. */
152
+ vaultUrl?: string;
153
+ /** Base for session workspaces (e.g. `~/.parachute/agent/sessions`). */
154
+ sessionsDir: string;
155
+ /** Read-only runtime/config binds the sandbox always grants (the claude config dir, …). */
156
+ runtimeReadOnly: string[];
157
+ /** Resolve the Claude OAuth token (channel override ?? default ?? throw). Stub in tests. */
158
+ resolveClaudeToken?: (channel: string) => string;
159
+ /** Resolve the per-channel env injection (GH_TOKEN, CLOUDFLARE_API_TOKEN, …). Stub in tests. */
160
+ resolveChannelEnv?: (channel: string) => Record<string, string>;
161
+ /** Sandbox engine override (tests inject a fake). */
162
+ sandboxEngine?: SandboxEngine;
163
+ /** fetch override for the mint client (tests). */
164
+ fetchFn?: typeof fetch;
165
+ /**
166
+ * The hub grants client (4b — design 2026-06-17-agent-connectors-4b.md). When
167
+ * wired, each turn fetches the agent's APPROVED cross-resource grants FRESH and
168
+ * injects their material: granted-vault material → an extra MCP server in the
169
+ * agent's `--mcp-config`; granted-service material → an env var (e.g. GITHUB_TOKEN)
170
+ * and/or the service's MCP server. Fetched per-turn (never cached) so a revocation
171
+ * takes effect on the NEXT spawn. Optional: null/absent → no cross-resource grants
172
+ * (own-vault only, today's behavior). A grants-list failure is logged + the turn
173
+ * runs WITHOUT the extra grants (own-vault still works).
174
+ */
175
+ grants?: GrantsClient | null;
176
+ /**
177
+ * The agent NAME used to key the agent's grants on the hub (`GET
178
+ * /admin/grants?agent=<name>`). Defaults to `spec.name` when absent. The grants are
179
+ * keyed by the agent name (= the def's name), which equals `spec.name` for a
180
+ * vault-native agent. Threaded explicitly so a future channel/agent-name split
181
+ * doesn't silently fetch the wrong agent's grants.
182
+ */
183
+ grantsAgentName?: string;
184
+ /**
185
+ * The subprocess spawner — runs the sandbox-wrapped `claude -p`. Tests inject a
186
+ * fake that emits canned stream-json; the daemon uses the real Bun.spawn adapter.
187
+ */
188
+ spawnFn: ProgrammaticSpawnFn;
189
+ /** Parent env to scrub from. Defaults to process.env. */
190
+ parentEnv?: Record<string, string | undefined>;
191
+ /** claude binary. Defaults to "claude". */
192
+ claudeBin?: string;
193
+ /** Optional ripgrep override threaded to the sandbox (macOS deny-path scan). */
194
+ ripgrep?: { command: string; args?: string[] };
195
+ /**
196
+ * Sleep used by the turn-level transient-retry backoff. Injected so tests don't
197
+ * actually wait the backoff. Defaults to a real `setTimeout`-backed sleep.
198
+ */
199
+ sleepFn?: (ms: number) => Promise<void>;
200
+ }
201
+
202
+ /**
203
+ * Turn-level retry on TRANSIENT upstream errors (API 529/overload, 5xx, rate-limit,
204
+ * network). A 529 with no retry is exactly the "silent no-reply" a user hits under
205
+ * load; incremental backoff turns most of those into a delivered reply. The detector
206
+ * ({@link isTransientTurnError}) is conservative — a 4xx (auth/validation), a missing
207
+ * credential, or a deterministic subtype failure is NOT retried (it'd only burn time).
208
+ */
209
+ export const TURN_MAX_ATTEMPTS = 3;
210
+ /** Incremental backoff before each retry (ms); length = TURN_MAX_ATTEMPTS - 1. */
211
+ export const TURN_RETRY_BACKOFF_MS: readonly number[] = [2_000, 5_000];
212
+
213
+ /** Does this turn-failure reason look like a transient upstream error worth retrying? */
214
+ export function isTransientTurnError(reason: string): boolean {
215
+ const r = reason.toLowerCase();
216
+ return (
217
+ /\b(429|500|502|503|504|529)\b/.test(reason) ||
218
+ r.includes("overloaded") ||
219
+ r.includes("rate limit") ||
220
+ r.includes("rate_limit") ||
221
+ r.includes("service unavailable") ||
222
+ r.includes("bad gateway") ||
223
+ r.includes("gateway time") ||
224
+ r.includes("internal server error") ||
225
+ r.includes("temporarily") ||
226
+ r.includes("timed out") ||
227
+ r.includes("timeout") ||
228
+ r.includes("etimedout") ||
229
+ r.includes("econnreset")
230
+ );
231
+ }
232
+
233
+ /**
234
+ * Does this turn-failure reason look like a `--resume` of a session that no longer
235
+ * exists — claude's "No conversation found with session ID" class (expiry / transcript
236
+ * cleanup / a missing session jsonl)? Governs the ONE-TIME fresh-create fallback in
237
+ * {@link ProgrammaticBackend.deliver} that keeps an expired session from BRICKING the
238
+ * thread on every future turn (issue #132).
239
+ *
240
+ * ⚠️ TEXT-BASED — this matches claude's ERROR WORDING (anthropics/claude-code#33912),
241
+ * which is VERSION-FRAGILE: if claude changes the phrasing this detector silently stops
242
+ * matching. It is deliberately CONSERVATIVE — it only governs a RECOVERY fallback, so a
243
+ * MISS degrades to today's behavior (the pre-existing brick), never anything worse; it
244
+ * can't, e.g., turn a real failure into a spurious success. A future STRUCTURED error
245
+ * signal (an exit-code or a stream-json error subtype) would be more robust and is the
246
+ * preferred long-term fix. Kept tight enough not to match a generic failure: it requires
247
+ * "conversation"/"session" near the not-found phrasing (never a bare "not found").
248
+ */
249
+ export function isSessionNotFoundError(reason: string): boolean {
250
+ return /no conversation found|session not found|no session (?:found |with )|conversation .{0,30}not found|could not find .{0,20}session/i.test(
251
+ reason,
252
+ );
253
+ }
254
+
255
+ /** Default real sleep for the retry backoff (overridable via `deps.sleepFn` in tests). */
256
+ const realSleep = (ms: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms));
257
+
258
+ /**
259
+ * Build the `claude -p` invocation argv (PRE-sandbox-wrap) for one turn.
260
+ *
261
+ * The verified shape (claude 2.1.179): headless `-p` with the message as the
262
+ * prompt, stream-json output, the strict multi-entry MCP config, and
263
+ * skip-permissions (the turn is autonomous; the sandbox is the containment). The
264
+ * caller resolves the turn's session (the daemon owns the uuid — it lives on the
265
+ * `#agent/thread` note): when `sessionId` is present, `--resume <id>` CONTINUES the
266
+ * prior conversation (`resumeSession: true`) or `--session-id <id>` CREATES a session
267
+ * with that uuid (`resumeSession: false`).
268
+ *
269
+ * DELIBERATELY ABSENT: `--dangerously-load-development-channels` (no channel MCP in
270
+ * this backend — the daemon mediates messaging), and any TUI flag (`-p` has none).
271
+ *
272
+ * SYSTEM PROMPT (design 2026-06-16-channel-system-prompt.md): when the spec carries
273
+ * a `systemPrompt`, the per-session prompt FILE path is passed via the `-file`
274
+ * variant — `--append-system-prompt-file <path>` (append mode, keeps CC's default)
275
+ * or `--system-prompt-file <path>` (replace mode). The flags are PER-INVOCATION, so
276
+ * this is added on EVERY turn (including `--resume` turns) — the argv is rebuilt per
277
+ * `deliver`, and the file is (re)written each turn (see {@link ProgrammaticBackend.deliver}).
278
+ * The `-file` form (over the inline string form) is robust to long/multiline prompts
279
+ * and keeps the prompt visible-on-disk to the backend.
280
+ */
281
+ export function buildProgrammaticClaudeArgs(opts: {
282
+ message: string;
283
+ mcpConfigPath: string;
284
+ /** The Claude session UUID for this turn (caller-resolved). Omitted → no session flag. */
285
+ sessionId?: string;
286
+ /** true → `--resume <sessionId>` (continue); false (default) → `--session-id <sessionId>` (create). */
287
+ resumeSession?: boolean;
288
+ claudeBin?: string;
289
+ /** Path to the per-session system-prompt file (omitted = no system-prompt flag). */
290
+ systemPromptFile?: string;
291
+ /** How the system prompt composes — append (default) keeps CC's base; replace overrides it. */
292
+ systemPromptMode?: "append" | "replace";
293
+ /**
294
+ * Model to run the turn on (`claude -p --model <value>`) — a CC alias
295
+ * (`opus`/`sonnet`/`haiku`) or a full model id. Omitted/empty → no `--model`
296
+ * flag, inheriting CC's default. Passed as a discrete argv element (no shell).
297
+ */
298
+ model?: string;
299
+ }): string[] {
300
+ const bin = opts.claudeBin ?? "claude";
301
+ const argv = [
302
+ bin,
303
+ "-p",
304
+ opts.message,
305
+ "--output-format",
306
+ "stream-json",
307
+ "--verbose",
308
+ "--strict-mcp-config",
309
+ "--mcp-config",
310
+ opts.mcpConfigPath,
311
+ "--dangerously-skip-permissions",
312
+ ];
313
+ // Model is OPTIONAL — only add the flag when the spec set one, so an unset
314
+ // model inherits Claude Code's own default rather than pinning a value here.
315
+ if (typeof opts.model === "string" && opts.model.trim().length > 0) {
316
+ argv.push("--model", opts.model.trim());
317
+ }
318
+ // System prompt (file-backed). Append KEEPS CC's capable default + adds the role;
319
+ // replace overrides it entirely. Re-passed every turn (the flag isn't persistent).
320
+ if (opts.systemPromptFile) {
321
+ const flag = opts.systemPromptMode === "replace" ? "--system-prompt-file" : "--append-system-prompt-file";
322
+ argv.push(flag, opts.systemPromptFile);
323
+ }
324
+ if (opts.sessionId) {
325
+ argv.push(opts.resumeSession ? "--resume" : "--session-id", opts.sessionId);
326
+ }
327
+ return argv;
328
+ }
329
+
330
+ /** Read the full text of a (possibly null) byte stream; null/error → "". */
331
+ async function drainStream(stream: ReadableStream<Uint8Array> | null): Promise<string> {
332
+ if (!stream) return "";
333
+ try {
334
+ return await new Response(stream).text();
335
+ } catch {
336
+ return "";
337
+ }
338
+ }
339
+
340
+ /**
341
+ * The programmatic backend — one sandboxed `claude -p` turn per message.
342
+ *
343
+ * `start` is lightweight (no resident process; a "session" is just the uuid the
344
+ * caller resolves per turn, persisted on the thread note — not here). `deliver` runs
345
+ * the turn with the caller-supplied {@link TurnSession} and returns a
346
+ * {@link DeliverResult} — a failure is a VALUE (`{ ok: false, error }`), never a
347
+ * throw. `stop` is a no-op (no process to kill, no store to clear — the session lives
348
+ * on the durable thread note). `status` is always live (there is nothing to keep alive).
349
+ */
350
+ export class ProgrammaticBackend implements AgentBackend {
351
+ readonly kind = PROGRAMMATIC_BACKEND_KIND;
352
+ private readonly deps: ProgrammaticBackendDeps;
353
+
354
+ constructor(deps: ProgrammaticBackendDeps) {
355
+ this.deps = deps;
356
+ }
357
+
358
+ /**
359
+ * Bring an agent up for a channel. There is no resident process (and no session to
360
+ * pre-establish — the session uuid is resolved per turn by the caller and lives on
361
+ * the thread note) — this validates the spec and returns a handle keyed on the wake
362
+ * channel (the first channel). The actual `claude -p` invocation happens per-message
363
+ * in {@link deliver}.
364
+ */
365
+ async start(spec: AgentSpec): Promise<AgentHandle> {
366
+ if (!AGENT_NAME_SLUG.test(spec.name)) {
367
+ throw new Error(
368
+ `ProgrammaticBackend.start: spec name "${spec.name}" must be a slug ` +
369
+ `(alphanumeric, dash, underscore only)`,
370
+ );
371
+ }
372
+ if (spec.channels.length === 0) {
373
+ throw new Error(`ProgrammaticBackend.start: spec "${spec.name}" declares no channels`);
374
+ }
375
+ const channel = normalizeChannel(spec.channels[0]!).name;
376
+ return { backend: this.kind, channel, name: spec.name, spec };
377
+ }
378
+
379
+ /**
380
+ * Run ONE `claude -p` turn for the handle's channel and return its reply.
381
+ *
382
+ * Order: resolve the Claude credential (throws → the daemon surfaces it) → mint
383
+ * the VAULT token (if the spec binds a vault) → write the vault-only `.mcp.json`
384
+ * → build the `-p` argv (with the caller's {@link TurnSession}: `--resume <id>` to
385
+ * continue, `--session-id <id>` to create) → sandbox-wrap via the shared seam →
386
+ * spawn → STREAM + parse the stream-json → return the DeliverResult (carrying the
387
+ * captured session id so the caller can persist it onto the thread note).
388
+ *
389
+ * STREAMING (design build item #1): the stdout stream-json is read INCREMENTALLY
390
+ * via {@link parseStreamJsonStream}. When `onInterim` is given, interim events
391
+ * (assistant text chunks + tool_use) are emitted as the turn runs so the daemon
392
+ * can render "watch it work" live; the FINAL parse (the authoritative `result`)
393
+ * is identical whether or not a sink is wired — the durable outbound note path is
394
+ * unchanged. `onInterim` is best-effort and must not throw.
395
+ *
396
+ * A failure (mint refused, non-zero exit, `is_error: true`, non-success subtype,
397
+ * empty output) returns `{ ok: false, error }` — it does NOT throw, so the daemon
398
+ * always learns the outcome inline.
399
+ *
400
+ * SESSION-EXPIRY SELF-HEAL (#132): a `--resume` turn whose Claude session no longer
401
+ * exists ("No conversation found with session ID" — expiry / transcript cleanup) is
402
+ * NOT a transient error (no retry helps) and, left alone, would BRICK the thread on
403
+ * EVERY future turn (the stale id stays on the thread note). When a resume turn fails
404
+ * with a {@link isSessionNotFoundError} reason, `deliver` falls back ONCE to a fresh
405
+ * `--session-id <new uuid>` create, re-establishing continuity from this turn forward.
406
+ * The new turn's echoed session id flows out on the {@link DeliverResult} exactly as
407
+ * usual, so the registry persists the NEW id onto the thread note and later turns
408
+ * resume it. The fallback fires AT MOST once (only on a resume turn; the create it
409
+ * runs has `resume: false`, so it can never re-trigger).
410
+ */
411
+ async deliver(
412
+ handle: AgentHandle,
413
+ message: string,
414
+ session: TurnSession,
415
+ onInterim?: InterimSink,
416
+ attachments?: InboundAttachment[],
417
+ ): Promise<DeliverResult> {
418
+ const spec = handle.spec;
419
+ if (!spec) {
420
+ return { ok: false, error: `ProgrammaticBackend.deliver: handle for "${handle.name}" carries no spec` };
421
+ }
422
+ const channel = handle.channel;
423
+ const workspace = sessionWorkspace(this.deps.sessionsDir, spec.name);
424
+
425
+ // Resolve the Claude OAuth credential keyed on the wake channel. A missing
426
+ // credential throws (CredentialNotConfigured) BEFORE any mint/spawn side effect.
427
+ const resolveToken = this.deps.resolveClaudeToken ?? ((ch: string) => resolveClaudeCredential(ch));
428
+ let claudeOauthToken: string;
429
+ try {
430
+ claudeOauthToken = resolveToken(channel);
431
+ } catch (err) {
432
+ return { ok: false, error: (err as Error).message };
433
+ }
434
+
435
+ // Per-channel env injection (GH_TOKEN, CLOUDFLARE_API_TOKEN, …), read at turn time.
436
+ const resolveEnv = this.deps.resolveChannelEnv ?? ((ch: string) => resolveChannelEnv(ch));
437
+ const channelEnv = resolveEnv(channel);
438
+
439
+ // Mint the VAULT token only — no channel MCP in this backend (the daemon
440
+ // mediates messaging). A spec with no vault gets an EMPTY mcpServers config
441
+ // (the agent still runs; it just has no vault tools this turn).
442
+ //
443
+ // FIX 2 (PR #3) — mid-turn token expiry, ASSESSED + DEFERRED (no re-mint added).
444
+ // The vault write token is MINTED FRESH per turn here (no `expiresIn` override → the
445
+ // hub default ~90d non-ephemeral TTL), so it CANNOT expire during a single `claude -p`
446
+ // turn (which lasts minutes). And the vault WRITES are made by the OPAQUE `claude -p`
447
+ // subprocess via the token baked into its 0600 `.mcp.json` (below) — the backend has
448
+ // NO in-process seam to observe a 401 from those writes and re-inject a new token
449
+ // mid-turn. A re-mint-on-401 would require the MCP-client-in-subprocess to surface
450
+ // 401s back here, which the architecture doesn't provide. So a re-mint is INFEASIBLE
451
+ // (and unnecessary given the fresh-per-turn ~90d mint). If a future long-running /
452
+ // multi-day single turn or a short operator-pinned TTL ever makes mid-turn expiry
453
+ // real, the fix is at the MCP-client layer (refresh-on-401), tracked as a follow-up
454
+ // — NOT a forced backend re-mint that can't see the failure.
455
+ let vaultArg: { url: string; entry: { name: string; token: string } } | undefined;
456
+ if (spec.vault) {
457
+ const v = spec.vault;
458
+ const mintDeps: MintTokenDeps = {
459
+ hubOrigin: this.deps.hubOrigin,
460
+ managerBearer: this.deps.managerBearer,
461
+ ...(this.deps.fetchFn ? { fetchFn: this.deps.fetchFn } : {}),
462
+ };
463
+ try {
464
+ const minted = await mintScopedToken(
465
+ {
466
+ scope: vaultScope(v.name, v.access),
467
+ audience: `vault.${v.name}`,
468
+ ...(v.tags && v.tags.length > 0 ? { permissions: { scoped_tags: v.tags } } : {}),
469
+ },
470
+ mintDeps,
471
+ );
472
+ vaultArg = {
473
+ url: this.deps.vaultUrl ?? this.deps.hubOrigin,
474
+ entry: { name: v.name, token: minted.token },
475
+ };
476
+ } catch (err) {
477
+ // A refused/over-broad mint aborts the turn with a clean error — no spawn.
478
+ return { ok: false, error: (err as Error).message };
479
+ }
480
+ }
481
+
482
+ // 4b: resolve the agent's APPROVED cross-resource grants FRESH this turn (design
483
+ // 2026-06-17-agent-connectors-4b.md §3). Granted-vault material → extra MCP
484
+ // servers (the agent reaches OTHER vaults alongside its own); granted-service
485
+ // material → env vars (GITHUB_TOKEN, …) and/or the service's MCP server. Fetched
486
+ // per-turn (never cached) so a revocation takes effect next spawn. Best-effort: a
487
+ // grants-list failure logs + the turn runs WITHOUT the extra grants — own-vault is
488
+ // unaffected. The secret material lands ONLY in the ephemeral 0600 .mcp.json + the
489
+ // child env below; NEVER in a vault note. mcp-kind grants stay pending server-side
490
+ // in 4b-1 (no OAuth) → getMaterial returns null for them → never injected.
491
+ let grantMcpEntries: { name: string; url: string; token: string }[] = [];
492
+ let grantEnv: Record<string, string> = {};
493
+ if (this.deps.grants) {
494
+ // The grants are keyed on the hub by the AGENT name, which for vault-native defs
495
+ // is `spec.name`. `grantsAgentName` is an explicit override reserved for a future
496
+ // channel-name≠agent-name split; today no caller sets it, so it falls through to
497
+ // `spec.name` — do NOT set it unless that split lands (else you'd fetch the wrong
498
+ // agent's grants).
499
+ const agentName = this.deps.grantsAgentName ?? spec.name;
500
+ try {
501
+ const injected = await resolveInjectedGrants(this.deps.grants, agentName);
502
+ grantMcpEntries = injected.mcpEntries;
503
+ grantEnv = injected.env;
504
+ } catch (err) {
505
+ // A failed grant LIST aborts only the cross-resource injection — the turn
506
+ // still runs with own-vault. (A revoked-mid-list / hub blip class.)
507
+ console.warn(
508
+ `parachute-agent: resolving grants for "${agentName}" failed (running this turn ` +
509
+ `WITHOUT cross-resource grants — own-vault unaffected): ${(err as Error).message}`,
510
+ );
511
+ }
512
+ }
513
+
514
+ // Write the strict MCP config 0600 — it inlines the vault token + any granted-
515
+ // resource tokens. No channels[] entry: messaging is the daemon's job, not the
516
+ // agent's. With an empty `channels`, `channelUrl` is never read (it only builds
517
+ // `/mcp/<channel>` entry URLs), so we pass "" rather than thread an unrelated
518
+ // origin into a slot that goes nowhere. The granted MCP servers are added as
519
+ // `otherMcps` (each with its own Bearer) — additive to the own-vault entry.
520
+ const mcpConfigJson = buildAgentMcpConfigJson({
521
+ channelUrl: "",
522
+ channels: [],
523
+ ...(vaultArg ? { vault: vaultArg } : {}),
524
+ ...(grantMcpEntries.length > 0 ? { otherMcps: grantMcpEntries } : {}),
525
+ });
526
+ mkdirSync(workspace, { recursive: true });
527
+ const mcpConfigPath = join(workspace, ".mcp.json");
528
+ writeFileSync(mcpConfigPath, mcpConfigJson, { mode: 0o600 });
529
+
530
+ // ── INBOUND FILE ATTACHMENTS (Phase 1) ─────────────────────────────────────────
531
+ // Stage each attached file into the agent's PRIVATE session workspace (under a SAFE
532
+ // basename — NO path traversal; the path/filename come from VAULT DATA), then append a
533
+ // pointer line to the turn message so the `claude -p` turn can `Read` them. The private
534
+ // workspace is already in the sandbox read scope (composeFilesystemView always allows
535
+ // `base.workspace`), so NO sandbox-policy change is needed. Staged into the PRIVATE dir
536
+ // (NEVER a shared `spec.workspace`) — mirroring how `.mcp.json`/`system-prompt.txt` stay
537
+ // per-agent even when the working dir is shared. Best-effort + isolated: a single
538
+ // attachment's fetch/stage failure logs + is SKIPPED (the turn still runs with the rest
539
+ // + the text). Absent/empty → no staging, no prompt change (today's behavior exactly).
540
+ let turnMessage = message;
541
+ if (attachments && attachments.length > 0) {
542
+ const staged = await this.stageAttachments(workspace, attachments, vaultArg);
543
+ if (staged.length > 0) {
544
+ const lines = staged.map((s) => `- ${s.absPath} (${s.mimeType})`);
545
+ turnMessage =
546
+ `${message}\n\n[Attached files — read them as needed:\n${lines.join("\n")}\n]`;
547
+ }
548
+ }
549
+
550
+ // System prompt (design 2026-06-16-channel-system-prompt.md). When the spec
551
+ // carries one, write it to a per-session file (0600) and pass the `-file` flag.
552
+ // The flag is PER-INVOCATION (not persistent), so we (re)write the file + pass
553
+ // it EVERY turn — including a `--resume` turn — so the role is always applied.
554
+ // Unset → no flag, no file (today's behavior unchanged). The `-file` form is
555
+ // robust to long/multiline prompts and keeps the prompt visible-on-disk. Its
556
+ // lifecycle is tied to the workspace (like .mcp.json) — it disappears with it.
557
+ let systemPromptFile: string | undefined;
558
+ if (typeof spec.systemPrompt === "string" && spec.systemPrompt.length > 0) {
559
+ systemPromptFile = join(workspace, "system-prompt.txt");
560
+ writeFileSync(systemPromptFile, spec.systemPrompt, { mode: 0o600 });
561
+ }
562
+
563
+ // The agent's WORKING dir (design 2026-06-16-agent-filesystem-and-sharing.md):
564
+ // the spec's `workspace` (a shared real dir) when set, else the private session
565
+ // dir (today's behavior). The cwd is decoupled from the private dir —
566
+ // `.mcp.json`/`system-prompt.txt`/seeded home stay PRIVATE under the session
567
+ // dir (passed by absolute path), so a shared workspace never receives the
568
+ // agent's secrets even when two agents point at the same dir.
569
+ const cwd = resolveAgentCwd(spec, workspace);
570
+
571
+ // The agent's private, writable, pre-seeded HOME + temp dirs (stability
572
+ // keystone) — always UNDER the private workspace, regardless of the cwd. The
573
+ // vault MCP server name is pre-approved so claude doesn't prompt; the pre-trusted
574
+ // project is the agent's actual cwd (the shared working dir when set).
575
+ const mcpServerNames = Object.keys(
576
+ (JSON.parse(mcpConfigJson) as { mcpServers?: Record<string, unknown> }).mcpServers ?? {},
577
+ );
578
+ const homeEnv = seedAgentHome(workspace, { mcpServers: mcpServerNames, projectRoot: cwd });
579
+
580
+ // Merge the granted-service env (GITHUB_TOKEN, …) with the operator-scoped
581
+ // per-channel env. The per-channel store wins on a key collision (it's the
582
+ // explicit operator override); both go in at the SAME (lowest) precedence layer of
583
+ // buildAgentChildEnv — which then applies its denylist (ANTHROPIC_API_KEY /
584
+ // CLAUDE_API_KEY / CLAUDE_CODE_OAUTH_TOKEN can NEVER be set from either source) and
585
+ // sets CLAUDE_CODE_OAUTH_TOKEN LAST, so a granted var can never clobber the
586
+ // session's managed auth or the subscription-billing guarantee.
587
+ const mergedChannelEnv: Record<string, string> = { ...grantEnv, ...channelEnv };
588
+
589
+ // Layer the scrubbed agent env UNDER the sandbox wrapper's env; the HOME/config/
590
+ // temp vars layer LAST so they win. CLAUDE_CODE_OAUTH_TOKEN injected;
591
+ // ANTHROPIC_API_KEY/CLAUDE_API_KEY absent (the subscription-billing guarantee).
592
+ // (Session-INDEPENDENT — computed ONCE; the per-turn `wrapped.env` layers on top
593
+ // inside `attemptTurn` below.)
594
+ const childEnv = buildAgentChildEnv(
595
+ this.deps.parentEnv ?? process.env,
596
+ claudeOauthToken,
597
+ mergedChannelEnv,
598
+ );
599
+
600
+ // The interim sink is best-effort + must not throw (session-INDEPENDENT). A push
601
+ // to a closed SSE stream / a sink fault must never break the turn.
602
+ const safeInterim: InterimSink = (e) => {
603
+ if (!onInterim) return;
604
+ try {
605
+ onInterim(e);
606
+ } catch {
607
+ // A push to a closed SSE stream / a sink fault must never break the turn.
608
+ }
609
+ };
610
+ const sleepFn = this.deps.sleepFn ?? realSleep;
611
+
612
+ // Run ONE turn for the given session — build the session-DEPENDENT argv
613
+ // (`--resume <id>` vs `--session-id <id>`), sandbox-wrap it, then run the bounded
614
+ // transient-retry loop. Relocated into a closure so a session-expiry FALLBACK can
615
+ // run it a SECOND time with a fresh create session (#132). The session-independent
616
+ // setup above (workspace, .mcp.json, system-prompt file, cwd, home/child env,
617
+ // interim sink) is shared across both attempts.
618
+ const attemptTurn = async (turnSession: TurnSession): Promise<DeliverResult> => {
619
+ // The DAEMON owns the session uuid (the caller resolved it from the durable
620
+ // `#agent/thread` note — single-threaded resumes its persisted session, multi-
621
+ // threaded gets a fresh uuid every fire). The backend reads no session store: it
622
+ // just runs the turn with the supplied {@link TurnSession} — `--resume <id>` to
623
+ // continue, `--session-id <id>` to create.
624
+ const argv = buildProgrammaticClaudeArgs({
625
+ message: turnMessage,
626
+ mcpConfigPath,
627
+ sessionId: turnSession.id,
628
+ resumeSession: turnSession.resume,
629
+ ...(this.deps.claudeBin ? { claudeBin: this.deps.claudeBin } : {}),
630
+ ...(systemPromptFile
631
+ ? { systemPromptFile, systemPromptMode: spec.systemPromptMode ?? "append" }
632
+ : {}),
633
+ ...(spec.model ? { model: spec.model } : {}),
634
+ });
635
+
636
+ // Sandbox-wrap via the SHARED seam — same egress floor + scoped-read
637
+ // confinement the interactive spawn gets. The wrapped argv carries the policy.
638
+ const wrapped = await wrapArgvInSandbox({
639
+ spec,
640
+ workspace,
641
+ runtimeReadOnly: this.deps.runtimeReadOnly,
642
+ hubOrigin: this.deps.hubOrigin,
643
+ ...(this.deps.vaultUrl ? { vaultUrl: this.deps.vaultUrl } : {}),
644
+ argv,
645
+ ...(this.deps.sandboxEngine ? { sandboxEngine: this.deps.sandboxEngine } : {}),
646
+ ...(this.deps.ripgrep ? { ripgrep: this.deps.ripgrep } : {}),
647
+ });
648
+
649
+ // Compose the launch env so the SCRUB WINS: the scrubbed `childEnv` is
650
+ // authoritative; only the ALLOWLISTED sandbox/proxy keys from `wrapped.env`
651
+ // (NOT the whole daemon `process.env` the engine returns) + the home overrides
652
+ // layer on top. A bare `...wrapped.env` spread would re-admit the daemon's
653
+ // ambient ANTHROPIC_API_KEY/secrets and defeat buildAgentChildEnv's scrub —
654
+ // an isolation/billing leak. See mergeSandboxLaunchEnv.
655
+ const launchEnv = mergeSandboxLaunchEnv(childEnv, wrapped.env, homeEnv);
656
+
657
+ // Run the turn, with a bounded retry on TRANSIENT upstream errors (API 529/overload,
658
+ // 5xx, rate-limit, network). The argv is fixed (built above for THIS turn's session),
659
+ // so each attempt re-runs the SAME turn. STREAM stdout incrementally (interim events
660
+ // for the live view) while draining stderr in parallel; the interim sink is best-effort
661
+ // + must not throw. A spawn/IO fault is a value (not a throw); a non-transient failure
662
+ // or exhausted retries returns the failure for the daemon to learn.
663
+ for (let attempt = 1; attempt <= TURN_MAX_ATTEMPTS; attempt++) {
664
+ let parsed;
665
+ let stderr: string;
666
+ let code: number;
667
+ try {
668
+ const proc = this.deps.spawnFn(wrapped.argv, { env: launchEnv, cwd });
669
+ [parsed, stderr] = await Promise.all([
670
+ parseStreamJsonStream(proc.stdout, safeInterim),
671
+ drainStream(proc.stderr),
672
+ ]);
673
+ code = await proc.exited;
674
+ } catch (err) {
675
+ // A spawn/IO fault (ENOENT, resource) is a config/permanent class — not retried.
676
+ return { ok: false, error: `claude -p spawn failed: ${(err as Error).message}` };
677
+ }
678
+
679
+ if (parsed.success === true) {
680
+ // The captured session id is RETURNED (below) for the caller to persist onto
681
+ // the thread note — the backend no longer owns a session store. The id we
682
+ // passed in (turnSession.id) and Claude's echoed parsed.sessionId are normally
683
+ // the same; the registry prefers the echoed one and falls back to turnSession.id.
684
+
685
+ const usage: DeliverUsage | undefined = parsed.usage
686
+ ? {
687
+ ...(typeof parsed.usage.input_tokens === "number" ? { inputTokens: parsed.usage.input_tokens } : {}),
688
+ ...(typeof parsed.usage.output_tokens === "number" ? { outputTokens: parsed.usage.output_tokens } : {}),
689
+ ...(typeof parsed.totalCostUsd === "number" ? { totalCostUsd: parsed.totalCostUsd } : {}),
690
+ }
691
+ : typeof parsed.totalCostUsd === "number"
692
+ ? { totalCostUsd: parsed.totalCostUsd }
693
+ : undefined;
694
+
695
+ return {
696
+ ok: true,
697
+ reply: parsed.reply ?? "",
698
+ ...(parsed.sessionId ? { sessionId: parsed.sessionId } : {}),
699
+ ...(usage ? { usage } : {}),
700
+ };
701
+ }
702
+
703
+ // FAILURE — compute the reason (non-zero exit / is_error / non-success subtype /
704
+ // no result event), same precedence as before.
705
+ const reason =
706
+ parsed.errorMessage ??
707
+ (parsed.subtype ? `claude -p turn failed (subtype: ${parsed.subtype})` : undefined) ??
708
+ (code !== 0
709
+ ? `claude -p exited ${code}${stderr.trim() ? `: ${stderr.trim().slice(0, 500)}` : ""}`
710
+ : "claude -p produced no success result (no result event in output)");
711
+
712
+ // Retry ONLY a transient error, and only while attempts remain (incremental backoff).
713
+ if (attempt < TURN_MAX_ATTEMPTS && isTransientTurnError(reason)) {
714
+ const backoff =
715
+ TURN_RETRY_BACKOFF_MS[attempt - 1] ?? TURN_RETRY_BACKOFF_MS[TURN_RETRY_BACKOFF_MS.length - 1] ?? 5_000;
716
+ console.warn(
717
+ `parachute-agent: transient turn error for channel "${channel}" ` +
718
+ `(attempt ${attempt}/${TURN_MAX_ATTEMPTS}, retrying in ${backoff}ms): ${reason}`,
719
+ );
720
+ await sleepFn(backoff);
721
+ continue;
722
+ }
723
+ // RETURN the session id even on a FINAL failure — a turn can fail AFTER
724
+ // establishing a session; the id is still the continuation handle for the next
725
+ // turn. The registry persists it onto the thread note (`result.sessionId ??
726
+ // turnSession.id`), so the next turn resumes the conversation.
727
+ // Non-transient, or out of attempts → return the failure (the daemon records
728
+ // status:error AND posts a user-facing failure note to the channel).
729
+ return {
730
+ ok: false,
731
+ error: reason,
732
+ ...(parsed.sessionId ? { sessionId: parsed.sessionId } : {}),
733
+ };
734
+ }
735
+ // Unreachable — every loop path returns — but satisfies the type checker.
736
+ return { ok: false, error: "claude -p: retries exhausted" };
737
+ };
738
+
739
+ let result = await attemptTurn(session);
740
+ // Session-expiry recovery (#132): a --resume turn whose session no longer exists is
741
+ // NOT transient (no retry would help) and would otherwise brick the thread on every
742
+ // future turn (the stale id stays on the note). Fall back ONCE to a fresh create so
743
+ // continuity self-heals from here — the new turn's echoed id flows out for the
744
+ // registry to persist. Only on a RESUME turn; the create itself is never retried this
745
+ // way (the fallback session has resume:false → a not-found on it can't re-trigger).
746
+ if (!result.ok && session.resume && isSessionNotFoundError(result.error)) {
747
+ const fresh = crypto.randomUUID();
748
+ console.warn(
749
+ `parachute-agent: resume session for channel "${channel}" not found (expired?) — ` +
750
+ `starting a fresh session ${fresh}: ${result.error}`,
751
+ );
752
+ result = await attemptTurn({ id: fresh, resume: false });
753
+ }
754
+ return result;
755
+ }
756
+
757
+ /**
758
+ * Stage inbound file attachments into the agent's PRIVATE session workspace so the turn
759
+ * can `Read` them (Phase 1). For each attachment: FETCH the blob from the vault storage
760
+ * REST endpoint (`GET <vaultUrl>/vault/<name>/api/storage/<path>`, Bearer the per-turn
761
+ * minted vault token), then WRITE it to `<workspace>/attachments/<safeBasename>`. Returns
762
+ * the staged files' ABSOLUTE paths + mime types (for the prompt pointer line).
763
+ *
764
+ * SECURITY:
765
+ * - The staged filename is a SAFE BASENAME ({@link safeAttachmentBasename}) — the vault
766
+ * `path`/`filename` are UNTRUSTED data, so a malicious `../../etc/passwd` collapses to a
767
+ * plain basename inside the staging dir. As defense in depth we ALSO verify the resolved
768
+ * write target stays UNDER the staging dir and skip it otherwise.
769
+ * - Staged ONLY into the PRIVATE session dir (`workspace`), NEVER a shared `spec.workspace`
770
+ * — mirroring `.mcp.json`/`system-prompt.txt`.
771
+ * - Per-attachment size cap ({@link ATTACHMENT_MAX_BYTES}, = the vault's 100MB upload
772
+ * ceiling) + a total count cap ({@link ATTACHMENT_MAX_COUNT}).
773
+ *
774
+ * Best-effort + ISOLATED: a single attachment's fetch/write failure logs + is SKIPPED (the
775
+ * turn still runs with the rest + the text). When the spec binds NO vault, there is no
776
+ * per-turn vault token to authenticate the storage fetch → ALL are skipped with one log.
777
+ */
778
+ private async stageAttachments(
779
+ workspace: string,
780
+ attachments: InboundAttachment[],
781
+ vaultArg: { url: string; entry: { name: string; token: string } } | undefined,
782
+ ): Promise<Array<{ absPath: string; mimeType: string }>> {
783
+ if (!vaultArg) {
784
+ console.warn(
785
+ `parachute-agent: ${attachments.length} inbound attachment(s) but this agent binds no ` +
786
+ `vault — cannot fetch the bytes; running the turn with text only.`,
787
+ );
788
+ return [];
789
+ }
790
+ const fetchFn = this.deps.fetchFn ?? fetch;
791
+ const stagingDir = join(workspace, ATTACHMENT_STAGING_DIR);
792
+ // The canonical staging-dir prefix the write target must stay under (defense in depth).
793
+ const stagingPrefix = stagingDir.endsWith("/") ? stagingDir : `${stagingDir}/`;
794
+ // Create the staging dir LAZILY — only just before the first real write. So a turn where
795
+ // every attachment fails/skips leaves NO empty `attachments/` dir behind (the "no staging
796
+ // side effects unless a file actually staged" contract).
797
+ let stagingDirReady = false;
798
+ const ensureStagingDir = (): void => {
799
+ if (!stagingDirReady) {
800
+ mkdirSync(stagingDir, { recursive: true });
801
+ stagingDirReady = true;
802
+ }
803
+ };
804
+
805
+ const staged: Array<{ absPath: string; mimeType: string }> = [];
806
+ const usedNames = new Set<string>();
807
+ const capped = attachments.slice(0, ATTACHMENT_MAX_COUNT);
808
+ if (attachments.length > ATTACHMENT_MAX_COUNT) {
809
+ console.warn(
810
+ `parachute-agent: ${attachments.length} inbound attachments exceeds the cap ` +
811
+ `(${ATTACHMENT_MAX_COUNT}); staging the first ${ATTACHMENT_MAX_COUNT}.`,
812
+ );
813
+ }
814
+
815
+ for (const att of capped) {
816
+ if (typeof att.path !== "string" || att.path.length === 0) continue;
817
+ // SAFE basename — defeats path traversal (the vault path/filename are untrusted).
818
+ let base = safeAttachmentBasename(att.filename || att.path);
819
+ // De-dup colliding basenames so a second `report.png` doesn't clobber the first.
820
+ if (usedNames.has(base)) {
821
+ let n = 2;
822
+ const dot = base.lastIndexOf(".");
823
+ const stem = dot > 0 ? base.slice(0, dot) : base;
824
+ const ext = dot > 0 ? base.slice(dot) : "";
825
+ while (usedNames.has(`${stem}-${n}${ext}`)) n++;
826
+ base = `${stem}-${n}${ext}`;
827
+ }
828
+ const target = join(stagingDir, base);
829
+ // Defense in depth: the join MUST stay inside the staging dir.
830
+ if (target !== stagingDir.replace(/\/$/, "") && !target.startsWith(stagingPrefix)) {
831
+ console.warn(
832
+ `parachute-agent: refusing to stage attachment "${att.path}" — resolved path ` +
833
+ `"${target}" escapes the staging dir; skipping.`,
834
+ );
835
+ continue;
836
+ }
837
+
838
+ try {
839
+ // The storage path is `date/filename` — `encodeURIComponent` percent-encodes the
840
+ // slash to `%2F`; the vault storage route `decodeURIComponent`s it back before
841
+ // matching (vault routes.ts), so a single encoded segment is the correct form.
842
+ const url = `${vaultArg.url}/vault/${vaultArg.entry.name}/api/storage/${encodeURIComponent(att.path)}`;
843
+ const res = await fetchFn(url, {
844
+ headers: { authorization: `Bearer ${vaultArg.entry.token}` },
845
+ });
846
+ if (!res.ok) {
847
+ const detail = await res.text().catch(() => "");
848
+ console.warn(
849
+ `parachute-agent: fetch attachment blob "${att.path}" failed (${res.status}) ` +
850
+ `${detail} — skipping this file`.trim(),
851
+ );
852
+ continue;
853
+ }
854
+ // Pre-flight on Content-Length so an over-cap blob is skipped WITHOUT buffering its
855
+ // whole body. Best-effort: a missing/garbage header falls through to the post-read
856
+ // check below (the real guard).
857
+ const declared = Number(res.headers.get("content-length"));
858
+ if (Number.isFinite(declared) && declared > ATTACHMENT_MAX_BYTES) {
859
+ console.warn(
860
+ `parachute-agent: attachment "${att.path}" declares ${declared} bytes, over the ` +
861
+ `${ATTACHMENT_MAX_BYTES}-byte cap — skipping this file.`,
862
+ );
863
+ continue;
864
+ }
865
+ const buf = Buffer.from(await res.arrayBuffer());
866
+ if (buf.byteLength > ATTACHMENT_MAX_BYTES) {
867
+ console.warn(
868
+ `parachute-agent: attachment "${att.path}" is ${buf.byteLength} bytes, over the ` +
869
+ `${ATTACHMENT_MAX_BYTES}-byte cap — skipping this file.`,
870
+ );
871
+ continue;
872
+ }
873
+ ensureStagingDir();
874
+ writeFileSync(target, buf, { mode: 0o600 });
875
+ usedNames.add(base);
876
+ staged.push({ absPath: target, mimeType: att.mimeType || "application/octet-stream" });
877
+ } catch (err) {
878
+ console.warn(
879
+ `parachute-agent: staging attachment "${att.path}" errored (skipping this file): ` +
880
+ `${(err as Error).message}`,
881
+ );
882
+ }
883
+ }
884
+ return staged;
885
+ }
886
+
887
+ /**
888
+ * Tear the agent down. A NO-OP for the programmatic backend: there is no resident
889
+ * process to kill, and no session store to clear — the session now lives on the
890
+ * durable `#agent/thread` note (`metadata.session`). So `stop` no longer resets
891
+ * conversation continuity; a single-threaded agent's next turn still resumes its
892
+ * persisted session. Starting a genuinely FRESH conversation is a separate operation
893
+ * (deleting the thread note), not a side effect of stop/deregister.
894
+ */
895
+ async stop(_handle: AgentHandle): Promise<void> {
896
+ // Intentionally empty — see the doc comment.
897
+ }
898
+
899
+ /**
900
+ * The programmatic backend has no resident process to keep alive — it is always
901
+ * available to run the next turn, so `live` is true.
902
+ */
903
+ async status(_handle: AgentHandle): Promise<AgentStatus> {
904
+ return { live: true };
905
+ }
906
+ }
907
+
908
+ /**
909
+ * The real `Bun.spawn` adapter for the programmatic backend — pipes stdout/stderr
910
+ * so the runner can drain the stream-json, applies the launch env + cwd. Used by
911
+ * the daemon; tests inject a fake `spawnFn` instead.
912
+ */
913
+ export function realProgrammaticSpawn(spawnFn: typeof Bun.spawn = Bun.spawn): ProgrammaticSpawnFn {
914
+ return (argv, opts) => {
915
+ const proc = spawnFn(argv, {
916
+ env: opts.env,
917
+ cwd: opts.cwd,
918
+ stdout: "pipe",
919
+ stderr: "pipe",
920
+ });
921
+ return {
922
+ stdout: proc.stdout as ReadableStream<Uint8Array> | null,
923
+ stderr: proc.stderr as ReadableStream<Uint8Array> | null,
924
+ exited: proc.exited,
925
+ };
926
+ };
927
+ }