@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,2975 @@
1
+ /**
2
+ * Tests for agentplate coordinator command.
3
+ *
4
+ * Uses real temp directories and real git repos for file I/O and config loading.
5
+ * Tmux is injected via the CoordinatorDeps DI interface instead of
6
+ * mock.module() to avoid the process-global mock leak issue
7
+ * (see loam record mx-56558b).
8
+ *
9
+ * WHY DI instead of mock.module: mock.module() in bun:test is process-global
10
+ * and leaks across test files. The DI approach (same pattern as daemon.ts
11
+ * _tmux/_triage/_nudge) ensures mocks are scoped to each test invocation.
12
+ */
13
+
14
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
15
+ import { mkdir, realpath } from "node:fs/promises";
16
+ import { join } from "node:path";
17
+ import { AgentError, ValidationError } from "../errors.ts";
18
+ import { createMailStore } from "../mail/store.ts";
19
+ import { openSessionStore } from "../sessions/compat.ts";
20
+ import { createRunStore, createSessionStore } from "../sessions/store.ts";
21
+ import { cleanupTempDir, createTempGitRepo } from "../test-helpers.ts";
22
+ import type { AgentSession } from "../types.ts";
23
+ import {
24
+ askCoordinator,
25
+ buildCoordinatorBeacon,
26
+ type CoordinatorDeps,
27
+ checkComplete,
28
+ coordinatorCommand,
29
+ createCoordinatorCommand,
30
+ resolveAttach,
31
+ startCoordinatorSession,
32
+ } from "./coordinator.ts";
33
+ import {
34
+ buildOrchestratorBeacon,
35
+ createOrchestratorCommand,
36
+ orchestratorCommand,
37
+ } from "./orchestrator.ts";
38
+
39
+ // --- Fake Tmux ---
40
+
41
+ /** Track calls to fake tmux for assertions. */
42
+ interface TmuxCallTracker {
43
+ createSession: Array<{
44
+ name: string;
45
+ cwd: string;
46
+ command: string;
47
+ env?: Record<string, string>;
48
+ }>;
49
+ isSessionAlive: Array<{ name: string; result: boolean }>;
50
+ checkSessionState: Array<{ name: string; result: "alive" | "dead" | "no_server" }>;
51
+ killSession: Array<{ name: string }>;
52
+ sendKeys: Array<{ name: string; keys: string }>;
53
+ waitForTuiReady: Array<{ name: string }>;
54
+ ensureTmuxAvailable: number;
55
+ }
56
+
57
+ // --- Fake Watchdog ---
58
+
59
+ /** Track calls to fake watchdog for assertions. */
60
+ interface WatchdogCallTracker {
61
+ start: number;
62
+ stop: number;
63
+ isRunning: number;
64
+ }
65
+
66
+ // --- Fake Monitor ---
67
+
68
+ /** Track calls to fake monitor for assertions. */
69
+ interface MonitorCallTracker {
70
+ start: number;
71
+ stop: number;
72
+ isRunning: number;
73
+ }
74
+
75
+ /** Build a fake tmux DI object with configurable session liveness. */
76
+ function makeFakeTmux(
77
+ sessionAliveMap: Record<string, boolean> = {},
78
+ options: {
79
+ waitForTuiReadyResult?: boolean;
80
+ ensureTmuxAvailableError?: Error;
81
+ checkSessionStateMap?: Record<string, "alive" | "dead" | "no_server">;
82
+ } = {},
83
+ ): {
84
+ tmux: NonNullable<CoordinatorDeps["_tmux"]>;
85
+ calls: TmuxCallTracker;
86
+ } {
87
+ const calls: TmuxCallTracker = {
88
+ createSession: [],
89
+ isSessionAlive: [],
90
+ checkSessionState: [],
91
+ killSession: [],
92
+ sendKeys: [],
93
+ waitForTuiReady: [],
94
+ ensureTmuxAvailable: 0,
95
+ };
96
+
97
+ const tmux: NonNullable<CoordinatorDeps["_tmux"]> = {
98
+ createSession: async (
99
+ name: string,
100
+ cwd: string,
101
+ command: string,
102
+ env?: Record<string, string>,
103
+ ): Promise<number> => {
104
+ calls.createSession.push({ name, cwd, command, env });
105
+ return 99999; // Fake PID
106
+ },
107
+ isSessionAlive: async (name: string): Promise<boolean> => {
108
+ const alive = sessionAliveMap[name] ?? false;
109
+ calls.isSessionAlive.push({ name, result: alive });
110
+ return alive;
111
+ },
112
+ checkSessionState: async (name: string): Promise<"alive" | "dead" | "no_server"> => {
113
+ const stateMap = options.checkSessionStateMap ?? {};
114
+ // Default: derive from sessionAliveMap for backwards compat
115
+ const state = stateMap[name] ?? (sessionAliveMap[name] ? "alive" : "dead");
116
+ calls.checkSessionState.push({ name, result: state });
117
+ return state;
118
+ },
119
+ killSession: async (name: string): Promise<void> => {
120
+ calls.killSession.push({ name });
121
+ },
122
+ sendKeys: async (name: string, keys: string): Promise<void> => {
123
+ calls.sendKeys.push({ name, keys });
124
+ },
125
+ waitForTuiReady: async (name: string): Promise<boolean> => {
126
+ calls.waitForTuiReady.push({ name });
127
+ return options.waitForTuiReadyResult ?? true;
128
+ },
129
+ ensureTmuxAvailable: async (): Promise<void> => {
130
+ calls.ensureTmuxAvailable++;
131
+ if (options.ensureTmuxAvailableError) {
132
+ throw options.ensureTmuxAvailableError;
133
+ }
134
+ },
135
+ };
136
+
137
+ return { tmux, calls };
138
+ }
139
+
140
+ /**
141
+ * Build a fake watchdog DI object with configurable behavior.
142
+ * @param running - Whether the watchdog should report as running
143
+ * @param startSuccess - Whether start() should succeed (return a PID)
144
+ * @param stopSuccess - Whether stop() should succeed (return true)
145
+ */
146
+ function makeFakeWatchdog(
147
+ running = false,
148
+ startSuccess = true,
149
+ stopSuccess = true,
150
+ ): {
151
+ watchdog: NonNullable<CoordinatorDeps["_watchdog"]>;
152
+ calls: WatchdogCallTracker;
153
+ } {
154
+ const calls: WatchdogCallTracker = {
155
+ start: 0,
156
+ stop: 0,
157
+ isRunning: 0,
158
+ };
159
+
160
+ const watchdog: NonNullable<CoordinatorDeps["_watchdog"]> = {
161
+ async start(): Promise<{ pid: number } | null> {
162
+ calls.start++;
163
+ return startSuccess ? { pid: 88888 } : null;
164
+ },
165
+ async stop(): Promise<boolean> {
166
+ calls.stop++;
167
+ return stopSuccess;
168
+ },
169
+ async isRunning(): Promise<boolean> {
170
+ calls.isRunning++;
171
+ return running;
172
+ },
173
+ };
174
+
175
+ return { watchdog, calls };
176
+ }
177
+
178
+ /**
179
+ * Build a fake monitor DI object with configurable behavior.
180
+ * @param running - Whether the monitor should report as running
181
+ * @param startSuccess - Whether start() should succeed (return a PID)
182
+ * @param stopSuccess - Whether stop() should succeed (return true)
183
+ */
184
+ function makeFakeMonitor(
185
+ running = false,
186
+ startSuccess = true,
187
+ stopSuccess = true,
188
+ ): {
189
+ monitor: NonNullable<CoordinatorDeps["_monitor"]>;
190
+ calls: MonitorCallTracker;
191
+ } {
192
+ const calls: MonitorCallTracker = {
193
+ start: 0,
194
+ stop: 0,
195
+ isRunning: 0,
196
+ };
197
+
198
+ const monitor: NonNullable<CoordinatorDeps["_monitor"]> = {
199
+ async start(): Promise<{ pid: number } | null> {
200
+ calls.start++;
201
+ return startSuccess ? { pid: 77777 } : null;
202
+ },
203
+ async stop(): Promise<boolean> {
204
+ calls.stop++;
205
+ return stopSuccess;
206
+ },
207
+ async isRunning(): Promise<boolean> {
208
+ calls.isRunning++;
209
+ return running;
210
+ },
211
+ };
212
+
213
+ return { monitor, calls };
214
+ }
215
+
216
+ // --- Test Setup ---
217
+
218
+ let tempDir: string;
219
+ let agentplateDir: string;
220
+ const originalCwd = process.cwd();
221
+
222
+ /** Save sessions to the SessionStore (sessions.db) for test setup. */
223
+ function saveSessionsToDb(sessions: AgentSession[]): void {
224
+ const { store } = openSessionStore(agentplateDir);
225
+ try {
226
+ for (const session of sessions) {
227
+ store.upsert(session);
228
+ }
229
+ } finally {
230
+ store.close();
231
+ }
232
+ }
233
+
234
+ /** Load all sessions from the SessionStore (sessions.db). */
235
+ function loadSessionsFromDb(): AgentSession[] {
236
+ const { store } = openSessionStore(agentplateDir);
237
+ try {
238
+ return store.getAll();
239
+ } finally {
240
+ store.close();
241
+ }
242
+ }
243
+
244
+ beforeEach(async () => {
245
+ // Restore cwd FIRST so createTempGitRepo's git operations don't fail
246
+ // if a prior test's tempDir was already cleaned up.
247
+ process.chdir(originalCwd);
248
+
249
+ tempDir = await realpath(await createTempGitRepo());
250
+ agentplateDir = join(tempDir, ".agentplate");
251
+ await mkdir(agentplateDir, { recursive: true });
252
+
253
+ // Write a minimal config.yaml so loadConfig succeeds
254
+ // tier2Enabled: true so existing --monitor tests pass (new skipped tests override inline)
255
+ await Bun.write(
256
+ join(agentplateDir, "config.yaml"),
257
+ [
258
+ "project:",
259
+ " name: test-project",
260
+ ` root: ${tempDir}`,
261
+ " canonicalBranch: main",
262
+ "watchdog:",
263
+ " tier2Enabled: true",
264
+ ].join("\n"),
265
+ );
266
+
267
+ // Write agent-manifest.json and stub agent-def .md files so manifest loading succeeds
268
+ const agentDefsDir = join(agentplateDir, "agent-defs");
269
+ await mkdir(agentDefsDir, { recursive: true });
270
+ const manifest = {
271
+ version: "1.0",
272
+ agents: {
273
+ coordinator: {
274
+ file: "coordinator.md",
275
+ model: "opus",
276
+ tools: ["Read", "Bash"],
277
+ capabilities: ["coordinate"],
278
+ canSpawn: true,
279
+ constraints: [],
280
+ },
281
+ orchestrator: {
282
+ file: "orchestrator.md",
283
+ model: "opus",
284
+ tools: ["Read", "Bash"],
285
+ capabilities: ["orchestrate", "coordinate"],
286
+ canSpawn: true,
287
+ constraints: [],
288
+ },
289
+ },
290
+ capabilityIndex: {
291
+ coordinate: ["coordinator", "orchestrator"],
292
+ orchestrate: ["orchestrator"],
293
+ },
294
+ };
295
+ await Bun.write(
296
+ join(agentplateDir, "agent-manifest.json"),
297
+ `${JSON.stringify(manifest, null, "\t")}\n`,
298
+ );
299
+ await Bun.write(join(agentDefsDir, "coordinator.md"), "# Coordinator\n");
300
+ await Bun.write(join(agentDefsDir, "orchestrator.md"), "# Orchestrator\n");
301
+
302
+ // Override cwd so coordinator commands find our temp project
303
+ process.chdir(tempDir);
304
+ });
305
+
306
+ afterEach(async () => {
307
+ process.chdir(originalCwd);
308
+ await cleanupTempDir(tempDir);
309
+ });
310
+
311
+ // --- Helpers ---
312
+
313
+ function makeCoordinatorSession(overrides: Partial<AgentSession> = {}): AgentSession {
314
+ return {
315
+ id: `session-${Date.now()}-coordinator`,
316
+ agentName: "coordinator",
317
+ capability: "coordinator",
318
+ worktreePath: tempDir,
319
+ branchName: "main",
320
+ taskId: "",
321
+ tmuxSession: "agentplate-test-project-coordinator",
322
+ state: "working",
323
+ pid: 99999,
324
+ parentAgent: null,
325
+ depth: 0,
326
+ runId: null,
327
+ startedAt: new Date().toISOString(),
328
+ lastActivity: new Date().toISOString(),
329
+ escalationLevel: 0,
330
+ stalledSince: null,
331
+ transcriptPath: null,
332
+ ...overrides,
333
+ };
334
+ }
335
+
336
+ /** Capture stdout.write output during a function call. */
337
+ async function captureStdout(fn: () => Promise<void>): Promise<string> {
338
+ const chunks: string[] = [];
339
+ const originalWrite = process.stdout.write;
340
+ process.stdout.write = ((chunk: string) => {
341
+ chunks.push(chunk);
342
+ return true;
343
+ }) as typeof process.stdout.write;
344
+ try {
345
+ await fn();
346
+ } finally {
347
+ process.stdout.write = originalWrite;
348
+ }
349
+ return chunks.join("");
350
+ }
351
+
352
+ /** Build default CoordinatorDeps with fake tmux, watchdog, and monitor.
353
+ * Always injects fakes for all three to prevent real Bun.spawn(["agentplate", ...])
354
+ * calls in tests (agentplate CLI is not available in CI). */
355
+ function makeDeps(
356
+ sessionAliveMap: Record<string, boolean> = {},
357
+ watchdogConfig?: { running?: boolean; startSuccess?: boolean; stopSuccess?: boolean },
358
+ monitorConfig?: { running?: boolean; startSuccess?: boolean; stopSuccess?: boolean },
359
+ tmuxOptions?: {
360
+ waitForTuiReadyResult?: boolean;
361
+ ensureTmuxAvailableError?: Error;
362
+ checkSessionStateMap?: Record<string, "alive" | "dead" | "no_server">;
363
+ },
364
+ ): {
365
+ deps: CoordinatorDeps;
366
+ calls: TmuxCallTracker;
367
+ watchdogCalls: WatchdogCallTracker;
368
+ monitorCalls: MonitorCallTracker;
369
+ } {
370
+ const { tmux, calls } = makeFakeTmux(sessionAliveMap, tmuxOptions);
371
+ const { watchdog, calls: watchdogCalls } = makeFakeWatchdog(
372
+ watchdogConfig?.running,
373
+ watchdogConfig?.startSuccess,
374
+ watchdogConfig?.stopSuccess,
375
+ );
376
+ const { monitor, calls: monitorCalls } = makeFakeMonitor(
377
+ monitorConfig?.running,
378
+ monitorConfig?.startSuccess,
379
+ monitorConfig?.stopSuccess,
380
+ );
381
+
382
+ const deps: CoordinatorDeps = {
383
+ _tmux: tmux,
384
+ _watchdog: watchdog,
385
+ _monitor: monitor,
386
+ };
387
+
388
+ return {
389
+ deps,
390
+ calls,
391
+ watchdogCalls,
392
+ monitorCalls,
393
+ };
394
+ }
395
+
396
+ // --- Tests ---
397
+
398
+ describe("coordinatorCommand help", () => {
399
+ test("--help outputs help text", async () => {
400
+ const output = await captureStdout(() => coordinatorCommand(["--help"]));
401
+ expect(output).toContain("coordinator");
402
+ expect(output).toContain("start");
403
+ expect(output).toContain("stop");
404
+ expect(output).toContain("status");
405
+ });
406
+
407
+ test("start --help includes --attach and --no-attach flags", async () => {
408
+ const cmd = createCoordinatorCommand({});
409
+ for (const sub of cmd.commands) {
410
+ sub.exitOverride();
411
+ }
412
+ const output = await captureStdout(async () => {
413
+ await cmd.parseAsync(["start", "--help"], { from: "user" }).catch(() => {});
414
+ });
415
+ expect(output).toContain("--attach");
416
+ expect(output).toContain("--no-attach");
417
+ });
418
+
419
+ test("-h outputs help text", async () => {
420
+ const output = await captureStdout(() => coordinatorCommand(["-h"]));
421
+ expect(output).toContain("coordinator");
422
+ });
423
+
424
+ test("empty args outputs help text", async () => {
425
+ const output = await captureStdout(() => coordinatorCommand([]));
426
+ expect(output).toContain("coordinator");
427
+ expect(output).toContain("Commands:");
428
+ });
429
+ });
430
+
431
+ describe("coordinatorCommand unknown subcommand", () => {
432
+ test("throws ValidationError for unknown subcommand", async () => {
433
+ await expect(coordinatorCommand(["frobnicate"])).rejects.toThrow(ValidationError);
434
+ });
435
+
436
+ test("error message includes the bad subcommand name", async () => {
437
+ try {
438
+ await coordinatorCommand(["frobnicate"]);
439
+ expect.unreachable("should have thrown");
440
+ } catch (err) {
441
+ expect(err).toBeInstanceOf(ValidationError);
442
+ const ve = err as ValidationError;
443
+ expect(ve.message).toContain("frobnicate");
444
+ expect(ve.field).toBe("subcommand");
445
+ }
446
+ });
447
+ });
448
+
449
+ describe("startCoordinator", () => {
450
+ test("writes session to sessions.json with correct fields", async () => {
451
+ const { deps, calls } = makeDeps();
452
+
453
+ // Override Bun.sleep to skip the 3s and 0.5s waits
454
+ const originalSleep = Bun.sleep;
455
+ Bun.sleep = (() => Promise.resolve()) as typeof Bun.sleep;
456
+
457
+ try {
458
+ await captureStdout(() => coordinatorCommand(["start"], deps));
459
+ } finally {
460
+ Bun.sleep = originalSleep;
461
+ }
462
+
463
+ // Verify sessions.json was written
464
+ const sessions = loadSessionsFromDb();
465
+ expect(sessions).toHaveLength(1);
466
+
467
+ const session = sessions[0];
468
+ expect(session).toBeDefined();
469
+ expect(session?.agentName).toBe("coordinator");
470
+ expect(session?.capability).toBe("coordinator");
471
+ expect(session?.tmuxSession).toBe("agentplate-test-project-coordinator");
472
+ expect(session?.state).toBe("booting");
473
+ expect(session?.pid).toBe(99999);
474
+ expect(session?.parentAgent).toBeNull();
475
+ expect(session?.depth).toBe(0);
476
+ expect(session?.taskId).toBe("");
477
+ expect(session?.branchName).toBe("main");
478
+ expect(session?.worktreePath).toBe(tempDir);
479
+ expect(session?.id).toMatch(/^session-\d+-coordinator$/);
480
+
481
+ // Verify the session has a runId set (not null)
482
+ expect(session?.runId).not.toBeNull();
483
+ expect(session?.runId).toMatch(/^run-/);
484
+
485
+ // Verify tmux createSession was called
486
+ expect(calls.createSession).toHaveLength(1);
487
+ expect(calls.createSession[0]?.name).toBe("agentplate-test-project-coordinator");
488
+ expect(calls.createSession[0]?.cwd).toBe(tempDir);
489
+
490
+ // Verify sendKeys was called (beacon + follow-up Enter)
491
+ expect(calls.sendKeys.length).toBeGreaterThanOrEqual(1);
492
+ });
493
+
494
+ test("creates a run record with coordinatorName set", async () => {
495
+ const { deps } = makeDeps();
496
+ const originalSleep = Bun.sleep;
497
+ Bun.sleep = (() => Promise.resolve()) as typeof Bun.sleep;
498
+
499
+ try {
500
+ await captureStdout(() => coordinatorCommand(["start", "--no-attach"], deps));
501
+ } finally {
502
+ Bun.sleep = originalSleep;
503
+ }
504
+
505
+ const runStore = createRunStore(join(agentplateDir, "sessions.db"));
506
+ try {
507
+ const run = runStore.getActiveRunForCoordinator("coordinator");
508
+ expect(run).not.toBeNull();
509
+ expect(run?.coordinatorName).toBe("coordinator");
510
+ expect(run?.status).toBe("active");
511
+ expect(run?.coordinatorSessionId).toMatch(/^session-\d+-coordinator$/);
512
+ } finally {
513
+ runStore.close();
514
+ }
515
+ });
516
+
517
+ test("writes current-run.txt for backward compatibility", async () => {
518
+ const { deps } = makeDeps();
519
+ const originalSleep = Bun.sleep;
520
+ Bun.sleep = (() => Promise.resolve()) as typeof Bun.sleep;
521
+
522
+ try {
523
+ await captureStdout(() => coordinatorCommand(["start", "--no-attach"], deps));
524
+ } finally {
525
+ Bun.sleep = originalSleep;
526
+ }
527
+
528
+ const currentRunFile = Bun.file(join(agentplateDir, "current-run.txt"));
529
+ expect(await currentRunFile.exists()).toBe(true);
530
+ const runId = (await currentRunFile.text()).trim();
531
+ expect(runId).toMatch(/^run-/);
532
+ });
533
+
534
+ test("run ID in current-run.txt matches session runId", async () => {
535
+ const { deps } = makeDeps();
536
+ const originalSleep = Bun.sleep;
537
+ Bun.sleep = (() => Promise.resolve()) as typeof Bun.sleep;
538
+
539
+ try {
540
+ await captureStdout(() => coordinatorCommand(["start", "--no-attach"], deps));
541
+ } finally {
542
+ Bun.sleep = originalSleep;
543
+ }
544
+
545
+ const sessions = loadSessionsFromDb();
546
+ const session = sessions[0];
547
+ expect(session?.runId).toBeDefined();
548
+
549
+ const currentRunFile = Bun.file(join(agentplateDir, "current-run.txt"));
550
+ const fileRunId = (await currentRunFile.text()).trim();
551
+
552
+ expect(session?.runId).toBe(fileRunId);
553
+ });
554
+
555
+ test("deploys hooks to project root .claude/settings.local.json", async () => {
556
+ const { deps } = makeDeps();
557
+ const originalSleep = Bun.sleep;
558
+ Bun.sleep = (() => Promise.resolve()) as typeof Bun.sleep;
559
+
560
+ try {
561
+ await captureStdout(() => coordinatorCommand(["start", "--no-attach"], deps));
562
+ } finally {
563
+ Bun.sleep = originalSleep;
564
+ }
565
+
566
+ // Verify .claude/settings.local.json was created at the project root
567
+ const settingsPath = join(tempDir, ".claude", "settings.local.json");
568
+ const settingsFile = Bun.file(settingsPath);
569
+ expect(await settingsFile.exists()).toBe(true);
570
+
571
+ const content = await settingsFile.text();
572
+ const config = JSON.parse(content) as {
573
+ hooks: Record<string, unknown[]>;
574
+ };
575
+
576
+ // Verify hook categories exist
577
+ expect(config.hooks).toBeDefined();
578
+ expect(config.hooks.SessionStart).toBeDefined();
579
+ expect(config.hooks.UserPromptSubmit).toBeDefined();
580
+ expect(config.hooks.PreToolUse).toBeDefined();
581
+ expect(config.hooks.PostToolUse).toBeDefined();
582
+ expect(config.hooks.Stop).toBeDefined();
583
+ });
584
+
585
+ test("hooks use coordinator agent name for event logging", async () => {
586
+ const { deps } = makeDeps();
587
+ const originalSleep = Bun.sleep;
588
+ Bun.sleep = (() => Promise.resolve()) as typeof Bun.sleep;
589
+
590
+ try {
591
+ await captureStdout(() => coordinatorCommand(["start", "--no-attach"], deps));
592
+ } finally {
593
+ Bun.sleep = originalSleep;
594
+ }
595
+
596
+ const settingsPath = join(tempDir, ".claude", "settings.local.json");
597
+ const content = await Bun.file(settingsPath).text();
598
+
599
+ // The hooks should reference the coordinator agent name
600
+ expect(content).toContain("--agent coordinator");
601
+ });
602
+
603
+ test("hooks include ENV_GUARD to avoid affecting user's Claude Code session", async () => {
604
+ const { deps } = makeDeps();
605
+ const originalSleep = Bun.sleep;
606
+ Bun.sleep = (() => Promise.resolve()) as typeof Bun.sleep;
607
+
608
+ try {
609
+ await captureStdout(() => coordinatorCommand(["start", "--no-attach"], deps));
610
+ } finally {
611
+ Bun.sleep = originalSleep;
612
+ }
613
+
614
+ const settingsPath = join(tempDir, ".claude", "settings.local.json");
615
+ const content = await Bun.file(settingsPath).text();
616
+
617
+ // PreToolUse guards should include the ENV_GUARD prefix
618
+ expect(content).toContain("AGENTPLATE_AGENT_NAME");
619
+ });
620
+
621
+ test("injects agent definition via --append-system-prompt when agent-defs/coordinator.md exists", async () => {
622
+ // Deploy a coordinator agent definition
623
+ const agentDefsDir = join(agentplateDir, "agent-defs");
624
+ await mkdir(agentDefsDir, { recursive: true });
625
+ await Bun.write(
626
+ join(agentDefsDir, "coordinator.md"),
627
+ "# Coordinator Agent\n\nYou are the coordinator.\n",
628
+ );
629
+
630
+ const { deps, calls } = makeDeps();
631
+ const originalSleep = Bun.sleep;
632
+ Bun.sleep = (() => Promise.resolve()) as typeof Bun.sleep;
633
+
634
+ try {
635
+ await captureStdout(() => coordinatorCommand(["start", "--no-attach", "--json"], deps));
636
+ } finally {
637
+ Bun.sleep = originalSleep;
638
+ }
639
+
640
+ expect(calls.createSession).toHaveLength(1);
641
+ const cmd = calls.createSession[0]?.command ?? "";
642
+ expect(cmd).toContain("--append-system-prompt");
643
+ // File path is passed via $(cat ...) instead of inlining content (agentplate#45)
644
+ expect(cmd).toContain("$(cat '");
645
+ expect(cmd).toContain("agent-defs/coordinator.md");
646
+ });
647
+
648
+ test("reads model from manifest instead of hardcoding", async () => {
649
+ // Override the manifest to use sonnet instead of default opus
650
+ const manifest = {
651
+ version: "1.0",
652
+ agents: {
653
+ coordinator: {
654
+ file: "coordinator.md",
655
+ model: "sonnet",
656
+ tools: ["Read", "Bash"],
657
+ capabilities: ["coordinate"],
658
+ canSpawn: true,
659
+ constraints: [],
660
+ },
661
+ },
662
+ capabilityIndex: { coordinate: ["coordinator"] },
663
+ };
664
+ await Bun.write(
665
+ join(agentplateDir, "agent-manifest.json"),
666
+ `${JSON.stringify(manifest, null, "\t")}\n`,
667
+ );
668
+
669
+ const { deps, calls } = makeDeps();
670
+ const originalSleep = Bun.sleep;
671
+ Bun.sleep = (() => Promise.resolve()) as typeof Bun.sleep;
672
+
673
+ try {
674
+ await captureStdout(() => coordinatorCommand(["start", "--no-attach", "--json"], deps));
675
+ } finally {
676
+ Bun.sleep = originalSleep;
677
+ }
678
+
679
+ expect(calls.createSession).toHaveLength(1);
680
+ const cmd = calls.createSession[0]?.command ?? "";
681
+ expect(cmd).toContain("--model sonnet");
682
+ expect(cmd).not.toContain("--model opus");
683
+ });
684
+
685
+ test("--json outputs JSON with expected fields", async () => {
686
+ const { deps } = makeDeps();
687
+ const originalSleep = Bun.sleep;
688
+ Bun.sleep = (() => Promise.resolve()) as typeof Bun.sleep;
689
+
690
+ let output: string;
691
+ try {
692
+ output = await captureStdout(() => coordinatorCommand(["start", "--json"], deps));
693
+ } finally {
694
+ Bun.sleep = originalSleep;
695
+ }
696
+
697
+ const parsed = JSON.parse(output) as Record<string, unknown>;
698
+ expect(parsed.success).toBe(true);
699
+ expect(parsed.command).toBe("coordinator start");
700
+ expect(parsed.agentName).toBe("coordinator");
701
+ expect(parsed.capability).toBe("coordinator");
702
+ expect(parsed.tmuxSession).toBe("agentplate-test-project-coordinator");
703
+ expect(parsed.pid).toBe(99999);
704
+ expect(parsed.projectRoot).toBe(tempDir);
705
+ });
706
+
707
+ test("rejects duplicate when coordinator is already running", async () => {
708
+ // Write an existing active coordinator session
709
+ const existing = makeCoordinatorSession({ state: "working", pid: process.pid });
710
+ saveSessionsToDb([existing]);
711
+
712
+ // Mock tmux as alive for the existing session
713
+ const { deps } = makeDeps({ "agentplate-test-project-coordinator": true });
714
+
715
+ await expect(coordinatorCommand(["start"], deps)).rejects.toThrow(AgentError);
716
+
717
+ try {
718
+ await coordinatorCommand(["start"], deps);
719
+ } catch (err) {
720
+ expect(err).toBeInstanceOf(AgentError);
721
+ const ae = err as AgentError;
722
+ expect(ae.message).toContain("already running");
723
+ }
724
+ });
725
+
726
+ test("rejects duplicate when pid is null but tmux session is alive", async () => {
727
+ // Session has null pid (e.g. migrated from older schema) but tmux is alive.
728
+ // Cannot prove it's a zombie without a pid, so treat as active.
729
+ const existing = makeCoordinatorSession({ state: "working", pid: null });
730
+ saveSessionsToDb([existing]);
731
+
732
+ const { deps } = makeDeps(
733
+ { "agentplate-test-project-coordinator": true },
734
+ undefined,
735
+ undefined,
736
+ { checkSessionStateMap: { "agentplate-test-project-coordinator": "alive" } },
737
+ );
738
+
739
+ try {
740
+ await coordinatorCommand(["start"], deps);
741
+ expect(true).toBe(false); // Should have thrown
742
+ } catch (err) {
743
+ expect(err).toBeInstanceOf(AgentError);
744
+ const ae = err as AgentError;
745
+ expect(ae.message).toContain("already running");
746
+ }
747
+ });
748
+
749
+ test("cleans up dead session and starts new one", async () => {
750
+ // Write an existing session that claims to be working
751
+ const deadSession = makeCoordinatorSession({
752
+ id: "session-dead-coordinator",
753
+ state: "working",
754
+ });
755
+ saveSessionsToDb([deadSession]);
756
+
757
+ // Mock tmux as NOT alive for the existing session
758
+ const { deps } = makeDeps({ "agentplate-test-project-coordinator": false });
759
+
760
+ const originalSleep = Bun.sleep;
761
+ Bun.sleep = (() => Promise.resolve()) as typeof Bun.sleep;
762
+
763
+ try {
764
+ await captureStdout(() => coordinatorCommand(["start"], deps));
765
+ } finally {
766
+ Bun.sleep = originalSleep;
767
+ }
768
+
769
+ // SessionStore uses UNIQUE(agent_name), so the new session replaces the old one.
770
+ // Verify the new session is in booting state with the coordinator name.
771
+ const sessions = loadSessionsFromDb();
772
+ expect(sessions).toHaveLength(1);
773
+
774
+ const newSession = sessions[0];
775
+ expect(newSession).toBeDefined();
776
+ expect(newSession?.state).toBe("booting");
777
+ expect(newSession?.agentName).toBe("coordinator");
778
+ // The new session should have a different ID than the dead one
779
+ expect(newSession?.id).not.toBe("session-dead-coordinator");
780
+ });
781
+
782
+ test("cleans up zombie session when tmux alive but PID dead", async () => {
783
+ // Session is "working" in DB, tmux session exists, but the PID is dead
784
+ const zombieSession = makeCoordinatorSession({
785
+ id: "session-zombie-coordinator",
786
+ state: "working",
787
+ pid: 999999, // Non-existent PID
788
+ });
789
+ saveSessionsToDb([zombieSession]);
790
+
791
+ // Tmux session is alive (pane exists) but PID 999999 is not running
792
+ const { deps } = makeDeps(
793
+ { "agentplate-test-project-coordinator": true },
794
+ undefined,
795
+ undefined,
796
+ { checkSessionStateMap: { "agentplate-test-project-coordinator": "alive" } },
797
+ );
798
+
799
+ const originalSleep = Bun.sleep;
800
+ Bun.sleep = (() => Promise.resolve()) as typeof Bun.sleep;
801
+
802
+ try {
803
+ await captureStdout(() => coordinatorCommand(["start"], deps));
804
+ } finally {
805
+ Bun.sleep = originalSleep;
806
+ }
807
+
808
+ // Zombie session should be cleaned up and new one created
809
+ const sessions = loadSessionsFromDb();
810
+ expect(sessions).toHaveLength(1);
811
+ const newSession = sessions[0];
812
+ expect(newSession?.state).toBe("booting");
813
+ expect(newSession?.id).not.toBe("session-zombie-coordinator");
814
+ });
815
+
816
+ test("cleans up stale session when tmux server is not running", async () => {
817
+ // Session is "booting" in DB but tmux server crashed
818
+ const staleSession = makeCoordinatorSession({
819
+ id: "session-stale-coordinator",
820
+ state: "booting",
821
+ });
822
+ saveSessionsToDb([staleSession]);
823
+
824
+ // checkSessionState returns no_server
825
+ const { deps } = makeDeps(
826
+ { "agentplate-test-project-coordinator": false },
827
+ undefined,
828
+ undefined,
829
+ { checkSessionStateMap: { "agentplate-test-project-coordinator": "no_server" } },
830
+ );
831
+
832
+ const originalSleep = Bun.sleep;
833
+ Bun.sleep = (() => Promise.resolve()) as typeof Bun.sleep;
834
+
835
+ try {
836
+ await captureStdout(() => coordinatorCommand(["start"], deps));
837
+ } finally {
838
+ Bun.sleep = originalSleep;
839
+ }
840
+
841
+ // Stale session cleaned up, new one created
842
+ const sessions = loadSessionsFromDb();
843
+ expect(sessions).toHaveLength(1);
844
+ const newSession = sessions[0];
845
+ expect(newSession?.state).toBe("booting");
846
+ expect(newSession?.id).not.toBe("session-stale-coordinator");
847
+ });
848
+
849
+ test("respects shellInitDelayMs config before polling TUI readiness", async () => {
850
+ // Append shellInitDelayMs to existing config (preserve tier2Enabled etc.)
851
+ const configPath = join(tempDir, ".agentplate", "config.yaml");
852
+ const existing = await Bun.file(configPath).text();
853
+ await Bun.write(configPath, `${existing}\nruntime:\n shellInitDelayMs: 500\n`);
854
+
855
+ const { deps } = makeDeps();
856
+
857
+ const sleepCalls: number[] = [];
858
+ const originalSleep = Bun.sleep;
859
+ Bun.sleep = ((ms: number | Date) => {
860
+ if (typeof ms === "number") sleepCalls.push(ms);
861
+ return Promise.resolve();
862
+ }) as typeof Bun.sleep;
863
+
864
+ try {
865
+ await captureStdout(() => coordinatorCommand(["start"], deps));
866
+ } finally {
867
+ Bun.sleep = originalSleep;
868
+ }
869
+
870
+ // The 500ms shell init delay should appear in the sleep calls
871
+ expect(sleepCalls).toContain(500);
872
+ });
873
+
874
+ test("throws AgentError when tmux is not available", async () => {
875
+ const { deps } = makeDeps({}, undefined, undefined, {
876
+ ensureTmuxAvailableError: new AgentError(
877
+ "tmux is not installed or not on PATH. Install tmux to use agentplate agent orchestration.",
878
+ ),
879
+ });
880
+
881
+ await expect(coordinatorCommand(["start"], deps)).rejects.toThrow(AgentError);
882
+ });
883
+
884
+ test("AgentError message mentions tmux not installed when tmux unavailable", async () => {
885
+ const { deps } = makeDeps({}, undefined, undefined, {
886
+ ensureTmuxAvailableError: new AgentError(
887
+ "tmux is not installed or not on PATH. Install tmux to use agentplate agent orchestration.",
888
+ ),
889
+ });
890
+
891
+ try {
892
+ await coordinatorCommand(["start"], deps);
893
+ expect(true).toBe(false); // Should have thrown
894
+ } catch (err: unknown) {
895
+ expect(err).toBeInstanceOf(AgentError);
896
+ const agentErr = err as AgentError;
897
+ expect(agentErr.message).toContain("tmux is not installed");
898
+ }
899
+ });
900
+
901
+ test("throws AgentError when session dies during startup", async () => {
902
+ // waitForTuiReady returns false AND isSessionAlive returns false — session died
903
+ const { deps } = makeDeps(
904
+ { "agentplate-test-project-coordinator": false },
905
+ undefined,
906
+ undefined,
907
+ { waitForTuiReadyResult: false },
908
+ );
909
+
910
+ await expect(coordinatorCommand(["start"], deps)).rejects.toThrow(AgentError);
911
+ });
912
+
913
+ test("AgentError message mentions session dying when session dies during startup", async () => {
914
+ const { deps } = makeDeps(
915
+ { "agentplate-test-project-coordinator": false },
916
+ undefined,
917
+ undefined,
918
+ { waitForTuiReadyResult: false },
919
+ );
920
+
921
+ try {
922
+ await coordinatorCommand(["start"], deps);
923
+ expect(true).toBe(false); // Should have thrown
924
+ } catch (err: unknown) {
925
+ expect(err).toBeInstanceOf(AgentError);
926
+ const agentErr = err as AgentError;
927
+ expect(agentErr.message).toContain("died during startup");
928
+ }
929
+ });
930
+
931
+ test("kills the coordinator and throws when waitForTuiReady times out but session is still alive", async () => {
932
+ // waitForTuiReady returns false (timeout) and the session is still alive,
933
+ // so startup should fail explicitly instead of sending the beacon blindly.
934
+ const { deps, calls } = makeDeps(
935
+ { "agentplate-test-project-coordinator": true },
936
+ undefined,
937
+ undefined,
938
+ { waitForTuiReadyResult: false },
939
+ );
940
+
941
+ const originalSleep = Bun.sleep;
942
+ Bun.sleep = (() => Promise.resolve()) as typeof Bun.sleep;
943
+
944
+ let thrownError: unknown;
945
+ try {
946
+ await captureStdout(() => coordinatorCommand(["start"], deps));
947
+ } catch (err: unknown) {
948
+ thrownError = err;
949
+ } finally {
950
+ Bun.sleep = originalSleep;
951
+ }
952
+
953
+ expect(thrownError).toBeInstanceOf(AgentError);
954
+ const agentErr = thrownError as AgentError;
955
+ expect(agentErr.message).toContain("did not become ready during startup");
956
+ expect(calls.killSession).toHaveLength(1);
957
+ expect(calls.killSession[0]?.name).toBe("agentplate-test-project-coordinator");
958
+ });
959
+ });
960
+
961
+ describe("stopCoordinator", () => {
962
+ test("marks session as completed after stopping", async () => {
963
+ const session = makeCoordinatorSession({ state: "working" });
964
+ saveSessionsToDb([session]);
965
+
966
+ // Tmux is alive so killSession will be called
967
+ const { deps, calls } = makeDeps({ "agentplate-test-project-coordinator": true });
968
+
969
+ await captureStdout(() => coordinatorCommand(["stop"], deps));
970
+
971
+ // Verify session is now completed
972
+ const sessions = loadSessionsFromDb();
973
+ expect(sessions).toHaveLength(1);
974
+ expect(sessions[0]?.state).toBe("completed");
975
+
976
+ // Verify killSession was called
977
+ expect(calls.killSession).toHaveLength(1);
978
+ expect(calls.killSession[0]?.name).toBe("agentplate-test-project-coordinator");
979
+ });
980
+
981
+ test("--json outputs JSON with stopped flag", async () => {
982
+ const session = makeCoordinatorSession({ state: "working" });
983
+ saveSessionsToDb([session]);
984
+ const { deps } = makeDeps({ "agentplate-test-project-coordinator": true });
985
+
986
+ const output = await captureStdout(() => coordinatorCommand(["stop", "--json"], deps));
987
+ const parsed = JSON.parse(output) as Record<string, unknown>;
988
+ expect(parsed.success).toBe(true);
989
+ expect(parsed.command).toBe("coordinator stop");
990
+ expect(parsed.stopped).toBe(true);
991
+ expect(parsed.sessionId).toBe(session.id);
992
+ });
993
+
994
+ test("handles already-dead tmux session gracefully", async () => {
995
+ const session = makeCoordinatorSession({ state: "working" });
996
+ saveSessionsToDb([session]);
997
+
998
+ // Tmux is NOT alive — should skip killSession
999
+ const { deps, calls } = makeDeps({ "agentplate-test-project-coordinator": false });
1000
+
1001
+ await captureStdout(() => coordinatorCommand(["stop"], deps));
1002
+
1003
+ // Verify session is completed
1004
+ const sessions = loadSessionsFromDb();
1005
+ expect(sessions[0]?.state).toBe("completed");
1006
+
1007
+ // killSession should NOT have been called since session was already dead
1008
+ expect(calls.killSession).toHaveLength(0);
1009
+ });
1010
+
1011
+ test("throws AgentError when no coordinator session exists", async () => {
1012
+ const { deps } = makeDeps();
1013
+
1014
+ // No sessions.json at all
1015
+ await expect(coordinatorCommand(["stop"], deps)).rejects.toThrow(AgentError);
1016
+
1017
+ try {
1018
+ await coordinatorCommand(["stop"], deps);
1019
+ } catch (err) {
1020
+ expect(err).toBeInstanceOf(AgentError);
1021
+ const ae = err as AgentError;
1022
+ expect(ae.message).toContain("No active coordinator session");
1023
+ }
1024
+ });
1025
+
1026
+ test("throws AgentError when only completed sessions exist", async () => {
1027
+ const completed = makeCoordinatorSession({ state: "completed" });
1028
+ saveSessionsToDb([completed]);
1029
+ const { deps } = makeDeps();
1030
+
1031
+ await expect(coordinatorCommand(["stop"], deps)).rejects.toThrow(AgentError);
1032
+ });
1033
+ });
1034
+
1035
+ describe("stopCoordinator run completion", () => {
1036
+ test("coordinator stop auto-completes the active run", async () => {
1037
+ // Create a coordinator session
1038
+ const session = makeCoordinatorSession({ state: "working" });
1039
+ saveSessionsToDb([session]);
1040
+
1041
+ // Create a run in RunStore
1042
+ const dbPath = join(agentplateDir, "sessions.db");
1043
+ const runStore = createRunStore(dbPath);
1044
+ runStore.createRun({
1045
+ id: "run-test-123",
1046
+ startedAt: new Date().toISOString(),
1047
+ coordinatorSessionId: null,
1048
+ status: "active",
1049
+ });
1050
+ runStore.close();
1051
+
1052
+ // Write current-run.txt
1053
+ await Bun.write(join(agentplateDir, "current-run.txt"), "run-test-123");
1054
+
1055
+ // Stop coordinator
1056
+ const { deps } = makeDeps({ "agentplate-test-project-coordinator": true });
1057
+ await captureStdout(() => coordinatorCommand(["stop"], deps));
1058
+
1059
+ // Verify run status is "completed"
1060
+ const runStoreCheck = createRunStore(dbPath);
1061
+ const run = runStoreCheck.getRun("run-test-123");
1062
+ runStoreCheck.close();
1063
+ expect(run?.status).toBe("completed");
1064
+
1065
+ // Verify current-run.txt is deleted
1066
+ const currentRunFile = Bun.file(join(agentplateDir, "current-run.txt"));
1067
+ expect(await currentRunFile.exists()).toBe(false);
1068
+ });
1069
+
1070
+ test("coordinator stop succeeds when no active run exists", async () => {
1071
+ // Create a coordinator session
1072
+ const session = makeCoordinatorSession({ state: "working" });
1073
+ saveSessionsToDb([session]);
1074
+
1075
+ // No current-run.txt
1076
+
1077
+ // Stop coordinator (should succeed without errors)
1078
+ const { deps } = makeDeps({ "agentplate-test-project-coordinator": true });
1079
+ await expect(captureStdout(() => coordinatorCommand(["stop"], deps))).resolves.toBeDefined();
1080
+
1081
+ // Verify session is completed
1082
+ const sessions = loadSessionsFromDb();
1083
+ expect(sessions[0]?.state).toBe("completed");
1084
+ });
1085
+
1086
+ test("coordinator stop succeeds when current-run.txt is empty", async () => {
1087
+ // Create a coordinator session
1088
+ const session = makeCoordinatorSession({ state: "working" });
1089
+ saveSessionsToDb([session]);
1090
+
1091
+ // Write empty current-run.txt
1092
+ await Bun.write(join(agentplateDir, "current-run.txt"), "");
1093
+
1094
+ // Stop coordinator (should succeed without errors)
1095
+ const { deps } = makeDeps({ "agentplate-test-project-coordinator": true });
1096
+ await expect(captureStdout(() => coordinatorCommand(["stop"], deps))).resolves.toBeDefined();
1097
+
1098
+ // Verify session is completed
1099
+ const sessions = loadSessionsFromDb();
1100
+ expect(sessions[0]?.state).toBe("completed");
1101
+ });
1102
+
1103
+ test("--json output includes runCompleted field", async () => {
1104
+ // Create a coordinator session
1105
+ const session = makeCoordinatorSession({ state: "working" });
1106
+ saveSessionsToDb([session]);
1107
+
1108
+ // Create a run in RunStore
1109
+ const dbPath = join(agentplateDir, "sessions.db");
1110
+ const runStore = createRunStore(dbPath);
1111
+ runStore.createRun({
1112
+ id: "run-test-456",
1113
+ startedAt: new Date().toISOString(),
1114
+ coordinatorSessionId: null,
1115
+ status: "active",
1116
+ });
1117
+ runStore.close();
1118
+
1119
+ // Write current-run.txt
1120
+ await Bun.write(join(agentplateDir, "current-run.txt"), "run-test-456");
1121
+
1122
+ // Stop coordinator with --json
1123
+ const { deps } = makeDeps({ "agentplate-test-project-coordinator": true });
1124
+ const output = await captureStdout(() => coordinatorCommand(["stop", "--json"], deps));
1125
+
1126
+ // Verify output includes runCompleted: true
1127
+ const parsed = JSON.parse(output) as Record<string, unknown>;
1128
+ expect(parsed.runCompleted).toBe(true);
1129
+ });
1130
+
1131
+ test("--json output includes runCompleted:false when no run", async () => {
1132
+ // Create a coordinator session
1133
+ const session = makeCoordinatorSession({ state: "working" });
1134
+ saveSessionsToDb([session]);
1135
+
1136
+ // No current-run.txt
1137
+
1138
+ // Stop coordinator with --json
1139
+ const { deps } = makeDeps({ "agentplate-test-project-coordinator": true });
1140
+ const output = await captureStdout(() => coordinatorCommand(["stop", "--json"], deps));
1141
+
1142
+ // Verify output includes runCompleted: false
1143
+ const parsed = JSON.parse(output) as Record<string, unknown>;
1144
+ expect(parsed.runCompleted).toBe(false);
1145
+ });
1146
+ });
1147
+
1148
+ describe("statusCoordinator", () => {
1149
+ test("shows 'not running' when no session exists", async () => {
1150
+ const { deps } = makeDeps();
1151
+ const output = await captureStdout(() => coordinatorCommand(["status"], deps));
1152
+ expect(output).toContain("not running");
1153
+ });
1154
+
1155
+ test("--json shows running:false when no session exists", async () => {
1156
+ const { deps } = makeDeps();
1157
+ const output = await captureStdout(() => coordinatorCommand(["status", "--json"], deps));
1158
+ const parsed = JSON.parse(output) as Record<string, unknown>;
1159
+ expect(parsed.success).toBe(true);
1160
+ expect(parsed.command).toBe("coordinator status");
1161
+ expect(parsed.running).toBe(false);
1162
+ });
1163
+
1164
+ test("shows running state when coordinator is alive", async () => {
1165
+ const session = makeCoordinatorSession({ state: "working" });
1166
+ saveSessionsToDb([session]);
1167
+ const { deps } = makeDeps({ "agentplate-test-project-coordinator": true });
1168
+
1169
+ const output = await captureStdout(() => coordinatorCommand(["status"], deps));
1170
+ expect(output).toContain("running");
1171
+ expect(output).toContain(session.id);
1172
+ expect(output).toContain("agentplate-test-project-coordinator");
1173
+ });
1174
+
1175
+ test("--json shows correct fields when running", async () => {
1176
+ const session = makeCoordinatorSession({ state: "working", pid: 99999 });
1177
+ saveSessionsToDb([session]);
1178
+ const { deps } = makeDeps({ "agentplate-test-project-coordinator": true });
1179
+
1180
+ const output = await captureStdout(() => coordinatorCommand(["status", "--json"], deps));
1181
+ const parsed = JSON.parse(output) as Record<string, unknown>;
1182
+ expect(parsed.success).toBe(true);
1183
+ expect(parsed.command).toBe("coordinator status");
1184
+ expect(parsed.running).toBe(true);
1185
+ expect(parsed.sessionId).toBe(session.id);
1186
+ expect(parsed.state).toBe("working");
1187
+ expect(parsed.tmuxSession).toBe("agentplate-test-project-coordinator");
1188
+ expect(parsed.pid).toBe(99999);
1189
+ });
1190
+
1191
+ test("reconciles zombie: updates state when tmux is dead but session says working", async () => {
1192
+ const session = makeCoordinatorSession({ state: "working" });
1193
+ saveSessionsToDb([session]);
1194
+
1195
+ // Tmux is NOT alive — triggers zombie reconciliation
1196
+ const { deps } = makeDeps({ "agentplate-test-project-coordinator": false });
1197
+
1198
+ const output = await captureStdout(() => coordinatorCommand(["status", "--json"], deps));
1199
+ const parsed = JSON.parse(output) as Record<string, unknown>;
1200
+ expect(parsed.running).toBe(false);
1201
+ expect(parsed.state).toBe("zombie");
1202
+
1203
+ // Verify sessions.json was updated
1204
+ const sessions = loadSessionsFromDb();
1205
+ expect(sessions[0]?.state).toBe("zombie");
1206
+ });
1207
+
1208
+ test("reconciles zombie for booting state too", async () => {
1209
+ const session = makeCoordinatorSession({ state: "booting" });
1210
+ saveSessionsToDb([session]);
1211
+ const { deps } = makeDeps({ "agentplate-test-project-coordinator": false });
1212
+
1213
+ const output = await captureStdout(() => coordinatorCommand(["status", "--json"], deps));
1214
+ const parsed = JSON.parse(output) as Record<string, unknown>;
1215
+ expect(parsed.state).toBe("zombie");
1216
+ });
1217
+
1218
+ test("headless: reports running when pid is alive even though tmuxSession is empty (agentplate-34a6)", async () => {
1219
+ // Headless coordinator: tmuxSession === "", liveness comes from PID check.
1220
+ // Use process.pid (test runner's own PID) as a guaranteed-alive process.
1221
+ const session = makeCoordinatorSession({
1222
+ state: "working",
1223
+ tmuxSession: "",
1224
+ pid: process.pid,
1225
+ });
1226
+ saveSessionsToDb([session]);
1227
+ // sessionAliveMap is empty — tmux.isSessionAlive should NOT be consulted
1228
+ // for headless sessions. If the regression returns, fakeTmux returns false
1229
+ // for an unknown name and the status flips to zombie.
1230
+ const { deps, calls } = makeDeps();
1231
+
1232
+ const output = await captureStdout(() => coordinatorCommand(["status", "--json"], deps));
1233
+ const parsed = JSON.parse(output) as Record<string, unknown>;
1234
+
1235
+ expect(parsed.running).toBe(true);
1236
+ expect(parsed.state).toBe("working");
1237
+ expect(parsed.tmuxSession).toBe("");
1238
+ // No tmux liveness check should have been made for the headless session.
1239
+ expect(calls.isSessionAlive).toEqual([]);
1240
+
1241
+ // SessionStore must NOT have been flipped to zombie.
1242
+ const sessions = loadSessionsFromDb();
1243
+ expect(sessions[0]?.state).toBe("working");
1244
+ });
1245
+
1246
+ test("headless: flips to zombie when pid is dead (sentinel non-existent PID)", async () => {
1247
+ // PID 2147483647 (INT32_MAX) is reserved/invalid and never alive.
1248
+ const session = makeCoordinatorSession({
1249
+ state: "working",
1250
+ tmuxSession: "",
1251
+ pid: 2147483647,
1252
+ });
1253
+ saveSessionsToDb([session]);
1254
+ const { deps } = makeDeps();
1255
+
1256
+ const output = await captureStdout(() => coordinatorCommand(["status", "--json"], deps));
1257
+ const parsed = JSON.parse(output) as Record<string, unknown>;
1258
+
1259
+ expect(parsed.running).toBe(false);
1260
+ expect(parsed.state).toBe("zombie");
1261
+
1262
+ const sessions = loadSessionsFromDb();
1263
+ expect(sessions[0]?.state).toBe("zombie");
1264
+ });
1265
+
1266
+ test("does not show completed sessions as active", async () => {
1267
+ const completed = makeCoordinatorSession({ state: "completed" });
1268
+ saveSessionsToDb([completed]);
1269
+ const { deps } = makeDeps();
1270
+
1271
+ const output = await captureStdout(() => coordinatorCommand(["status", "--json"], deps));
1272
+ const parsed = JSON.parse(output) as Record<string, unknown>;
1273
+ expect(parsed.running).toBe(false);
1274
+ });
1275
+ });
1276
+
1277
+ describe("buildCoordinatorBeacon", () => {
1278
+ test("is a single line (no newlines)", () => {
1279
+ const beacon = buildCoordinatorBeacon();
1280
+ expect(beacon).not.toContain("\n");
1281
+ });
1282
+
1283
+ test("includes coordinator identity in header", () => {
1284
+ const beacon = buildCoordinatorBeacon();
1285
+ expect(beacon).toContain("[AGENTPLATE] coordinator (coordinator)");
1286
+ });
1287
+
1288
+ test("includes ISO timestamp", () => {
1289
+ const beacon = buildCoordinatorBeacon();
1290
+ expect(beacon).toMatch(/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/);
1291
+ });
1292
+
1293
+ test("includes depth and parent info", () => {
1294
+ const beacon = buildCoordinatorBeacon();
1295
+ expect(beacon).toContain("Depth: 0 | Parent: none");
1296
+ });
1297
+
1298
+ test("includes persistent orchestrator role", () => {
1299
+ const beacon = buildCoordinatorBeacon();
1300
+ expect(beacon).toContain("Role: persistent orchestrator");
1301
+ });
1302
+
1303
+ test("includes startup instructions", () => {
1304
+ const beacon = buildCoordinatorBeacon();
1305
+ expect(beacon).toContain("loam prime");
1306
+ expect(beacon).toContain("ap mail check --agent coordinator");
1307
+ expect(beacon).toContain("bd ready");
1308
+ expect(beacon).toContain("ap group status");
1309
+ });
1310
+
1311
+ test("defaults to bd ready when no cliName provided", () => {
1312
+ const beacon = buildCoordinatorBeacon();
1313
+ expect(beacon).toContain("bd ready");
1314
+ });
1315
+
1316
+ test("uses sr ready when cliName is sr", () => {
1317
+ const beacon = buildCoordinatorBeacon("sr");
1318
+ expect(beacon).toContain("sr ready");
1319
+ expect(beacon).not.toContain("bd ready");
1320
+ });
1321
+
1322
+ test("includes hierarchy enforcement instruction", () => {
1323
+ const beacon = buildCoordinatorBeacon();
1324
+ expect(beacon).toContain("Default to leads");
1325
+ expect(beacon).toContain("spawn scout/builder directly");
1326
+ expect(beacon).toContain("NEVER spawn reviewer or merger directly");
1327
+ });
1328
+
1329
+ test("includes delegation instruction", () => {
1330
+ const beacon = buildCoordinatorBeacon();
1331
+ expect(beacon).toContain("DELEGATION");
1332
+ expect(beacon).toContain("spawn a lead who will handle scouts/builders/reviewers");
1333
+ expect(beacon).toContain("--dispatch-max-agents 1/2");
1334
+ });
1335
+
1336
+ test("parts are joined with em-dash separator", () => {
1337
+ const beacon = buildCoordinatorBeacon();
1338
+ // Should have exactly 4 " — " separators (5 parts)
1339
+ const dashes = beacon.split(" — ");
1340
+ expect(dashes).toHaveLength(5);
1341
+ });
1342
+ });
1343
+
1344
+ describe("orchestratorCommand", () => {
1345
+ test("help shows orchestrator command name", async () => {
1346
+ const output = await captureStdout(() => orchestratorCommand(["--help"]));
1347
+ expect(output).toContain("orchestrator");
1348
+ });
1349
+
1350
+ test("start creates orchestrator session with orchestrator capability", async () => {
1351
+ const { deps, calls } = makeDeps({ "agentplate-test-project-orchestrator": true });
1352
+ const originalSleep = Bun.sleep;
1353
+ Bun.sleep = (() => Promise.resolve()) as typeof Bun.sleep;
1354
+
1355
+ try {
1356
+ const output = await captureStdout(() =>
1357
+ orchestratorCommand(["start", "--no-attach", "--json"], deps),
1358
+ );
1359
+ const parsed = JSON.parse(output) as Record<string, unknown>;
1360
+
1361
+ expect(parsed.agentName).toBe("orchestrator");
1362
+ expect(parsed.capability).toBe("orchestrator");
1363
+ expect(parsed.tmuxSession).toBe("agentplate-test-project-orchestrator");
1364
+ expect(calls.createSession[0]?.name).toBe("agentplate-test-project-orchestrator");
1365
+ expect(calls.createSession[0]?.command).toContain("orchestrator.md");
1366
+
1367
+ const session = loadSessionsFromDb().find((entry) => entry.agentName === "orchestrator");
1368
+ expect(session?.capability).toBe("orchestrator");
1369
+ } finally {
1370
+ Bun.sleep = originalSleep;
1371
+ }
1372
+ });
1373
+
1374
+ test("command registration includes orchestrator start/stop/status", () => {
1375
+ const cmd = createOrchestratorCommand({});
1376
+ const subcommandNames = cmd.commands.map((c) => c.name());
1377
+ expect(subcommandNames).toContain("start");
1378
+ expect(subcommandNames).toContain("stop");
1379
+ expect(subcommandNames).toContain("status");
1380
+ expect(subcommandNames).not.toContain("check-complete");
1381
+ });
1382
+ });
1383
+
1384
+ describe("buildOrchestratorBeacon", () => {
1385
+ test("includes orchestrator identity in header", () => {
1386
+ const beacon = buildOrchestratorBeacon();
1387
+ expect(beacon).toContain("[AGENTPLATE] orchestrator (orchestrator)");
1388
+ });
1389
+
1390
+ test("includes ecosystem startup instructions", () => {
1391
+ const beacon = buildOrchestratorBeacon("sr");
1392
+ expect(beacon).toContain("ap mail check --agent orchestrator");
1393
+ expect(beacon).toContain("sr ready");
1394
+ expect(beacon).toContain("inspect ecosystem status");
1395
+ });
1396
+ });
1397
+
1398
+ describe("resolveAttach", () => {
1399
+ test("--attach flag forces attach regardless of TTY", () => {
1400
+ expect(resolveAttach(["--attach"], false)).toBe(true);
1401
+ expect(resolveAttach(["--attach"], true)).toBe(true);
1402
+ });
1403
+
1404
+ test("--no-attach flag forces no attach regardless of TTY", () => {
1405
+ expect(resolveAttach(["--no-attach"], false)).toBe(false);
1406
+ expect(resolveAttach(["--no-attach"], true)).toBe(false);
1407
+ });
1408
+
1409
+ test("--attach takes precedence when both flags are present", () => {
1410
+ expect(resolveAttach(["--attach", "--no-attach"], false)).toBe(true);
1411
+ expect(resolveAttach(["--attach", "--no-attach"], true)).toBe(true);
1412
+ });
1413
+
1414
+ test("defaults to TTY state when no flag is set", () => {
1415
+ expect(resolveAttach([], true)).toBe(true);
1416
+ expect(resolveAttach([], false)).toBe(false);
1417
+ });
1418
+
1419
+ test("works with other flags present", () => {
1420
+ expect(resolveAttach(["--json", "--attach"], false)).toBe(true);
1421
+ expect(resolveAttach(["--json", "--no-attach"], true)).toBe(false);
1422
+ expect(resolveAttach(["--json"], true)).toBe(true);
1423
+ });
1424
+ });
1425
+
1426
+ describe("watchdog integration", () => {
1427
+ describe("startCoordinator with --watchdog", () => {
1428
+ test("calls watchdog.start() when --watchdog flag is present", async () => {
1429
+ const { deps, watchdogCalls } = makeDeps({}, { startSuccess: true });
1430
+ const originalSleep = Bun.sleep;
1431
+ Bun.sleep = (() => Promise.resolve()) as typeof Bun.sleep;
1432
+
1433
+ try {
1434
+ await captureStdout(() => coordinatorCommand(["start", "--watchdog", "--json"], deps));
1435
+ } finally {
1436
+ Bun.sleep = originalSleep;
1437
+ }
1438
+
1439
+ expect(watchdogCalls?.start).toBe(1);
1440
+ });
1441
+
1442
+ test("does NOT call watchdog.start() when --watchdog flag is absent", async () => {
1443
+ const { deps, watchdogCalls } = makeDeps({}, { startSuccess: true });
1444
+ const originalSleep = Bun.sleep;
1445
+ Bun.sleep = (() => Promise.resolve()) as typeof Bun.sleep;
1446
+
1447
+ try {
1448
+ await captureStdout(() => coordinatorCommand(["start", "--json"], deps));
1449
+ } finally {
1450
+ Bun.sleep = originalSleep;
1451
+ }
1452
+
1453
+ expect(watchdogCalls?.start).toBe(0);
1454
+ });
1455
+
1456
+ test("--json output includes watchdog field when --watchdog is present and succeeds", async () => {
1457
+ const { deps } = makeDeps({}, { startSuccess: true });
1458
+ const originalSleep = Bun.sleep;
1459
+ Bun.sleep = (() => Promise.resolve()) as typeof Bun.sleep;
1460
+
1461
+ let output: string;
1462
+ try {
1463
+ output = await captureStdout(() =>
1464
+ coordinatorCommand(["start", "--watchdog", "--json"], deps),
1465
+ );
1466
+ } finally {
1467
+ Bun.sleep = originalSleep;
1468
+ }
1469
+
1470
+ const parsed = JSON.parse(output) as Record<string, unknown>;
1471
+ expect(parsed.watchdog).toBe(true);
1472
+ });
1473
+
1474
+ test("--json output includes watchdog:false when --watchdog is present but start fails", async () => {
1475
+ const { deps } = makeDeps({}, { startSuccess: false });
1476
+ const originalSleep = Bun.sleep;
1477
+ Bun.sleep = (() => Promise.resolve()) as typeof Bun.sleep;
1478
+
1479
+ let output: string;
1480
+ try {
1481
+ output = await captureStdout(() =>
1482
+ coordinatorCommand(["start", "--watchdog", "--json"], deps),
1483
+ );
1484
+ } finally {
1485
+ Bun.sleep = originalSleep;
1486
+ }
1487
+
1488
+ const parsed = JSON.parse(output) as Record<string, unknown>;
1489
+ expect(parsed.watchdog).toBe(false);
1490
+ });
1491
+
1492
+ test("--json output includes watchdog:false when --watchdog is absent", async () => {
1493
+ const { deps } = makeDeps({}, { startSuccess: true });
1494
+ const originalSleep = Bun.sleep;
1495
+ Bun.sleep = (() => Promise.resolve()) as typeof Bun.sleep;
1496
+
1497
+ let output: string;
1498
+ try {
1499
+ output = await captureStdout(() => coordinatorCommand(["start", "--json"], deps));
1500
+ } finally {
1501
+ Bun.sleep = originalSleep;
1502
+ }
1503
+
1504
+ const parsed = JSON.parse(output) as Record<string, unknown>;
1505
+ expect(parsed.watchdog).toBe(false);
1506
+ });
1507
+
1508
+ test("text output includes watchdog PID when --watchdog succeeds", async () => {
1509
+ const { deps } = makeDeps({}, { startSuccess: true });
1510
+ const originalSleep = Bun.sleep;
1511
+ Bun.sleep = (() => Promise.resolve()) as typeof Bun.sleep;
1512
+
1513
+ let output: string;
1514
+ try {
1515
+ output = await captureStdout(() =>
1516
+ coordinatorCommand(["start", "--watchdog", "--no-attach"], deps),
1517
+ );
1518
+ } finally {
1519
+ Bun.sleep = originalSleep;
1520
+ }
1521
+
1522
+ expect(output).toContain("Watchdog started");
1523
+ });
1524
+ });
1525
+
1526
+ describe("stopCoordinator watchdog cleanup", () => {
1527
+ test("always calls watchdog.stop() when stopping coordinator", async () => {
1528
+ const session = makeCoordinatorSession({ state: "working" });
1529
+ saveSessionsToDb([session]);
1530
+ const { deps, watchdogCalls } = makeDeps(
1531
+ { "agentplate-test-project-coordinator": true },
1532
+ { stopSuccess: true },
1533
+ );
1534
+
1535
+ await captureStdout(() => coordinatorCommand(["stop"], deps));
1536
+
1537
+ expect(watchdogCalls?.stop).toBe(1);
1538
+ });
1539
+
1540
+ test("--json output includes watchdogStopped:true when watchdog was running", async () => {
1541
+ const session = makeCoordinatorSession({ state: "working" });
1542
+ saveSessionsToDb([session]);
1543
+ const { deps } = makeDeps(
1544
+ { "agentplate-test-project-coordinator": true },
1545
+ { stopSuccess: true },
1546
+ );
1547
+
1548
+ const output = await captureStdout(() => coordinatorCommand(["stop", "--json"], deps));
1549
+ const parsed = JSON.parse(output) as Record<string, unknown>;
1550
+ expect(parsed.watchdogStopped).toBe(true);
1551
+ });
1552
+
1553
+ test("--json output includes watchdogStopped:false when no watchdog was running", async () => {
1554
+ const session = makeCoordinatorSession({ state: "working" });
1555
+ saveSessionsToDb([session]);
1556
+ const { deps } = makeDeps(
1557
+ { "agentplate-test-project-coordinator": true },
1558
+ { stopSuccess: false },
1559
+ );
1560
+
1561
+ const output = await captureStdout(() => coordinatorCommand(["stop", "--json"], deps));
1562
+ const parsed = JSON.parse(output) as Record<string, unknown>;
1563
+ expect(parsed.watchdogStopped).toBe(false);
1564
+ });
1565
+
1566
+ test("text output shows 'Watchdog stopped' when watchdog was running", async () => {
1567
+ const session = makeCoordinatorSession({ state: "working" });
1568
+ saveSessionsToDb([session]);
1569
+ const { deps } = makeDeps(
1570
+ { "agentplate-test-project-coordinator": true },
1571
+ { stopSuccess: true },
1572
+ );
1573
+
1574
+ const output = await captureStdout(() => coordinatorCommand(["stop"], deps));
1575
+ expect(output).toContain("Watchdog stopped");
1576
+ });
1577
+
1578
+ test("text output shows 'No watchdog running' when no watchdog was running", async () => {
1579
+ const session = makeCoordinatorSession({ state: "working" });
1580
+ saveSessionsToDb([session]);
1581
+ const { deps } = makeDeps(
1582
+ { "agentplate-test-project-coordinator": true },
1583
+ { stopSuccess: false },
1584
+ );
1585
+
1586
+ const output = await captureStdout(() => coordinatorCommand(["stop"], deps));
1587
+ expect(output).toContain("No watchdog running");
1588
+ });
1589
+ });
1590
+
1591
+ describe("statusCoordinator watchdog state", () => {
1592
+ test("includes watchdogRunning in JSON output when coordinator is running", async () => {
1593
+ const session = makeCoordinatorSession({ state: "working" });
1594
+ saveSessionsToDb([session]);
1595
+ const { deps } = makeDeps({ "agentplate-test-project-coordinator": true }, { running: true });
1596
+
1597
+ const output = await captureStdout(() => coordinatorCommand(["status", "--json"], deps));
1598
+ const parsed = JSON.parse(output) as Record<string, unknown>;
1599
+ expect(parsed.watchdogRunning).toBe(true);
1600
+ });
1601
+
1602
+ test("includes watchdogRunning:false in JSON output when watchdog is not running", async () => {
1603
+ const session = makeCoordinatorSession({ state: "working" });
1604
+ saveSessionsToDb([session]);
1605
+ const { deps } = makeDeps(
1606
+ { "agentplate-test-project-coordinator": true },
1607
+ { running: false },
1608
+ );
1609
+
1610
+ const output = await captureStdout(() => coordinatorCommand(["status", "--json"], deps));
1611
+ const parsed = JSON.parse(output) as Record<string, unknown>;
1612
+ expect(parsed.watchdogRunning).toBe(false);
1613
+ });
1614
+
1615
+ test("text output shows watchdog status when coordinator is running", async () => {
1616
+ const session = makeCoordinatorSession({ state: "working" });
1617
+ saveSessionsToDb([session]);
1618
+ const { deps } = makeDeps({ "agentplate-test-project-coordinator": true }, { running: true });
1619
+
1620
+ const output = await captureStdout(() => coordinatorCommand(["status"], deps));
1621
+ expect(output).toContain("Watchdog: running");
1622
+ });
1623
+
1624
+ test("text output shows 'not running' when watchdog is not running", async () => {
1625
+ const session = makeCoordinatorSession({ state: "working" });
1626
+ saveSessionsToDb([session]);
1627
+ const { deps } = makeDeps(
1628
+ { "agentplate-test-project-coordinator": true },
1629
+ { running: false },
1630
+ );
1631
+
1632
+ const output = await captureStdout(() => coordinatorCommand(["status"], deps));
1633
+ expect(output).toContain("Watchdog: not running");
1634
+ });
1635
+
1636
+ test("includes watchdogRunning in JSON output when coordinator is not running", async () => {
1637
+ const { deps } = makeDeps({}, { running: true });
1638
+
1639
+ const output = await captureStdout(() => coordinatorCommand(["status", "--json"], deps));
1640
+ const parsed = JSON.parse(output) as Record<string, unknown>;
1641
+ expect(parsed.running).toBe(false);
1642
+ expect(parsed.watchdogRunning).toBe(true);
1643
+ });
1644
+ });
1645
+
1646
+ describe("COORDINATOR_HELP", () => {
1647
+ test("start help text includes --watchdog flag", async () => {
1648
+ const cmd = createCoordinatorCommand({});
1649
+ for (const sub of cmd.commands) {
1650
+ sub.exitOverride();
1651
+ }
1652
+ const output = await captureStdout(async () => {
1653
+ await cmd.parseAsync(["start", "--help"], { from: "user" }).catch(() => {});
1654
+ });
1655
+ expect(output).toContain("--watchdog");
1656
+ expect(output).toContain("watchdog");
1657
+ });
1658
+
1659
+ test("start help text includes --accept-existing-watchdog flag", async () => {
1660
+ const cmd = createCoordinatorCommand({});
1661
+ for (const sub of cmd.commands) {
1662
+ sub.exitOverride();
1663
+ }
1664
+ const output = await captureStdout(async () => {
1665
+ await cmd.parseAsync(["start", "--help"], { from: "user" }).catch(() => {});
1666
+ });
1667
+ expect(output).toContain("--accept-existing-watchdog");
1668
+ });
1669
+ });
1670
+
1671
+ // agentplate-3f0c: detect leftover watchdog from a previous session before
1672
+ // spawning, so operators do not get unexpected watchdog supervision.
1673
+ describe("orphan watchdog detection (agentplate-3f0c)", () => {
1674
+ // (a) start (no --watchdog) + isRunning=true -> throws AgentError with PID
1675
+ // and mention of --accept-existing-watchdog in the message
1676
+ test("rejects start with AgentError when no flag passed and watchdog already running", async () => {
1677
+ const { deps, watchdogCalls } = makeDeps({}, { running: true, startSuccess: true });
1678
+ const originalSleep = Bun.sleep;
1679
+ Bun.sleep = (() => Promise.resolve()) as typeof Bun.sleep;
1680
+
1681
+ try {
1682
+ await coordinatorCommand(["start", "--json"], deps);
1683
+ expect.unreachable("should have thrown AgentError");
1684
+ } catch (err) {
1685
+ expect(err).toBeInstanceOf(AgentError);
1686
+ const ae = err as AgentError;
1687
+ expect(ae.message).toContain("Watchdog daemon");
1688
+ // PID is unavailable from the fake watchdog (no PID file written),
1689
+ // so the message reports "unknown PID" — but it must reference the
1690
+ // concept and the suppress flag explicitly.
1691
+ expect(ae.message).toMatch(/PID/);
1692
+ expect(ae.message).toContain("--accept-existing-watchdog");
1693
+ expect(ae.message).toContain("--watchdog");
1694
+ expect(ae.message).toContain("ap watch --kill-others");
1695
+ } finally {
1696
+ Bun.sleep = originalSleep;
1697
+ }
1698
+
1699
+ // Detection ran but auto-start did NOT — the throw fired first.
1700
+ expect(watchdogCalls?.isRunning).toBeGreaterThanOrEqual(1);
1701
+ expect(watchdogCalls?.start).toBe(0);
1702
+ });
1703
+
1704
+ // (b) start --watchdog + isRunning=true -> does NOT throw;
1705
+ // watchdog.start() is still called once
1706
+ test("--watchdog with already-running daemon does NOT throw and still calls start()", async () => {
1707
+ const { deps, watchdogCalls } = makeDeps(
1708
+ {},
1709
+ { running: true, startSuccess: false }, // startSuccess:false simulates the no-op-when-already-running return
1710
+ );
1711
+ const originalSleep = Bun.sleep;
1712
+ Bun.sleep = (() => Promise.resolve()) as typeof Bun.sleep;
1713
+
1714
+ let output: string;
1715
+ try {
1716
+ output = await captureStdout(() =>
1717
+ coordinatorCommand(["start", "--watchdog", "--json"], deps),
1718
+ );
1719
+ } finally {
1720
+ Bun.sleep = originalSleep;
1721
+ }
1722
+
1723
+ expect(watchdogCalls?.start).toBe(1);
1724
+ const parsed = JSON.parse(output) as Record<string, unknown>;
1725
+ // reused-daemon sentinel keeps watchdog truthy in the JSON output
1726
+ expect(parsed.watchdog).toBe(true);
1727
+ expect(parsed.watchdogPreexisting).toBe(true);
1728
+ });
1729
+
1730
+ // (c) start --accept-existing-watchdog + isRunning=true -> does NOT throw;
1731
+ // coordinator starts normally; watchdog.start() is NOT called
1732
+ test("--accept-existing-watchdog allows start without calling watchdog.start()", async () => {
1733
+ const { deps, watchdogCalls } = makeDeps({}, { running: true, startSuccess: true });
1734
+ const originalSleep = Bun.sleep;
1735
+ Bun.sleep = (() => Promise.resolve()) as typeof Bun.sleep;
1736
+
1737
+ let output: string;
1738
+ try {
1739
+ output = await captureStdout(() =>
1740
+ coordinatorCommand(["start", "--accept-existing-watchdog", "--json"], deps),
1741
+ );
1742
+ } finally {
1743
+ Bun.sleep = originalSleep;
1744
+ }
1745
+
1746
+ expect(watchdogCalls?.start).toBe(0);
1747
+ const parsed = JSON.parse(output) as Record<string, unknown>;
1748
+ expect(parsed.watchdog).toBe(true);
1749
+ expect(parsed.watchdogPreexisting).toBe(true);
1750
+ });
1751
+
1752
+ // (d) start (no --watchdog) + isRunning=false -> no error, no start
1753
+ // (regression — preserves the original "no flag, no daemon activity" path)
1754
+ test("no flag + watchdog not running: starts normally without calling start()", async () => {
1755
+ const { deps, watchdogCalls } = makeDeps({}, { running: false, startSuccess: true });
1756
+ const originalSleep = Bun.sleep;
1757
+ Bun.sleep = (() => Promise.resolve()) as typeof Bun.sleep;
1758
+
1759
+ let output: string;
1760
+ try {
1761
+ output = await captureStdout(() => coordinatorCommand(["start", "--json"], deps));
1762
+ } finally {
1763
+ Bun.sleep = originalSleep;
1764
+ }
1765
+
1766
+ expect(watchdogCalls?.start).toBe(0);
1767
+ const parsed = JSON.parse(output) as Record<string, unknown>;
1768
+ expect(parsed.watchdog).toBe(false);
1769
+ expect(parsed.watchdogPreexisting).toBe(false);
1770
+ });
1771
+
1772
+ test("orchestrator inherits the same orphan-watchdog detection", async () => {
1773
+ const { deps, watchdogCalls } = makeDeps({}, { running: true });
1774
+ const originalSleep = Bun.sleep;
1775
+ Bun.sleep = (() => Promise.resolve()) as typeof Bun.sleep;
1776
+
1777
+ try {
1778
+ await expect(orchestratorCommand(["start", "--json"], deps)).rejects.toThrow(AgentError);
1779
+ } finally {
1780
+ Bun.sleep = originalSleep;
1781
+ }
1782
+
1783
+ expect(watchdogCalls?.start).toBe(0);
1784
+ });
1785
+ });
1786
+ });
1787
+
1788
+ describe("monitor integration", () => {
1789
+ describe("startCoordinator with --monitor", () => {
1790
+ test("calls monitor.start() when --monitor flag is present", async () => {
1791
+ const { deps, monitorCalls } = makeDeps({}, undefined, { startSuccess: true });
1792
+ const originalSleep = Bun.sleep;
1793
+ Bun.sleep = (() => Promise.resolve()) as typeof Bun.sleep;
1794
+
1795
+ try {
1796
+ await captureStdout(() => coordinatorCommand(["start", "--monitor", "--json"], deps));
1797
+ } finally {
1798
+ Bun.sleep = originalSleep;
1799
+ }
1800
+
1801
+ expect(monitorCalls?.start).toBe(1);
1802
+ });
1803
+
1804
+ test("does NOT call monitor.start() when --monitor flag is absent", async () => {
1805
+ const { deps, monitorCalls } = makeDeps({}, undefined, { startSuccess: true });
1806
+ const originalSleep = Bun.sleep;
1807
+ Bun.sleep = (() => Promise.resolve()) as typeof Bun.sleep;
1808
+
1809
+ try {
1810
+ await captureStdout(() => coordinatorCommand(["start", "--json"], deps));
1811
+ } finally {
1812
+ Bun.sleep = originalSleep;
1813
+ }
1814
+
1815
+ expect(monitorCalls?.start).toBe(0);
1816
+ });
1817
+
1818
+ test("--json output includes monitor field when --monitor is present and succeeds", async () => {
1819
+ const { deps } = makeDeps({}, undefined, { startSuccess: true });
1820
+ const originalSleep = Bun.sleep;
1821
+ Bun.sleep = (() => Promise.resolve()) as typeof Bun.sleep;
1822
+
1823
+ let output: string;
1824
+ try {
1825
+ output = await captureStdout(() =>
1826
+ coordinatorCommand(["start", "--monitor", "--json"], deps),
1827
+ );
1828
+ } finally {
1829
+ Bun.sleep = originalSleep;
1830
+ }
1831
+
1832
+ const parsed = JSON.parse(output) as Record<string, unknown>;
1833
+ expect(parsed.monitor).toBe(true);
1834
+ });
1835
+
1836
+ test("--json output includes monitor:false when --monitor is present but start fails", async () => {
1837
+ const { deps } = makeDeps({}, undefined, { startSuccess: false });
1838
+ const originalSleep = Bun.sleep;
1839
+ Bun.sleep = (() => Promise.resolve()) as typeof Bun.sleep;
1840
+
1841
+ let output: string;
1842
+ try {
1843
+ output = await captureStdout(() =>
1844
+ coordinatorCommand(["start", "--monitor", "--json"], deps),
1845
+ );
1846
+ } finally {
1847
+ Bun.sleep = originalSleep;
1848
+ }
1849
+
1850
+ const parsed = JSON.parse(output) as Record<string, unknown>;
1851
+ expect(parsed.monitor).toBe(false);
1852
+ });
1853
+
1854
+ test("--json output includes monitor:false when --monitor is absent", async () => {
1855
+ const { deps } = makeDeps({}, undefined, { startSuccess: true });
1856
+ const originalSleep = Bun.sleep;
1857
+ Bun.sleep = (() => Promise.resolve()) as typeof Bun.sleep;
1858
+
1859
+ let output: string;
1860
+ try {
1861
+ output = await captureStdout(() => coordinatorCommand(["start", "--json"], deps));
1862
+ } finally {
1863
+ Bun.sleep = originalSleep;
1864
+ }
1865
+
1866
+ const parsed = JSON.parse(output) as Record<string, unknown>;
1867
+ expect(parsed.monitor).toBe(false);
1868
+ });
1869
+
1870
+ test("text output includes monitor PID when --monitor succeeds", async () => {
1871
+ const { deps } = makeDeps({}, undefined, { startSuccess: true });
1872
+ const originalSleep = Bun.sleep;
1873
+ Bun.sleep = (() => Promise.resolve()) as typeof Bun.sleep;
1874
+
1875
+ let output: string;
1876
+ try {
1877
+ output = await captureStdout(() =>
1878
+ coordinatorCommand(["start", "--monitor", "--no-attach"], deps),
1879
+ );
1880
+ } finally {
1881
+ Bun.sleep = originalSleep;
1882
+ }
1883
+
1884
+ expect(output).toContain("Monitor started");
1885
+ });
1886
+
1887
+ test("does NOT call monitor.start() when tier2Enabled is false", async () => {
1888
+ // Override config with tier2Enabled: false
1889
+ await Bun.write(
1890
+ join(agentplateDir, "config.yaml"),
1891
+ [
1892
+ "project:",
1893
+ " name: test-project",
1894
+ ` root: ${tempDir}`,
1895
+ " canonicalBranch: main",
1896
+ "watchdog:",
1897
+ " tier2Enabled: false",
1898
+ ].join("\n"),
1899
+ );
1900
+ const { deps, monitorCalls } = makeDeps({}, undefined, { startSuccess: true });
1901
+ const originalSleep = Bun.sleep;
1902
+ Bun.sleep = (() => Promise.resolve()) as typeof Bun.sleep;
1903
+
1904
+ try {
1905
+ await captureStdout(() => coordinatorCommand(["start", "--monitor", "--json"], deps));
1906
+ } finally {
1907
+ Bun.sleep = originalSleep;
1908
+ }
1909
+
1910
+ expect(monitorCalls?.start).toBe(0);
1911
+ });
1912
+
1913
+ test("text output shows skipped message when tier2Enabled is false", async () => {
1914
+ // Override config with tier2Enabled: false
1915
+ await Bun.write(
1916
+ join(agentplateDir, "config.yaml"),
1917
+ [
1918
+ "project:",
1919
+ " name: test-project",
1920
+ ` root: ${tempDir}`,
1921
+ " canonicalBranch: main",
1922
+ "watchdog:",
1923
+ " tier2Enabled: false",
1924
+ ].join("\n"),
1925
+ );
1926
+ const { deps } = makeDeps({}, undefined, { startSuccess: true });
1927
+ const originalSleep = Bun.sleep;
1928
+ Bun.sleep = (() => Promise.resolve()) as typeof Bun.sleep;
1929
+
1930
+ let output: string;
1931
+ try {
1932
+ output = await captureStdout(() =>
1933
+ coordinatorCommand(["start", "--monitor", "--no-attach"], deps),
1934
+ );
1935
+ } finally {
1936
+ Bun.sleep = originalSleep;
1937
+ }
1938
+
1939
+ expect(output).toContain("skipped");
1940
+ });
1941
+ });
1942
+
1943
+ describe("stopCoordinator monitor cleanup", () => {
1944
+ test("always calls monitor.stop() when stopping coordinator", async () => {
1945
+ const session = makeCoordinatorSession({ state: "working" });
1946
+ saveSessionsToDb([session]);
1947
+ const { deps, monitorCalls } = makeDeps(
1948
+ { "agentplate-test-project-coordinator": true },
1949
+ undefined,
1950
+ { stopSuccess: true },
1951
+ );
1952
+
1953
+ await captureStdout(() => coordinatorCommand(["stop"], deps));
1954
+
1955
+ expect(monitorCalls?.stop).toBe(1);
1956
+ });
1957
+
1958
+ test("--json output includes monitorStopped:true when monitor was running", async () => {
1959
+ const session = makeCoordinatorSession({ state: "working" });
1960
+ saveSessionsToDb([session]);
1961
+ const { deps } = makeDeps({ "agentplate-test-project-coordinator": true }, undefined, {
1962
+ stopSuccess: true,
1963
+ });
1964
+
1965
+ const output = await captureStdout(() => coordinatorCommand(["stop", "--json"], deps));
1966
+ const parsed = JSON.parse(output) as Record<string, unknown>;
1967
+ expect(parsed.monitorStopped).toBe(true);
1968
+ });
1969
+
1970
+ test("--json output includes monitorStopped:false when no monitor was running", async () => {
1971
+ const session = makeCoordinatorSession({ state: "working" });
1972
+ saveSessionsToDb([session]);
1973
+ const { deps } = makeDeps({ "agentplate-test-project-coordinator": true }, undefined, {
1974
+ stopSuccess: false,
1975
+ });
1976
+
1977
+ const output = await captureStdout(() => coordinatorCommand(["stop", "--json"], deps));
1978
+ const parsed = JSON.parse(output) as Record<string, unknown>;
1979
+ expect(parsed.monitorStopped).toBe(false);
1980
+ });
1981
+
1982
+ test("text output shows 'Monitor stopped' when monitor was running", async () => {
1983
+ const session = makeCoordinatorSession({ state: "working" });
1984
+ saveSessionsToDb([session]);
1985
+ const { deps } = makeDeps({ "agentplate-test-project-coordinator": true }, undefined, {
1986
+ stopSuccess: true,
1987
+ });
1988
+
1989
+ const output = await captureStdout(() => coordinatorCommand(["stop"], deps));
1990
+ expect(output).toContain("Monitor stopped");
1991
+ });
1992
+
1993
+ test("text output shows 'No monitor running' when no monitor was running", async () => {
1994
+ const session = makeCoordinatorSession({ state: "working" });
1995
+ saveSessionsToDb([session]);
1996
+ const { deps } = makeDeps({ "agentplate-test-project-coordinator": true }, undefined, {
1997
+ stopSuccess: false,
1998
+ });
1999
+
2000
+ const output = await captureStdout(() => coordinatorCommand(["stop"], deps));
2001
+ expect(output).toContain("No monitor running");
2002
+ });
2003
+ });
2004
+
2005
+ describe("statusCoordinator monitor state", () => {
2006
+ test("includes monitorRunning in JSON output when coordinator is running", async () => {
2007
+ const session = makeCoordinatorSession({ state: "working" });
2008
+ saveSessionsToDb([session]);
2009
+ const { deps } = makeDeps({ "agentplate-test-project-coordinator": true }, undefined, {
2010
+ running: true,
2011
+ });
2012
+
2013
+ const output = await captureStdout(() => coordinatorCommand(["status", "--json"], deps));
2014
+ const parsed = JSON.parse(output) as Record<string, unknown>;
2015
+ expect(parsed.monitorRunning).toBe(true);
2016
+ });
2017
+
2018
+ test("includes monitorRunning:false in JSON output when monitor is not running", async () => {
2019
+ const session = makeCoordinatorSession({ state: "working" });
2020
+ saveSessionsToDb([session]);
2021
+ const { deps } = makeDeps({ "agentplate-test-project-coordinator": true }, undefined, {
2022
+ running: false,
2023
+ });
2024
+
2025
+ const output = await captureStdout(() => coordinatorCommand(["status", "--json"], deps));
2026
+ const parsed = JSON.parse(output) as Record<string, unknown>;
2027
+ expect(parsed.monitorRunning).toBe(false);
2028
+ });
2029
+
2030
+ test("text output shows monitor status when coordinator is running", async () => {
2031
+ const session = makeCoordinatorSession({ state: "working" });
2032
+ saveSessionsToDb([session]);
2033
+ const { deps } = makeDeps({ "agentplate-test-project-coordinator": true }, undefined, {
2034
+ running: true,
2035
+ });
2036
+
2037
+ const output = await captureStdout(() => coordinatorCommand(["status"], deps));
2038
+ expect(output).toContain("Monitor: running");
2039
+ });
2040
+
2041
+ test("text output shows 'not running' when monitor is not running", async () => {
2042
+ const session = makeCoordinatorSession({ state: "working" });
2043
+ saveSessionsToDb([session]);
2044
+ const { deps } = makeDeps({ "agentplate-test-project-coordinator": true }, undefined, {
2045
+ running: false,
2046
+ });
2047
+
2048
+ const output = await captureStdout(() => coordinatorCommand(["status"], deps));
2049
+ expect(output).toContain("Monitor: not running");
2050
+ });
2051
+
2052
+ test("includes monitorRunning in JSON output when coordinator is not running", async () => {
2053
+ const { deps } = makeDeps({}, undefined, { running: true });
2054
+
2055
+ const output = await captureStdout(() => coordinatorCommand(["status", "--json"], deps));
2056
+ const parsed = JSON.parse(output) as Record<string, unknown>;
2057
+ expect(parsed.running).toBe(false);
2058
+ expect(parsed.monitorRunning).toBe(true);
2059
+ });
2060
+ });
2061
+
2062
+ describe("COORDINATOR_HELP", () => {
2063
+ test("start help text includes --monitor flag", async () => {
2064
+ const cmd = createCoordinatorCommand({});
2065
+ for (const sub of cmd.commands) {
2066
+ sub.exitOverride();
2067
+ }
2068
+ const output = await captureStdout(async () => {
2069
+ await cmd.parseAsync(["start", "--help"], { from: "user" }).catch(() => {});
2070
+ });
2071
+ expect(output).toContain("--monitor");
2072
+ expect(output).toContain("monitor");
2073
+ });
2074
+ });
2075
+ });
2076
+
2077
+ describe("SessionStore round-trip", () => {
2078
+ test("returns empty array when no sessions exist", () => {
2079
+ const sessions = loadSessionsFromDb();
2080
+ expect(sessions).toEqual([]);
2081
+ });
2082
+
2083
+ test("save then load round-trips correctly", () => {
2084
+ const original = [makeCoordinatorSession()];
2085
+ saveSessionsToDb(original);
2086
+ const loaded = loadSessionsFromDb();
2087
+
2088
+ expect(loaded).toHaveLength(1);
2089
+ expect(loaded[0]?.agentName).toBe("coordinator");
2090
+ expect(loaded[0]?.capability).toBe("coordinator");
2091
+ });
2092
+
2093
+ test("sessions.db is created after save", () => {
2094
+ saveSessionsToDb([makeCoordinatorSession()]);
2095
+ const dbPath = join(agentplateDir, "sessions.db");
2096
+ const exists = Bun.file(dbPath).size > 0;
2097
+ expect(exists).toBe(true);
2098
+ });
2099
+ });
2100
+
2101
+ // --- Helpers for send/output tests ---
2102
+
2103
+ /** Read all messages from the mail store at mail.db for assertions. */
2104
+ function loadMailMessages() {
2105
+ const mailDbPath = join(agentplateDir, "mail.db");
2106
+ const mailStore = createMailStore(mailDbPath);
2107
+ try {
2108
+ return mailStore.getAll();
2109
+ } finally {
2110
+ mailStore.close();
2111
+ }
2112
+ }
2113
+
2114
+ describe("sendCoordinator", () => {
2115
+ test("send succeeds with running coordinator — mail is in DB", async () => {
2116
+ const session = makeCoordinatorSession({ state: "working" });
2117
+ saveSessionsToDb([session]);
2118
+
2119
+ let nudgeCalled = false;
2120
+ const { deps } = makeDeps({ "agentplate-test-project-coordinator": true });
2121
+ deps._nudge = async () => {
2122
+ nudgeCalled = true;
2123
+ return { delivered: true };
2124
+ };
2125
+
2126
+ await captureStdout(() => coordinatorCommand(["send", "--body", "hello world"], deps));
2127
+
2128
+ const messages = loadMailMessages();
2129
+ expect(messages).toHaveLength(1);
2130
+ expect(messages[0]?.from).toBe("operator");
2131
+ expect(messages[0]?.to).toBe("coordinator");
2132
+ expect(messages[0]?.body).toBe("hello world");
2133
+ expect(messages[0]?.type).toBe("dispatch");
2134
+ expect(nudgeCalled).toBe(true);
2135
+ });
2136
+
2137
+ test("send fails when no coordinator running", async () => {
2138
+ const { deps } = makeDeps();
2139
+
2140
+ await expect(coordinatorCommand(["send", "--body", "hello"], deps)).rejects.toThrow(AgentError);
2141
+ });
2142
+
2143
+ test("send fails when coordinator tmux is dead — state updated to zombie", async () => {
2144
+ const session = makeCoordinatorSession({ state: "working" });
2145
+ saveSessionsToDb([session]);
2146
+
2147
+ const { deps } = makeDeps({ "agentplate-test-project-coordinator": false });
2148
+
2149
+ await expect(coordinatorCommand(["send", "--body", "hello"], deps)).rejects.toThrow(AgentError);
2150
+
2151
+ const sessions = loadSessionsFromDb();
2152
+ expect(sessions[0]?.state).toBe("zombie");
2153
+ });
2154
+
2155
+ test("send --json outputs JSON with id and nudged fields", async () => {
2156
+ const session = makeCoordinatorSession({ state: "working" });
2157
+ saveSessionsToDb([session]);
2158
+
2159
+ const { deps } = makeDeps({ "agentplate-test-project-coordinator": true });
2160
+ deps._nudge = async () => ({ delivered: true });
2161
+
2162
+ const output = await captureStdout(() =>
2163
+ coordinatorCommand(["send", "--body", "hello", "--json"], deps),
2164
+ );
2165
+ const parsed = JSON.parse(output) as Record<string, unknown>;
2166
+ expect(typeof parsed.id).toBe("string");
2167
+ expect(parsed.nudged).toBe(true);
2168
+ });
2169
+
2170
+ test("send with custom --subject uses subject in mail", async () => {
2171
+ const session = makeCoordinatorSession({ state: "working" });
2172
+ saveSessionsToDb([session]);
2173
+
2174
+ const { deps } = makeDeps({ "agentplate-test-project-coordinator": true });
2175
+ deps._nudge = async () => ({ delivered: false });
2176
+
2177
+ await captureStdout(() =>
2178
+ coordinatorCommand(
2179
+ ["send", "--body", "build feature X", "--subject", "Deploy feature X"],
2180
+ deps,
2181
+ ),
2182
+ );
2183
+
2184
+ const messages = loadMailMessages();
2185
+ expect(messages[0]?.subject).toBe("Deploy feature X");
2186
+ });
2187
+ });
2188
+
2189
+ describe("outputCoordinator", () => {
2190
+ test("output shows pane content", async () => {
2191
+ const session = makeCoordinatorSession({ state: "working" });
2192
+ saveSessionsToDb([session]);
2193
+
2194
+ const { deps } = makeDeps({ "agentplate-test-project-coordinator": true });
2195
+ deps._capturePaneContent = async () => "Hello from coordinator pane\n";
2196
+
2197
+ const output = await captureStdout(() => coordinatorCommand(["output"], deps));
2198
+ expect(output).toContain("Hello from coordinator pane");
2199
+ });
2200
+
2201
+ test("output fails when no coordinator running", async () => {
2202
+ const { deps } = makeDeps();
2203
+
2204
+ await expect(coordinatorCommand(["output"], deps)).rejects.toThrow(AgentError);
2205
+ });
2206
+
2207
+ test("output fails when coordinator tmux is dead — state updated to zombie", async () => {
2208
+ const session = makeCoordinatorSession({ state: "working" });
2209
+ saveSessionsToDb([session]);
2210
+
2211
+ const { deps } = makeDeps({ "agentplate-test-project-coordinator": false });
2212
+
2213
+ await expect(coordinatorCommand(["output"], deps)).rejects.toThrow(AgentError);
2214
+
2215
+ const sessions = loadSessionsFromDb();
2216
+ expect(sessions[0]?.state).toBe("zombie");
2217
+ });
2218
+
2219
+ test("output --json wraps content in JSON", async () => {
2220
+ const session = makeCoordinatorSession({ state: "working" });
2221
+ saveSessionsToDb([session]);
2222
+
2223
+ const { deps } = makeDeps({ "agentplate-test-project-coordinator": true });
2224
+ deps._capturePaneContent = async () => "some output";
2225
+
2226
+ const output = await captureStdout(() => coordinatorCommand(["output", "--json"], deps));
2227
+ const parsed = JSON.parse(output) as Record<string, unknown>;
2228
+ expect(parsed.content).toBe("some output");
2229
+ expect(typeof parsed.lines).toBe("number");
2230
+ });
2231
+
2232
+ test("output --lines passes lines parameter to capturePaneContent", async () => {
2233
+ const session = makeCoordinatorSession({ state: "working" });
2234
+ saveSessionsToDb([session]);
2235
+
2236
+ let capturedLines: number | undefined;
2237
+ const { deps } = makeDeps({ "agentplate-test-project-coordinator": true });
2238
+ deps._capturePaneContent = async (_name: string, lines?: number) => {
2239
+ capturedLines = lines;
2240
+ return "output";
2241
+ };
2242
+
2243
+ await captureStdout(() => coordinatorCommand(["output", "--lines", "100"], deps));
2244
+ expect(capturedLines).toBe(100);
2245
+ });
2246
+ });
2247
+
2248
+ describe("askCoordinator", () => {
2249
+ test("sends mail and returns reply body on stdout", async () => {
2250
+ const session = makeCoordinatorSession({ state: "working" });
2251
+ saveSessionsToDb([session]);
2252
+
2253
+ const { deps } = makeDeps({ "agentplate-test-project-coordinator": true });
2254
+ deps._nudge = async () => ({ delivered: true });
2255
+ deps._pollIntervalMs = 50; // Fast polling for test
2256
+
2257
+ const mailDbPath = join(agentplateDir, "mail.db");
2258
+ const outputChunks: string[] = [];
2259
+ const originalWrite = process.stdout.write;
2260
+ process.stdout.write = ((chunk: string) => {
2261
+ outputChunks.push(chunk);
2262
+ return true;
2263
+ }) as typeof process.stdout.write;
2264
+
2265
+ try {
2266
+ // Start ask without awaiting — lets us insert the reply concurrently
2267
+ const askPromise = askCoordinator(
2268
+ "what is the status",
2269
+ { subject: "status check", timeout: 10, json: false },
2270
+ deps,
2271
+ );
2272
+
2273
+ // Wait for the ask to complete setup and send mail, then insert a reply
2274
+ await Bun.sleep(300);
2275
+ const replyStore = createMailStore(mailDbPath);
2276
+ try {
2277
+ const messages = replyStore.getAll({ from: "operator", to: "coordinator" });
2278
+ const sent = messages[0];
2279
+ if (sent) {
2280
+ replyStore.insert({
2281
+ id: "",
2282
+ from: "coordinator",
2283
+ to: "operator",
2284
+ subject: `Re: ${sent.subject}`,
2285
+ body: "Here is your answer",
2286
+ type: "status",
2287
+ priority: "normal",
2288
+ threadId: sent.id,
2289
+ payload: JSON.stringify({
2290
+ correlationId: JSON.parse(sent.payload ?? "{}").correlationId,
2291
+ }),
2292
+ });
2293
+ }
2294
+ } finally {
2295
+ replyStore.close();
2296
+ }
2297
+
2298
+ await askPromise;
2299
+ } finally {
2300
+ process.stdout.write = originalWrite;
2301
+ }
2302
+
2303
+ expect(outputChunks.join("")).toBe("Here is your answer\n");
2304
+ });
2305
+
2306
+ test("times out when no reply arrives", async () => {
2307
+ const session = makeCoordinatorSession({ state: "working" });
2308
+ saveSessionsToDb([session]);
2309
+
2310
+ const { deps } = makeDeps({ "agentplate-test-project-coordinator": true });
2311
+ deps._nudge = async () => ({ delivered: false });
2312
+ deps._pollIntervalMs = 50; // Fast polling so the 1s timeout exhausts quickly
2313
+
2314
+ let caughtError: unknown;
2315
+ try {
2316
+ await askCoordinator(
2317
+ "will you answer?",
2318
+ { subject: "timeout test", timeout: 1, json: false },
2319
+ deps,
2320
+ );
2321
+ } catch (err) {
2322
+ caughtError = err;
2323
+ }
2324
+
2325
+ expect(caughtError).toBeInstanceOf(AgentError);
2326
+ const ae = caughtError as AgentError;
2327
+ expect(ae.message).toContain("Timed out");
2328
+ });
2329
+
2330
+ test("throws when coordinator is not running", async () => {
2331
+ // No session in DB
2332
+ const { deps } = makeDeps();
2333
+
2334
+ let caughtError: unknown;
2335
+ try {
2336
+ await askCoordinator("hello", { subject: "test", timeout: 5, json: false }, deps);
2337
+ } catch (err) {
2338
+ caughtError = err;
2339
+ }
2340
+
2341
+ expect(caughtError).toBeInstanceOf(AgentError);
2342
+ const ae = caughtError as AgentError;
2343
+ expect(ae.message).toContain("No active coordinator");
2344
+ });
2345
+
2346
+ test("throws when coordinator tmux session is dead", async () => {
2347
+ const session = makeCoordinatorSession({ state: "working" });
2348
+ saveSessionsToDb([session]);
2349
+
2350
+ // Tmux reports session as dead
2351
+ const { deps } = makeDeps({ "agentplate-test-project-coordinator": false });
2352
+
2353
+ let caughtError: unknown;
2354
+ try {
2355
+ await askCoordinator("hello", { subject: "test", timeout: 5, json: false }, deps);
2356
+ } catch (err) {
2357
+ caughtError = err;
2358
+ }
2359
+
2360
+ expect(caughtError).toBeInstanceOf(AgentError);
2361
+ const ae = caughtError as AgentError;
2362
+ expect(ae.message).toContain("not alive");
2363
+
2364
+ // Session state should be updated to zombie
2365
+ const sessions = loadSessionsFromDb();
2366
+ expect(sessions[0]?.state).toBe("zombie");
2367
+ });
2368
+
2369
+ test("JSON output includes correlationId and reply details", async () => {
2370
+ const session = makeCoordinatorSession({ state: "working" });
2371
+ saveSessionsToDb([session]);
2372
+
2373
+ const { deps } = makeDeps({ "agentplate-test-project-coordinator": true });
2374
+ deps._nudge = async () => ({ delivered: true });
2375
+ deps._pollIntervalMs = 50;
2376
+
2377
+ const mailDbPath = join(agentplateDir, "mail.db");
2378
+ let output = "";
2379
+
2380
+ const askPromise = captureStdout(async () => {
2381
+ const innerAskPromise = askCoordinator(
2382
+ "report status",
2383
+ { subject: "status", timeout: 10, json: true },
2384
+ deps,
2385
+ );
2386
+
2387
+ // Insert reply while ask is polling
2388
+ await Bun.sleep(300);
2389
+ const replyStore = createMailStore(mailDbPath);
2390
+ try {
2391
+ const messages = replyStore.getAll({ from: "operator", to: "coordinator" });
2392
+ const sent = messages[0];
2393
+ if (sent) {
2394
+ replyStore.insert({
2395
+ id: "",
2396
+ from: "coordinator",
2397
+ to: "operator",
2398
+ subject: `Re: ${sent.subject}`,
2399
+ body: "Status: all good",
2400
+ type: "status",
2401
+ priority: "normal",
2402
+ threadId: sent.id,
2403
+ payload: JSON.stringify({
2404
+ correlationId: JSON.parse(sent.payload ?? "{}").correlationId,
2405
+ }),
2406
+ });
2407
+ }
2408
+ } finally {
2409
+ replyStore.close();
2410
+ }
2411
+
2412
+ await innerAskPromise;
2413
+ });
2414
+
2415
+ output = await askPromise;
2416
+
2417
+ const parsed = JSON.parse(output) as Record<string, unknown>;
2418
+ expect(parsed.success).toBe(true);
2419
+ expect(parsed.command).toBe("coordinator ask");
2420
+ expect(typeof parsed.correlationId).toBe("string");
2421
+ expect(typeof parsed.sentId).toBe("string");
2422
+ expect(typeof parsed.replyId).toBe("string");
2423
+ expect(parsed.body).toBe("Status: all good");
2424
+ });
2425
+
2426
+ test("command registration — createCoordinatorCommand has ask subcommand", () => {
2427
+ const cmd = createCoordinatorCommand({});
2428
+ const subcommandNames = cmd.commands.map((c) => c.name());
2429
+ expect(subcommandNames).toContain("ask");
2430
+ });
2431
+ });
2432
+
2433
+ // ─── checkComplete ─────────────────────────────────────────────────────────
2434
+
2435
+ describe("checkComplete", () => {
2436
+ test("all triggers disabled → complete: false", async () => {
2437
+ // Default config has no coordinator section → all triggers default to false
2438
+ const result = await checkComplete({ json: false });
2439
+ expect(result.complete).toBe(false);
2440
+ expect(result.triggers.allAgentsDone.enabled).toBe(false);
2441
+ expect(result.triggers.taskTrackerEmpty.enabled).toBe(false);
2442
+ expect(result.triggers.onShutdownSignal.enabled).toBe(false);
2443
+ });
2444
+
2445
+ test("allAgentsDone met when all non-coordinator agents completed", async () => {
2446
+ // Enable allAgentsDone in config
2447
+ await Bun.write(
2448
+ join(agentplateDir, "config.yaml"),
2449
+ [
2450
+ "project:",
2451
+ " name: test-project",
2452
+ ` root: ${tempDir}`,
2453
+ " canonicalBranch: main",
2454
+ "coordinator:",
2455
+ " exitTriggers:",
2456
+ " allAgentsDone: true",
2457
+ " taskTrackerEmpty: false",
2458
+ " onShutdownSignal: false",
2459
+ ].join("\n"),
2460
+ );
2461
+
2462
+ // Write current-run.txt
2463
+ const runId = `run-${Date.now()}`;
2464
+ await Bun.write(join(agentplateDir, "current-run.txt"), runId);
2465
+
2466
+ // Create sessions.db with two completed agents
2467
+ const store = createSessionStore(join(agentplateDir, "sessions.db"));
2468
+ try {
2469
+ const base: AgentSession = {
2470
+ id: "s1",
2471
+ agentName: "builder-1",
2472
+ capability: "builder",
2473
+ worktreePath: tempDir,
2474
+ branchName: "feat/x",
2475
+ taskId: "t1",
2476
+ tmuxSession: "tmux-1",
2477
+ state: "completed",
2478
+ pid: null,
2479
+ parentAgent: "coordinator",
2480
+ depth: 1,
2481
+ runId,
2482
+ startedAt: new Date().toISOString(),
2483
+ lastActivity: new Date().toISOString(),
2484
+ escalationLevel: 0,
2485
+ stalledSince: null,
2486
+ transcriptPath: null,
2487
+ };
2488
+ store.upsert(base);
2489
+ store.upsert({ ...base, id: "s2", agentName: "builder-2" });
2490
+ } finally {
2491
+ store.close();
2492
+ }
2493
+
2494
+ const result = await checkComplete({ json: false });
2495
+ expect(result.triggers.allAgentsDone.enabled).toBe(true);
2496
+ expect(result.triggers.allAgentsDone.met).toBe(true);
2497
+ expect(result.complete).toBe(true);
2498
+ });
2499
+
2500
+ test("allAgentsDone not met when agents still working", async () => {
2501
+ await Bun.write(
2502
+ join(agentplateDir, "config.yaml"),
2503
+ [
2504
+ "project:",
2505
+ " name: test-project",
2506
+ ` root: ${tempDir}`,
2507
+ " canonicalBranch: main",
2508
+ "coordinator:",
2509
+ " exitTriggers:",
2510
+ " allAgentsDone: true",
2511
+ " taskTrackerEmpty: false",
2512
+ " onShutdownSignal: false",
2513
+ ].join("\n"),
2514
+ );
2515
+
2516
+ const runId = `run-${Date.now()}`;
2517
+ await Bun.write(join(agentplateDir, "current-run.txt"), runId);
2518
+
2519
+ const store = createSessionStore(join(agentplateDir, "sessions.db"));
2520
+ try {
2521
+ const session: AgentSession = {
2522
+ id: "s1",
2523
+ agentName: "builder-1",
2524
+ capability: "builder",
2525
+ worktreePath: tempDir,
2526
+ branchName: "feat/x",
2527
+ taskId: "t1",
2528
+ tmuxSession: "tmux-1",
2529
+ state: "working",
2530
+ pid: null,
2531
+ parentAgent: "coordinator",
2532
+ depth: 1,
2533
+ runId,
2534
+ startedAt: new Date().toISOString(),
2535
+ lastActivity: new Date().toISOString(),
2536
+ escalationLevel: 0,
2537
+ stalledSince: null,
2538
+ transcriptPath: null,
2539
+ };
2540
+ store.upsert(session);
2541
+ } finally {
2542
+ store.close();
2543
+ }
2544
+
2545
+ const result = await checkComplete({ json: false });
2546
+ expect(result.triggers.allAgentsDone.enabled).toBe(true);
2547
+ expect(result.triggers.allAgentsDone.met).toBe(false);
2548
+ expect(result.complete).toBe(false);
2549
+ });
2550
+
2551
+ test("allAgentsDone filters out coordinator session", async () => {
2552
+ await Bun.write(
2553
+ join(agentplateDir, "config.yaml"),
2554
+ [
2555
+ "project:",
2556
+ " name: test-project",
2557
+ ` root: ${tempDir}`,
2558
+ " canonicalBranch: main",
2559
+ "coordinator:",
2560
+ " exitTriggers:",
2561
+ " allAgentsDone: true",
2562
+ " taskTrackerEmpty: false",
2563
+ " onShutdownSignal: false",
2564
+ ].join("\n"),
2565
+ );
2566
+
2567
+ const runId = `run-${Date.now()}`;
2568
+ await Bun.write(join(agentplateDir, "current-run.txt"), runId);
2569
+
2570
+ const store = createSessionStore(join(agentplateDir, "sessions.db"));
2571
+ try {
2572
+ // coordinator session (should be excluded)
2573
+ store.upsert({
2574
+ id: "coord",
2575
+ agentName: "coordinator",
2576
+ capability: "coordinator",
2577
+ worktreePath: tempDir,
2578
+ branchName: "main",
2579
+ taskId: "",
2580
+ tmuxSession: "tmux-coord",
2581
+ state: "working",
2582
+ pid: null,
2583
+ parentAgent: null,
2584
+ depth: 0,
2585
+ runId,
2586
+ startedAt: new Date().toISOString(),
2587
+ lastActivity: new Date().toISOString(),
2588
+ escalationLevel: 0,
2589
+ stalledSince: null,
2590
+ transcriptPath: null,
2591
+ });
2592
+ // worker session that is completed
2593
+ store.upsert({
2594
+ id: "worker",
2595
+ agentName: "builder-1",
2596
+ capability: "builder",
2597
+ worktreePath: tempDir,
2598
+ branchName: "feat/x",
2599
+ taskId: "t1",
2600
+ tmuxSession: "tmux-w",
2601
+ state: "completed",
2602
+ pid: null,
2603
+ parentAgent: "coordinator",
2604
+ depth: 1,
2605
+ runId,
2606
+ startedAt: new Date().toISOString(),
2607
+ lastActivity: new Date().toISOString(),
2608
+ escalationLevel: 0,
2609
+ stalledSince: null,
2610
+ transcriptPath: null,
2611
+ });
2612
+ } finally {
2613
+ store.close();
2614
+ }
2615
+
2616
+ const result = await checkComplete({ json: false });
2617
+ expect(result.triggers.allAgentsDone.enabled).toBe(true);
2618
+ // coordinator is filtered out; only the builder counts → all done
2619
+ expect(result.triggers.allAgentsDone.met).toBe(true);
2620
+ expect(result.complete).toBe(true);
2621
+ });
2622
+
2623
+ test("onShutdownSignal met when shutdown mail exists", async () => {
2624
+ await Bun.write(
2625
+ join(agentplateDir, "config.yaml"),
2626
+ [
2627
+ "project:",
2628
+ " name: test-project",
2629
+ ` root: ${tempDir}`,
2630
+ " canonicalBranch: main",
2631
+ "coordinator:",
2632
+ " exitTriggers:",
2633
+ " allAgentsDone: false",
2634
+ " taskTrackerEmpty: false",
2635
+ " onShutdownSignal: true",
2636
+ ].join("\n"),
2637
+ );
2638
+
2639
+ // Insert a shutdown message into mail.db
2640
+ const mailStore = createMailStore(join(agentplateDir, "mail.db"));
2641
+ try {
2642
+ mailStore.insert({
2643
+ id: "",
2644
+ from: "greenhouse",
2645
+ to: "coordinator",
2646
+ subject: "shutdown",
2647
+ body: "All work done, please shutdown",
2648
+ type: "status",
2649
+ priority: "normal",
2650
+ threadId: null,
2651
+ payload: null,
2652
+ });
2653
+ } finally {
2654
+ mailStore.close();
2655
+ }
2656
+
2657
+ const result = await checkComplete({ json: false });
2658
+ expect(result.triggers.onShutdownSignal.enabled).toBe(true);
2659
+ expect(result.triggers.onShutdownSignal.met).toBe(true);
2660
+ expect(result.complete).toBe(true);
2661
+ });
2662
+
2663
+ test("overall complete false when only one of two enabled triggers is met", async () => {
2664
+ // Enable allAgentsDone + onShutdownSignal; satisfy only onShutdownSignal
2665
+ await Bun.write(
2666
+ join(agentplateDir, "config.yaml"),
2667
+ [
2668
+ "project:",
2669
+ " name: test-project",
2670
+ ` root: ${tempDir}`,
2671
+ " canonicalBranch: main",
2672
+ "coordinator:",
2673
+ " exitTriggers:",
2674
+ " allAgentsDone: true",
2675
+ " taskTrackerEmpty: false",
2676
+ " onShutdownSignal: true",
2677
+ ].join("\n"),
2678
+ );
2679
+
2680
+ // Write current-run.txt but no sessions → allAgentsDone not met (empty run)
2681
+ const runId = `run-${Date.now()}`;
2682
+ await Bun.write(join(agentplateDir, "current-run.txt"), runId);
2683
+ // Sessions DB will be created empty — no agents → allAgentsDone.met = false (length === 0)
2684
+
2685
+ // Insert shutdown mail so onShutdownSignal is met
2686
+ const mailStore = createMailStore(join(agentplateDir, "mail.db"));
2687
+ try {
2688
+ mailStore.insert({
2689
+ id: "",
2690
+ from: "operator",
2691
+ to: "coordinator",
2692
+ subject: "shutdown now",
2693
+ body: "Please shutdown",
2694
+ type: "status",
2695
+ priority: "normal",
2696
+ threadId: null,
2697
+ payload: null,
2698
+ });
2699
+ } finally {
2700
+ mailStore.close();
2701
+ }
2702
+
2703
+ const result = await checkComplete({ json: false });
2704
+ expect(result.triggers.allAgentsDone.enabled).toBe(true);
2705
+ expect(result.triggers.allAgentsDone.met).toBe(false);
2706
+ expect(result.triggers.onShutdownSignal.enabled).toBe(true);
2707
+ expect(result.triggers.onShutdownSignal.met).toBe(true);
2708
+ // Both must be met → false
2709
+ expect(result.complete).toBe(false);
2710
+ });
2711
+
2712
+ test("allAgentsDone false when merge queue has pending branches", async () => {
2713
+ await Bun.write(
2714
+ join(agentplateDir, "config.yaml"),
2715
+ [
2716
+ "project:",
2717
+ " name: test-project",
2718
+ ` root: ${tempDir}`,
2719
+ " canonicalBranch: main",
2720
+ "coordinator:",
2721
+ " exitTriggers:",
2722
+ " allAgentsDone: true",
2723
+ " taskTrackerEmpty: false",
2724
+ " onShutdownSignal: false",
2725
+ ].join("\n"),
2726
+ );
2727
+
2728
+ const runId = `run-${Date.now()}`;
2729
+ await Bun.write(join(agentplateDir, "current-run.txt"), runId);
2730
+
2731
+ // All agent sessions completed
2732
+ const store = createSessionStore(join(agentplateDir, "sessions.db"));
2733
+ try {
2734
+ store.upsert({
2735
+ id: "s1",
2736
+ agentName: "lead-1",
2737
+ capability: "lead",
2738
+ worktreePath: tempDir,
2739
+ branchName: "agentplate/lead-1/task-1",
2740
+ taskId: "task-1",
2741
+ tmuxSession: "tmux-1",
2742
+ state: "completed",
2743
+ pid: null,
2744
+ parentAgent: "coordinator",
2745
+ depth: 1,
2746
+ runId,
2747
+ startedAt: new Date().toISOString(),
2748
+ lastActivity: new Date().toISOString(),
2749
+ escalationLevel: 0,
2750
+ stalledSince: null,
2751
+ transcriptPath: null,
2752
+ });
2753
+ } finally {
2754
+ store.close();
2755
+ }
2756
+
2757
+ // Merge queue has a pending entry — lead branch not yet merged
2758
+ const { createMergeQueue } = await import("../merge/queue.ts");
2759
+ const queue = createMergeQueue(join(agentplateDir, "merge-queue.db"));
2760
+ try {
2761
+ queue.enqueue({
2762
+ branchName: "agentplate/lead-1/task-1",
2763
+ taskId: "task-1",
2764
+ agentName: "lead-1",
2765
+ filesModified: ["src/foo.ts"],
2766
+ });
2767
+ } finally {
2768
+ queue.close();
2769
+ }
2770
+
2771
+ const result = await checkComplete({ json: false });
2772
+ expect(result.triggers.allAgentsDone.enabled).toBe(true);
2773
+ expect(result.triggers.allAgentsDone.met).toBe(false);
2774
+ expect(result.triggers.allAgentsDone.detail).toInclude("pending merge");
2775
+ expect(result.triggers.allAgentsDone.detail).toInclude("agentplate/lead-1/task-1");
2776
+ expect(result.complete).toBe(false);
2777
+ });
2778
+
2779
+ test("allAgentsDone true when all agents completed and merge queue is empty", async () => {
2780
+ await Bun.write(
2781
+ join(agentplateDir, "config.yaml"),
2782
+ [
2783
+ "project:",
2784
+ " name: test-project",
2785
+ ` root: ${tempDir}`,
2786
+ " canonicalBranch: main",
2787
+ "coordinator:",
2788
+ " exitTriggers:",
2789
+ " allAgentsDone: true",
2790
+ " taskTrackerEmpty: false",
2791
+ " onShutdownSignal: false",
2792
+ ].join("\n"),
2793
+ );
2794
+
2795
+ const runId = `run-${Date.now()}`;
2796
+ await Bun.write(join(agentplateDir, "current-run.txt"), runId);
2797
+
2798
+ const store = createSessionStore(join(agentplateDir, "sessions.db"));
2799
+ try {
2800
+ store.upsert({
2801
+ id: "s1",
2802
+ agentName: "lead-1",
2803
+ capability: "lead",
2804
+ worktreePath: tempDir,
2805
+ branchName: "agentplate/lead-1/task-1",
2806
+ taskId: "task-1",
2807
+ tmuxSession: "tmux-1",
2808
+ state: "completed",
2809
+ pid: null,
2810
+ parentAgent: "coordinator",
2811
+ depth: 1,
2812
+ runId,
2813
+ startedAt: new Date().toISOString(),
2814
+ lastActivity: new Date().toISOString(),
2815
+ escalationLevel: 0,
2816
+ stalledSince: null,
2817
+ transcriptPath: null,
2818
+ });
2819
+ } finally {
2820
+ store.close();
2821
+ }
2822
+
2823
+ // Merge queue exists but all entries are already merged (no pending)
2824
+ const { createMergeQueue } = await import("../merge/queue.ts");
2825
+ const queue = createMergeQueue(join(agentplateDir, "merge-queue.db"));
2826
+ try {
2827
+ const entry = queue.enqueue({
2828
+ branchName: "agentplate/lead-1/task-1",
2829
+ taskId: "task-1",
2830
+ agentName: "lead-1",
2831
+ filesModified: ["src/foo.ts"],
2832
+ });
2833
+ queue.updateStatus(entry.branchName, "merged", "clean-merge");
2834
+ } finally {
2835
+ queue.close();
2836
+ }
2837
+
2838
+ const result = await checkComplete({ json: false });
2839
+ expect(result.triggers.allAgentsDone.enabled).toBe(true);
2840
+ expect(result.triggers.allAgentsDone.met).toBe(true);
2841
+ expect(result.complete).toBe(true);
2842
+ });
2843
+
2844
+ test("command registration — createCoordinatorCommand has check-complete subcommand", () => {
2845
+ const cmd = createCoordinatorCommand({});
2846
+ const subcommandNames = cmd.commands.map((c) => c.name());
2847
+ expect(subcommandNames).toContain("check-complete");
2848
+ });
2849
+ });
2850
+
2851
+ describe("startCoordinatorSession headless", () => {
2852
+ test("with headless: true, calls spawnHeadlessAgent and skips tmux", async () => {
2853
+ const { tmux, calls: tmuxCalls } = makeFakeTmux();
2854
+ const { watchdog } = makeFakeWatchdog();
2855
+ const { monitor } = makeFakeMonitor();
2856
+
2857
+ const spawnCalls: Array<{
2858
+ argv: string[];
2859
+ cwd: string;
2860
+ agentName?: string;
2861
+ }> = [];
2862
+ const writes: string[] = [];
2863
+
2864
+ const fakeSpawn = async (
2865
+ argv: string[],
2866
+ opts: { cwd: string; env: Record<string, string>; agentName?: string },
2867
+ ): Promise<{
2868
+ pid: number;
2869
+ stdin: { write(data: string | Uint8Array): number | Promise<number> };
2870
+ stdout: ReadableStream<Uint8Array> | null;
2871
+ }> => {
2872
+ spawnCalls.push({ argv, cwd: opts.cwd, agentName: opts.agentName });
2873
+ return {
2874
+ pid: 55555,
2875
+ stdin: {
2876
+ write(data: string | Uint8Array): number {
2877
+ writes.push(typeof data === "string" ? data : new TextDecoder().decode(data));
2878
+ return 0;
2879
+ },
2880
+ },
2881
+ stdout: null,
2882
+ };
2883
+ };
2884
+
2885
+ const deps: CoordinatorDeps = {
2886
+ _tmux: tmux,
2887
+ _watchdog: watchdog,
2888
+ _monitor: monitor,
2889
+ _spawnHeadless: fakeSpawn,
2890
+ };
2891
+
2892
+ await captureStdout(async () => {
2893
+ await startCoordinatorSession(
2894
+ {
2895
+ json: true,
2896
+ attach: false,
2897
+ watchdog: false,
2898
+ monitor: false,
2899
+ headless: true,
2900
+ },
2901
+ deps,
2902
+ );
2903
+ });
2904
+
2905
+ // spawnHeadlessAgent was called exactly once with agentName: "coordinator"
2906
+ expect(spawnCalls.length).toBe(1);
2907
+ expect(spawnCalls[0]?.agentName).toBe("coordinator");
2908
+ expect(spawnCalls[0]?.cwd).toBe(tempDir);
2909
+
2910
+ // initial stdin prompt was written
2911
+ expect(writes.length).toBeGreaterThanOrEqual(1);
2912
+
2913
+ // tmux helpers were never called for the headless path
2914
+ expect(tmuxCalls.createSession.length).toBe(0);
2915
+ expect(tmuxCalls.sendKeys.length).toBe(0);
2916
+ expect(tmuxCalls.waitForTuiReady.length).toBe(0);
2917
+ expect(tmuxCalls.ensureTmuxAvailable).toBe(0);
2918
+
2919
+ // Session row records empty tmuxSession + the headless spawn pid
2920
+ const sessions = loadSessionsFromDb();
2921
+ expect(sessions.length).toBe(1);
2922
+ expect(sessions[0]?.agentName).toBe("coordinator");
2923
+ expect(sessions[0]?.tmuxSession).toBe("");
2924
+ expect(sessions[0]?.pid).toBe(55555);
2925
+ expect(sessions[0]?.state).toBe("booting");
2926
+
2927
+ // current-run.txt was written for downstream consumers
2928
+ const runFile = Bun.file(join(agentplateDir, "current-run.txt"));
2929
+ expect(await runFile.exists()).toBe(true);
2930
+ });
2931
+
2932
+ test("rejects when runtime has no buildDirectSpawn", async () => {
2933
+ // Override config to route the coordinator capability to a runtime that
2934
+ // lacks buildDirectSpawn (e.g. cursor). The headless path must reject.
2935
+ await Bun.write(
2936
+ join(agentplateDir, "config.yaml"),
2937
+ [
2938
+ "project:",
2939
+ " name: test-project",
2940
+ ` root: ${tempDir}`,
2941
+ " canonicalBranch: main",
2942
+ "watchdog:",
2943
+ " tier2Enabled: true",
2944
+ "runtime:",
2945
+ " capabilities:",
2946
+ " coordinator: cursor",
2947
+ ].join("\n"),
2948
+ );
2949
+
2950
+ const { tmux } = makeFakeTmux();
2951
+ const { watchdog } = makeFakeWatchdog();
2952
+ const { monitor } = makeFakeMonitor();
2953
+ const deps: CoordinatorDeps = {
2954
+ _tmux: tmux,
2955
+ _watchdog: watchdog,
2956
+ _monitor: monitor,
2957
+ _spawnHeadless: async () => {
2958
+ throw new Error("should not be called");
2959
+ },
2960
+ };
2961
+
2962
+ await expect(
2963
+ startCoordinatorSession(
2964
+ {
2965
+ json: true,
2966
+ attach: false,
2967
+ watchdog: false,
2968
+ monitor: false,
2969
+ headless: true,
2970
+ },
2971
+ deps,
2972
+ ),
2973
+ ).rejects.toThrow(ValidationError);
2974
+ });
2975
+ });