@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,1841 @@
1
+ /**
2
+ * CLI command: ap coordinator start|stop|status
3
+ *
4
+ * Manages the persistent coordinator agent lifecycle. The coordinator runs
5
+ * at the project root (NOT in a worktree), receives work via mail and tasks,
6
+ * and dispatches agents via ap sling.
7
+ *
8
+ * Unlike regular agents spawned by sling, the coordinator:
9
+ * - Has no worktree (operates on the main working tree)
10
+ * - Has no task assignment (it creates tasks, not works on them)
11
+ * - Has no overlay CLAUDE.md (context comes via mail + tasks + checkpoints)
12
+ * - Persists across work batches
13
+ */
14
+
15
+ import { mkdir, unlink } from "node:fs/promises";
16
+ import { join } from "node:path";
17
+ import { Command } from "commander";
18
+ import { buildInitialHeadlessPrompt, formatMailSection } from "../agents/headless-prompt.ts";
19
+ import { createIdentity, loadIdentity } from "../agents/identity.ts";
20
+ import { createManifestLoader, resolveModel } from "../agents/manifest.ts";
21
+ import { loadConfig } from "../config.ts";
22
+ import { AgentError, ValidationError } from "../errors.ts";
23
+ import { jsonOutput } from "../json.ts";
24
+ import { printHint, printSuccess, printWarning } from "../logging/color.ts";
25
+ import { createMailClient } from "../mail/client.ts";
26
+ import { createMailStore } from "../mail/store.ts";
27
+ import { getRuntime } from "../runtimes/registry.ts";
28
+ import { openSessionStore } from "../sessions/compat.ts";
29
+ import { createRunStore, createSessionStore } from "../sessions/store.ts";
30
+ import { resolveBackend, trackerCliName } from "../tracker/factory.ts";
31
+ import type { AgentSession } from "../types.ts";
32
+ import { isProcessRunning } from "../watchdog/health.ts";
33
+ import type { SpawnHeadlessOptions } from "../worktree/process.ts";
34
+ import { spawnHeadlessAgent } from "../worktree/process.ts";
35
+ import type { SessionState } from "../worktree/tmux.ts";
36
+ import {
37
+ capturePaneContent,
38
+ checkSessionState,
39
+ createSession,
40
+ ensureTmuxAvailable,
41
+ isSessionAlive,
42
+ killSession,
43
+ sanitizeTmuxName,
44
+ sendKeys,
45
+ TMUX_SOCKET,
46
+ waitForTuiReady,
47
+ } from "../worktree/tmux.ts";
48
+ import { nudgeAgent } from "./nudge.ts";
49
+ import { isRunningAsRoot } from "./sling.ts";
50
+
51
+ /** Default coordinator agent name. */
52
+ export const COORDINATOR_NAME = "coordinator";
53
+
54
+ export interface PersistentAgentSpec {
55
+ commandName: string;
56
+ displayName: string;
57
+ agentName: string;
58
+ capability: string;
59
+ agentDefFile: string;
60
+ beaconBuilder: (trackerCli: string) => string;
61
+ }
62
+
63
+ const COORDINATOR_SPEC: PersistentAgentSpec = {
64
+ commandName: "coordinator",
65
+ displayName: "Coordinator",
66
+ agentName: COORDINATOR_NAME,
67
+ capability: "coordinator",
68
+ agentDefFile: "coordinator.md",
69
+ beaconBuilder: buildCoordinatorBeacon,
70
+ };
71
+
72
+ /** Poll interval for the ask subcommand reply loop. */
73
+ const ASK_POLL_INTERVAL_MS = 2_000;
74
+
75
+ /** Default timeout in seconds for the ask subcommand. */
76
+ const ASK_DEFAULT_TIMEOUT_S = 120;
77
+
78
+ /**
79
+ * Build the tmux session name for the coordinator.
80
+ * Includes the project name to prevent cross-project collisions (agentplate-pcef).
81
+ */
82
+ function coordinatorTmuxSession(projectName: string, name: string = COORDINATOR_NAME): string {
83
+ return `agentplate-${sanitizeTmuxName(projectName)}-${name}`;
84
+ }
85
+
86
+ /** Dependency injection for testing. Uses real implementations when omitted. */
87
+ export interface CoordinatorDeps {
88
+ _tmux?: {
89
+ createSession: (
90
+ name: string,
91
+ cwd: string,
92
+ command: string,
93
+ env?: Record<string, string>,
94
+ ) => Promise<number>;
95
+ isSessionAlive: (name: string) => Promise<boolean>;
96
+ checkSessionState: (name: string) => Promise<SessionState>;
97
+ killSession: (name: string) => Promise<void>;
98
+ sendKeys: (name: string, keys: string) => Promise<void>;
99
+ waitForTuiReady: (
100
+ name: string,
101
+ detectReady: (paneContent: string) => import("../runtimes/types.ts").ReadyState,
102
+ timeoutMs?: number,
103
+ pollIntervalMs?: number,
104
+ ) => Promise<boolean>;
105
+ ensureTmuxAvailable: () => Promise<void>;
106
+ };
107
+ _watchdog?: {
108
+ start: () => Promise<{ pid: number } | null>;
109
+ stop: () => Promise<boolean>;
110
+ isRunning: () => Promise<boolean>;
111
+ };
112
+ _monitor?: {
113
+ start: (args: string[]) => Promise<{ pid: number } | null>;
114
+ stop: () => Promise<boolean>;
115
+ isRunning: () => Promise<boolean>;
116
+ };
117
+ _nudge?: (
118
+ projectRoot: string,
119
+ agentName: string,
120
+ message: string,
121
+ force: boolean,
122
+ ) => Promise<{ delivered: boolean; reason?: string }>;
123
+ _capturePaneContent?: (name: string, lines?: number) => Promise<string | null>;
124
+ /** Override poll interval for ask subcommand (default: ASK_POLL_INTERVAL_MS). Used in tests. */
125
+ _pollIntervalMs?: number;
126
+ /** Override headless spawn (used by tests to avoid forking real subprocesses). */
127
+ _spawnHeadless?: (
128
+ argv: string[],
129
+ opts: SpawnHeadlessOptions,
130
+ ) => Promise<{
131
+ pid: number;
132
+ stdin: { write(data: string | Uint8Array): number | Promise<number> };
133
+ stdout: ReadableStream<Uint8Array> | null;
134
+ }>;
135
+ }
136
+
137
+ /**
138
+ * Read the PID from the watchdog PID file.
139
+ * Returns null if the file doesn't exist or can't be parsed.
140
+ */
141
+ async function readWatchdogPid(projectRoot: string): Promise<number | null> {
142
+ const pidFilePath = join(projectRoot, ".agentplate", "watchdog.pid");
143
+ const file = Bun.file(pidFilePath);
144
+ const exists = await file.exists();
145
+ if (!exists) {
146
+ return null;
147
+ }
148
+
149
+ try {
150
+ const text = await file.text();
151
+ const pid = Number.parseInt(text.trim(), 10);
152
+ if (Number.isNaN(pid) || pid <= 0) {
153
+ return null;
154
+ }
155
+ return pid;
156
+ } catch {
157
+ return null;
158
+ }
159
+ }
160
+
161
+ /**
162
+ * Remove the watchdog PID file.
163
+ */
164
+ async function removeWatchdogPid(projectRoot: string): Promise<void> {
165
+ const pidFilePath = join(projectRoot, ".agentplate", "watchdog.pid");
166
+ try {
167
+ await unlink(pidFilePath);
168
+ } catch {
169
+ // File may already be gone — not an error
170
+ }
171
+ }
172
+
173
+ /**
174
+ * Default watchdog implementation for production use.
175
+ * Starts/stops the watchdog daemon via `ap watch --background`.
176
+ */
177
+ function createDefaultWatchdog(projectRoot: string): NonNullable<CoordinatorDeps["_watchdog"]> {
178
+ return {
179
+ async start(): Promise<{ pid: number } | null> {
180
+ // Check if watchdog is already running
181
+ const existingPid = await readWatchdogPid(projectRoot);
182
+ if (existingPid !== null && isProcessRunning(existingPid)) {
183
+ return null; // Already running
184
+ }
185
+
186
+ // Clean up stale PID file
187
+ if (existingPid !== null) {
188
+ await removeWatchdogPid(projectRoot);
189
+ }
190
+
191
+ // Start watchdog in background
192
+ const proc = Bun.spawn(["ap", "watch", "--background"], {
193
+ cwd: projectRoot,
194
+ stdout: "pipe",
195
+ stderr: "pipe",
196
+ });
197
+
198
+ const exitCode = await proc.exited;
199
+ if (exitCode !== 0) {
200
+ return null; // Failed to start
201
+ }
202
+
203
+ // Read the PID file that was written by the background process
204
+ const pid = await readWatchdogPid(projectRoot);
205
+ if (pid === null) {
206
+ return null; // PID file wasn't created
207
+ }
208
+
209
+ return { pid };
210
+ },
211
+
212
+ async stop(): Promise<boolean> {
213
+ const pid = await readWatchdogPid(projectRoot);
214
+ if (pid === null) {
215
+ return false; // No PID file
216
+ }
217
+
218
+ // Check if process is running
219
+ if (!isProcessRunning(pid)) {
220
+ // Process is dead, clean up PID file
221
+ await removeWatchdogPid(projectRoot);
222
+ return false;
223
+ }
224
+
225
+ // Kill the process
226
+ try {
227
+ process.kill(pid, 15); // SIGTERM
228
+ } catch {
229
+ return false;
230
+ }
231
+
232
+ // Remove PID file
233
+ await removeWatchdogPid(projectRoot);
234
+ return true;
235
+ },
236
+
237
+ async isRunning(): Promise<boolean> {
238
+ const pid = await readWatchdogPid(projectRoot);
239
+ if (pid === null) {
240
+ return false;
241
+ }
242
+ return isProcessRunning(pid);
243
+ },
244
+ };
245
+ }
246
+
247
+ /**
248
+ * Default monitor implementation for production use.
249
+ * Starts/stops the monitor agent via `ap monitor start/stop`.
250
+ */
251
+ function createDefaultMonitor(projectRoot: string): NonNullable<CoordinatorDeps["_monitor"]> {
252
+ return {
253
+ async start(): Promise<{ pid: number } | null> {
254
+ const proc = Bun.spawn(["ap", "monitor", "start", "--no-attach", "--json"], {
255
+ cwd: projectRoot,
256
+ stdout: "pipe",
257
+ stderr: "pipe",
258
+ });
259
+ const exitCode = await proc.exited;
260
+ if (exitCode !== 0) return null;
261
+ try {
262
+ const stdout = await new Response(proc.stdout).text();
263
+ const result = JSON.parse(stdout.trim()) as { pid?: number };
264
+ return result.pid ? { pid: result.pid } : null;
265
+ } catch {
266
+ return null;
267
+ }
268
+ },
269
+ async stop(): Promise<boolean> {
270
+ const proc = Bun.spawn(["ap", "monitor", "stop", "--json"], {
271
+ cwd: projectRoot,
272
+ stdout: "pipe",
273
+ stderr: "pipe",
274
+ });
275
+ const exitCode = await proc.exited;
276
+ return exitCode === 0;
277
+ },
278
+ async isRunning(): Promise<boolean> {
279
+ const proc = Bun.spawn(["ap", "monitor", "status", "--json"], {
280
+ cwd: projectRoot,
281
+ stdout: "pipe",
282
+ stderr: "pipe",
283
+ });
284
+ const exitCode = await proc.exited;
285
+ if (exitCode !== 0) return false;
286
+ try {
287
+ const stdout = await new Response(proc.stdout).text();
288
+ const result = JSON.parse(stdout.trim()) as { running?: boolean };
289
+ return result.running === true;
290
+ } catch {
291
+ return false;
292
+ }
293
+ },
294
+ };
295
+ }
296
+
297
+ /**
298
+ * Build the coordinator startup beacon — the first message sent to the coordinator
299
+ * via tmux send-keys after Claude Code initializes.
300
+ *
301
+ * @param cliName - The tracker CLI name to use in startup instructions (default: "bd")
302
+ */
303
+ export function buildCoordinatorBeacon(cliName = "bd"): string {
304
+ const timestamp = new Date().toISOString();
305
+ const parts = [
306
+ `[AGENTPLATE] ${COORDINATOR_NAME} (coordinator) ${timestamp}`,
307
+ "Depth: 0 | Parent: none | Role: persistent orchestrator",
308
+ "HIERARCHY: Default to leads (ap sling --capability lead). For low-budget or very narrow work, you may spawn scout/builder directly. NEVER spawn reviewer or merger directly.",
309
+ "DELEGATION: For substantial work streams, spawn a lead who will handle scouts/builders/reviewers. For tight agent budgets, compress roles by using direct scout/builder fallback or --dispatch-max-agents 1/2 on the lead.",
310
+ `Startup: run loam prime, check mail (ap mail check --agent ${COORDINATOR_NAME}), check ${cliName} ready, check ap group status, then begin work`,
311
+ ];
312
+ return parts.join(" — ");
313
+ }
314
+
315
+ /**
316
+ * Determine whether to auto-attach to the tmux session after starting.
317
+ * Exported for testing.
318
+ */
319
+ export function resolveAttach(args: string[], isTTY: boolean): boolean {
320
+ if (args.includes("--attach")) return true;
321
+ if (args.includes("--no-attach")) return false;
322
+ return isTTY;
323
+ }
324
+
325
+ /**
326
+ * Options for the reusable coordinator session startup core.
327
+ * Used by startCoordinatorSession() and consumed by commands like ap discover.
328
+ */
329
+ export interface CoordinatorSessionOptions {
330
+ json: boolean;
331
+ attach: boolean;
332
+ watchdog: boolean;
333
+ monitor: boolean;
334
+ profile?: string;
335
+ /** Override coordinator name (default: "coordinator"). */
336
+ coordinatorName?: string;
337
+ /** Generic persistent agent name override. Preferred over coordinatorName for new callers. */
338
+ agentName?: string;
339
+ /** Capability stored in the session registry and used for manifest/runtime resolution. */
340
+ capability?: string;
341
+ /** Agent definition file to append as the system prompt. */
342
+ agentDefFile?: string;
343
+ /** Human-readable label for output. */
344
+ displayName?: string;
345
+ /** Custom beacon builder. Receives tracker CLI name, returns beacon string. */
346
+ beaconBuilder?: (trackerCli: string) => string;
347
+ /**
348
+ * When true, spawn the coordinator headless (no tmux pane). The runtime must
349
+ * implement buildDirectSpawn(). The CLI command `ap coordinator start` does
350
+ * not yet pass this flag — it is consumed by the headless start path used by
351
+ * the web UI's POST /api/coordinator/start endpoint.
352
+ */
353
+ headless?: boolean;
354
+ /**
355
+ * Acknowledge that a watchdog daemon from a previous session may already be
356
+ * running and should be allowed to supervise this coordinator. Without this
357
+ * (or `--watchdog`), the start command refuses to spawn when a leftover
358
+ * daemon is detected, to surface the "watchdog persists across runs" trap
359
+ * that agentplate-3f0c was filed for.
360
+ */
361
+ acceptExistingWatchdog?: boolean;
362
+ }
363
+
364
+ /**
365
+ * Core coordinator session startup logic. Reusable by commands that need to
366
+ * start a coordinator-like session with a custom name or beacon
367
+ * (e.g., ap discover uses coordinatorName: "discover-coordinator").
368
+ */
369
+ export async function startCoordinatorSession(
370
+ opts: CoordinatorSessionOptions,
371
+ deps: CoordinatorDeps = {},
372
+ ): Promise<void> {
373
+ const tmux = deps._tmux ?? {
374
+ createSession,
375
+ isSessionAlive,
376
+ checkSessionState,
377
+ killSession,
378
+ sendKeys,
379
+ waitForTuiReady,
380
+ ensureTmuxAvailable,
381
+ };
382
+
383
+ const {
384
+ json,
385
+ attach: shouldAttach,
386
+ watchdog: watchdogFlag,
387
+ monitor: monitorFlag,
388
+ profile: profileFlag,
389
+ coordinatorName: coordinatorNameOpt,
390
+ agentName: agentNameOpt,
391
+ capability: capabilityOpt,
392
+ agentDefFile: agentDefFileOpt,
393
+ displayName: displayNameOpt,
394
+ beaconBuilder: beaconBuilderOpt,
395
+ headless: headlessFlag,
396
+ acceptExistingWatchdog: acceptExistingWatchdogFlag,
397
+ } = opts;
398
+
399
+ const coordinatorName = agentNameOpt ?? coordinatorNameOpt ?? COORDINATOR_NAME;
400
+ const capability = capabilityOpt ?? COORDINATOR_SPEC.capability;
401
+ const agentDefFile = agentDefFileOpt ?? COORDINATOR_SPEC.agentDefFile;
402
+ const displayName = displayNameOpt ?? COORDINATOR_SPEC.displayName;
403
+ const beaconBuilder = beaconBuilderOpt ?? buildCoordinatorBeacon;
404
+
405
+ if (isRunningAsRoot()) {
406
+ throw new AgentError(
407
+ "Cannot spawn agents as root (UID 0). The claude CLI rejects --permission-mode bypassPermissions when run as root, causing the tmux session to die immediately. Run agentplate as a non-root user.",
408
+ );
409
+ }
410
+
411
+ const cwd = process.cwd();
412
+ const config = await loadConfig(cwd);
413
+ const projectRoot = config.project.root;
414
+ const watchdog = deps._watchdog ?? createDefaultWatchdog(projectRoot);
415
+ const monitor = deps._monitor ?? createDefaultMonitor(projectRoot);
416
+ const tmuxSession = coordinatorTmuxSession(config.project.name, coordinatorName);
417
+
418
+ // Detect leftover watchdog daemon from a previous session (agentplate-3f0c).
419
+ // If a watchdog is already running and the operator did not pass --watchdog
420
+ // or --accept-existing-watchdog, refuse to start: a persistent daemon will
421
+ // supervise this coordinator with policy decided by the original invocation,
422
+ // not the current one. This prevents "I didn't run --watchdog, why is the
423
+ // watchdog killing things?" surprises.
424
+ const watchdogAlreadyRunning = await watchdog.isRunning();
425
+ if (watchdogAlreadyRunning && !watchdogFlag && !acceptExistingWatchdogFlag) {
426
+ const existingPid = await readWatchdogPid(projectRoot);
427
+ const pidLabel = existingPid !== null ? `PID ${existingPid}` : "unknown PID";
428
+ throw new AgentError(
429
+ `Watchdog daemon (${pidLabel}) is already running from a previous session. ` +
430
+ `It will supervise this ${displayName.toLowerCase()} run and may take escalation actions you did not opt into. ` +
431
+ `To proceed: pass --watchdog to acknowledge, pass --accept-existing-watchdog to suppress this check, ` +
432
+ `or run 'ap watch --kill-others' (or remove .agentplate/watchdog.pid) first.`,
433
+ { agentName: coordinatorName },
434
+ );
435
+ }
436
+
437
+ // Check for existing coordinator session with the same name
438
+ const agentplateDir = join(projectRoot, ".agentplate");
439
+ const { store } = openSessionStore(agentplateDir);
440
+ try {
441
+ const existing = store.getByName(coordinatorName);
442
+
443
+ if (
444
+ existing &&
445
+ existing.capability === capability &&
446
+ existing.state !== "completed" &&
447
+ existing.state !== "zombie"
448
+ ) {
449
+ const sessionState = await tmux.checkSessionState(existing.tmuxSession);
450
+
451
+ if (sessionState === "alive") {
452
+ // Tmux session exists -- but is the process inside still running?
453
+ // A crashed Claude Code leaves a zombie tmux pane that blocks retries.
454
+ if (existing.pid !== null && !isProcessRunning(existing.pid)) {
455
+ // Zombie: tmux pane exists but agent process has exited.
456
+ // Kill the empty session and reclaim the slot.
457
+ await tmux.killSession(existing.tmuxSession);
458
+ store.updateState(coordinatorName, "completed");
459
+ } else {
460
+ // Either the process is genuinely running (pid alive), or pid is null
461
+ // (e.g. sessions migrated from an older schema). In both cases we
462
+ // cannot prove the session is a zombie, so treat it as active.
463
+ throw new AgentError(
464
+ `${displayName} is already running (tmux: ${existing.tmuxSession}, since: ${existing.startedAt})`,
465
+ { agentName: coordinatorName },
466
+ );
467
+ }
468
+ } else {
469
+ // Session is dead or tmux server is not running -- clean up stale DB entry.
470
+ store.updateState(coordinatorName, "completed");
471
+ }
472
+ }
473
+
474
+ // Resolve model and runtime early (needed for deployConfig and spawn)
475
+ const manifestLoader = createManifestLoader(
476
+ join(projectRoot, config.agents.manifestPath),
477
+ join(projectRoot, config.agents.baseDir),
478
+ );
479
+ const manifest = await manifestLoader.load();
480
+ const resolvedModel = resolveModel(config, manifest, capability, "opus");
481
+ const runtime = getRuntime(undefined, config, capability);
482
+
483
+ // Deploy hooks to the project root so the coordinator gets event logging,
484
+ // mail check --inject, and activity tracking via the standard hook pipeline.
485
+ // The ENV_GUARD prefix on all hooks (both template and generated guards)
486
+ // ensures they only activate when AGENTPLATE_AGENT_NAME is set (i.e. for
487
+ // the coordinator's tmux session), so the user's own Claude Code session
488
+ // at the project root is unaffected.
489
+ await runtime.deployConfig(projectRoot, undefined, {
490
+ agentName: coordinatorName,
491
+ capability,
492
+ worktreePath: projectRoot,
493
+ });
494
+
495
+ // Create coordinator identity if first run
496
+ const identityBaseDir = join(projectRoot, ".agentplate", "agents");
497
+ await mkdir(identityBaseDir, { recursive: true });
498
+ const existingIdentity = await loadIdentity(identityBaseDir, coordinatorName);
499
+ if (!existingIdentity) {
500
+ await createIdentity(identityBaseDir, {
501
+ name: coordinatorName,
502
+ capability,
503
+ created: new Date().toISOString(),
504
+ sessionsCompleted: 0,
505
+ expertiseDomains: config.loam.enabled ? config.loam.domains : [],
506
+ recentTasks: [],
507
+ });
508
+ }
509
+
510
+ // Headless start path: bypass tmux entirely and spawn the coordinator
511
+ // process directly via runtime.buildDirectSpawn(). Same hooks, identity,
512
+ // and run-tracking as the tmux path — only the spawn mechanism differs.
513
+ if (headlessFlag === true) {
514
+ if (!runtime.buildDirectSpawn) {
515
+ throw new ValidationError(
516
+ `Headless coordinator start requires a runtime with buildDirectSpawn (got: ${runtime.id})`,
517
+ { field: "runtime", value: runtime.id },
518
+ );
519
+ }
520
+
521
+ const spawnHeadless = deps._spawnHeadless ?? spawnHeadlessAgent;
522
+ const directEnv: Record<string, string> = {
523
+ ...runtime.buildEnv(resolvedModel),
524
+ AGENTPLATE_AGENT_NAME: coordinatorName,
525
+ AGENTPLATE_PROJECT_ROOT: projectRoot,
526
+ ...(profileFlag ? { AGENTPLATE_PROFILE: profileFlag } : {}),
527
+ };
528
+ const argv = runtime.buildDirectSpawn({
529
+ cwd: projectRoot,
530
+ env: directEnv,
531
+ ...(resolvedModel.isExplicitOverride ? { model: resolvedModel.model } : {}),
532
+ instructionPath: runtime.instructionPath,
533
+ });
534
+
535
+ // Per-session log dir mirrors sling.ts headless path.
536
+ const logTimestamp = new Date().toISOString().replace(/[:.]/g, "-");
537
+ const headlessLogDir = join(agentplateDir, "logs", "coordinator", logTimestamp);
538
+ await mkdir(headlessLogDir, { recursive: true });
539
+
540
+ const headlessProc = await spawnHeadless(argv, {
541
+ cwd: projectRoot,
542
+ env: { ...(process.env as Record<string, string>), ...directEnv },
543
+ stdoutFile: join(headlessLogDir, "stdout.log"),
544
+ stderrFile: join(headlessLogDir, "stderr.log"),
545
+ agentName: coordinatorName,
546
+ });
547
+
548
+ // Build the initial stdin prompt from agent definition + pending dispatch
549
+ // mail + activation beacon. Replaces SessionStart hooks (no-op headless).
550
+ const agentDefPath = join(projectRoot, ".agentplate", "agent-defs", agentDefFile);
551
+ const agentDefHandle = Bun.file(agentDefPath);
552
+ const primeContext = (await agentDefHandle.exists()) ? await agentDefHandle.text() : "";
553
+
554
+ const mailDbPath = join(agentplateDir, "mail.db");
555
+ const pendingMailStore = createMailStore(mailDbPath);
556
+ let mailSection = "";
557
+ try {
558
+ const pendingMailClient = createMailClient(pendingMailStore);
559
+ const pendingMessages = pendingMailClient.check(coordinatorName);
560
+ mailSection = formatMailSection(pendingMessages);
561
+ } finally {
562
+ pendingMailStore.close();
563
+ }
564
+
565
+ const resolvedBackend = await resolveBackend(config.taskTracker.backend, config.project.root);
566
+ const trackerCli = trackerCliName(resolvedBackend);
567
+ const beacon = beaconBuilder(trackerCli);
568
+ const initialPrompt = buildInitialHeadlessPrompt(
569
+ primeContext || undefined,
570
+ mailSection || undefined,
571
+ beacon,
572
+ );
573
+ await headlessProc.stdin.write(initialPrompt);
574
+
575
+ // Create run record + current-run.txt + session row.
576
+ const sessionId = `session-${Date.now()}-${coordinatorName}`;
577
+ const runId = `run-${new Date().toISOString().replace(/[:.]/g, "-")}`;
578
+ const runStore = createRunStore(join(agentplateDir, "sessions.db"));
579
+ try {
580
+ runStore.createRun({
581
+ id: runId,
582
+ startedAt: new Date().toISOString(),
583
+ coordinatorSessionId: sessionId,
584
+ coordinatorName,
585
+ status: "active",
586
+ });
587
+ } finally {
588
+ runStore.close();
589
+ }
590
+ await Bun.write(join(agentplateDir, "current-run.txt"), runId);
591
+
592
+ const session: AgentSession = {
593
+ id: sessionId,
594
+ agentName: coordinatorName,
595
+ capability,
596
+ worktreePath: projectRoot,
597
+ branchName: config.project.canonicalBranch,
598
+ taskId: "",
599
+ tmuxSession: "", // headless: no tmux pane
600
+ state: "booting",
601
+ pid: headlessProc.pid,
602
+ parentAgent: null,
603
+ depth: 0,
604
+ runId,
605
+ startedAt: new Date().toISOString(),
606
+ lastActivity: new Date().toISOString(),
607
+ escalationLevel: 0,
608
+ stalledSince: null,
609
+ transcriptPath: null,
610
+ };
611
+ store.upsert(session);
612
+
613
+ // Auto-start watchdog / monitor (same as tmux path).
614
+ let watchdogPid: number | undefined;
615
+ if (watchdogFlag) {
616
+ const watchdogResult = await watchdog.start();
617
+ if (watchdogResult) {
618
+ watchdogPid = watchdogResult.pid;
619
+ if (!json) printHint("Watchdog started");
620
+ } else if (watchdogAlreadyRunning) {
621
+ // createDefaultWatchdog.start() returns null when an existing PID
622
+ // is alive — that's a no-op success, not a failure. Reuse the
623
+ // existing daemon. Sentinel value keeps `watchdogPid !== undefined`
624
+ // truthy in the JSON output.
625
+ watchdogPid = -1;
626
+ if (!json) printHint("Watchdog already running, reusing existing daemon");
627
+ } else {
628
+ if (!json) printWarning("Watchdog failed to start");
629
+ }
630
+ } else if (watchdogAlreadyRunning && acceptExistingWatchdogFlag) {
631
+ // --accept-existing-watchdog without --watchdog: surface that an
632
+ // existing daemon is supervising this run, but do not call start().
633
+ watchdogPid = -1;
634
+ if (!json) printHint("Watchdog already running, reusing existing daemon");
635
+ }
636
+ let monitorPid: number | undefined;
637
+ if (monitorFlag) {
638
+ if (!config.watchdog.tier2Enabled) {
639
+ if (!json) printWarning("Monitor skipped", "watchdog.tier2Enabled is false");
640
+ } else {
641
+ const monitorResult = await monitor.start([]);
642
+ if (monitorResult) {
643
+ monitorPid = monitorResult.pid;
644
+ if (!json) printHint("Monitor started");
645
+ } else {
646
+ if (!json) printWarning("Monitor failed to start");
647
+ }
648
+ }
649
+ }
650
+
651
+ const output = {
652
+ agentName: coordinatorName,
653
+ capability,
654
+ tmuxSession: "",
655
+ projectRoot,
656
+ pid: headlessProc.pid,
657
+ headless: true,
658
+ watchdog: watchdogPid !== undefined,
659
+ watchdogPreexisting: watchdogAlreadyRunning,
660
+ monitor: monitorFlag ? monitorPid !== undefined : false,
661
+ };
662
+
663
+ if (json) {
664
+ jsonOutput(`${capability} start`, output);
665
+ } else {
666
+ printSuccess(`${displayName} started (headless)`);
667
+ process.stdout.write(` Root: ${projectRoot}\n`);
668
+ process.stdout.write(` PID: ${headlessProc.pid}\n`);
669
+ process.stdout.write(` Logs: ${headlessLogDir}\n`);
670
+ }
671
+ return;
672
+ }
673
+
674
+ // Preflight: verify tmux is installed before attempting to spawn.
675
+ // Without this check, a missing tmux leads to cryptic errors later.
676
+ await tmux.ensureTmuxAvailable();
677
+
678
+ // Spawn tmux session at project root with Claude Code (interactive mode).
679
+ // Inject the coordinator base definition via --append-system-prompt so the
680
+ // coordinator knows its role, hierarchy rules, and delegation patterns
681
+ // (agentplate-gaio, agentplate-0kwf).
682
+ // Pass the file path (not content) so the shell inside the tmux pane reads
683
+ // it via $(cat ...) — avoids tmux IPC "command too long" errors with large
684
+ // agent definitions (agentplate#45).
685
+ const agentDefPath = join(projectRoot, ".agentplate", "agent-defs", agentDefFile);
686
+ const agentDefHandle = Bun.file(agentDefPath);
687
+ let appendSystemPromptFile: string | undefined;
688
+ if (await agentDefHandle.exists()) {
689
+ appendSystemPromptFile = agentDefPath;
690
+ }
691
+ const spawnCmd = runtime.buildSpawnCommand({
692
+ model: resolvedModel.model,
693
+ permissionMode: "bypass",
694
+ cwd: projectRoot,
695
+ appendSystemPromptFile,
696
+ env: {
697
+ ...runtime.buildEnv(resolvedModel),
698
+ AGENTPLATE_AGENT_NAME: coordinatorName,
699
+ ...(profileFlag ? { AGENTPLATE_PROFILE: profileFlag } : {}),
700
+ },
701
+ });
702
+ const pid = await tmux.createSession(tmuxSession, projectRoot, spawnCmd, {
703
+ ...runtime.buildEnv(resolvedModel),
704
+ AGENTPLATE_AGENT_NAME: coordinatorName,
705
+ ...(profileFlag ? { AGENTPLATE_PROFILE: profileFlag } : {}),
706
+ });
707
+
708
+ // Create a run for this coordinator session BEFORE recording the session,
709
+ // so the session can reference the run ID from the start.
710
+ const sessionId = `session-${Date.now()}-${coordinatorName}`;
711
+ const runId = `run-${new Date().toISOString().replace(/[:.]/g, "-")}`;
712
+ const runStore = createRunStore(join(agentplateDir, "sessions.db"));
713
+ try {
714
+ runStore.createRun({
715
+ id: runId,
716
+ startedAt: new Date().toISOString(),
717
+ coordinatorSessionId: sessionId,
718
+ coordinatorName,
719
+ status: "active",
720
+ });
721
+ } finally {
722
+ runStore.close();
723
+ }
724
+ // Write current-run.txt for backward compatibility with ap sling and other consumers.
725
+ await Bun.write(join(agentplateDir, "current-run.txt"), runId);
726
+
727
+ // Record session BEFORE sending the beacon so that hook-triggered
728
+ // updateLastActivity() can find the entry and transition booting->working.
729
+ // Without this, a race exists: hooks fire before the session is persisted,
730
+ // leaving the coordinator stuck in "booting" (agentplate-036f).
731
+ const session: AgentSession = {
732
+ id: sessionId,
733
+ agentName: coordinatorName,
734
+ capability,
735
+ worktreePath: projectRoot, // Coordinator uses project root, not a worktree
736
+ branchName: config.project.canonicalBranch, // Operates on canonical branch
737
+ taskId: "", // No specific task assignment
738
+ tmuxSession,
739
+ state: "booting",
740
+ pid,
741
+ parentAgent: null, // Top of hierarchy
742
+ depth: 0,
743
+ runId,
744
+ startedAt: new Date().toISOString(),
745
+ lastActivity: new Date().toISOString(),
746
+ escalationLevel: 0,
747
+ stalledSince: null,
748
+ transcriptPath: null,
749
+ };
750
+
751
+ store.upsert(session);
752
+
753
+ // Give slow shells time to finish initializing before polling for TUI readiness.
754
+ const shellDelay = config.runtime?.shellInitDelayMs ?? 0;
755
+ if (shellDelay > 0) {
756
+ await Bun.sleep(shellDelay);
757
+ }
758
+
759
+ // Wait for Claude Code TUI to render before sending input
760
+ const tuiReady = await tmux.waitForTuiReady(tmuxSession, (content) =>
761
+ runtime.detectReady(content),
762
+ );
763
+ if (!tuiReady) {
764
+ // Session may have died — check liveness before proceeding
765
+ const alive = await tmux.isSessionAlive(tmuxSession);
766
+ if (!alive) {
767
+ // Clean up the stale session record
768
+ store.updateState(coordinatorName, "completed");
769
+ const sessionState = await tmux.checkSessionState(tmuxSession);
770
+ const detail =
771
+ sessionState === "no_server"
772
+ ? "The tmux server is no longer running. It may have crashed or been killed externally."
773
+ : "The Claude Code process may have crashed or exited immediately. Check tmux logs or try running the claude command manually.";
774
+ throw new AgentError(
775
+ `${displayName} tmux session "${tmuxSession}" died during startup. ${detail}`,
776
+ { agentName: coordinatorName },
777
+ );
778
+ }
779
+ await tmux.killSession(tmuxSession);
780
+ store.updateState(coordinatorName, "completed");
781
+ throw new AgentError(
782
+ `${displayName} tmux session "${tmuxSession}" did not become ready during startup. Claude Code may still be waiting on an interactive dialog or initializing too slowly.`,
783
+ { agentName: coordinatorName },
784
+ );
785
+ }
786
+ await Bun.sleep(1_000);
787
+
788
+ const resolvedBackend = await resolveBackend(config.taskTracker.backend, config.project.root);
789
+ const trackerCli = trackerCliName(resolvedBackend);
790
+ const beacon = beaconBuilder(trackerCli);
791
+ await tmux.sendKeys(tmuxSession, beacon);
792
+
793
+ // Follow-up Enters with increasing delays to ensure submission
794
+ for (const delay of [1_000, 2_000, 3_000, 5_000]) {
795
+ await Bun.sleep(delay);
796
+ await tmux.sendKeys(tmuxSession, "");
797
+ }
798
+
799
+ // Auto-start watchdog if --watchdog flag is present.
800
+ let watchdogPid: number | undefined;
801
+ if (watchdogFlag) {
802
+ const watchdogResult = await watchdog.start();
803
+ if (watchdogResult) {
804
+ watchdogPid = watchdogResult.pid;
805
+ if (!json) printHint("Watchdog started");
806
+ } else if (watchdogAlreadyRunning) {
807
+ // createDefaultWatchdog.start() returns null when an existing PID
808
+ // is alive — that's a no-op success, not a failure. Reuse the
809
+ // existing daemon. Sentinel value keeps `watchdogPid !== undefined`
810
+ // truthy in the JSON output.
811
+ watchdogPid = -1;
812
+ if (!json) printHint("Watchdog already running, reusing existing daemon");
813
+ } else {
814
+ if (!json) printWarning("Watchdog failed to start");
815
+ }
816
+ } else if (watchdogAlreadyRunning && acceptExistingWatchdogFlag) {
817
+ // --accept-existing-watchdog without --watchdog: surface that an
818
+ // existing daemon is supervising this run, but do not call start().
819
+ watchdogPid = -1;
820
+ if (!json) printHint("Watchdog already running, reusing existing daemon");
821
+ }
822
+
823
+ // Auto-start monitor if --monitor flag is present and tier2 is enabled
824
+ let monitorPid: number | undefined;
825
+ if (monitorFlag) {
826
+ if (!config.watchdog.tier2Enabled) {
827
+ if (!json) printWarning("Monitor skipped", "watchdog.tier2Enabled is false");
828
+ } else {
829
+ const monitorResult = await monitor.start([]);
830
+ if (monitorResult) {
831
+ monitorPid = monitorResult.pid;
832
+ if (!json) printHint("Monitor started");
833
+ } else {
834
+ if (!json) printWarning("Monitor failed to start");
835
+ }
836
+ }
837
+ }
838
+
839
+ const output = {
840
+ agentName: coordinatorName,
841
+ capability,
842
+ tmuxSession,
843
+ projectRoot,
844
+ pid,
845
+ watchdog: watchdogPid !== undefined,
846
+ watchdogPreexisting: watchdogAlreadyRunning,
847
+ monitor: monitorFlag ? monitorPid !== undefined : false,
848
+ };
849
+
850
+ if (json) {
851
+ jsonOutput(`${capability} start`, output);
852
+ } else {
853
+ printSuccess(`${displayName} started`);
854
+ process.stdout.write(` Tmux: ${tmuxSession}\n`);
855
+ process.stdout.write(` Root: ${projectRoot}\n`);
856
+ process.stdout.write(` PID: ${pid}\n`);
857
+ printHint("Open the UI: `ap serve` then http://localhost:7321 — primary operator surface");
858
+ }
859
+
860
+ if (shouldAttach) {
861
+ Bun.spawnSync(["tmux", "-L", TMUX_SOCKET, "attach-session", "-t", tmuxSession], {
862
+ stdio: ["inherit", "inherit", "inherit"],
863
+ });
864
+ }
865
+ } finally {
866
+ store.close();
867
+ }
868
+ }
869
+
870
+ async function startPersistentAgent(
871
+ spec: PersistentAgentSpec,
872
+ opts: {
873
+ json: boolean;
874
+ attach: boolean;
875
+ watchdog: boolean;
876
+ monitor: boolean;
877
+ profile?: string;
878
+ acceptExistingWatchdog?: boolean;
879
+ },
880
+ deps: CoordinatorDeps = {},
881
+ ): Promise<void> {
882
+ await startCoordinatorSession(
883
+ {
884
+ ...opts,
885
+ agentName: spec.agentName,
886
+ capability: spec.capability,
887
+ agentDefFile: spec.agentDefFile,
888
+ displayName: spec.displayName,
889
+ beaconBuilder: spec.beaconBuilder,
890
+ },
891
+ deps,
892
+ );
893
+ }
894
+
895
+ function isActivePersistentAgentSession(
896
+ session: AgentSession | null,
897
+ spec: PersistentAgentSpec,
898
+ ): session is AgentSession {
899
+ return (
900
+ session !== null &&
901
+ session.capability === spec.capability &&
902
+ session.state !== "completed" &&
903
+ session.state !== "zombie"
904
+ );
905
+ }
906
+
907
+ /**
908
+ * Liveness check that handles both tmux and headless persistent agents.
909
+ *
910
+ * Tmux-backed sessions check the tmux server. Headless sessions
911
+ * (`tmuxSession === ""`) fall back to OS-level PID liveness via
912
+ * `isProcessRunning(session.pid)`. Without this branch, callers that asked
913
+ * tmux about an empty session name got `false` and incorrectly flipped a
914
+ * healthy headless coordinator to `zombie` (agentplate-34a6).
915
+ */
916
+ async function isPersistentAgentAlive(
917
+ session: AgentSession,
918
+ tmux: { isSessionAlive: (name: string) => Promise<boolean> },
919
+ ): Promise<boolean> {
920
+ if (session.tmuxSession === "") {
921
+ return session.pid !== null && isProcessRunning(session.pid);
922
+ }
923
+ return await tmux.isSessionAlive(session.tmuxSession);
924
+ }
925
+
926
+ /**
927
+ * Stop the coordinator agent.
928
+ *
929
+ * 1. Find the active coordinator session
930
+ * 2. Kill the tmux session (with process tree cleanup)
931
+ * 3. Mark session as completed in SessionStore
932
+ * 4. Auto-complete the active run (if current-run.txt exists)
933
+ */
934
+ /**
935
+ * Stop the default coordinator. Handles both tmux and headless sessions.
936
+ * Exposed for callers outside the CLI command surface (e.g. the web-UI POST
937
+ * /api/coordinator/stop endpoint, which lives in coordinator-actions.ts).
938
+ */
939
+ export async function stopCoordinatorSession(
940
+ opts: { json: boolean },
941
+ deps: CoordinatorDeps = {},
942
+ ): Promise<void> {
943
+ await stopPersistentAgent(COORDINATOR_SPEC, opts, deps);
944
+ }
945
+
946
+ async function stopPersistentAgent(
947
+ spec: PersistentAgentSpec,
948
+ opts: { json: boolean },
949
+ deps: CoordinatorDeps = {},
950
+ ): Promise<void> {
951
+ const tmux = deps._tmux ?? {
952
+ createSession,
953
+ isSessionAlive,
954
+ checkSessionState,
955
+ killSession,
956
+ sendKeys,
957
+ waitForTuiReady,
958
+ ensureTmuxAvailable,
959
+ };
960
+
961
+ const { json } = opts;
962
+ const cwd = process.cwd();
963
+ const config = await loadConfig(cwd);
964
+ const projectRoot = config.project.root;
965
+ const watchdog = deps._watchdog ?? createDefaultWatchdog(projectRoot);
966
+ const monitor = deps._monitor ?? createDefaultMonitor(projectRoot);
967
+
968
+ const agentplateDir = join(projectRoot, ".agentplate");
969
+ const { store } = openSessionStore(agentplateDir);
970
+ try {
971
+ const session = store.getByName(spec.agentName);
972
+
973
+ if (!isActivePersistentAgentSession(session, spec)) {
974
+ throw new AgentError(`No active ${spec.commandName} session found`, {
975
+ agentName: spec.agentName,
976
+ });
977
+ }
978
+
979
+ // Headless sessions have no tmux pane (tmuxSession === ""). Tear down via
980
+ // the connection registry (SIGTERM-with-SIGKILL-escalation) and skip tmux.
981
+ if (session.tmuxSession === "") {
982
+ const { removeConnection } = await import("../runtimes/connections.ts");
983
+ removeConnection(spec.agentName);
984
+ if (session.pid !== null && isProcessRunning(session.pid)) {
985
+ try {
986
+ process.kill(session.pid, "SIGTERM");
987
+ } catch {
988
+ // process may have exited between the check and the signal
989
+ }
990
+ }
991
+ } else {
992
+ // Kill tmux session with process tree cleanup
993
+ const alive = await tmux.isSessionAlive(session.tmuxSession);
994
+ if (alive) {
995
+ await tmux.killSession(session.tmuxSession);
996
+ }
997
+ }
998
+
999
+ // Always attempt to stop watchdog
1000
+ const watchdogStopped = await watchdog.stop();
1001
+
1002
+ // Always attempt to stop monitor
1003
+ const monitorStopped = await monitor.stop();
1004
+
1005
+ // Update session state
1006
+ store.updateState(spec.agentName, "completed");
1007
+ store.updateLastActivity(spec.agentName);
1008
+
1009
+ // Auto-complete the current run
1010
+ let runCompleted = false;
1011
+ try {
1012
+ const currentRunPath = join(agentplateDir, "current-run.txt");
1013
+ const currentRunFile = Bun.file(currentRunPath);
1014
+ if (await currentRunFile.exists()) {
1015
+ const runId = (await currentRunFile.text()).trim();
1016
+ if (runId.length > 0) {
1017
+ const runStore = createRunStore(join(agentplateDir, "sessions.db"));
1018
+ try {
1019
+ runStore.completeRun(runId, "completed");
1020
+ runCompleted = true;
1021
+ } finally {
1022
+ runStore.close();
1023
+ }
1024
+ try {
1025
+ await unlink(currentRunPath);
1026
+ } catch {
1027
+ // File may already be gone
1028
+ }
1029
+ }
1030
+ }
1031
+ } catch {
1032
+ // Non-fatal: run completion should not break coordinator stop
1033
+ }
1034
+
1035
+ if (json) {
1036
+ jsonOutput(`${spec.commandName} stop`, {
1037
+ stopped: true,
1038
+ sessionId: session.id,
1039
+ watchdogStopped,
1040
+ monitorStopped,
1041
+ runCompleted,
1042
+ });
1043
+ } else {
1044
+ printSuccess(`${spec.displayName} stopped`, session.id);
1045
+ if (watchdogStopped) {
1046
+ printHint("Watchdog stopped");
1047
+ } else {
1048
+ printHint("No watchdog running");
1049
+ }
1050
+ if (monitorStopped) {
1051
+ printHint("Monitor stopped");
1052
+ } else {
1053
+ printHint("No monitor running");
1054
+ }
1055
+ if (runCompleted) {
1056
+ printHint("Run completed");
1057
+ } else {
1058
+ printHint("No active run");
1059
+ }
1060
+ }
1061
+ } finally {
1062
+ store.close();
1063
+ }
1064
+ }
1065
+
1066
+ /**
1067
+ * Show coordinator status.
1068
+ *
1069
+ * Checks session registry and tmux liveness to report actual state.
1070
+ */
1071
+ async function statusPersistentAgent(
1072
+ spec: PersistentAgentSpec,
1073
+ opts: { json: boolean },
1074
+ deps: CoordinatorDeps = {},
1075
+ ): Promise<void> {
1076
+ const tmux = deps._tmux ?? {
1077
+ createSession,
1078
+ isSessionAlive,
1079
+ checkSessionState,
1080
+ killSession,
1081
+ sendKeys,
1082
+ waitForTuiReady,
1083
+ ensureTmuxAvailable,
1084
+ };
1085
+
1086
+ const { json } = opts;
1087
+ const cwd = process.cwd();
1088
+ const config = await loadConfig(cwd);
1089
+ const projectRoot = config.project.root;
1090
+ const watchdog = deps._watchdog ?? createDefaultWatchdog(projectRoot);
1091
+ const monitor = deps._monitor ?? createDefaultMonitor(projectRoot);
1092
+
1093
+ const agentplateDir = join(projectRoot, ".agentplate");
1094
+ const { store } = openSessionStore(agentplateDir);
1095
+ try {
1096
+ const session = store.getByName(spec.agentName);
1097
+ const watchdogRunning = await watchdog.isRunning();
1098
+ const monitorRunning = await monitor.isRunning();
1099
+
1100
+ if (!isActivePersistentAgentSession(session, spec)) {
1101
+ if (json) {
1102
+ jsonOutput(`${spec.commandName} status`, {
1103
+ running: false,
1104
+ watchdogRunning,
1105
+ monitorRunning,
1106
+ });
1107
+ } else {
1108
+ printHint(`${spec.displayName} is not running`);
1109
+ if (watchdogRunning) {
1110
+ printHint("Watchdog: running");
1111
+ }
1112
+ if (monitorRunning) {
1113
+ printHint("Monitor: running");
1114
+ }
1115
+ }
1116
+ return;
1117
+ }
1118
+
1119
+ const alive = await isPersistentAgentAlive(session, tmux);
1120
+
1121
+ // Reconcile state: if session says active but the underlying process/tmux
1122
+ // is dead, update. We already filtered out completed/zombie states above,
1123
+ // so if it's actually dead this session needs to be marked as zombie.
1124
+ if (!alive) {
1125
+ store.updateState(spec.agentName, "zombie");
1126
+ store.updateLastActivity(spec.agentName);
1127
+ session.state = "zombie";
1128
+ }
1129
+
1130
+ const status = {
1131
+ running: alive,
1132
+ sessionId: session.id,
1133
+ state: session.state,
1134
+ tmuxSession: session.tmuxSession,
1135
+ pid: session.pid,
1136
+ startedAt: session.startedAt,
1137
+ lastActivity: session.lastActivity,
1138
+ watchdogRunning,
1139
+ monitorRunning,
1140
+ };
1141
+
1142
+ if (json) {
1143
+ jsonOutput(`${spec.commandName} status`, status);
1144
+ } else {
1145
+ const stateLabel = alive ? "running" : session.state;
1146
+ process.stdout.write(`${spec.displayName}: ${stateLabel}\n`);
1147
+ process.stdout.write(` Session: ${session.id}\n`);
1148
+ process.stdout.write(` Tmux: ${session.tmuxSession}\n`);
1149
+ process.stdout.write(` PID: ${session.pid}\n`);
1150
+ process.stdout.write(` Started: ${session.startedAt}\n`);
1151
+ process.stdout.write(` Activity: ${session.lastActivity}\n`);
1152
+ process.stdout.write(` Watchdog: ${watchdogRunning ? "running" : "not running"}\n`);
1153
+ process.stdout.write(` Monitor: ${monitorRunning ? "running" : "not running"}\n`);
1154
+ }
1155
+ } finally {
1156
+ store.close();
1157
+ }
1158
+ }
1159
+
1160
+ /**
1161
+ * Send a fire-and-forget message to the running coordinator.
1162
+ *
1163
+ * Sends a mail message (from: operator, type: dispatch) and auto-nudges the
1164
+ * coordinator via tmux sendKeys. Replaces the two-step `ap mail send + ap nudge` pattern.
1165
+ */
1166
+ async function sendToPersistentAgent(
1167
+ spec: PersistentAgentSpec,
1168
+ body: string,
1169
+ opts: { subject: string; json: boolean },
1170
+ deps: CoordinatorDeps = {},
1171
+ ): Promise<void> {
1172
+ const tmux = deps._tmux ?? {
1173
+ createSession,
1174
+ isSessionAlive,
1175
+ checkSessionState,
1176
+ killSession,
1177
+ sendKeys,
1178
+ waitForTuiReady,
1179
+ ensureTmuxAvailable,
1180
+ };
1181
+ const nudge = deps._nudge ?? nudgeAgent;
1182
+
1183
+ const { subject, json } = opts;
1184
+ const cwd = process.cwd();
1185
+ const config = await loadConfig(cwd);
1186
+ const projectRoot = config.project.root;
1187
+
1188
+ const agentplateDir = join(projectRoot, ".agentplate");
1189
+ const { store } = openSessionStore(agentplateDir);
1190
+ try {
1191
+ const session = store.getByName(spec.agentName);
1192
+
1193
+ if (!isActivePersistentAgentSession(session, spec)) {
1194
+ throw new AgentError(`No active ${spec.commandName} session found`, {
1195
+ agentName: spec.agentName,
1196
+ });
1197
+ }
1198
+
1199
+ const alive = await isPersistentAgentAlive(session, tmux);
1200
+ if (!alive) {
1201
+ store.updateState(spec.agentName, "zombie");
1202
+ store.updateLastActivity(spec.agentName);
1203
+ const target =
1204
+ session.tmuxSession === ""
1205
+ ? `process pid ${session.pid ?? "unknown"}`
1206
+ : `tmux session "${session.tmuxSession}"`;
1207
+ throw new AgentError(`${spec.displayName} ${target} is not alive`, {
1208
+ agentName: spec.agentName,
1209
+ });
1210
+ }
1211
+
1212
+ // Send mail
1213
+ const mailDbPath = join(agentplateDir, "mail.db");
1214
+ const mailStore = createMailStore(mailDbPath);
1215
+ const mailClient = createMailClient(mailStore);
1216
+ let id: string;
1217
+ try {
1218
+ id = mailClient.send({
1219
+ from: "operator",
1220
+ to: spec.agentName,
1221
+ subject,
1222
+ body,
1223
+ type: "dispatch",
1224
+ priority: "normal",
1225
+ });
1226
+ } finally {
1227
+ mailClient.close();
1228
+ }
1229
+
1230
+ // Auto-nudge (fire-and-forget)
1231
+ const nudgeMessage = `[DISPATCH] ${subject}: ${body.slice(0, 500)}`;
1232
+ let nudged = false;
1233
+ try {
1234
+ const nudgeResult = await nudge(projectRoot, spec.agentName, nudgeMessage, true);
1235
+ nudged = nudgeResult.delivered;
1236
+ } catch {
1237
+ // Nudge is fire-and-forget — silently ignore errors
1238
+ }
1239
+
1240
+ if (json) {
1241
+ jsonOutput(`${spec.commandName} send`, { id, nudged });
1242
+ } else {
1243
+ printSuccess(`Sent to ${spec.commandName}`, id);
1244
+ }
1245
+ } finally {
1246
+ store.close();
1247
+ }
1248
+ }
1249
+
1250
+ /**
1251
+ * Send a synchronous request to the coordinator and wait for a reply.
1252
+ *
1253
+ * Sends a mail message (from: operator, type: dispatch) with a correlationId,
1254
+ * auto-nudges the coordinator via tmux, then polls mail.db for a reply in the
1255
+ * same thread. Prints the reply body (or structured JSON) and exits.
1256
+ * Throws AgentError if no reply arrives before the timeout.
1257
+ */
1258
+ export async function askCoordinator(
1259
+ body: string,
1260
+ opts: { subject: string; timeout: number; json: boolean },
1261
+ deps: CoordinatorDeps = {},
1262
+ ): Promise<void> {
1263
+ await askPersistentAgent(COORDINATOR_SPEC, body, opts, deps);
1264
+ }
1265
+
1266
+ export async function askPersistentAgent(
1267
+ spec: PersistentAgentSpec,
1268
+ body: string,
1269
+ opts: { subject: string; timeout: number; json: boolean },
1270
+ deps: CoordinatorDeps = {},
1271
+ ): Promise<void> {
1272
+ const tmux = deps._tmux ?? {
1273
+ createSession,
1274
+ isSessionAlive,
1275
+ checkSessionState,
1276
+ killSession,
1277
+ sendKeys,
1278
+ waitForTuiReady,
1279
+ ensureTmuxAvailable,
1280
+ };
1281
+ const nudge = deps._nudge ?? nudgeAgent;
1282
+ const pollIntervalMs = deps._pollIntervalMs ?? ASK_POLL_INTERVAL_MS;
1283
+
1284
+ const { subject, timeout, json } = opts;
1285
+ const cwd = process.cwd();
1286
+ const config = await loadConfig(cwd);
1287
+ const projectRoot = config.project.root;
1288
+
1289
+ const agentplateDir = join(projectRoot, ".agentplate");
1290
+ const { store } = openSessionStore(agentplateDir);
1291
+ try {
1292
+ const session = store.getByName(spec.agentName);
1293
+
1294
+ if (!isActivePersistentAgentSession(session, spec)) {
1295
+ throw new AgentError(`No active ${spec.commandName} session found`, {
1296
+ agentName: spec.agentName,
1297
+ });
1298
+ }
1299
+
1300
+ const alive = await isPersistentAgentAlive(session, tmux);
1301
+ if (!alive) {
1302
+ store.updateState(spec.agentName, "zombie");
1303
+ store.updateLastActivity(spec.agentName);
1304
+ const target =
1305
+ session.tmuxSession === ""
1306
+ ? `process pid ${session.pid ?? "unknown"}`
1307
+ : `tmux session "${session.tmuxSession}"`;
1308
+ throw new AgentError(`${spec.displayName} ${target} is not alive`, {
1309
+ agentName: spec.agentName,
1310
+ });
1311
+ }
1312
+
1313
+ // Generate correlation ID for tracking this request/response pair
1314
+ const correlationId = crypto.randomUUID();
1315
+
1316
+ // Send mail with correlationId in payload
1317
+ const mailDbPath = join(agentplateDir, "mail.db");
1318
+ const mailStore = createMailStore(mailDbPath);
1319
+ const mailClient = createMailClient(mailStore);
1320
+ let sentId: string;
1321
+ try {
1322
+ sentId = mailClient.send({
1323
+ from: "operator",
1324
+ to: spec.agentName,
1325
+ subject,
1326
+ body,
1327
+ type: "dispatch",
1328
+ priority: "normal",
1329
+ payload: JSON.stringify({ correlationId }),
1330
+ });
1331
+ } finally {
1332
+ mailClient.close();
1333
+ }
1334
+
1335
+ // Auto-nudge (fire-and-forget)
1336
+ const nudgeMessage = `[ASK] ${subject}: ${body.slice(0, 500)}`;
1337
+ try {
1338
+ await nudge(projectRoot, spec.agentName, nudgeMessage, true);
1339
+ } catch {
1340
+ // Nudge is fire-and-forget — silently ignore errors
1341
+ }
1342
+
1343
+ // Poll for a reply in the same thread
1344
+ const deadline = Date.now() + timeout * 1000;
1345
+ while (Date.now() < deadline) {
1346
+ await Bun.sleep(pollIntervalMs);
1347
+ // Open a fresh store connection each cycle so we see the latest committed writes
1348
+ const pollStore = createMailStore(mailDbPath);
1349
+ let reply: import("../types.ts").MailMessage | undefined;
1350
+ try {
1351
+ const replies = pollStore.getByThread(sentId);
1352
+ reply = replies.find((m) => m.from === spec.agentName && m.to === "operator");
1353
+ } finally {
1354
+ pollStore.close();
1355
+ }
1356
+ if (reply) {
1357
+ if (json) {
1358
+ jsonOutput(`${spec.commandName} ask`, {
1359
+ correlationId,
1360
+ sentId,
1361
+ replyId: reply.id,
1362
+ subject: reply.subject,
1363
+ body: reply.body,
1364
+ payload: reply.payload,
1365
+ });
1366
+ } else {
1367
+ process.stdout.write(`${reply.body}\n`);
1368
+ }
1369
+ return;
1370
+ }
1371
+ }
1372
+
1373
+ throw new AgentError(
1374
+ `Timed out after ${timeout}s waiting for ${spec.commandName} reply (correlationId: ${correlationId})`,
1375
+ { agentName: spec.agentName },
1376
+ );
1377
+ } finally {
1378
+ store.close();
1379
+ }
1380
+ }
1381
+
1382
+ /**
1383
+ * Show recent coordinator tmux pane content without attaching.
1384
+ *
1385
+ * Wraps capturePaneContent() from tmux.ts. Supports --follow for continuous polling.
1386
+ */
1387
+ async function outputPersistentAgent(
1388
+ spec: PersistentAgentSpec,
1389
+ opts: { follow: boolean; lines: number; interval: number; json: boolean },
1390
+ deps: CoordinatorDeps = {},
1391
+ ): Promise<void> {
1392
+ const tmux = deps._tmux ?? {
1393
+ createSession,
1394
+ isSessionAlive,
1395
+ checkSessionState,
1396
+ killSession,
1397
+ sendKeys,
1398
+ waitForTuiReady,
1399
+ ensureTmuxAvailable,
1400
+ };
1401
+ const capturePane = deps._capturePaneContent ?? capturePaneContent;
1402
+
1403
+ const { follow, lines, interval, json } = opts;
1404
+ const cwd = process.cwd();
1405
+ const config = await loadConfig(cwd);
1406
+ const projectRoot = config.project.root;
1407
+
1408
+ const agentplateDir = join(projectRoot, ".agentplate");
1409
+ const { store } = openSessionStore(agentplateDir);
1410
+ try {
1411
+ const session = store.getByName(spec.agentName);
1412
+
1413
+ if (!isActivePersistentAgentSession(session, spec)) {
1414
+ throw new AgentError(`No active ${spec.commandName} session found`, {
1415
+ agentName: spec.agentName,
1416
+ });
1417
+ }
1418
+
1419
+ // Headless sessions have no tmux pane to capture from. Surface a clear
1420
+ // error instead of falling through to capture-pane on an empty session
1421
+ // name (which would otherwise return null and confuse callers).
1422
+ if (session.tmuxSession === "") {
1423
+ throw new AgentError(
1424
+ `${spec.displayName} is running headless — no tmux pane to capture. Use 'ap logs --agent ${spec.agentName}' instead.`,
1425
+ { agentName: spec.agentName },
1426
+ );
1427
+ }
1428
+
1429
+ const alive = await isPersistentAgentAlive(session, tmux);
1430
+ if (!alive) {
1431
+ store.updateState(spec.agentName, "zombie");
1432
+ store.updateLastActivity(spec.agentName);
1433
+ throw new AgentError(
1434
+ `${spec.displayName} tmux session "${session.tmuxSession}" is not alive`,
1435
+ {
1436
+ agentName: spec.agentName,
1437
+ },
1438
+ );
1439
+ }
1440
+
1441
+ const tmuxSession = session.tmuxSession;
1442
+
1443
+ if (follow) {
1444
+ // Set up SIGINT handler for clean exit
1445
+ let running = true;
1446
+ process.once("SIGINT", () => {
1447
+ running = false;
1448
+ });
1449
+
1450
+ while (running) {
1451
+ const content = await capturePane(tmuxSession, lines);
1452
+ if (json) {
1453
+ jsonOutput(`${spec.commandName} output`, { content, lines });
1454
+ } else {
1455
+ process.stdout.write(content ?? "");
1456
+ }
1457
+ if (running) {
1458
+ await Bun.sleep(interval);
1459
+ }
1460
+ }
1461
+ } else {
1462
+ const content = await capturePane(tmuxSession, lines);
1463
+ if (json) {
1464
+ jsonOutput(`${spec.commandName} output`, { content, lines });
1465
+ } else {
1466
+ process.stdout.write(content ?? "");
1467
+ }
1468
+ }
1469
+ } finally {
1470
+ store.close();
1471
+ }
1472
+ }
1473
+
1474
+ /** Per-trigger evaluation result for checkComplete. */
1475
+ export interface TriggerResult {
1476
+ enabled: boolean;
1477
+ met: boolean;
1478
+ detail: string;
1479
+ }
1480
+
1481
+ /** Result of `ap coordinator check-complete`. */
1482
+ export interface CheckCompleteResult {
1483
+ complete: boolean;
1484
+ triggers: {
1485
+ allAgentsDone: TriggerResult;
1486
+ taskTrackerEmpty: TriggerResult;
1487
+ onShutdownSignal: TriggerResult;
1488
+ };
1489
+ }
1490
+
1491
+ /**
1492
+ * Evaluate configured exit triggers and return per-trigger status.
1493
+ *
1494
+ * Logic:
1495
+ * - complete = true only if ALL enabled triggers are met
1496
+ * - No enabled triggers → complete: false (safety default)
1497
+ */
1498
+ export async function checkComplete(
1499
+ opts: { json: boolean },
1500
+ deps?: CoordinatorDeps,
1501
+ ): Promise<CheckCompleteResult> {
1502
+ void deps; // reserved for future DI
1503
+
1504
+ const config = await loadConfig(process.cwd());
1505
+ const triggers = config.coordinator?.exitTriggers ?? {
1506
+ allAgentsDone: false,
1507
+ taskTrackerEmpty: false,
1508
+ onShutdownSignal: false,
1509
+ };
1510
+
1511
+ const result: CheckCompleteResult = {
1512
+ complete: false,
1513
+ triggers: {
1514
+ allAgentsDone: { enabled: triggers.allAgentsDone, met: false, detail: "" },
1515
+ taskTrackerEmpty: { enabled: triggers.taskTrackerEmpty, met: false, detail: "" },
1516
+ onShutdownSignal: { enabled: triggers.onShutdownSignal, met: false, detail: "" },
1517
+ },
1518
+ };
1519
+
1520
+ // allAgentsDone: read current-run.txt, query SessionStore
1521
+ if (triggers.allAgentsDone) {
1522
+ const runIdPath = join(config.project.root, ".agentplate", "current-run.txt");
1523
+ const runIdFile = Bun.file(runIdPath);
1524
+ if (await runIdFile.exists()) {
1525
+ const runId = (await runIdFile.text()).trim();
1526
+ const sessionsDb = join(config.project.root, ".agentplate", "sessions.db");
1527
+ const store = createSessionStore(sessionsDb);
1528
+ try {
1529
+ const sessions = store.getByRun(runId);
1530
+ const agentSessions = sessions.filter((s) => s.capability !== "coordinator");
1531
+ let allDone =
1532
+ agentSessions.length > 0 && agentSessions.every((s) => s.state === "completed");
1533
+ const states = agentSessions.map((s) => `${s.agentName}:${s.state}`);
1534
+
1535
+ // Also check the merge queue — agents may be "completed" but branches
1536
+ // not yet merged. This prevents premature issue closure when a builder
1537
+ // finishes but its lead hasn't merged yet (agentplate-5c08).
1538
+ if (allDone) {
1539
+ const mergeQueuePath = join(config.project.root, ".agentplate", "merge-queue.db");
1540
+ const mergeQueueFile = Bun.file(mergeQueuePath);
1541
+ if (await mergeQueueFile.exists()) {
1542
+ const { createMergeQueue } = await import("../merge/queue.ts");
1543
+ const queue = createMergeQueue(mergeQueuePath);
1544
+ try {
1545
+ const pending = queue.list("pending");
1546
+ if (pending.length > 0) {
1547
+ allDone = false;
1548
+ result.triggers.allAgentsDone.detail = `${pending.length} branch(es) pending merge: ${pending.map((e) => e.branchName).join(", ")}`;
1549
+ }
1550
+ } finally {
1551
+ queue.close();
1552
+ }
1553
+ }
1554
+ }
1555
+
1556
+ result.triggers.allAgentsDone.met = allDone;
1557
+ if (result.triggers.allAgentsDone.detail === "") {
1558
+ result.triggers.allAgentsDone.detail = allDone
1559
+ ? `All ${agentSessions.length} agents completed`
1560
+ : states.join(", ");
1561
+ }
1562
+ } finally {
1563
+ store.close();
1564
+ }
1565
+ } else {
1566
+ result.triggers.allAgentsDone.detail = "No current run found";
1567
+ }
1568
+ }
1569
+
1570
+ // taskTrackerEmpty: shell out to tracker CLI
1571
+ if (triggers.taskTrackerEmpty) {
1572
+ try {
1573
+ const backend = await resolveBackend(config.taskTracker.backend, config.project.root);
1574
+ const cliName = trackerCliName(backend);
1575
+ const proc = Bun.spawn([cliName, "ready", "--json"], {
1576
+ cwd: config.project.root,
1577
+ stdout: "pipe",
1578
+ stderr: "pipe",
1579
+ });
1580
+ const exitCode = await proc.exited;
1581
+ const stdout = await new Response(proc.stdout).text();
1582
+ if (exitCode === 0) {
1583
+ try {
1584
+ const issues = JSON.parse(stdout.trim()) as unknown;
1585
+ const isEmpty = Array.isArray(issues) && issues.length === 0;
1586
+ result.triggers.taskTrackerEmpty.met = isEmpty;
1587
+ result.triggers.taskTrackerEmpty.detail = isEmpty
1588
+ ? "No unblocked issues"
1589
+ : `${(issues as unknown[]).length} unblocked issue(s)`;
1590
+ } catch {
1591
+ const isEmpty = stdout.trim() === "" || stdout.trim() === "[]";
1592
+ result.triggers.taskTrackerEmpty.met = isEmpty;
1593
+ result.triggers.taskTrackerEmpty.detail = isEmpty
1594
+ ? "No unblocked issues"
1595
+ : "Issues found";
1596
+ }
1597
+ } else {
1598
+ result.triggers.taskTrackerEmpty.detail = `Tracker command failed (exit ${exitCode})`;
1599
+ }
1600
+ } catch (err) {
1601
+ result.triggers.taskTrackerEmpty.detail = `Tracker error: ${err instanceof Error ? err.message : String(err)}`;
1602
+ }
1603
+ }
1604
+
1605
+ // onShutdownSignal: check mail for shutdown messages to coordinator
1606
+ if (triggers.onShutdownSignal) {
1607
+ const mailDb = join(config.project.root, ".agentplate", "mail.db");
1608
+ const mailStore = createMailStore(mailDb);
1609
+ try {
1610
+ const unread = mailStore.getUnread("coordinator");
1611
+ const shutdownMsg = unread.find((m) => m.subject.toLowerCase().includes("shutdown"));
1612
+ result.triggers.onShutdownSignal.met = shutdownMsg !== undefined;
1613
+ result.triggers.onShutdownSignal.detail = shutdownMsg
1614
+ ? `Shutdown signal from ${shutdownMsg.from}: ${shutdownMsg.subject}`
1615
+ : "No shutdown signal received";
1616
+ } finally {
1617
+ mailStore.close();
1618
+ }
1619
+ }
1620
+
1621
+ // Overall: complete only if ALL enabled triggers are met
1622
+ const enabledTriggers = Object.values(result.triggers).filter((t) => t.enabled);
1623
+ result.complete = enabledTriggers.length > 0 && enabledTriggers.every((t) => t.met);
1624
+
1625
+ if (opts.json) {
1626
+ jsonOutput("coordinator check-complete", result as unknown as Record<string, unknown>);
1627
+ } else {
1628
+ for (const [name, trigger] of Object.entries(result.triggers)) {
1629
+ const status = !trigger.enabled ? "disabled" : trigger.met ? "MET" : "NOT MET";
1630
+ process.stdout.write(` ${name}: ${status} — ${trigger.detail}\n`);
1631
+ }
1632
+ process.stdout.write(`\nComplete: ${result.complete ? "YES" : "NO"}\n`);
1633
+ }
1634
+
1635
+ return result;
1636
+ }
1637
+
1638
+ export function createPersistentAgentCommand(
1639
+ spec: PersistentAgentSpec,
1640
+ deps: CoordinatorDeps = {},
1641
+ ): Command {
1642
+ const cmd = new Command(spec.commandName).description(
1643
+ `Manage the persistent ${spec.commandName} agent`,
1644
+ );
1645
+
1646
+ cmd
1647
+ .command("start")
1648
+ .description(`Start the ${spec.commandName} (spawns Claude Code at project root)`)
1649
+ .option("--attach", "Always attach to tmux session after start")
1650
+ .option("--no-attach", "Never attach to tmux session after start")
1651
+ .option("--watchdog", `Auto-start watchdog daemon with ${spec.commandName}`)
1652
+ .option(
1653
+ "--accept-existing-watchdog",
1654
+ "Continue when a watchdog daemon from a previous session is already running (it will supervise this run)",
1655
+ )
1656
+ .option("--monitor", `Auto-start Tier 2 monitor agent with ${spec.commandName}`)
1657
+ .option("--profile <name>", "Trellis profile to apply to spawned agents")
1658
+ .option("--json", "Output as JSON")
1659
+ .action(
1660
+ async (opts: {
1661
+ attach?: boolean;
1662
+ watchdog?: boolean;
1663
+ acceptExistingWatchdog?: boolean;
1664
+ monitor?: boolean;
1665
+ json?: boolean;
1666
+ profile?: string;
1667
+ }) => {
1668
+ // opts.attach = true if --attach, false if --no-attach, undefined if neither
1669
+ const shouldAttach = opts.attach !== undefined ? opts.attach : !!process.stdout.isTTY;
1670
+ await startPersistentAgent(
1671
+ spec,
1672
+ {
1673
+ json: opts.json ?? false,
1674
+ attach: shouldAttach,
1675
+ watchdog: opts.watchdog ?? false,
1676
+ acceptExistingWatchdog: opts.acceptExistingWatchdog ?? false,
1677
+ monitor: opts.monitor ?? false,
1678
+ profile: opts.profile,
1679
+ },
1680
+ deps,
1681
+ );
1682
+ },
1683
+ );
1684
+
1685
+ cmd
1686
+ .command("stop")
1687
+ .description(`Stop the ${spec.commandName} (kills tmux session)`)
1688
+ .option("--json", "Output as JSON")
1689
+ .action(async (opts: { json?: boolean }) => {
1690
+ await stopPersistentAgent(spec, { json: opts.json ?? false }, deps);
1691
+ });
1692
+
1693
+ cmd
1694
+ .command("status")
1695
+ .description(`Show ${spec.commandName} state`)
1696
+ .option("--json", "Output as JSON")
1697
+ .action(async (opts: { json?: boolean }) => {
1698
+ await statusPersistentAgent(spec, { json: opts.json ?? false }, deps);
1699
+ });
1700
+
1701
+ cmd
1702
+ .command("send")
1703
+ .description(`Send a message to the ${spec.commandName} (fire-and-forget)`)
1704
+ .requiredOption("--body <text>", "Message body")
1705
+ .option("--subject <text>", "Message subject", "operator dispatch")
1706
+ .option("--json", "Output as JSON")
1707
+ .action(async (opts: { body: string; subject: string; json?: boolean }) => {
1708
+ await sendToPersistentAgent(
1709
+ spec,
1710
+ opts.body,
1711
+ { subject: opts.subject, json: opts.json ?? false },
1712
+ deps,
1713
+ );
1714
+ });
1715
+
1716
+ cmd
1717
+ .command("ask")
1718
+ .description(`Send a request to the ${spec.commandName} and wait for a reply`)
1719
+ .requiredOption("--body <text>", "Message body")
1720
+ .option("--subject <text>", "Message subject", "operator request")
1721
+ .option("--timeout <seconds>", "Timeout in seconds", String(ASK_DEFAULT_TIMEOUT_S))
1722
+ .option("--json", "Output as JSON")
1723
+ .action(async (opts: { body: string; subject: string; timeout?: string; json?: boolean }) => {
1724
+ await askPersistentAgent(
1725
+ spec,
1726
+ opts.body,
1727
+ {
1728
+ subject: opts.subject,
1729
+ timeout: Number.parseInt(opts.timeout ?? String(ASK_DEFAULT_TIMEOUT_S), 10),
1730
+ json: opts.json ?? false,
1731
+ },
1732
+ deps,
1733
+ );
1734
+ });
1735
+
1736
+ cmd
1737
+ .command("output")
1738
+ .description(`Show recent ${spec.commandName} output (tmux pane content)`)
1739
+ .option("--follow, -f", "Continuously poll for new output")
1740
+ .option("--lines <n>", "Number of lines to capture", "50")
1741
+ .option("--interval <ms>", "Poll interval in milliseconds (with --follow)", "2000")
1742
+ .option("--json", "Output as JSON")
1743
+ .action(
1744
+ async (opts: { follow?: boolean; lines?: string; interval?: string; json?: boolean }) => {
1745
+ await outputPersistentAgent(
1746
+ spec,
1747
+ {
1748
+ follow: opts.follow ?? false,
1749
+ lines: Number.parseInt(opts.lines ?? "50", 10),
1750
+ interval: Number.parseInt(opts.interval ?? "2000", 10),
1751
+ json: opts.json ?? false,
1752
+ },
1753
+ deps,
1754
+ );
1755
+ },
1756
+ );
1757
+
1758
+ return cmd;
1759
+ }
1760
+
1761
+ /**
1762
+ * Create the Commander command for `ap coordinator`.
1763
+ */
1764
+ export function createCoordinatorCommand(deps: CoordinatorDeps = {}): Command {
1765
+ const cmd = createPersistentAgentCommand(COORDINATOR_SPEC, deps);
1766
+
1767
+ cmd
1768
+ .command("check-complete")
1769
+ .description("Evaluate exit triggers and report completion status")
1770
+ .option("--json", "Output as JSON")
1771
+ .action(async (opts: { json?: boolean }) => {
1772
+ await checkComplete({ json: opts.json ?? false }, deps);
1773
+ });
1774
+
1775
+ return cmd;
1776
+ }
1777
+
1778
+ export async function persistentAgentCommand(
1779
+ args: string[],
1780
+ spec: PersistentAgentSpec,
1781
+ deps: CoordinatorDeps = {},
1782
+ ): Promise<void> {
1783
+ const cmd = createPersistentAgentCommand(spec, deps);
1784
+ cmd.exitOverride();
1785
+
1786
+ if (args.length === 0) {
1787
+ process.stdout.write(cmd.helpInformation());
1788
+ return;
1789
+ }
1790
+
1791
+ try {
1792
+ await cmd.parseAsync(args, { from: "user" });
1793
+ } catch (err: unknown) {
1794
+ if (err && typeof err === "object" && "code" in err) {
1795
+ const code = (err as { code: string }).code;
1796
+ if (code === "commander.helpDisplayed" || code === "commander.version") {
1797
+ return;
1798
+ }
1799
+ if (code === "commander.unknownCommand") {
1800
+ const message = err instanceof Error ? err.message : String(err);
1801
+ throw new ValidationError(message, { field: "subcommand" });
1802
+ }
1803
+ }
1804
+ throw err;
1805
+ }
1806
+ }
1807
+
1808
+ /**
1809
+ * Entry point for `ap coordinator <subcommand>`.
1810
+ *
1811
+ * @param args - CLI arguments after "coordinator"
1812
+ * @param deps - Optional dependency injection for testing (tmux)
1813
+ */
1814
+ export async function coordinatorCommand(
1815
+ args: string[],
1816
+ deps: CoordinatorDeps = {},
1817
+ ): Promise<void> {
1818
+ const cmd = createCoordinatorCommand(deps);
1819
+ cmd.exitOverride();
1820
+
1821
+ if (args.length === 0) {
1822
+ process.stdout.write(cmd.helpInformation());
1823
+ return;
1824
+ }
1825
+
1826
+ try {
1827
+ await cmd.parseAsync(args, { from: "user" });
1828
+ } catch (err: unknown) {
1829
+ if (err && typeof err === "object" && "code" in err) {
1830
+ const code = (err as { code: string }).code;
1831
+ if (code === "commander.helpDisplayed" || code === "commander.version") {
1832
+ return;
1833
+ }
1834
+ if (code === "commander.unknownCommand") {
1835
+ const message = err instanceof Error ? err.message : String(err);
1836
+ throw new ValidationError(message, { field: "subcommand" });
1837
+ }
1838
+ }
1839
+ throw err;
1840
+ }
1841
+ }