@ag-eco/agentplate-cli 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (455) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +462 -0
  3. package/agents/ap-co-creation.md +90 -0
  4. package/agents/builder.md +144 -0
  5. package/agents/coordinator.md +377 -0
  6. package/agents/lead.md +435 -0
  7. package/agents/merger.md +164 -0
  8. package/agents/monitor.md +214 -0
  9. package/agents/orchestrator.md +239 -0
  10. package/agents/reviewer.md +140 -0
  11. package/agents/scout.md +125 -0
  12. package/agents/supervisor.md +427 -0
  13. package/package.json +66 -0
  14. package/src/agents/capabilities.test.ts +85 -0
  15. package/src/agents/capabilities.ts +125 -0
  16. package/src/agents/checkpoint.test.ts +88 -0
  17. package/src/agents/checkpoint.ts +101 -0
  18. package/src/agents/copilot-hooks-deployer.test.ts +162 -0
  19. package/src/agents/copilot-hooks-deployer.ts +93 -0
  20. package/src/agents/guard-rules.test.ts +372 -0
  21. package/src/agents/guard-rules.ts +97 -0
  22. package/src/agents/headless-mail-injector.test.ts +709 -0
  23. package/src/agents/headless-mail-injector.ts +377 -0
  24. package/src/agents/headless-prompt.test.ts +102 -0
  25. package/src/agents/headless-prompt.ts +68 -0
  26. package/src/agents/hooks-deployer.test.ts +3119 -0
  27. package/src/agents/hooks-deployer.ts +804 -0
  28. package/src/agents/identity.test.ts +604 -0
  29. package/src/agents/identity.ts +384 -0
  30. package/src/agents/lifecycle.test.ts +196 -0
  31. package/src/agents/lifecycle.ts +183 -0
  32. package/src/agents/mail-poll-detect.test.ts +153 -0
  33. package/src/agents/mail-poll-detect.ts +73 -0
  34. package/src/agents/manifest.test.ts +1026 -0
  35. package/src/agents/manifest.ts +376 -0
  36. package/src/agents/overlay.test.ts +1058 -0
  37. package/src/agents/overlay.ts +490 -0
  38. package/src/agents/scope-detect.test.ts +190 -0
  39. package/src/agents/scope-detect.ts +146 -0
  40. package/src/agents/turn-lock.test.ts +181 -0
  41. package/src/agents/turn-lock.ts +235 -0
  42. package/src/agents/turn-runner-dispatch.test.ts +182 -0
  43. package/src/agents/turn-runner-dispatch.ts +105 -0
  44. package/src/agents/turn-runner.test.ts +2312 -0
  45. package/src/agents/turn-runner.ts +1383 -0
  46. package/src/beads/client.test.ts +217 -0
  47. package/src/beads/client.ts +230 -0
  48. package/src/beads/molecules.test.ts +338 -0
  49. package/src/beads/molecules.ts +198 -0
  50. package/src/commands/agents.test.ts +328 -0
  51. package/src/commands/agents.ts +299 -0
  52. package/src/commands/clean.test.ts +797 -0
  53. package/src/commands/clean.ts +791 -0
  54. package/src/commands/completions.test.ts +348 -0
  55. package/src/commands/completions.ts +981 -0
  56. package/src/commands/coordinator.test.ts +2975 -0
  57. package/src/commands/coordinator.ts +1841 -0
  58. package/src/commands/costs.test.ts +1183 -0
  59. package/src/commands/costs.ts +599 -0
  60. package/src/commands/dashboard.test.ts +954 -0
  61. package/src/commands/dashboard.ts +1212 -0
  62. package/src/commands/discover.test.ts +288 -0
  63. package/src/commands/discover.ts +202 -0
  64. package/src/commands/doctor.test.ts +303 -0
  65. package/src/commands/doctor.ts +311 -0
  66. package/src/commands/ecosystem.test.ts +226 -0
  67. package/src/commands/ecosystem.ts +248 -0
  68. package/src/commands/errors.test.ts +654 -0
  69. package/src/commands/errors.ts +197 -0
  70. package/src/commands/feed.test.ts +709 -0
  71. package/src/commands/feed.ts +260 -0
  72. package/src/commands/group.test.ts +475 -0
  73. package/src/commands/group.ts +546 -0
  74. package/src/commands/hooks.test.ts +458 -0
  75. package/src/commands/hooks.ts +263 -0
  76. package/src/commands/init.test.ts +1011 -0
  77. package/src/commands/init.ts +967 -0
  78. package/src/commands/inspect.test.ts +1239 -0
  79. package/src/commands/inspect.ts +648 -0
  80. package/src/commands/log.test.ts +1913 -0
  81. package/src/commands/log.ts +958 -0
  82. package/src/commands/logs.test.ts +801 -0
  83. package/src/commands/logs.ts +483 -0
  84. package/src/commands/mail.test.ts +1501 -0
  85. package/src/commands/mail.ts +848 -0
  86. package/src/commands/merge.test.ts +864 -0
  87. package/src/commands/merge.ts +381 -0
  88. package/src/commands/metrics.test.ts +458 -0
  89. package/src/commands/metrics.ts +129 -0
  90. package/src/commands/monitor.test.ts +191 -0
  91. package/src/commands/monitor.ts +409 -0
  92. package/src/commands/nudge.test.ts +579 -0
  93. package/src/commands/nudge.ts +646 -0
  94. package/src/commands/orchestrator.ts +42 -0
  95. package/src/commands/prime.test.ts +612 -0
  96. package/src/commands/prime.ts +359 -0
  97. package/src/commands/replay.test.ts +757 -0
  98. package/src/commands/replay.ts +231 -0
  99. package/src/commands/run.test.ts +469 -0
  100. package/src/commands/run.ts +353 -0
  101. package/src/commands/serve/agent-actions.test.ts +210 -0
  102. package/src/commands/serve/agent-actions.ts +192 -0
  103. package/src/commands/serve/build.test.ts +202 -0
  104. package/src/commands/serve/build.ts +206 -0
  105. package/src/commands/serve/coordinator-actions.test.ts +339 -0
  106. package/src/commands/serve/coordinator-actions.ts +410 -0
  107. package/src/commands/serve/dev.test.ts +168 -0
  108. package/src/commands/serve/dev.ts +117 -0
  109. package/src/commands/serve/mail-actions.test.ts +312 -0
  110. package/src/commands/serve/mail-actions.ts +167 -0
  111. package/src/commands/serve/rest.test.ts +1680 -0
  112. package/src/commands/serve/rest.ts +1130 -0
  113. package/src/commands/serve/static.ts +51 -0
  114. package/src/commands/serve/ws.test.ts +361 -0
  115. package/src/commands/serve/ws.ts +332 -0
  116. package/src/commands/serve.test.ts +459 -0
  117. package/src/commands/serve.ts +654 -0
  118. package/src/commands/sling.test.ts +1583 -0
  119. package/src/commands/sling.ts +1351 -0
  120. package/src/commands/spec.test.ts +179 -0
  121. package/src/commands/spec.ts +105 -0
  122. package/src/commands/status.test.ts +614 -0
  123. package/src/commands/status.ts +403 -0
  124. package/src/commands/stop.test.ts +964 -0
  125. package/src/commands/stop.ts +319 -0
  126. package/src/commands/supervisor.test.ts +185 -0
  127. package/src/commands/supervisor.ts +537 -0
  128. package/src/commands/trace.test.ts +762 -0
  129. package/src/commands/trace.ts +205 -0
  130. package/src/commands/update.test.ts +466 -0
  131. package/src/commands/update.ts +263 -0
  132. package/src/commands/upgrade.test.ts +48 -0
  133. package/src/commands/upgrade.ts +240 -0
  134. package/src/commands/watch.test.ts +257 -0
  135. package/src/commands/watch.ts +308 -0
  136. package/src/commands/worktree.test.ts +1297 -0
  137. package/src/commands/worktree.ts +451 -0
  138. package/src/config.test.ts +1535 -0
  139. package/src/config.ts +1064 -0
  140. package/src/doctor/agents.test.ts +523 -0
  141. package/src/doctor/agents.ts +399 -0
  142. package/src/doctor/config-check.test.ts +191 -0
  143. package/src/doctor/config-check.ts +183 -0
  144. package/src/doctor/consistency.test.ts +807 -0
  145. package/src/doctor/consistency.ts +347 -0
  146. package/src/doctor/databases.test.ts +350 -0
  147. package/src/doctor/databases.ts +243 -0
  148. package/src/doctor/dependencies.test.ts +296 -0
  149. package/src/doctor/dependencies.ts +272 -0
  150. package/src/doctor/ecosystem.test.ts +308 -0
  151. package/src/doctor/ecosystem.ts +156 -0
  152. package/src/doctor/logs.test.ts +253 -0
  153. package/src/doctor/logs.ts +295 -0
  154. package/src/doctor/merge-queue.test.ts +315 -0
  155. package/src/doctor/merge-queue.ts +167 -0
  156. package/src/doctor/providers.test.ts +409 -0
  157. package/src/doctor/providers.ts +250 -0
  158. package/src/doctor/serve.test.ts +95 -0
  159. package/src/doctor/serve.ts +86 -0
  160. package/src/doctor/structure.test.ts +423 -0
  161. package/src/doctor/structure.ts +285 -0
  162. package/src/doctor/types.ts +43 -0
  163. package/src/doctor/version.test.ts +241 -0
  164. package/src/doctor/version.ts +132 -0
  165. package/src/doctor/watchdog.test.ts +167 -0
  166. package/src/doctor/watchdog.ts +214 -0
  167. package/src/e2e/init-sling-lifecycle.test.ts +283 -0
  168. package/src/errors.test.ts +350 -0
  169. package/src/errors.ts +217 -0
  170. package/src/events/store.test.ts +660 -0
  171. package/src/events/store.ts +369 -0
  172. package/src/events/tailer.test.ts +719 -0
  173. package/src/events/tailer.ts +332 -0
  174. package/src/events/tool-filter.test.ts +330 -0
  175. package/src/events/tool-filter.ts +126 -0
  176. package/src/index.ts +533 -0
  177. package/src/insights/analyzer.test.ts +466 -0
  178. package/src/insights/analyzer.ts +203 -0
  179. package/src/insights/quality-gates.test.ts +141 -0
  180. package/src/insights/quality-gates.ts +156 -0
  181. package/src/json.test.ts +72 -0
  182. package/src/json.ts +53 -0
  183. package/src/loam/client.test.ts +752 -0
  184. package/src/loam/client.ts +664 -0
  185. package/src/logging/color.test.ts +252 -0
  186. package/src/logging/color.ts +105 -0
  187. package/src/logging/format.test.ts +110 -0
  188. package/src/logging/format.ts +255 -0
  189. package/src/logging/logger.test.ts +814 -0
  190. package/src/logging/logger.ts +266 -0
  191. package/src/logging/reporter.test.ts +259 -0
  192. package/src/logging/reporter.ts +110 -0
  193. package/src/logging/sanitizer.test.ts +190 -0
  194. package/src/logging/sanitizer.ts +57 -0
  195. package/src/logging/theme.ts +140 -0
  196. package/src/mail/broadcast.test.ts +204 -0
  197. package/src/mail/broadcast.ts +92 -0
  198. package/src/mail/client.test.ts +774 -0
  199. package/src/mail/client.ts +236 -0
  200. package/src/mail/store.test.ts +898 -0
  201. package/src/mail/store.ts +425 -0
  202. package/src/merge/lock.test.ts +149 -0
  203. package/src/merge/lock.ts +140 -0
  204. package/src/merge/predict.test.ts +387 -0
  205. package/src/merge/predict.ts +249 -0
  206. package/src/merge/queue.test.ts +426 -0
  207. package/src/merge/queue.ts +246 -0
  208. package/src/merge/resolver.test.ts +1993 -0
  209. package/src/merge/resolver.ts +926 -0
  210. package/src/metrics/pricing.test.ts +258 -0
  211. package/src/metrics/pricing.ts +135 -0
  212. package/src/metrics/store.test.ts +978 -0
  213. package/src/metrics/store.ts +501 -0
  214. package/src/metrics/summary.test.ts +398 -0
  215. package/src/metrics/summary.ts +178 -0
  216. package/src/metrics/transcript.test.ts +483 -0
  217. package/src/metrics/transcript.ts +114 -0
  218. package/src/runtimes/__fixtures__/claude-stream-fixture.ts +22 -0
  219. package/src/runtimes/aider.test.ts +124 -0
  220. package/src/runtimes/aider.ts +147 -0
  221. package/src/runtimes/amp.test.ts +164 -0
  222. package/src/runtimes/amp.ts +154 -0
  223. package/src/runtimes/claude.test.ts +1474 -0
  224. package/src/runtimes/claude.ts +579 -0
  225. package/src/runtimes/codex.test.ts +805 -0
  226. package/src/runtimes/codex.ts +273 -0
  227. package/src/runtimes/connections.test.ts +214 -0
  228. package/src/runtimes/connections.ts +103 -0
  229. package/src/runtimes/copilot.test.ts +707 -0
  230. package/src/runtimes/copilot.ts +316 -0
  231. package/src/runtimes/cursor.test.ts +497 -0
  232. package/src/runtimes/cursor.ts +205 -0
  233. package/src/runtimes/gemini.test.ts +537 -0
  234. package/src/runtimes/gemini.ts +243 -0
  235. package/src/runtimes/goose.test.ts +133 -0
  236. package/src/runtimes/goose.ts +157 -0
  237. package/src/runtimes/headless-connection.test.ts +264 -0
  238. package/src/runtimes/headless-connection.ts +158 -0
  239. package/src/runtimes/opencode.test.ts +325 -0
  240. package/src/runtimes/opencode.ts +188 -0
  241. package/src/runtimes/pi-guards.test.ts +486 -0
  242. package/src/runtimes/pi-guards.ts +367 -0
  243. package/src/runtimes/pi.test.ts +789 -0
  244. package/src/runtimes/pi.ts +305 -0
  245. package/src/runtimes/registry.test.ts +196 -0
  246. package/src/runtimes/registry.ts +99 -0
  247. package/src/runtimes/sapling.test.ts +1267 -0
  248. package/src/runtimes/sapling.ts +710 -0
  249. package/src/runtimes/types.ts +266 -0
  250. package/src/schema-consistency.test.ts +246 -0
  251. package/src/sessions/compat.test.ts +281 -0
  252. package/src/sessions/compat.ts +105 -0
  253. package/src/sessions/store.test.ts +1748 -0
  254. package/src/sessions/store.ts +858 -0
  255. package/src/test-helpers.test.ts +124 -0
  256. package/src/test-helpers.ts +145 -0
  257. package/src/test-setup.test.ts +31 -0
  258. package/src/test-setup.ts +28 -0
  259. package/src/tools/loam/api.ts +368 -0
  260. package/src/tools/loam/cli.ts +278 -0
  261. package/src/tools/loam/commands/add.ts +52 -0
  262. package/src/tools/loam/commands/archive.ts +214 -0
  263. package/src/tools/loam/commands/audit.ts +276 -0
  264. package/src/tools/loam/commands/compact.ts +1062 -0
  265. package/src/tools/loam/commands/completions.ts +79 -0
  266. package/src/tools/loam/commands/config.ts +381 -0
  267. package/src/tools/loam/commands/delete-domain.ts +121 -0
  268. package/src/tools/loam/commands/delete.ts +316 -0
  269. package/src/tools/loam/commands/diff.ts +200 -0
  270. package/src/tools/loam/commands/doctor.ts +1113 -0
  271. package/src/tools/loam/commands/edit.ts +226 -0
  272. package/src/tools/loam/commands/init.ts +31 -0
  273. package/src/tools/loam/commands/learn.ts +179 -0
  274. package/src/tools/loam/commands/move.ts +323 -0
  275. package/src/tools/loam/commands/onboard.ts +374 -0
  276. package/src/tools/loam/commands/outcome.ts +185 -0
  277. package/src/tools/loam/commands/prime.ts +688 -0
  278. package/src/tools/loam/commands/prune.ts +614 -0
  279. package/src/tools/loam/commands/query.ts +218 -0
  280. package/src/tools/loam/commands/rank.ts +180 -0
  281. package/src/tools/loam/commands/ready.ts +189 -0
  282. package/src/tools/loam/commands/record.ts +1210 -0
  283. package/src/tools/loam/commands/restore.ts +166 -0
  284. package/src/tools/loam/commands/search.ts +327 -0
  285. package/src/tools/loam/commands/setup.ts +887 -0
  286. package/src/tools/loam/commands/status.ts +103 -0
  287. package/src/tools/loam/commands/sync.ts +298 -0
  288. package/src/tools/loam/commands/update.ts +19 -0
  289. package/src/tools/loam/commands/upgrade.ts +93 -0
  290. package/src/tools/loam/commands/validate.ts +190 -0
  291. package/src/tools/loam/index.ts +62 -0
  292. package/src/tools/loam/log.ts +127 -0
  293. package/src/tools/loam/registry/builtins.ts +409 -0
  294. package/src/tools/loam/registry/custom.ts +431 -0
  295. package/src/tools/loam/registry/init.ts +55 -0
  296. package/src/tools/loam/registry/template.ts +40 -0
  297. package/src/tools/loam/registry/type-registry.ts +113 -0
  298. package/src/tools/loam/schemas/config-schema.ts +489 -0
  299. package/src/tools/loam/schemas/config.ts +245 -0
  300. package/src/tools/loam/schemas/index.ts +18 -0
  301. package/src/tools/loam/schemas/record-schema.ts +191 -0
  302. package/src/tools/loam/schemas/record.ts +115 -0
  303. package/src/tools/loam/utils/active-work.ts +205 -0
  304. package/src/tools/loam/utils/anchor-validity.ts +80 -0
  305. package/src/tools/loam/utils/archive.ts +146 -0
  306. package/src/tools/loam/utils/audit.ts +667 -0
  307. package/src/tools/loam/utils/bm25.ts +238 -0
  308. package/src/tools/loam/utils/budget.ts +142 -0
  309. package/src/tools/loam/utils/config.ts +344 -0
  310. package/src/tools/loam/utils/dir-anchors.ts +62 -0
  311. package/src/tools/loam/utils/domain-rules.ts +114 -0
  312. package/src/tools/loam/utils/expertise.ts +393 -0
  313. package/src/tools/loam/utils/format-helpers.ts +96 -0
  314. package/src/tools/loam/utils/format.ts +1234 -0
  315. package/src/tools/loam/utils/git-context.ts +50 -0
  316. package/src/tools/loam/utils/git.ts +183 -0
  317. package/src/tools/loam/utils/hooks.ts +299 -0
  318. package/src/tools/loam/utils/index.ts +52 -0
  319. package/src/tools/loam/utils/json-output.ts +13 -0
  320. package/src/tools/loam/utils/lock.ts +76 -0
  321. package/src/tools/loam/utils/markers.ts +48 -0
  322. package/src/tools/loam/utils/numeric-flags.ts +20 -0
  323. package/src/tools/loam/utils/palette.ts +44 -0
  324. package/src/tools/loam/utils/prime-ranking.ts +135 -0
  325. package/src/tools/loam/utils/recipe-discovery.ts +195 -0
  326. package/src/tools/loam/utils/runtime-flags.ts +28 -0
  327. package/src/tools/loam/utils/scoring.ts +94 -0
  328. package/src/tools/loam/utils/version.ts +116 -0
  329. package/src/tools/sprout/commands/block.ts +64 -0
  330. package/src/tools/sprout/commands/blocked.ts +86 -0
  331. package/src/tools/sprout/commands/close.ts +129 -0
  332. package/src/tools/sprout/commands/completions.ts +198 -0
  333. package/src/tools/sprout/commands/config.ts +238 -0
  334. package/src/tools/sprout/commands/create.ts +164 -0
  335. package/src/tools/sprout/commands/dep.ts +148 -0
  336. package/src/tools/sprout/commands/doctor.ts +979 -0
  337. package/src/tools/sprout/commands/init.ts +83 -0
  338. package/src/tools/sprout/commands/label.ts +178 -0
  339. package/src/tools/sprout/commands/list.ts +210 -0
  340. package/src/tools/sprout/commands/migrate.ts +133 -0
  341. package/src/tools/sprout/commands/onboard.ts +207 -0
  342. package/src/tools/sprout/commands/plan-show.ts +278 -0
  343. package/src/tools/sprout/commands/plan.ts +2526 -0
  344. package/src/tools/sprout/commands/prime.ts +399 -0
  345. package/src/tools/sprout/commands/ready.ts +245 -0
  346. package/src/tools/sprout/commands/search.ts +221 -0
  347. package/src/tools/sprout/commands/show.ts +277 -0
  348. package/src/tools/sprout/commands/stats.ts +146 -0
  349. package/src/tools/sprout/commands/sync.ts +134 -0
  350. package/src/tools/sprout/commands/tpl.ts +364 -0
  351. package/src/tools/sprout/commands/unblock.ts +115 -0
  352. package/src/tools/sprout/commands/update.ts +257 -0
  353. package/src/tools/sprout/commands/upgrade.ts +91 -0
  354. package/src/tools/sprout/config-schema.ts +152 -0
  355. package/src/tools/sprout/config.ts +355 -0
  356. package/src/tools/sprout/filter.ts +107 -0
  357. package/src/tools/sprout/format.ts +43 -0
  358. package/src/tools/sprout/id.ts +22 -0
  359. package/src/tools/sprout/index.ts +204 -0
  360. package/src/tools/sprout/log.ts +76 -0
  361. package/src/tools/sprout/markers.ts +22 -0
  362. package/src/tools/sprout/output.ts +121 -0
  363. package/src/tools/sprout/plan-backref.ts +93 -0
  364. package/src/tools/sprout/plan-context.ts +81 -0
  365. package/src/tools/sprout/plan-domain.ts +139 -0
  366. package/src/tools/sprout/plan-lifecycle.ts +65 -0
  367. package/src/tools/sprout/plan-loam.ts +207 -0
  368. package/src/tools/sprout/plan-schema.ts +209 -0
  369. package/src/tools/sprout/sort.ts +31 -0
  370. package/src/tools/sprout/store.ts +172 -0
  371. package/src/tools/sprout/types.ts +118 -0
  372. package/src/tools/sprout/validation.ts +119 -0
  373. package/src/tools/sprout/version.ts +1 -0
  374. package/src/tools/sprout/yaml.ts +387 -0
  375. package/src/tools/trellis/commands/archive.ts +87 -0
  376. package/src/tools/trellis/commands/completions.ts +610 -0
  377. package/src/tools/trellis/commands/config.ts +382 -0
  378. package/src/tools/trellis/commands/create.ts +252 -0
  379. package/src/tools/trellis/commands/diff.ts +150 -0
  380. package/src/tools/trellis/commands/doctor.ts +771 -0
  381. package/src/tools/trellis/commands/emit.ts +365 -0
  382. package/src/tools/trellis/commands/history.ts +83 -0
  383. package/src/tools/trellis/commands/import.ts +198 -0
  384. package/src/tools/trellis/commands/init.ts +81 -0
  385. package/src/tools/trellis/commands/list.ts +103 -0
  386. package/src/tools/trellis/commands/onboard.ts +156 -0
  387. package/src/tools/trellis/commands/pin.ts +172 -0
  388. package/src/tools/trellis/commands/prime.ts +193 -0
  389. package/src/tools/trellis/commands/render.ts +122 -0
  390. package/src/tools/trellis/commands/schema.ts +353 -0
  391. package/src/tools/trellis/commands/show.ts +115 -0
  392. package/src/tools/trellis/commands/stats.ts +65 -0
  393. package/src/tools/trellis/commands/sync.ts +112 -0
  394. package/src/tools/trellis/commands/tree.ts +123 -0
  395. package/src/tools/trellis/commands/update.ts +330 -0
  396. package/src/tools/trellis/commands/upgrade.ts +95 -0
  397. package/src/tools/trellis/commands/validate.ts +166 -0
  398. package/src/tools/trellis/config-schema.ts +81 -0
  399. package/src/tools/trellis/config.ts +108 -0
  400. package/src/tools/trellis/frontmatter.ts +348 -0
  401. package/src/tools/trellis/id.ts +24 -0
  402. package/src/tools/trellis/index.ts +209 -0
  403. package/src/tools/trellis/markers.ts +28 -0
  404. package/src/tools/trellis/output.ts +84 -0
  405. package/src/tools/trellis/render.ts +212 -0
  406. package/src/tools/trellis/store.ts +144 -0
  407. package/src/tools/trellis/types.ts +82 -0
  408. package/src/tools/trellis/validate.ts +199 -0
  409. package/src/tools/trellis/yaml.ts +309 -0
  410. package/src/tracker/beads.test.ts +454 -0
  411. package/src/tracker/beads.ts +56 -0
  412. package/src/tracker/factory.test.ts +90 -0
  413. package/src/tracker/factory.ts +65 -0
  414. package/src/tracker/sprout.test.ts +461 -0
  415. package/src/tracker/sprout.ts +182 -0
  416. package/src/tracker/types.ts +52 -0
  417. package/src/trellis/client.test.ts +107 -0
  418. package/src/trellis/client.ts +179 -0
  419. package/src/types.ts +970 -0
  420. package/src/utils/bin.test.ts +10 -0
  421. package/src/utils/bin.ts +37 -0
  422. package/src/utils/browser.test.ts +49 -0
  423. package/src/utils/browser.ts +48 -0
  424. package/src/utils/fs.test.ts +119 -0
  425. package/src/utils/fs.ts +62 -0
  426. package/src/utils/pid.test.ts +152 -0
  427. package/src/utils/pid.ts +130 -0
  428. package/src/utils/process-scan.test.ts +53 -0
  429. package/src/utils/process-scan.ts +76 -0
  430. package/src/utils/time.test.ts +43 -0
  431. package/src/utils/time.ts +37 -0
  432. package/src/utils/version.test.ts +33 -0
  433. package/src/utils/version.ts +70 -0
  434. package/src/version.ts +5 -0
  435. package/src/watchdog/daemon.test.ts +3721 -0
  436. package/src/watchdog/daemon.ts +1257 -0
  437. package/src/watchdog/health.test.ts +830 -0
  438. package/src/watchdog/health.ts +434 -0
  439. package/src/watchdog/triage.test.ts +205 -0
  440. package/src/watchdog/triage.ts +205 -0
  441. package/src/worktree/manager.test.ts +720 -0
  442. package/src/worktree/manager.ts +405 -0
  443. package/src/worktree/process.test.ts +172 -0
  444. package/src/worktree/process.ts +131 -0
  445. package/src/worktree/tmux.test.ts +1616 -0
  446. package/src/worktree/tmux.ts +721 -0
  447. package/templates/CLAUDE.md.tmpl +100 -0
  448. package/templates/copilot-hooks.json.tmpl +13 -0
  449. package/templates/hooks.json.tmpl +109 -0
  450. package/templates/overlay.md.tmpl +88 -0
  451. package/ui/dist/apple-touch-icon-bdy6teep.png +0 -0
  452. package/ui/dist/chunk-8s31f05k.css +1 -0
  453. package/ui/dist/chunk-vm5rz679.js +300 -0
  454. package/ui/dist/favicon-nzb39vza.svg +4 -0
  455. package/ui/dist/index.html +17 -0
@@ -0,0 +1,405 @@
1
+ import { unlink } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import { WorktreeError } from "../errors.ts";
4
+
5
+ /**
6
+ * Run a git command and return stdout. Throws WorktreeError on non-zero exit.
7
+ */
8
+ async function runGit(
9
+ repoRoot: string,
10
+ args: string[],
11
+ context?: { worktreePath?: string; branchName?: string },
12
+ ): Promise<string> {
13
+ const proc = Bun.spawn(["git", ...args], {
14
+ cwd: repoRoot,
15
+ stdout: "pipe",
16
+ stderr: "pipe",
17
+ });
18
+
19
+ const [stdout, stderr, exitCode] = await Promise.all([
20
+ new Response(proc.stdout).text(),
21
+ new Response(proc.stderr).text(),
22
+ proc.exited,
23
+ ]);
24
+
25
+ if (exitCode !== 0) {
26
+ throw new WorktreeError(
27
+ `git ${args.join(" ")} failed (exit ${exitCode}): ${stderr.trim() || stdout.trim()}`,
28
+ {
29
+ worktreePath: context?.worktreePath,
30
+ branchName: context?.branchName,
31
+ },
32
+ );
33
+ }
34
+
35
+ return stdout;
36
+ }
37
+
38
+ /**
39
+ * Create a new git worktree for an agent.
40
+ *
41
+ * Creates a worktree at `{baseDir}/{agentName}` with a new branch
42
+ * named `agentplate/{agentName}/{taskId}` based on `baseBranch`.
43
+ *
44
+ * Before running `git worktree add`, rejects when the target branch is
45
+ * already checked out in another worktree — this avoids the silent-overwrite
46
+ * class of failure entirely. After `git worktree add` returns, validates
47
+ * that the worktree is actually registered with git AND contains tracked
48
+ * files; if either check fails, rolls back and throws. sling has previously
49
+ * hit edge cases where the dir exists but git did not populate it
50
+ * (agentplate-6878), trapping the agent in a non-worktree directory.
51
+ *
52
+ * @returns The absolute worktree path and branch name.
53
+ */
54
+ export async function createWorktree(options: {
55
+ repoRoot: string;
56
+ baseDir: string;
57
+ agentName: string;
58
+ baseBranch: string;
59
+ taskId: string;
60
+ }): Promise<{ path: string; branch: string }> {
61
+ const { repoRoot, baseDir, agentName, baseBranch, taskId } = options;
62
+
63
+ const worktreePath = join(baseDir, agentName);
64
+ const branchName = `agentplate/${agentName}/${taskId}`;
65
+
66
+ const existing = await listWorktrees(repoRoot);
67
+ const occupied = existing.find((entry) => entry.branch === branchName);
68
+ if (occupied !== undefined) {
69
+ throw new WorktreeError(`branch ${branchName} is already checked out at ${occupied.path}`, {
70
+ worktreePath,
71
+ branchName,
72
+ });
73
+ }
74
+
75
+ await runGit(repoRoot, ["worktree", "add", "-b", branchName, worktreePath, baseBranch], {
76
+ worktreePath,
77
+ branchName,
78
+ });
79
+
80
+ await validateWorktreeCreation({ repoRoot, worktreePath, branchName });
81
+
82
+ return { path: worktreePath, branch: branchName };
83
+ }
84
+
85
+ /**
86
+ * Verify that a freshly created worktree is registered with git and contains
87
+ * tracked files. Throws WorktreeError with a precise diagnostic on failure
88
+ * and rolls back the worktree + branch so callers don't leak state.
89
+ *
90
+ * Exported for direct testing of edge cases (empty base branches, racy
91
+ * cleanup) that are awkward to provoke through createWorktree end-to-end.
92
+ */
93
+ export async function validateWorktreeCreation(opts: {
94
+ repoRoot: string;
95
+ worktreePath: string;
96
+ branchName: string;
97
+ }): Promise<void> {
98
+ const { repoRoot, worktreePath, branchName } = opts;
99
+
100
+ const entries = await listWorktrees(repoRoot);
101
+ const registered = entries.some((entry) => entry.path === worktreePath);
102
+ if (!registered) {
103
+ await rollbackWorktree(repoRoot, worktreePath, branchName);
104
+ throw new WorktreeError(
105
+ `Worktree creation reported success but path is not registered with git: ${worktreePath}. Possible causes: pre-existing directory, branch already checked out elsewhere, or git worktree add failed silently.`,
106
+ { worktreePath, branchName },
107
+ );
108
+ }
109
+
110
+ const lsFiles = await runGit(worktreePath, ["ls-files"], { worktreePath, branchName });
111
+ const fileCount = lsFiles.split("\n").filter((line) => line.length > 0).length;
112
+ if (fileCount === 0) {
113
+ await rollbackWorktree(repoRoot, worktreePath, branchName);
114
+ throw new WorktreeError(
115
+ `Worktree was registered but contains zero tracked files: ${worktreePath}. The base branch may be empty or the working tree was not populated.`,
116
+ { worktreePath, branchName },
117
+ );
118
+ }
119
+ }
120
+
121
+ /**
122
+ * Roll back a worktree and its associated branch after a failed spawn.
123
+ *
124
+ * Best-effort cleanup: errors are swallowed because the caller's original
125
+ * error is more important. Always call this inside a catch block.
126
+ */
127
+ export async function rollbackWorktree(
128
+ repoRoot: string,
129
+ worktreePath: string,
130
+ branchName: string,
131
+ ): Promise<void> {
132
+ try {
133
+ const removeProc = Bun.spawn(["git", "worktree", "remove", "--force", worktreePath], {
134
+ cwd: repoRoot,
135
+ stdout: "pipe",
136
+ stderr: "pipe",
137
+ });
138
+ await removeProc.exited;
139
+ } catch {
140
+ // Best-effort
141
+ }
142
+
143
+ if (branchName.length > 0) {
144
+ try {
145
+ const branchProc = Bun.spawn(["git", "branch", "-D", branchName], {
146
+ cwd: repoRoot,
147
+ stdout: "pipe",
148
+ stderr: "pipe",
149
+ });
150
+ await branchProc.exited;
151
+ } catch {
152
+ // Best-effort
153
+ }
154
+ }
155
+ }
156
+
157
+ /**
158
+ * Parsed representation of a single worktree entry from `git worktree list --porcelain`.
159
+ */
160
+ interface WorktreeEntry {
161
+ path: string;
162
+ branch: string;
163
+ head: string;
164
+ }
165
+
166
+ /**
167
+ * Parse the output of `git worktree list --porcelain` into structured entries.
168
+ *
169
+ * Porcelain format example:
170
+ * ```
171
+ * worktree /path/to/main
172
+ * HEAD abc123
173
+ * branch refs/heads/main
174
+ *
175
+ * worktree /path/to/wt
176
+ * HEAD def456
177
+ * branch refs/heads/agentplate/agent/bead
178
+ * ```
179
+ */
180
+ function parseWorktreeOutput(output: string): WorktreeEntry[] {
181
+ const entries: WorktreeEntry[] = [];
182
+ const blocks = output.trim().split("\n\n");
183
+
184
+ for (const block of blocks) {
185
+ if (block.trim() === "") continue;
186
+
187
+ let path = "";
188
+ let head = "";
189
+ let branch = "";
190
+
191
+ const lines = block.trim().split("\n");
192
+ for (const line of lines) {
193
+ if (line.startsWith("worktree ")) {
194
+ path = line.slice("worktree ".length);
195
+ } else if (line.startsWith("HEAD ")) {
196
+ head = line.slice("HEAD ".length);
197
+ } else if (line.startsWith("branch ")) {
198
+ // Strip refs/heads/ prefix to get the short branch name
199
+ const ref = line.slice("branch ".length);
200
+ branch = ref.replace(/^refs\/heads\//, "");
201
+ }
202
+ }
203
+
204
+ if (path.length > 0) {
205
+ entries.push({ path, head, branch });
206
+ }
207
+ }
208
+
209
+ return entries;
210
+ }
211
+
212
+ /**
213
+ * List all git worktrees in the repository.
214
+ *
215
+ * @returns Array of worktree entries with path, branch name, and HEAD commit.
216
+ */
217
+ export async function listWorktrees(
218
+ repoRoot: string,
219
+ ): Promise<Array<{ path: string; branch: string; head: string }>> {
220
+ const stdout = await runGit(repoRoot, ["worktree", "list", "--porcelain"]);
221
+ return parseWorktreeOutput(stdout);
222
+ }
223
+
224
+ /**
225
+ * Check if a branch has been merged into a target branch.
226
+ * Uses `git merge-base --is-ancestor` which returns exit 0 if merged, 1 if not.
227
+ */
228
+ export async function isBranchMerged(
229
+ repoRoot: string,
230
+ branch: string,
231
+ targetBranch: string,
232
+ ): Promise<boolean> {
233
+ const proc = Bun.spawn(["git", "merge-base", "--is-ancestor", branch, targetBranch], {
234
+ cwd: repoRoot,
235
+ stdout: "pipe",
236
+ stderr: "pipe",
237
+ });
238
+
239
+ const [stderr, exitCode] = await Promise.all([new Response(proc.stderr).text(), proc.exited]);
240
+
241
+ if (exitCode === 0) return true;
242
+ if (exitCode === 1) return false;
243
+
244
+ throw new WorktreeError(
245
+ `git merge-base --is-ancestor failed (exit ${exitCode}): ${stderr.trim()}`,
246
+ { branchName: branch },
247
+ );
248
+ }
249
+
250
+ /**
251
+ * Remove a git worktree and delete its associated branch.
252
+ *
253
+ * Runs `git worktree remove {path}` to remove the worktree, then
254
+ * deletes the branch. With `forceBranch: true`, uses `git branch -D`
255
+ * to force-delete even unmerged branches. Otherwise uses `git branch -d`
256
+ * which only deletes merged branches.
257
+ */
258
+ export async function removeWorktree(
259
+ repoRoot: string,
260
+ path: string,
261
+ options?: { force?: boolean; forceBranch?: boolean },
262
+ ): Promise<void> {
263
+ // First, figure out which branch this worktree is on so we can clean it up
264
+ const worktrees = await listWorktrees(repoRoot);
265
+ const entry = worktrees.find((wt) => wt.path === path);
266
+ const branchName = entry?.branch ?? "";
267
+
268
+ // Remove the worktree (--force handles untracked files and uncommitted changes)
269
+ const removeArgs = ["worktree", "remove", path];
270
+ if (options?.force) {
271
+ removeArgs.push("--force");
272
+ }
273
+ await runGit(repoRoot, removeArgs, {
274
+ worktreePath: path,
275
+ branchName,
276
+ });
277
+
278
+ // Delete the associated branch after worktree removal.
279
+ // Use -D (force) when forceBranch is set, since the branch may not have
280
+ // been merged yet. Use -d (safe) otherwise, which only deletes merged branches.
281
+ if (branchName.length > 0) {
282
+ const deleteFlag = options?.forceBranch ? "-D" : "-d";
283
+ try {
284
+ await runGit(repoRoot, ["branch", deleteFlag, branchName], { branchName });
285
+ } catch {
286
+ // Branch deletion failed — may be unmerged (with -d) or checked out elsewhere.
287
+ // This is best-effort; the worktree itself is already removed.
288
+ }
289
+ }
290
+ }
291
+
292
+ /**
293
+ * Preserve .sprout/ changes from a branch into the canonical branch.
294
+ *
295
+ * Lead agent branches are never merged via the normal merge pipeline, so
296
+ * any .sprout/ issue files they create would be lost when the worktree is
297
+ * cleaned. This function extracts only the .sprout/ diff from the branch
298
+ * and applies it to the canonical branch via a patch.
299
+ *
300
+ * @returns `{ preserved: true }` if changes were found and committed,
301
+ * `{ preserved: false }` if there were no .sprout/ changes,
302
+ * `{ preserved: false, error: "..." }` if something went wrong.
303
+ */
304
+ export async function preserveSproutChanges(
305
+ repoRoot: string,
306
+ branch: string,
307
+ canonicalBranch: string,
308
+ agentName: string,
309
+ ): Promise<{ preserved: boolean; error?: string }> {
310
+ // Step 1: Get the .sprout/ diff between canonical and the branch (three-dot diff).
311
+ // Three-dot diff shows changes introduced on branch since it diverged from canonicalBranch.
312
+ let diff: string;
313
+ try {
314
+ diff = await runGit(repoRoot, ["diff", `${canonicalBranch}...${branch}`, "--", ".sprout/"]);
315
+ } catch (err) {
316
+ const msg = err instanceof Error ? err.message : String(err);
317
+ return { preserved: false, error: `Failed to compute .sprout/ diff: ${msg}` };
318
+ }
319
+
320
+ if (diff.trim() === "") {
321
+ // No .sprout/ changes on this branch
322
+ return { preserved: false };
323
+ }
324
+
325
+ // Step 2: Verify the repo root is currently on canonicalBranch.
326
+ let currentBranch: string;
327
+ try {
328
+ currentBranch = (await runGit(repoRoot, ["rev-parse", "--abbrev-ref", "HEAD"])).trim();
329
+ } catch (err) {
330
+ const msg = err instanceof Error ? err.message : String(err);
331
+ return { preserved: false, error: `Failed to determine current branch: ${msg}` };
332
+ }
333
+
334
+ if (currentBranch !== canonicalBranch) {
335
+ return {
336
+ preserved: false,
337
+ error: `Repo root is on '${currentBranch}', expected '${canonicalBranch}'. Cannot apply patch.`,
338
+ };
339
+ }
340
+
341
+ // Step 3: Check that .sprout/ is clean in the canonical branch.
342
+ let statusOutput: string;
343
+ try {
344
+ statusOutput = await runGit(repoRoot, ["status", "--porcelain", "--", ".sprout/"]);
345
+ } catch (err) {
346
+ const msg = err instanceof Error ? err.message : String(err);
347
+ return { preserved: false, error: `Failed to check .sprout/ status: ${msg}` };
348
+ }
349
+
350
+ if (statusOutput.trim() !== "") {
351
+ return {
352
+ preserved: false,
353
+ error: `.sprout/ has uncommitted changes in canonical branch. Cannot apply patch safely.`,
354
+ };
355
+ }
356
+
357
+ // Step 4: Write diff to a temp file.
358
+ const tmpFile = join(repoRoot, ".agentplate", `_sprout-patch-${Date.now()}.diff`);
359
+ try {
360
+ await Bun.write(tmpFile, diff);
361
+
362
+ // Step 5: Apply the patch with --index (stages changes).
363
+ try {
364
+ await runGit(repoRoot, ["apply", "--index", tmpFile]);
365
+ } catch (err) {
366
+ const msg = err instanceof Error ? err.message : String(err);
367
+ // Revert any partial changes
368
+ try {
369
+ await runGit(repoRoot, ["reset", "HEAD", "--", ".sprout/"]);
370
+ await runGit(repoRoot, ["checkout", "--", ".sprout/"]);
371
+ } catch {
372
+ // Best-effort revert
373
+ }
374
+ return { preserved: false, error: `Failed to apply .sprout/ patch: ${msg}` };
375
+ }
376
+
377
+ // Step 6: Commit the changes.
378
+ try {
379
+ await runGit(repoRoot, [
380
+ "commit",
381
+ "-m",
382
+ `chore: preserve .sprout/ changes from lead ${agentName}`,
383
+ ]);
384
+ } catch (err) {
385
+ const msg = err instanceof Error ? err.message : String(err);
386
+ // Revert any staged changes
387
+ try {
388
+ await runGit(repoRoot, ["reset", "HEAD", "--", ".sprout/"]);
389
+ await runGit(repoRoot, ["checkout", "--", ".sprout/"]);
390
+ } catch {
391
+ // Best-effort revert
392
+ }
393
+ return { preserved: false, error: `Failed to commit .sprout/ changes: ${msg}` };
394
+ }
395
+
396
+ return { preserved: true };
397
+ } finally {
398
+ // Step 8: Always clean up the temp file.
399
+ try {
400
+ await unlink(tmpFile);
401
+ } catch {
402
+ // Ignore cleanup errors
403
+ }
404
+ }
405
+ }
@@ -0,0 +1,172 @@
1
+ import { afterEach, beforeEach, describe, expect, it } from "bun:test";
2
+ import { mkdtemp, rm } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { getConnection, removeConnection } from "../runtimes/connections.ts";
6
+ import { HeadlessClaudeConnection } from "../runtimes/headless-connection.ts";
7
+ import { spawnHeadlessAgent } from "./process.ts";
8
+
9
+ describe("spawnHeadlessAgent", () => {
10
+ it("spawns a command and returns a valid PID", async () => {
11
+ const proc = await spawnHeadlessAgent(["echo", "hello"], {
12
+ cwd: process.cwd(),
13
+ env: { ...(process.env as Record<string, string>) },
14
+ });
15
+ expect(typeof proc.pid).toBe("number");
16
+ expect(proc.pid).toBeGreaterThan(0);
17
+ expect(proc.stdout).toBeDefined();
18
+ expect(proc.stdin).toBeDefined();
19
+ });
20
+
21
+ it("throws AgentError when argv is empty", async () => {
22
+ await expect(spawnHeadlessAgent([], { cwd: process.cwd(), env: {} })).rejects.toThrow(
23
+ "empty argv",
24
+ );
25
+ });
26
+
27
+ describe("agentName connection registration", () => {
28
+ const registeredNames: string[] = [];
29
+
30
+ afterEach(() => {
31
+ for (const name of registeredNames.splice(0)) {
32
+ removeConnection(name);
33
+ }
34
+ });
35
+
36
+ it("registers a HeadlessClaudeConnection when agentName is provided", async () => {
37
+ const agentName = "test-headless-agent-xyz";
38
+ registeredNames.push(agentName);
39
+
40
+ const proc = await spawnHeadlessAgent(["sleep", "5"], {
41
+ cwd: process.cwd(),
42
+ env: { ...(process.env as Record<string, string>) },
43
+ agentName,
44
+ });
45
+
46
+ expect(proc.pid).toBeGreaterThan(0);
47
+ const conn = getConnection(agentName);
48
+ expect(conn).toBeDefined();
49
+ expect(conn).toBeInstanceOf(HeadlessClaudeConnection);
50
+
51
+ // Clean up the spawned process
52
+ try {
53
+ process.kill(proc.pid, "SIGTERM");
54
+ } catch {
55
+ // ignore
56
+ }
57
+ });
58
+
59
+ it("does not register a connection when agentName is omitted", async () => {
60
+ const proc = await spawnHeadlessAgent(["echo", "no-register"], {
61
+ cwd: process.cwd(),
62
+ env: { ...(process.env as Record<string, string>) },
63
+ });
64
+
65
+ // Drain stdout so process exits cleanly
66
+ if (proc.stdout) {
67
+ await new Response(proc.stdout).text();
68
+ }
69
+
70
+ // No connection was registered (use a stable lookup key that was never set)
71
+ expect(getConnection("never-registered-in-this-test")).toBeUndefined();
72
+ });
73
+
74
+ it("registered connection pid matches the spawned process pid", async () => {
75
+ const agentName = "test-headless-pid-check-xyz";
76
+ registeredNames.push(agentName);
77
+
78
+ const proc = await spawnHeadlessAgent(["sleep", "5"], {
79
+ cwd: process.cwd(),
80
+ env: { ...(process.env as Record<string, string>) },
81
+ agentName,
82
+ });
83
+
84
+ const conn = getConnection(agentName) as HeadlessClaudeConnection;
85
+ expect(conn).toBeDefined();
86
+ expect(conn.pid).toBe(proc.pid);
87
+
88
+ try {
89
+ process.kill(proc.pid, "SIGTERM");
90
+ } catch {
91
+ // ignore
92
+ }
93
+ });
94
+ });
95
+
96
+ describe("file redirect mode", () => {
97
+ let tmpDir: string;
98
+
99
+ beforeEach(async () => {
100
+ tmpDir = await mkdtemp(join(tmpdir(), "ap-process-test-"));
101
+ });
102
+
103
+ afterEach(async () => {
104
+ await rm(tmpDir, { recursive: true, force: true });
105
+ });
106
+
107
+ it("redirects stdout to file when stdoutFile is provided", async () => {
108
+ const stdoutFile = join(tmpDir, "stdout.log");
109
+ const proc = await spawnHeadlessAgent(["echo", "hello from file"], {
110
+ cwd: process.cwd(),
111
+ env: { ...(process.env as Record<string, string>) },
112
+ stdoutFile,
113
+ });
114
+
115
+ expect(typeof proc.pid).toBe("number");
116
+ expect(proc.pid).toBeGreaterThan(0);
117
+ // stdout is null when redirected to file — no pipe, no backpressure
118
+ expect(proc.stdout).toBeNull();
119
+ expect(proc.stdin).toBeDefined();
120
+
121
+ // Wait for process to finish, then check file content
122
+ const exitProc = Bun.spawn(["sh", "-c", "true"], { stdout: "pipe" });
123
+ await exitProc.exited;
124
+ // Give echo a moment to flush
125
+ await Bun.sleep(100);
126
+
127
+ const content = await Bun.file(stdoutFile).text();
128
+ expect(content.trim()).toBe("hello from file");
129
+ });
130
+
131
+ it("redirects stderr to file when stderrFile is provided", async () => {
132
+ const stderrFile = join(tmpDir, "stderr.log");
133
+ // Write to stderr via sh -c
134
+ const proc = await spawnHeadlessAgent(["sh", "-c", "echo error output >&2"], {
135
+ cwd: process.cwd(),
136
+ env: { ...(process.env as Record<string, string>) },
137
+ stderrFile,
138
+ });
139
+
140
+ expect(typeof proc.pid).toBe("number");
141
+ // stdout still piped (no stdoutFile provided)
142
+ expect(proc.stdout).not.toBeNull();
143
+
144
+ // Drain stdout to let process exit cleanly
145
+ if (proc.stdout) {
146
+ const reader = proc.stdout.getReader();
147
+ while (!(await reader.read()).done) {
148
+ // drain
149
+ }
150
+ reader.releaseLock();
151
+ }
152
+
153
+ await Bun.sleep(100);
154
+ const content = await Bun.file(stderrFile).text();
155
+ expect(content.trim()).toBe("error output");
156
+ });
157
+
158
+ it("stdout remains a ReadableStream when no stdoutFile provided (default mode)", async () => {
159
+ const proc = await spawnHeadlessAgent(["echo", "piped"], {
160
+ cwd: process.cwd(),
161
+ env: { ...(process.env as Record<string, string>) },
162
+ });
163
+
164
+ expect(proc.stdout).not.toBeNull();
165
+ expect(proc.stdout).toBeInstanceOf(ReadableStream);
166
+
167
+ // Read the content via the stream
168
+ const text = await new Response(proc.stdout as ReadableStream<Uint8Array>).text();
169
+ expect(text.trim()).toBe("piped");
170
+ });
171
+ });
172
+ });
@@ -0,0 +1,131 @@
1
+ /**
2
+ * Headless subprocess management for non-tmux agent runtimes.
3
+ *
4
+ * Used by long-lived headless runtimes that bypass tmux (e.g., Sapling running
5
+ * with --json). Provides spawnHeadlessAgent() for direct Bun.spawn() invocation.
6
+ *
7
+ * Headless Claude Code does NOT use this path — under spawn-per-turn (Phase 3),
8
+ * Claude agents have no persistent process; each turn spawns a fresh claude
9
+ * inside `runTurn` (src/agents/turn-runner.ts). This module remains for
10
+ * runtimes that genuinely need a long-lived RPC channel.
11
+ *
12
+ * Note: isProcessAlive() and killProcessTree() for headless process lifecycle
13
+ * management already exist in src/worktree/tmux.ts — not duplicated here.
14
+ */
15
+
16
+ import { AgentError } from "../errors.ts";
17
+ import { registerHeadlessConnection } from "../runtimes/connections.ts";
18
+
19
+ /**
20
+ * Handle to a spawned headless agent subprocess.
21
+ *
22
+ * Provides the PID for session tracking, stdin for sending input to the
23
+ * agent process, and stdout for consuming NDJSON event output.
24
+ *
25
+ * stdout is null when the process was spawned with a stdoutFile redirect
26
+ * (file-redirect mode). In that case, stdout is written directly to the
27
+ * log file and no pipe backpressure can occur.
28
+ */
29
+ export interface HeadlessProcess {
30
+ /** OS-level process ID. Stored in AgentSession.pid for watchdog monitoring. */
31
+ pid: number;
32
+ /** Writable sink for sending input to the process (e.g., RPC messages). */
33
+ stdin: { write(data: string | Uint8Array): number | Promise<number> };
34
+ /**
35
+ * Readable stream of the process stdout, or null when stdout was redirected
36
+ * to a file via stdoutFile. Consumed via runtime.parseEvents() when piped.
37
+ */
38
+ stdout: ReadableStream<Uint8Array> | null;
39
+ }
40
+
41
+ /**
42
+ * Options for spawning a headless agent subprocess.
43
+ *
44
+ * When stdoutFile or stderrFile are provided, the corresponding stream is
45
+ * redirected to the given file path instead of a pipe. This eliminates
46
+ * backpressure: the child process can write unlimited output without blocking.
47
+ *
48
+ * Log files are useful for post-mortem inspection and do not need to be
49
+ * consumed by the caller.
50
+ */
51
+ export interface SpawnHeadlessOptions {
52
+ /** Working directory for the subprocess. */
53
+ cwd: string;
54
+ /** Full environment for the subprocess (no implicit merging with process.env). */
55
+ env: Record<string, string>;
56
+ /**
57
+ * When set, redirect subprocess stdout to this file path instead of a pipe.
58
+ * HeadlessProcess.stdout will be null in this case.
59
+ */
60
+ stdoutFile?: string;
61
+ /**
62
+ * When set, redirect subprocess stderr to this file path instead of a pipe.
63
+ */
64
+ stderrFile?: string;
65
+ /**
66
+ * When set, registers the spawned process as a `RuntimeConnection` keyed by
67
+ * this agent name (sibling of Sapling's RPC connect() flow). Lets `ap nudge`,
68
+ * the watchdog's liveness/abort path, etc. find the live process via
69
+ * `getConnection(agentName)`.
70
+ *
71
+ * Same namespace as AgentSession.agentName.
72
+ */
73
+ agentName?: string;
74
+ }
75
+
76
+ /**
77
+ * Spawn a headless agent subprocess directly via Bun.spawn().
78
+ *
79
+ * Used by `ap sling` when runtime.headless === true to bypass all tmux
80
+ * session management.
81
+ *
82
+ * **Backpressure prevention:** Pass stdoutFile (and stderrFile) to redirect
83
+ * output to log files instead of pipes. This is the recommended mode for
84
+ * `ap sling` — it prevents the OS pipe buffer (~64 KB) from filling up and
85
+ * blocking the child process when the caller does not actively consume stdout.
86
+ *
87
+ * When no file paths are provided (default/legacy mode), stdout is a pipe and
88
+ * the caller is responsible for consuming it to prevent backpressure.
89
+ *
90
+ * The provided env is used as the full subprocess environment (no implicit
91
+ * merging with process.env — callers should merge explicitly if needed).
92
+ *
93
+ * @param argv - Full argv array from runtime.buildDirectSpawn(); first element is the executable
94
+ * @param opts - Working directory, environment, and optional log file paths
95
+ * @returns HeadlessProcess with pid, stdin, and stdout (null if file-redirected)
96
+ * @throws AgentError if argv is empty
97
+ */
98
+ export async function spawnHeadlessAgent(
99
+ argv: string[],
100
+ opts: SpawnHeadlessOptions,
101
+ ): Promise<HeadlessProcess> {
102
+ const [cmd, ...args] = argv;
103
+ if (!cmd) {
104
+ throw new AgentError("buildDirectSpawn returned empty argv array", {
105
+ agentName: "headless",
106
+ });
107
+ }
108
+
109
+ const stdoutTarget = opts.stdoutFile ? Bun.file(opts.stdoutFile) : "pipe";
110
+ const stderrTarget = opts.stderrFile ? Bun.file(opts.stderrFile) : "pipe";
111
+
112
+ const proc = Bun.spawn([cmd, ...args], {
113
+ cwd: opts.cwd,
114
+ env: opts.env,
115
+ stdout: stdoutTarget,
116
+ stderr: stderrTarget,
117
+ stdin: "pipe",
118
+ });
119
+
120
+ const result: HeadlessProcess = {
121
+ pid: proc.pid,
122
+ stdin: proc.stdin as HeadlessProcess["stdin"],
123
+ stdout: opts.stdoutFile ? null : (proc.stdout as ReadableStream<Uint8Array>),
124
+ };
125
+
126
+ if (opts.agentName) {
127
+ registerHeadlessConnection(opts.agentName, result);
128
+ }
129
+
130
+ return result;
131
+ }