@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,1616 @@
1
+ import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test";
2
+ import { AgentError } from "../errors.ts";
3
+ import type { ReadyState } from "../runtimes/types.ts";
4
+ import {
5
+ capturePaneContent,
6
+ checkSessionState,
7
+ createSession,
8
+ ensureTmuxAvailable,
9
+ getDescendantPids,
10
+ getPanePid,
11
+ isProcessAlive,
12
+ isSessionAlive,
13
+ killProcessTree,
14
+ killSession,
15
+ listSessions,
16
+ sanitizeTmuxName,
17
+ sendKeys,
18
+ waitForTuiReady,
19
+ } from "./tmux.ts";
20
+
21
+ /**
22
+ * tmux tests use Bun.spawn mocks — legitimate exception to "never mock what you can use for real".
23
+ * Real tmux operations would hijack the developer's session and are unavailable in CI.
24
+ */
25
+
26
+ /**
27
+ * Helper to create a mock Bun.spawn return value.
28
+ *
29
+ * The actual code reads stdout/stderr via `new Response(proc.stdout).text()`
30
+ * and `new Response(proc.stderr).text()`, so we need ReadableStreams.
31
+ */
32
+ function mockSpawnResult(
33
+ stdout: string,
34
+ stderr: string,
35
+ exitCode: number,
36
+ ): {
37
+ stdout: ReadableStream<Uint8Array>;
38
+ stderr: ReadableStream<Uint8Array>;
39
+ exited: Promise<number>;
40
+ pid: number;
41
+ } {
42
+ return {
43
+ stdout: new Response(stdout).body as ReadableStream<Uint8Array>,
44
+ stderr: new Response(stderr).body as ReadableStream<Uint8Array>,
45
+ exited: Promise.resolve(exitCode),
46
+ pid: 12345,
47
+ };
48
+ }
49
+
50
+ describe("createSession", () => {
51
+ let spawnSpy: ReturnType<typeof spyOn>;
52
+
53
+ beforeEach(() => {
54
+ spawnSpy = spyOn(Bun, "spawn");
55
+ });
56
+
57
+ afterEach(() => {
58
+ spawnSpy.mockRestore();
59
+ });
60
+
61
+ test("creates session and returns pane PID", async () => {
62
+ let callCount = 0;
63
+ spawnSpy.mockImplementation(() => {
64
+ callCount++;
65
+ if (callCount === 1) {
66
+ // which agentplate — return a bin path
67
+ return mockSpawnResult("/usr/local/bin/agentplate\n", "", 0);
68
+ }
69
+ if (callCount === 2) {
70
+ // tmux new-session
71
+ return mockSpawnResult("", "", 0);
72
+ }
73
+ // tmux list-panes -t agentplate-auth -F '#{pane_pid}'
74
+ return mockSpawnResult("42\n", "", 0);
75
+ });
76
+
77
+ const pid = await createSession(
78
+ "agentplate-auth",
79
+ "/repo/worktrees/auth",
80
+ "claude --task 'do work'",
81
+ );
82
+
83
+ expect(pid).toBe(42);
84
+ });
85
+
86
+ test("passes correct args to tmux new-session with PATH wrapping", async () => {
87
+ let callCount = 0;
88
+ spawnSpy.mockImplementation(() => {
89
+ callCount++;
90
+ if (callCount === 1) {
91
+ // which agentplate
92
+ return mockSpawnResult("/usr/local/bin/agentplate\n", "", 0);
93
+ }
94
+ if (callCount === 2) {
95
+ return mockSpawnResult("", "", 0);
96
+ }
97
+ return mockSpawnResult("1234\n", "", 0);
98
+ });
99
+
100
+ await createSession("my-session", "/work/dir", "echo hello");
101
+
102
+ // Call 0 is 'which agentplate', call 1 is 'tmux new-session'
103
+ const tmuxCallArgs = spawnSpy.mock.calls[1] as unknown[];
104
+ const cmd = tmuxCallArgs[0] as string[];
105
+ expect(cmd[0]).toBe("tmux");
106
+ expect(cmd[3]).toBe("new-session");
107
+ expect(cmd[5]).toBe("-s");
108
+ expect(cmd[6]).toBe("my-session");
109
+ expect(cmd[7]).toBe("-c");
110
+ expect(cmd[8]).toBe("/work/dir");
111
+ // The command should be wrapped with PATH export
112
+ const wrappedCmd = cmd[9] as string;
113
+ expect(wrappedCmd).toContain("echo hello");
114
+ expect(wrappedCmd).toContain("export PATH=");
115
+ // `exec` replaces the bash wrapper with the command so SIGHUP from a
116
+ // dying tmux server is delivered directly to claude (agentplate-505d).
117
+ expect(wrappedCmd).toContain("exec echo hello");
118
+
119
+ const opts = tmuxCallArgs[1] as { cwd: string };
120
+ expect(opts.cwd).toBe("/work/dir");
121
+ });
122
+
123
+ test("calls list-panes after creating to get pane PID", async () => {
124
+ let callCount = 0;
125
+ spawnSpy.mockImplementation(() => {
126
+ callCount++;
127
+ if (callCount === 1) {
128
+ // which agentplate
129
+ return mockSpawnResult("/usr/local/bin/agentplate\n", "", 0);
130
+ }
131
+ if (callCount === 2) {
132
+ return mockSpawnResult("", "", 0);
133
+ }
134
+ return mockSpawnResult("7777\n", "", 0);
135
+ });
136
+
137
+ await createSession("test-agent", "/tmp", "ls");
138
+
139
+ // 3 calls: which agentplate, tmux new-session, tmux list-panes
140
+ expect(spawnSpy).toHaveBeenCalledTimes(3);
141
+ const thirdCallArgs = spawnSpy.mock.calls[2] as unknown[];
142
+ const cmd = thirdCallArgs[0] as string[];
143
+ expect(cmd).toEqual([
144
+ "tmux",
145
+ "-L",
146
+ "agentplate",
147
+ "list-panes",
148
+ "-t",
149
+ "test-agent",
150
+ "-F",
151
+ "#{pane_pid}",
152
+ ]);
153
+ });
154
+
155
+ test("throws AgentError if session creation fails", async () => {
156
+ let callCount = 0;
157
+ spawnSpy.mockImplementation(() => {
158
+ callCount++;
159
+ if (callCount === 1) {
160
+ // which agentplate
161
+ return mockSpawnResult("/usr/local/bin/agentplate\n", "", 0);
162
+ }
163
+ return mockSpawnResult("", "duplicate session: my-session", 1);
164
+ });
165
+
166
+ await expect(createSession("my-session", "/tmp", "ls")).rejects.toThrow(AgentError);
167
+ });
168
+
169
+ test("throws AgentError if list-panes fails after creation", async () => {
170
+ let callCount = 0;
171
+ spawnSpy.mockImplementation(() => {
172
+ callCount++;
173
+ if (callCount === 1) {
174
+ // which agentplate
175
+ return mockSpawnResult("/usr/local/bin/agentplate\n", "", 0);
176
+ }
177
+ if (callCount === 2) {
178
+ // new-session succeeds
179
+ return mockSpawnResult("", "", 0);
180
+ }
181
+ // list-panes fails
182
+ return mockSpawnResult("", "error listing panes", 1);
183
+ });
184
+
185
+ await expect(createSession("my-session", "/tmp", "ls")).rejects.toThrow(AgentError);
186
+ });
187
+
188
+ test("throws AgentError if pane PID output is empty", async () => {
189
+ let callCount = 0;
190
+ spawnSpy.mockImplementation(() => {
191
+ callCount++;
192
+ if (callCount === 1) {
193
+ // which agentplate
194
+ return mockSpawnResult("/usr/local/bin/agentplate\n", "", 0);
195
+ }
196
+ if (callCount === 2) {
197
+ return mockSpawnResult("", "", 0);
198
+ }
199
+ // list-panes returns empty output
200
+ return mockSpawnResult("", "", 0);
201
+ });
202
+
203
+ await expect(createSession("my-session", "/tmp", "ls")).rejects.toThrow(AgentError);
204
+ });
205
+
206
+ test("AgentError includes session name context", async () => {
207
+ let callCount = 0;
208
+ spawnSpy.mockImplementation(() => {
209
+ callCount++;
210
+ if (callCount === 1) {
211
+ // which agentplate
212
+ return mockSpawnResult("/usr/local/bin/agentplate\n", "", 0);
213
+ }
214
+ return mockSpawnResult("", "duplicate session: agent-foo", 1);
215
+ });
216
+
217
+ try {
218
+ await createSession("agent-foo", "/tmp", "ls");
219
+ expect(true).toBe(false);
220
+ } catch (err: unknown) {
221
+ expect(err).toBeInstanceOf(AgentError);
222
+ const agentErr = err as AgentError;
223
+ expect(agentErr.message).toContain("agent-foo");
224
+ expect(agentErr.agentName).toBe("agent-foo");
225
+ }
226
+ });
227
+
228
+ test("still creates session when which ap and which agentplate both fail (uses fallback)", async () => {
229
+ let callCount = 0;
230
+ spawnSpy.mockImplementation(() => {
231
+ callCount++;
232
+ if (callCount === 1) {
233
+ // which ap fails
234
+ return mockSpawnResult("", "ap not found", 1);
235
+ }
236
+ if (callCount === 2) {
237
+ // which agentplate fails
238
+ return mockSpawnResult("", "agentplate not found", 1);
239
+ }
240
+ if (callCount === 3) {
241
+ // tmux new-session
242
+ return mockSpawnResult("", "", 0);
243
+ }
244
+ // tmux list-panes
245
+ return mockSpawnResult("5555\n", "", 0);
246
+ });
247
+
248
+ const pid = await createSession("fallback-agent", "/tmp", "echo test");
249
+ expect(pid).toBe(5555);
250
+
251
+ // The tmux command should contain the original command
252
+ // Call 0: which ap, Call 1: which agentplate, Call 2: tmux new-session
253
+ const tmuxCallArgs = spawnSpy.mock.calls[2] as unknown[];
254
+ const cmd = tmuxCallArgs[0] as string[];
255
+ const tmuxCmd = cmd[9] as string;
256
+ expect(tmuxCmd).toContain("echo test");
257
+ });
258
+
259
+ test("retries list-panes on transient failure", async () => {
260
+ let callCount = 0;
261
+ spawnSpy.mockImplementation(() => {
262
+ callCount++;
263
+ if (callCount === 1) {
264
+ // which agentplate
265
+ return mockSpawnResult("/usr/local/bin/agentplate\n", "", 0);
266
+ }
267
+ if (callCount === 2) {
268
+ // tmux new-session
269
+ return mockSpawnResult("", "", 0);
270
+ }
271
+ if (callCount === 3) {
272
+ // First list-panes fails (WSL2 race)
273
+ return mockSpawnResult("", "can't find pane\n", 1);
274
+ }
275
+ // Second list-panes succeeds
276
+ return mockSpawnResult("42\n", "", 0);
277
+ });
278
+
279
+ const pid = await createSession("retry-session", "/work/dir", "echo hello");
280
+ expect(pid).toBe(42);
281
+ // which + new-session + list-panes(fail) + list-panes(ok)
282
+ expect(spawnSpy).toHaveBeenCalledTimes(4);
283
+ });
284
+
285
+ test("throws after exhausting all list-panes retries", async () => {
286
+ let callCount = 0;
287
+ spawnSpy.mockImplementation(() => {
288
+ callCount++;
289
+ if (callCount === 1) {
290
+ // which agentplate
291
+ return mockSpawnResult("/usr/local/bin/agentplate\n", "", 0);
292
+ }
293
+ if (callCount === 2) {
294
+ // tmux new-session
295
+ return mockSpawnResult("", "", 0);
296
+ }
297
+ // All list-panes attempts fail
298
+ return mockSpawnResult("", "can't find pane\n", 1);
299
+ });
300
+
301
+ await expect(
302
+ createSession("retry-exhaust", "/work/dir", "echo hello", undefined, 2),
303
+ ).rejects.toThrow(/failed to retrieve PID/);
304
+ });
305
+ });
306
+
307
+ describe("listSessions", () => {
308
+ let spawnSpy: ReturnType<typeof spyOn>;
309
+
310
+ beforeEach(() => {
311
+ spawnSpy = spyOn(Bun, "spawn");
312
+ });
313
+
314
+ afterEach(() => {
315
+ spawnSpy.mockRestore();
316
+ });
317
+
318
+ test("parses session list output", async () => {
319
+ spawnSpy.mockImplementation(() =>
320
+ mockSpawnResult("agentplate-auth:42\nagentplate-data:99\n", "", 0),
321
+ );
322
+
323
+ const sessions = await listSessions();
324
+
325
+ expect(sessions).toHaveLength(2);
326
+ expect(sessions[0]?.name).toBe("agentplate-auth");
327
+ expect(sessions[0]?.pid).toBe(42);
328
+ expect(sessions[1]?.name).toBe("agentplate-data");
329
+ expect(sessions[1]?.pid).toBe(99);
330
+ });
331
+
332
+ test("returns empty array when no server running", async () => {
333
+ spawnSpy.mockImplementation(() =>
334
+ mockSpawnResult("", "no server running on /tmp/tmux-501/default", 1),
335
+ );
336
+
337
+ const sessions = await listSessions();
338
+
339
+ expect(sessions).toHaveLength(0);
340
+ });
341
+
342
+ test("returns empty array when 'no sessions' in stderr", async () => {
343
+ spawnSpy.mockImplementation(() => mockSpawnResult("", "no sessions", 1));
344
+
345
+ const sessions = await listSessions();
346
+
347
+ expect(sessions).toHaveLength(0);
348
+ });
349
+
350
+ test("throws AgentError on other tmux failures", async () => {
351
+ spawnSpy.mockImplementation(() => mockSpawnResult("", "protocol version mismatch", 1));
352
+
353
+ await expect(listSessions()).rejects.toThrow(AgentError);
354
+ });
355
+
356
+ test("skips malformed lines", async () => {
357
+ spawnSpy.mockImplementation(() =>
358
+ mockSpawnResult("valid-session:123\nmalformed-no-colon\n:no-name\n\n", "", 0),
359
+ );
360
+
361
+ const sessions = await listSessions();
362
+
363
+ expect(sessions).toHaveLength(1);
364
+ expect(sessions[0]?.name).toBe("valid-session");
365
+ expect(sessions[0]?.pid).toBe(123);
366
+ });
367
+
368
+ test("passes correct args to tmux", async () => {
369
+ spawnSpy.mockImplementation(() => mockSpawnResult("", "", 0));
370
+
371
+ await listSessions();
372
+
373
+ expect(spawnSpy).toHaveBeenCalledTimes(1);
374
+ const callArgs = spawnSpy.mock.calls[0] as unknown[];
375
+ const cmd = callArgs[0] as string[];
376
+ expect(cmd).toEqual([
377
+ "tmux",
378
+ "-L",
379
+ "agentplate",
380
+ "list-sessions",
381
+ "-F",
382
+ "#{session_name}:#{pid}",
383
+ ]);
384
+ });
385
+ });
386
+
387
+ describe("getPanePid", () => {
388
+ let spawnSpy: ReturnType<typeof spyOn>;
389
+
390
+ beforeEach(() => {
391
+ spawnSpy = spyOn(Bun, "spawn");
392
+ });
393
+
394
+ afterEach(() => {
395
+ spawnSpy.mockRestore();
396
+ });
397
+
398
+ test("returns PID from tmux display-message", async () => {
399
+ spawnSpy.mockImplementation(() => mockSpawnResult("42\n", "", 0));
400
+
401
+ const pid = await getPanePid("agentplate-auth");
402
+
403
+ expect(pid).toBe(42);
404
+ const callArgs = spawnSpy.mock.calls[0] as unknown[];
405
+ const cmd = callArgs[0] as string[];
406
+ expect(cmd).toEqual([
407
+ "tmux",
408
+ "-L",
409
+ "agentplate",
410
+ "display-message",
411
+ "-p",
412
+ "-t",
413
+ "agentplate-auth",
414
+ "#{pane_pid}",
415
+ ]);
416
+ });
417
+
418
+ test("returns null when session does not exist", async () => {
419
+ spawnSpy.mockImplementation(() => mockSpawnResult("", "can't find session: gone", 1));
420
+
421
+ const pid = await getPanePid("gone");
422
+
423
+ expect(pid).toBeNull();
424
+ });
425
+
426
+ test("returns null when output is empty", async () => {
427
+ spawnSpy.mockImplementation(() => mockSpawnResult("", "", 0));
428
+
429
+ const pid = await getPanePid("empty-output");
430
+
431
+ expect(pid).toBeNull();
432
+ });
433
+
434
+ test("returns null when output is not a number", async () => {
435
+ spawnSpy.mockImplementation(() => mockSpawnResult("not-a-pid\n", "", 0));
436
+
437
+ const pid = await getPanePid("bad-output");
438
+
439
+ expect(pid).toBeNull();
440
+ });
441
+ });
442
+
443
+ describe("getDescendantPids", () => {
444
+ let spawnSpy: ReturnType<typeof spyOn>;
445
+
446
+ beforeEach(() => {
447
+ spawnSpy = spyOn(Bun, "spawn");
448
+ });
449
+
450
+ afterEach(() => {
451
+ spawnSpy.mockRestore();
452
+ });
453
+
454
+ test("returns empty array when process has no children", async () => {
455
+ spawnSpy.mockImplementation(() => mockSpawnResult("", "", 1));
456
+
457
+ const pids = await getDescendantPids(100);
458
+
459
+ expect(pids).toEqual([]);
460
+ });
461
+
462
+ test("returns direct children when they have no grandchildren", async () => {
463
+ let callCount = 0;
464
+ spawnSpy.mockImplementation(() => {
465
+ callCount++;
466
+ if (callCount === 1) {
467
+ // pgrep -P 100 → children 200, 300
468
+ return mockSpawnResult("200\n300\n", "", 0);
469
+ }
470
+ // pgrep -P 200 and pgrep -P 300 → no grandchildren
471
+ return mockSpawnResult("", "", 1);
472
+ });
473
+
474
+ const pids = await getDescendantPids(100);
475
+
476
+ expect(pids).toEqual([200, 300]);
477
+ });
478
+
479
+ test("returns descendants in depth-first order (deepest first)", async () => {
480
+ // Tree: 100 → 200 → 400
481
+ // → 300
482
+ let callCount = 0;
483
+ spawnSpy.mockImplementation(() => {
484
+ callCount++;
485
+ if (callCount === 1) {
486
+ // pgrep -P 100 → children 200, 300
487
+ return mockSpawnResult("200\n300\n", "", 0);
488
+ }
489
+ if (callCount === 2) {
490
+ // pgrep -P 200 → child 400
491
+ return mockSpawnResult("400\n", "", 0);
492
+ }
493
+ if (callCount === 3) {
494
+ // pgrep -P 400 → no children
495
+ return mockSpawnResult("", "", 1);
496
+ }
497
+ // pgrep -P 300 → no children
498
+ return mockSpawnResult("", "", 1);
499
+ });
500
+
501
+ const pids = await getDescendantPids(100);
502
+
503
+ // Deepest-first: 400 (grandchild), then 200, 300 (direct children)
504
+ expect(pids).toEqual([400, 200, 300]);
505
+ });
506
+
507
+ test("handles deeply nested tree", async () => {
508
+ // Tree: 1 → 2 → 3 → 4
509
+ let callCount = 0;
510
+ spawnSpy.mockImplementation(() => {
511
+ callCount++;
512
+ if (callCount === 1) {
513
+ // pgrep -P 1 → 2
514
+ return mockSpawnResult("2\n", "", 0);
515
+ }
516
+ if (callCount === 2) {
517
+ // pgrep -P 2 → 3
518
+ return mockSpawnResult("3\n", "", 0);
519
+ }
520
+ if (callCount === 3) {
521
+ // pgrep -P 3 → 4
522
+ return mockSpawnResult("4\n", "", 0);
523
+ }
524
+ // pgrep -P 4 → no children
525
+ return mockSpawnResult("", "", 1);
526
+ });
527
+
528
+ const pids = await getDescendantPids(1);
529
+
530
+ // Deepest-first: 4, 3, 2
531
+ expect(pids).toEqual([4, 3, 2]);
532
+ });
533
+
534
+ test("skips non-numeric pgrep output lines", async () => {
535
+ spawnSpy.mockImplementation((...args: unknown[]) => {
536
+ const cmd = (args[0] as string[])[2];
537
+ if (cmd === "100") {
538
+ return mockSpawnResult("200\nnot-a-pid\n300\n", "", 0);
539
+ }
540
+ return mockSpawnResult("", "", 1);
541
+ });
542
+
543
+ const pids = await getDescendantPids(100);
544
+
545
+ expect(pids).toEqual([200, 300]);
546
+ });
547
+ });
548
+
549
+ describe("isProcessAlive", () => {
550
+ test("returns true for current process (self-check)", () => {
551
+ // process.pid is always alive
552
+ expect(isProcessAlive(process.pid)).toBe(true);
553
+ });
554
+
555
+ test("returns false for a non-existent PID", () => {
556
+ // PID 2147483647 (max int32) is extremely unlikely to exist
557
+ expect(isProcessAlive(2147483647)).toBe(false);
558
+ });
559
+ });
560
+
561
+ describe("killProcessTree", () => {
562
+ let spawnSpy: ReturnType<typeof spyOn>;
563
+ let killSpy: ReturnType<typeof spyOn>;
564
+
565
+ beforeEach(() => {
566
+ spawnSpy = spyOn(Bun, "spawn");
567
+ killSpy = spyOn(process, "kill");
568
+ });
569
+
570
+ afterEach(() => {
571
+ spawnSpy.mockRestore();
572
+ killSpy.mockRestore();
573
+ });
574
+
575
+ test("sends SIGTERM to root when no descendants", async () => {
576
+ // pgrep -P 100 → no children
577
+ spawnSpy.mockImplementation(() => mockSpawnResult("", "", 1));
578
+ killSpy.mockImplementation(() => true);
579
+
580
+ await killProcessTree(100, 0);
581
+
582
+ expect(killSpy).toHaveBeenCalledWith(100, "SIGTERM");
583
+ });
584
+
585
+ test("sends SIGTERM deepest-first then SIGKILL survivors", async () => {
586
+ // Tree: 100 → 200 → 300
587
+ let pgrepCallCount = 0;
588
+ spawnSpy.mockImplementation(() => {
589
+ pgrepCallCount++;
590
+ if (pgrepCallCount === 1) {
591
+ // pgrep -P 100 → 200
592
+ return mockSpawnResult("200\n", "", 0);
593
+ }
594
+ if (pgrepCallCount === 2) {
595
+ // pgrep -P 200 → 300
596
+ return mockSpawnResult("300\n", "", 0);
597
+ }
598
+ // pgrep -P 300 → no children
599
+ return mockSpawnResult("", "", 1);
600
+ });
601
+
602
+ const signals: Array<{ pid: number; signal: string }> = [];
603
+ killSpy.mockImplementation((pid: number, signal: string | number) => {
604
+ signals.push({ pid, signal: String(signal) });
605
+ return true;
606
+ });
607
+
608
+ await killProcessTree(100, 0);
609
+
610
+ // Phase 1 (SIGTERM): deepest-first → 300, 200, then root 100
611
+ // Phase 2 (SIGKILL): isProcessAlive check (signal 0), then SIGKILL for survivors
612
+ const sigterms = signals.filter((s) => s.signal === "SIGTERM");
613
+ expect(sigterms).toEqual([
614
+ { pid: 300, signal: "SIGTERM" },
615
+ { pid: 200, signal: "SIGTERM" },
616
+ { pid: 100, signal: "SIGTERM" },
617
+ ]);
618
+ });
619
+
620
+ test("sends SIGKILL to survivors after grace period", async () => {
621
+ // Tree: 100 → 200 (no grandchildren)
622
+ let pgrepCallCount = 0;
623
+ spawnSpy.mockImplementation(() => {
624
+ pgrepCallCount++;
625
+ if (pgrepCallCount === 1) {
626
+ return mockSpawnResult("200\n", "", 0);
627
+ }
628
+ return mockSpawnResult("", "", 1);
629
+ });
630
+
631
+ const signals: Array<{ pid: number; signal: string | number }> = [];
632
+ killSpy.mockImplementation((pid: number, signal: string | number) => {
633
+ signals.push({ pid, signal });
634
+ // signal 0 is the isProcessAlive check — simulate processes still alive
635
+ return true;
636
+ });
637
+
638
+ await killProcessTree(100, 10); // 10ms grace period for test speed
639
+
640
+ // Should have: SIGTERM(200), SIGTERM(100), alive-check(200), SIGKILL(200),
641
+ // alive-check(100), SIGKILL(100)
642
+ const sigkills = signals.filter((s) => s.signal === "SIGKILL");
643
+ expect(sigkills.length).toBe(2);
644
+ expect(sigkills[0]).toEqual({ pid: 200, signal: "SIGKILL" });
645
+ expect(sigkills[1]).toEqual({ pid: 100, signal: "SIGKILL" });
646
+ });
647
+
648
+ test("skips SIGKILL for processes that died during grace period", async () => {
649
+ // No children
650
+ spawnSpy.mockImplementation(() => mockSpawnResult("200\n", "", 0));
651
+ // First call for pgrep children of 200
652
+ let pgrepCallCount = 0;
653
+ spawnSpy.mockImplementation(() => {
654
+ pgrepCallCount++;
655
+ if (pgrepCallCount === 1) {
656
+ return mockSpawnResult("200\n", "", 0);
657
+ }
658
+ return mockSpawnResult("", "", 1);
659
+ });
660
+
661
+ const signals: Array<{ pid: number; signal: string | number }> = [];
662
+ killSpy.mockImplementation((pid: number, signal: string | number) => {
663
+ signals.push({ pid, signal });
664
+ // signal 0 (isProcessAlive) — processes are dead
665
+ if (signal === 0) {
666
+ throw new Error("ESRCH");
667
+ }
668
+ return true;
669
+ });
670
+
671
+ await killProcessTree(100, 10);
672
+
673
+ // Should have SIGTERM calls but no SIGKILL (processes died)
674
+ const sigkills = signals.filter((s) => s.signal === "SIGKILL");
675
+ expect(sigkills).toEqual([]);
676
+ });
677
+
678
+ test("silently handles SIGTERM errors for already-dead processes", async () => {
679
+ // No children
680
+ spawnSpy.mockImplementation(() => mockSpawnResult("", "", 1));
681
+
682
+ killSpy.mockImplementation(() => {
683
+ throw new Error("ESRCH: No such process");
684
+ });
685
+
686
+ // Should not throw
687
+ await killProcessTree(100, 0);
688
+ });
689
+ });
690
+
691
+ describe("killSession", () => {
692
+ let spawnSpy: ReturnType<typeof spyOn>;
693
+ let killSpy: ReturnType<typeof spyOn>;
694
+
695
+ beforeEach(() => {
696
+ spawnSpy = spyOn(Bun, "spawn");
697
+ killSpy = spyOn(process, "kill");
698
+ });
699
+
700
+ afterEach(() => {
701
+ spawnSpy.mockRestore();
702
+ killSpy.mockRestore();
703
+ });
704
+
705
+ test("gets pane PID, kills process tree, then kills tmux session", async () => {
706
+ const cmds: string[][] = [];
707
+ spawnSpy.mockImplementation((...args: unknown[]) => {
708
+ const cmd = args[0] as string[];
709
+ cmds.push(cmd);
710
+
711
+ if (cmd[0] === "tmux" && cmd[3] === "display-message") {
712
+ // getPanePid → returns PID 500
713
+ return mockSpawnResult("500\n", "", 0);
714
+ }
715
+ if (cmd[0] === "pgrep") {
716
+ // getDescendantPids → no children
717
+ return mockSpawnResult("", "", 1);
718
+ }
719
+ if (cmd[0] === "tmux" && cmd[3] === "kill-session") {
720
+ return mockSpawnResult("", "", 0);
721
+ }
722
+ return mockSpawnResult("", "", 0);
723
+ });
724
+
725
+ killSpy.mockImplementation(() => true);
726
+
727
+ await killSession("agentplate-auth");
728
+
729
+ // Should have called: tmux display-message, pgrep, tmux kill-session
730
+ expect(cmds[0]).toEqual([
731
+ "tmux",
732
+ "-L",
733
+ "agentplate",
734
+ "display-message",
735
+ "-p",
736
+ "-t",
737
+ "agentplate-auth",
738
+ "#{pane_pid}",
739
+ ]);
740
+ expect(cmds[1]).toEqual(["pgrep", "-P", "500"]);
741
+ const lastCmd = cmds[cmds.length - 1];
742
+ expect(lastCmd).toEqual(["tmux", "-L", "agentplate", "kill-session", "-t", "agentplate-auth"]);
743
+
744
+ // Should have sent SIGTERM to root PID 500
745
+ expect(killSpy).toHaveBeenCalledWith(500, "SIGTERM");
746
+ });
747
+
748
+ test("skips process cleanup when pane PID is not available", async () => {
749
+ const cmds: string[][] = [];
750
+ spawnSpy.mockImplementation((...args: unknown[]) => {
751
+ const cmd = args[0] as string[];
752
+ cmds.push(cmd);
753
+
754
+ if (cmd[0] === "tmux" && cmd[3] === "display-message") {
755
+ // getPanePid → session not found
756
+ return mockSpawnResult("", "can't find session", 1);
757
+ }
758
+ if (cmd[0] === "tmux" && cmd[3] === "kill-session") {
759
+ return mockSpawnResult("", "", 0);
760
+ }
761
+ return mockSpawnResult("", "", 0);
762
+ });
763
+
764
+ await killSession("agentplate-auth");
765
+
766
+ // Should go straight to tmux kill-session (no pgrep calls)
767
+ expect(cmds).toHaveLength(2);
768
+ expect(cmds[0]?.[3]).toBe("display-message");
769
+ expect(cmds[1]?.[3]).toBe("kill-session");
770
+ // No process.kill calls since we had no PID
771
+ expect(killSpy).not.toHaveBeenCalled();
772
+ });
773
+
774
+ test("succeeds silently when session is already gone after process cleanup", async () => {
775
+ spawnSpy.mockImplementation((...args: unknown[]) => {
776
+ const cmd = args[0] as string[];
777
+ if (cmd[0] === "tmux" && cmd[3] === "display-message") {
778
+ return mockSpawnResult("500\n", "", 0);
779
+ }
780
+ if (cmd[0] === "pgrep") {
781
+ return mockSpawnResult("", "", 1);
782
+ }
783
+ if (cmd[0] === "tmux" && cmd[3] === "kill-session") {
784
+ // Session already gone after process cleanup
785
+ return mockSpawnResult("", "can't find session: agentplate-auth", 1);
786
+ }
787
+ return mockSpawnResult("", "", 0);
788
+ });
789
+
790
+ killSpy.mockImplementation(() => true);
791
+
792
+ // Should not throw — session disappearing is expected
793
+ await killSession("agentplate-auth");
794
+ });
795
+
796
+ test("throws AgentError on unexpected tmux kill-session failure", async () => {
797
+ spawnSpy.mockImplementation((...args: unknown[]) => {
798
+ const cmd = args[0] as string[];
799
+ if (cmd[0] === "tmux" && cmd[3] === "display-message") {
800
+ return mockSpawnResult("", "can't find session", 1);
801
+ }
802
+ if (cmd[0] === "tmux" && cmd[3] === "kill-session") {
803
+ return mockSpawnResult("", "server exited unexpectedly", 1);
804
+ }
805
+ return mockSpawnResult("", "", 0);
806
+ });
807
+
808
+ await expect(killSession("broken-session")).rejects.toThrow(AgentError);
809
+ });
810
+
811
+ test("AgentError contains session name on failure", async () => {
812
+ spawnSpy.mockImplementation((...args: unknown[]) => {
813
+ const cmd = args[0] as string[];
814
+ if (cmd[0] === "tmux" && cmd[3] === "display-message") {
815
+ return mockSpawnResult("", "error", 1);
816
+ }
817
+ if (cmd[0] === "tmux" && cmd[3] === "kill-session") {
818
+ return mockSpawnResult("", "server exited unexpectedly", 1);
819
+ }
820
+ return mockSpawnResult("", "", 0);
821
+ });
822
+
823
+ try {
824
+ await killSession("ghost-agent");
825
+ expect(true).toBe(false);
826
+ } catch (err: unknown) {
827
+ expect(err).toBeInstanceOf(AgentError);
828
+ const agentErr = err as AgentError;
829
+ expect(agentErr.message).toContain("ghost-agent");
830
+ expect(agentErr.agentName).toBe("ghost-agent");
831
+ }
832
+ });
833
+
834
+ test("throws AgentError when called with empty session name", async () => {
835
+ // Defense in depth (agentplate-74ce): tmux's `-t` argument prefix-matches
836
+ // every session in the server when given an empty string. Without this
837
+ // guard a regression in any caller would wildcard-kill the entire
838
+ // agentplate swarm. spawn must NOT be invoked.
839
+ await expect(killSession("")).rejects.toThrow(AgentError);
840
+ expect(spawnSpy).not.toHaveBeenCalled();
841
+
842
+ try {
843
+ await killSession("");
844
+ } catch (err: unknown) {
845
+ const agentErr = err as AgentError;
846
+ expect(agentErr.message).toContain("wildcard");
847
+ }
848
+ });
849
+ });
850
+
851
+ describe("isSessionAlive", () => {
852
+ let spawnSpy: ReturnType<typeof spyOn>;
853
+
854
+ beforeEach(() => {
855
+ spawnSpy = spyOn(Bun, "spawn");
856
+ });
857
+
858
+ afterEach(() => {
859
+ spawnSpy.mockRestore();
860
+ });
861
+
862
+ test("returns true when session exists (exit 0)", async () => {
863
+ spawnSpy.mockImplementation(() => mockSpawnResult("", "", 0));
864
+
865
+ const alive = await isSessionAlive("agentplate-auth");
866
+
867
+ expect(alive).toBe(true);
868
+ });
869
+
870
+ test("returns false when session does not exist (non-zero exit)", async () => {
871
+ spawnSpy.mockImplementation(() => mockSpawnResult("", "can't find session: nonexistent", 1));
872
+
873
+ const alive = await isSessionAlive("nonexistent");
874
+
875
+ expect(alive).toBe(false);
876
+ });
877
+
878
+ test("passes correct args to tmux has-session", async () => {
879
+ spawnSpy.mockImplementation(() => mockSpawnResult("", "", 0));
880
+
881
+ await isSessionAlive("my-agent");
882
+
883
+ expect(spawnSpy).toHaveBeenCalledTimes(1);
884
+ const callArgs = spawnSpy.mock.calls[0] as unknown[];
885
+ const cmd = callArgs[0] as string[];
886
+ expect(cmd).toEqual(["tmux", "-L", "agentplate", "has-session", "-t", "my-agent"]);
887
+ });
888
+
889
+ test("returns false for empty session name without calling tmux", async () => {
890
+ // Defense in depth (agentplate-74ce): an empty `-t` argument prefix-matches
891
+ // every agentplate session, so `has-session` would falsely report alive
892
+ // whenever any agent is running. Short-circuit to false without invoking tmux.
893
+ const alive = await isSessionAlive("");
894
+ expect(alive).toBe(false);
895
+ expect(spawnSpy).not.toHaveBeenCalled();
896
+ });
897
+ });
898
+
899
+ describe("checkSessionState", () => {
900
+ let spawnSpy: ReturnType<typeof spyOn>;
901
+
902
+ beforeEach(() => {
903
+ spawnSpy = spyOn(Bun, "spawn");
904
+ });
905
+
906
+ afterEach(() => {
907
+ spawnSpy.mockRestore();
908
+ });
909
+
910
+ test("returns alive when tmux has-session succeeds", async () => {
911
+ spawnSpy.mockReturnValue(mockSpawnResult("", "", 0));
912
+ const state = await checkSessionState("agentplate-test-coordinator");
913
+ expect(state).toBe("alive");
914
+ });
915
+
916
+ test("returns no_server when tmux reports no server running", async () => {
917
+ spawnSpy.mockReturnValue(
918
+ mockSpawnResult("", "no server running on /tmp/tmux-1000/default\n", 1),
919
+ );
920
+ const state = await checkSessionState("agentplate-test-coordinator");
921
+ expect(state).toBe("no_server");
922
+ });
923
+
924
+ test("returns no_server when tmux reports no sessions", async () => {
925
+ spawnSpy.mockReturnValue(mockSpawnResult("", "no sessions\n", 1));
926
+ const state = await checkSessionState("agentplate-test-coordinator");
927
+ expect(state).toBe("no_server");
928
+ });
929
+
930
+ test("returns dead when session not found", async () => {
931
+ spawnSpy.mockReturnValue(
932
+ mockSpawnResult("", "can't find session: agentplate-test-coordinator\n", 1),
933
+ );
934
+ const state = await checkSessionState("agentplate-test-coordinator");
935
+ expect(state).toBe("dead");
936
+ });
937
+
938
+ test("returns dead for generic tmux failure", async () => {
939
+ spawnSpy.mockReturnValue(
940
+ mockSpawnResult("", "error connecting to /tmp/tmux-1000/default\n", 1),
941
+ );
942
+ const state = await checkSessionState("agentplate-test-coordinator");
943
+ expect(state).toBe("dead");
944
+ });
945
+ });
946
+
947
+ describe("sendKeys", () => {
948
+ let spawnSpy: ReturnType<typeof spyOn>;
949
+
950
+ beforeEach(() => {
951
+ spawnSpy = spyOn(Bun, "spawn");
952
+ });
953
+
954
+ afterEach(() => {
955
+ spawnSpy.mockRestore();
956
+ });
957
+
958
+ test("passes correct args to tmux send-keys", async () => {
959
+ spawnSpy.mockImplementation(() => mockSpawnResult("", "", 0));
960
+
961
+ await sendKeys("agentplate-auth", "echo hello world");
962
+
963
+ expect(spawnSpy).toHaveBeenCalledTimes(1);
964
+ const callArgs = spawnSpy.mock.calls[0] as unknown[];
965
+ const cmd = callArgs[0] as string[];
966
+ expect(cmd).toEqual([
967
+ "tmux",
968
+ "-L",
969
+ "agentplate",
970
+ "send-keys",
971
+ "-t",
972
+ "agentplate-auth",
973
+ "echo hello world",
974
+ "Enter",
975
+ ]);
976
+ });
977
+
978
+ test("flattens newlines in keys to spaces", async () => {
979
+ spawnSpy.mockImplementation(() => mockSpawnResult("", "", 0));
980
+
981
+ await sendKeys("agentplate-agent", "line1\nline2\nline3");
982
+
983
+ expect(spawnSpy).toHaveBeenCalledTimes(1);
984
+ const callArgs = spawnSpy.mock.calls[0] as unknown[];
985
+ const cmd = callArgs[0] as string[];
986
+ expect(cmd).toEqual([
987
+ "tmux",
988
+ "-L",
989
+ "agentplate",
990
+ "send-keys",
991
+ "-t",
992
+ "agentplate-agent",
993
+ "line1 line2 line3",
994
+ "Enter",
995
+ ]);
996
+ });
997
+
998
+ test("throws AgentError on failure", async () => {
999
+ spawnSpy.mockImplementation(() => mockSpawnResult("", "session not found: dead-agent", 1));
1000
+
1001
+ await expect(sendKeys("dead-agent", "echo test")).rejects.toThrow(AgentError);
1002
+ });
1003
+
1004
+ test("AgentError contains session name on failure", async () => {
1005
+ spawnSpy.mockImplementation(() => mockSpawnResult("", "session not found: my-agent", 1));
1006
+
1007
+ try {
1008
+ await sendKeys("my-agent", "test command");
1009
+ expect(true).toBe(false);
1010
+ } catch (err: unknown) {
1011
+ expect(err).toBeInstanceOf(AgentError);
1012
+ const agentErr = err as AgentError;
1013
+ expect(agentErr.message).toContain("my-agent");
1014
+ expect(agentErr.agentName).toBe("my-agent");
1015
+ }
1016
+ });
1017
+
1018
+ test("sends Enter with empty string (follow-up submission)", async () => {
1019
+ spawnSpy.mockImplementation(() => mockSpawnResult("", "", 0));
1020
+
1021
+ await sendKeys("agentplate-agent", "");
1022
+
1023
+ expect(spawnSpy).toHaveBeenCalledTimes(1);
1024
+ const callArgs = spawnSpy.mock.calls[0] as unknown[];
1025
+ const cmd = callArgs[0] as string[];
1026
+ expect(cmd).toEqual([
1027
+ "tmux",
1028
+ "-L",
1029
+ "agentplate",
1030
+ "send-keys",
1031
+ "-t",
1032
+ "agentplate-agent",
1033
+ "",
1034
+ "Enter",
1035
+ ]);
1036
+ });
1037
+
1038
+ test("throws descriptive error when tmux server is not running", async () => {
1039
+ spawnSpy.mockImplementation(() =>
1040
+ mockSpawnResult("", "no server running on /tmp/tmux-0/default\n", 1),
1041
+ );
1042
+ await expect(sendKeys("agentplate-agent-fake", "hello")).rejects.toThrow(
1043
+ /Tmux server is not running/,
1044
+ );
1045
+ });
1046
+
1047
+ test("throws descriptive error when session not found", async () => {
1048
+ spawnSpy.mockImplementation(() =>
1049
+ mockSpawnResult("", "cant find session: agentplate-agent-fake\n", 1),
1050
+ );
1051
+ await expect(sendKeys("agentplate-agent-fake", "hello")).rejects.toThrow(/does not exist/);
1052
+ });
1053
+
1054
+ test("throws generic error for other failures", async () => {
1055
+ spawnSpy.mockImplementation(() => mockSpawnResult("", "some other error\n", 1));
1056
+ await expect(sendKeys("agentplate-agent-fake", "hello")).rejects.toThrow(/Failed to send keys/);
1057
+ });
1058
+
1059
+ test("retries on transient 'can't find pane' error", async () => {
1060
+ let callCount = 0;
1061
+ spawnSpy.mockImplementation(() => {
1062
+ callCount++;
1063
+ if (callCount === 1) {
1064
+ // First send-keys fails with transient pane error
1065
+ return mockSpawnResult("", "can't find pane\n", 1);
1066
+ }
1067
+ // Second attempt succeeds
1068
+ return mockSpawnResult("", "", 0);
1069
+ });
1070
+
1071
+ await sendKeys("agentplate-retry-agent", "hello world");
1072
+ expect(spawnSpy).toHaveBeenCalledTimes(2);
1073
+ });
1074
+
1075
+ test("does not retry on permanent 'session not found' error", async () => {
1076
+ spawnSpy.mockImplementation(() => mockSpawnResult("", "cant find session: gone-agent\n", 1));
1077
+
1078
+ await expect(sendKeys("gone-agent", "hello", 3)).rejects.toThrow(/does not exist/);
1079
+ // Only called once — no retries for permanent errors
1080
+ expect(spawnSpy).toHaveBeenCalledTimes(1);
1081
+ });
1082
+
1083
+ test("throws after exhausting retries on transient error", async () => {
1084
+ spawnSpy.mockImplementation(() => mockSpawnResult("", "can't find pane\n", 1));
1085
+
1086
+ await expect(sendKeys("agentplate-exhaust", "hello", 2)).rejects.toThrow(/not found after/);
1087
+ // Initial + 2 retries = 3 calls
1088
+ expect(spawnSpy).toHaveBeenCalledTimes(3);
1089
+ });
1090
+ });
1091
+
1092
+ describe("capturePaneContent", () => {
1093
+ let spawnSpy: ReturnType<typeof spyOn>;
1094
+
1095
+ beforeEach(() => {
1096
+ spawnSpy = spyOn(Bun, "spawn");
1097
+ });
1098
+
1099
+ afterEach(() => {
1100
+ spawnSpy.mockRestore();
1101
+ });
1102
+
1103
+ test("returns trimmed content on success", async () => {
1104
+ spawnSpy.mockImplementation(() => mockSpawnResult(" Welcome to Claude Code! \n\n", "", 0));
1105
+
1106
+ const content = await capturePaneContent("agentplate-agent");
1107
+
1108
+ expect(content).toBe("Welcome to Claude Code!");
1109
+ });
1110
+
1111
+ test("passes correct args to tmux capture-pane", async () => {
1112
+ spawnSpy.mockImplementation(() => mockSpawnResult("some content", "", 0));
1113
+
1114
+ await capturePaneContent("my-session", 100);
1115
+
1116
+ const callArgs = spawnSpy.mock.calls[0] as unknown[];
1117
+ const cmd = callArgs[0] as string[];
1118
+ expect(cmd).toEqual([
1119
+ "tmux",
1120
+ "-L",
1121
+ "agentplate",
1122
+ "capture-pane",
1123
+ "-t",
1124
+ "my-session",
1125
+ "-p",
1126
+ "-S",
1127
+ "-100",
1128
+ ]);
1129
+ });
1130
+
1131
+ test("uses default 50 lines when not specified", async () => {
1132
+ spawnSpy.mockImplementation(() => mockSpawnResult("content", "", 0));
1133
+
1134
+ await capturePaneContent("my-session");
1135
+
1136
+ const callArgs = spawnSpy.mock.calls[0] as unknown[];
1137
+ const cmd = callArgs[0] as string[];
1138
+ expect(cmd[8]).toBe("-50");
1139
+ });
1140
+
1141
+ test("returns null when capture-pane fails", async () => {
1142
+ spawnSpy.mockImplementation(() => mockSpawnResult("", "can't find session: gone", 1));
1143
+
1144
+ const content = await capturePaneContent("gone");
1145
+
1146
+ expect(content).toBeNull();
1147
+ });
1148
+
1149
+ test("returns null when pane is empty (whitespace only)", async () => {
1150
+ spawnSpy.mockImplementation(() => mockSpawnResult(" \n\n \n", "", 0));
1151
+
1152
+ const content = await capturePaneContent("empty-pane");
1153
+
1154
+ expect(content).toBeNull();
1155
+ });
1156
+ });
1157
+
1158
+ /** Claude-like detectReady for tests — matches the existing hardcoded behavior. */
1159
+ function claudeDetectReady(paneContent: string): ReadyState {
1160
+ if (
1161
+ paneContent.includes("WARNING: Claude Code running in Bypass Permissions mode") &&
1162
+ paneContent.includes("1. No, exit") &&
1163
+ paneContent.includes("2. Yes, I accept")
1164
+ ) {
1165
+ return { phase: "dialog", action: "type:2" };
1166
+ }
1167
+ if (paneContent.includes("trust this folder")) {
1168
+ return { phase: "dialog", action: "Enter" };
1169
+ }
1170
+ const hasPrompt = paneContent.includes("\u276f") || paneContent.includes('Try "');
1171
+ const hasStatusBar =
1172
+ paneContent.includes("bypass permissions") || paneContent.includes("shift+tab");
1173
+ if (hasPrompt && hasStatusBar) {
1174
+ return { phase: "ready" };
1175
+ }
1176
+ return { phase: "loading" };
1177
+ }
1178
+
1179
+ describe("waitForTuiReady", () => {
1180
+ let spawnSpy: ReturnType<typeof spyOn>;
1181
+ let sleepSpy: ReturnType<typeof spyOn>;
1182
+
1183
+ beforeEach(() => {
1184
+ spawnSpy = spyOn(Bun, "spawn");
1185
+ // Mock Bun.sleep to avoid real delays in tests.
1186
+ // Cast needed because Bun.sleep has overloads that confuse spyOn's type inference.
1187
+ sleepSpy = spyOn(Bun as Record<string, unknown>, "sleep").mockResolvedValue(undefined);
1188
+ });
1189
+
1190
+ afterEach(() => {
1191
+ spawnSpy.mockRestore();
1192
+ sleepSpy.mockRestore();
1193
+ });
1194
+
1195
+ test("returns true immediately when pane has content on first poll", async () => {
1196
+ spawnSpy.mockImplementation(() =>
1197
+ mockSpawnResult('Try "help" to get started\nbypass permissions', "", 0),
1198
+ );
1199
+
1200
+ const ready = await waitForTuiReady("agentplate-agent", claudeDetectReady, 5_000, 500);
1201
+
1202
+ expect(ready).toBe(true);
1203
+ // Should not have needed to sleep (content found on first poll)
1204
+ expect(sleepSpy).not.toHaveBeenCalled();
1205
+ });
1206
+
1207
+ test("returns true after content appears on later poll", async () => {
1208
+ let captureCallCount = 0;
1209
+ spawnSpy.mockImplementation((...args: unknown[]) => {
1210
+ const cmd = args[0] as string[];
1211
+ if (cmd[3] === "capture-pane") {
1212
+ captureCallCount++;
1213
+ if (captureCallCount <= 3) {
1214
+ // First 3 capture-pane polls: empty pane (TUI still loading)
1215
+ return mockSpawnResult("", "", 0);
1216
+ }
1217
+ // 4th poll: content appears with both prompt indicator and status bar
1218
+ return mockSpawnResult("Welcome to Claude Code!\n\n\u276f\nbypass permissions", "", 0);
1219
+ }
1220
+ // has-session: session is alive throughout
1221
+ return mockSpawnResult("", "", 0);
1222
+ });
1223
+
1224
+ const ready = await waitForTuiReady("agentplate-agent", claudeDetectReady, 10_000, 500);
1225
+
1226
+ expect(ready).toBe(true);
1227
+ // Should have slept 3 times (3 empty capture-pane polls before content appeared)
1228
+ expect(sleepSpy).toHaveBeenCalledTimes(3);
1229
+ });
1230
+
1231
+ test("returns false when timeout expires without content", async () => {
1232
+ // Pane always empty
1233
+ spawnSpy.mockImplementation(() => mockSpawnResult("", "", 0));
1234
+
1235
+ const ready = await waitForTuiReady("agentplate-agent", claudeDetectReady, 2_000, 500);
1236
+
1237
+ expect(ready).toBe(false);
1238
+ // 2000ms / 500ms = 4 polls, 4 sleeps
1239
+ expect(sleepSpy).toHaveBeenCalledTimes(4);
1240
+ });
1241
+
1242
+ test("returns false when capture-pane always fails", async () => {
1243
+ spawnSpy.mockImplementation(() => mockSpawnResult("", "session not found", 1));
1244
+
1245
+ const ready = await waitForTuiReady("dead-session", claudeDetectReady, 1_000, 500);
1246
+
1247
+ expect(ready).toBe(false);
1248
+ });
1249
+
1250
+ test("uses default timeout and poll interval", async () => {
1251
+ // Return content immediately with both indicators
1252
+ spawnSpy.mockImplementation(() => mockSpawnResult('Try "help"\nshift+tab', "", 0));
1253
+
1254
+ const ready = await waitForTuiReady("agentplate-agent", claudeDetectReady);
1255
+
1256
+ expect(ready).toBe(true);
1257
+ });
1258
+
1259
+ test("returns false immediately when session is dead", async () => {
1260
+ // capture-pane fails (session dead), has-session also fails (session dead)
1261
+ spawnSpy.mockImplementation((...args: unknown[]) => {
1262
+ const cmd = args[0] as string[];
1263
+ if (cmd[3] === "capture-pane") {
1264
+ return mockSpawnResult("", "can't find session", 1);
1265
+ }
1266
+ // has-session: session is dead
1267
+ return mockSpawnResult("", "can't find session", 1);
1268
+ });
1269
+
1270
+ const ready = await waitForTuiReady("dead-session", claudeDetectReady, 15_000, 500);
1271
+
1272
+ expect(ready).toBe(false);
1273
+ // Should NOT have polled the full timeout (no sleeps — returned immediately)
1274
+ expect(sleepSpy).not.toHaveBeenCalled();
1275
+ });
1276
+
1277
+ test("continues polling when session is alive but pane is empty", async () => {
1278
+ let captureCallCount = 0;
1279
+ spawnSpy.mockImplementation((...args: unknown[]) => {
1280
+ const cmd = args[0] as string[];
1281
+ if (cmd[3] === "capture-pane") {
1282
+ captureCallCount++;
1283
+ // Pane stays empty for all polls (session alive but TUI not rendered yet)
1284
+ return mockSpawnResult("", "", 0);
1285
+ }
1286
+ // has-session: session is alive
1287
+ return mockSpawnResult("", "", 0);
1288
+ });
1289
+
1290
+ // Use a short timeout so the test doesn't take long
1291
+ const ready = await waitForTuiReady("loading-session", claudeDetectReady, 1_000, 500);
1292
+
1293
+ expect(ready).toBe(false);
1294
+ // Should have polled multiple times (not returned early)
1295
+ expect(captureCallCount).toBeGreaterThan(1);
1296
+ expect(sleepSpy).toHaveBeenCalled();
1297
+ });
1298
+
1299
+ test("returns false when only prompt seen but no status bar", async () => {
1300
+ // Pane always shows prompt indicator but never shows status bar text
1301
+ spawnSpy.mockImplementation((...args: unknown[]) => {
1302
+ const cmd = args[0] as string[];
1303
+ if (cmd[3] === "capture-pane") {
1304
+ return mockSpawnResult("Welcome to Claude Code!\n\u276f", "", 0);
1305
+ }
1306
+ // has-session: session is alive
1307
+ return mockSpawnResult("", "", 0);
1308
+ });
1309
+
1310
+ const ready = await waitForTuiReady("agentplate-agent", claudeDetectReady, 1_000, 500);
1311
+
1312
+ expect(ready).toBe(false);
1313
+ });
1314
+
1315
+ test("returns false when only status bar seen but no prompt", async () => {
1316
+ // Pane always shows status bar but never shows prompt indicator
1317
+ spawnSpy.mockImplementation((...args: unknown[]) => {
1318
+ const cmd = args[0] as string[];
1319
+ if (cmd[3] === "capture-pane") {
1320
+ return mockSpawnResult("bypass permissions", "", 0);
1321
+ }
1322
+ // has-session: session is alive
1323
+ return mockSpawnResult("", "", 0);
1324
+ });
1325
+
1326
+ const ready = await waitForTuiReady("agentplate-agent", claudeDetectReady, 1_000, 500);
1327
+
1328
+ expect(ready).toBe(false);
1329
+ });
1330
+
1331
+ test("returns true when prompt and status bar appear on different polls", async () => {
1332
+ let captureCallCount = 0;
1333
+ spawnSpy.mockImplementation((...args: unknown[]) => {
1334
+ const cmd = args[0] as string[];
1335
+ if (cmd[3] === "capture-pane") {
1336
+ captureCallCount++;
1337
+ if (captureCallCount <= 2) {
1338
+ // First 2 polls: only prompt indicator visible (phase 1 only)
1339
+ return mockSpawnResult("Welcome to Claude Code!\n\u276f", "", 0);
1340
+ }
1341
+ // 3rd poll onwards: both prompt and status bar visible
1342
+ return mockSpawnResult("Welcome to Claude Code!\n\u276f\nbypass permissions", "", 0);
1343
+ }
1344
+ // has-session: session is alive
1345
+ return mockSpawnResult("", "", 0);
1346
+ });
1347
+
1348
+ const ready = await waitForTuiReady("agentplate-agent", claudeDetectReady, 10_000, 500);
1349
+
1350
+ expect(ready).toBe(true);
1351
+ // Should have slept at least twice (2 polls with only prompt before both appeared)
1352
+ expect(sleepSpy).toHaveBeenCalledTimes(2);
1353
+ });
1354
+
1355
+ test("detects trust dialog and auto-confirms with Enter", async () => {
1356
+ const sendKeysCalls: string[][] = [];
1357
+ let captureCallCount = 0;
1358
+ spawnSpy.mockImplementation((...args: unknown[]) => {
1359
+ const cmd = args[0] as string[];
1360
+ if (cmd[3] === "capture-pane") {
1361
+ captureCallCount++;
1362
+ if (captureCallCount === 1) {
1363
+ // First poll: trust dialog is showing
1364
+ return mockSpawnResult("Do you trust this folder?", "", 0);
1365
+ }
1366
+ // Subsequent polls: trust confirmed, real TUI with both indicators
1367
+ return mockSpawnResult('Try "help"\nshift+tab', "", 0);
1368
+ }
1369
+ if (cmd[3] === "send-keys") {
1370
+ sendKeysCalls.push(cmd);
1371
+ return mockSpawnResult("", "", 0);
1372
+ }
1373
+ // has-session: session is alive
1374
+ return mockSpawnResult("", "", 0);
1375
+ });
1376
+
1377
+ const ready = await waitForTuiReady("agentplate-agent", claudeDetectReady, 10_000, 500);
1378
+
1379
+ expect(ready).toBe(true);
1380
+ // sendKeys should have been called once to confirm the trust dialog
1381
+ expect(sendKeysCalls).toHaveLength(1);
1382
+ const trustCall = sendKeysCalls[0];
1383
+ expect(trustCall).toEqual([
1384
+ "tmux",
1385
+ "-L",
1386
+ "agentplate",
1387
+ "send-keys",
1388
+ "-t",
1389
+ "agentplate-agent",
1390
+ "",
1391
+ "Enter",
1392
+ ]);
1393
+ });
1394
+
1395
+ test("detects bypass permissions dialog and types 2 before Enter", async () => {
1396
+ const sendKeysCalls: string[][] = [];
1397
+ let captureCallCount = 0;
1398
+ spawnSpy.mockImplementation((...args: unknown[]) => {
1399
+ const cmd = args[0] as string[];
1400
+ if (cmd[3] === "capture-pane") {
1401
+ captureCallCount++;
1402
+ if (captureCallCount === 1) {
1403
+ return mockSpawnResult(
1404
+ "WARNING: Claude Code running in Bypass Permissions mode\n❯ 1. No, exit\n2. Yes, I accept",
1405
+ "",
1406
+ 0,
1407
+ );
1408
+ }
1409
+ return mockSpawnResult('Try "help"\nshift+tab', "", 0);
1410
+ }
1411
+ if (cmd[3] === "send-keys") {
1412
+ sendKeysCalls.push(cmd);
1413
+ return mockSpawnResult("", "", 0);
1414
+ }
1415
+ return mockSpawnResult("", "", 0);
1416
+ });
1417
+
1418
+ const ready = await waitForTuiReady("agentplate-agent", claudeDetectReady, 10_000, 500);
1419
+
1420
+ expect(ready).toBe(true);
1421
+ expect(sendKeysCalls).toHaveLength(2);
1422
+ expect(sendKeysCalls[0]).toEqual([
1423
+ "tmux",
1424
+ "-L",
1425
+ "agentplate",
1426
+ "send-keys",
1427
+ "-t",
1428
+ "agentplate-agent",
1429
+ "2",
1430
+ ]);
1431
+ expect(sendKeysCalls[1]).toEqual([
1432
+ "tmux",
1433
+ "-L",
1434
+ "agentplate",
1435
+ "send-keys",
1436
+ "-t",
1437
+ "agentplate-agent",
1438
+ "",
1439
+ "Enter",
1440
+ ]);
1441
+ });
1442
+
1443
+ test("retries typed bypass dialog action when the same dialog persists", async () => {
1444
+ const sendKeysCalls: string[][] = [];
1445
+ let captureCallCount = 0;
1446
+ spawnSpy.mockImplementation((...args: unknown[]) => {
1447
+ const cmd = args[0] as string[];
1448
+ if (cmd[3] === "capture-pane") {
1449
+ captureCallCount++;
1450
+ if (captureCallCount <= 3) {
1451
+ return mockSpawnResult(
1452
+ "WARNING: Claude Code running in Bypass Permissions mode\n❯ 1. No, exit\n2. Yes, I accept",
1453
+ "",
1454
+ 0,
1455
+ );
1456
+ }
1457
+ return mockSpawnResult('Try "help"\nshift+tab', "", 0);
1458
+ }
1459
+ if (cmd[3] === "send-keys") {
1460
+ sendKeysCalls.push(cmd);
1461
+ return mockSpawnResult("", "", 0);
1462
+ }
1463
+ return mockSpawnResult("", "", 0);
1464
+ });
1465
+
1466
+ const ready = await waitForTuiReady("agentplate-agent", claudeDetectReady, 10_000, 500);
1467
+
1468
+ expect(ready).toBe(true);
1469
+ expect(sendKeysCalls).toHaveLength(4);
1470
+ expect(sendKeysCalls[0]).toEqual([
1471
+ "tmux",
1472
+ "-L",
1473
+ "agentplate",
1474
+ "send-keys",
1475
+ "-t",
1476
+ "agentplate-agent",
1477
+ "2",
1478
+ ]);
1479
+ expect(sendKeysCalls[1]).toEqual([
1480
+ "tmux",
1481
+ "-L",
1482
+ "agentplate",
1483
+ "send-keys",
1484
+ "-t",
1485
+ "agentplate-agent",
1486
+ "",
1487
+ "Enter",
1488
+ ]);
1489
+ expect(sendKeysCalls[2]).toEqual([
1490
+ "tmux",
1491
+ "-L",
1492
+ "agentplate",
1493
+ "send-keys",
1494
+ "-t",
1495
+ "agentplate-agent",
1496
+ "2",
1497
+ ]);
1498
+ expect(sendKeysCalls[3]).toEqual([
1499
+ "tmux",
1500
+ "-L",
1501
+ "agentplate",
1502
+ "send-keys",
1503
+ "-t",
1504
+ "agentplate-agent",
1505
+ "",
1506
+ "Enter",
1507
+ ]);
1508
+ });
1509
+
1510
+ test("handles trust dialog only once (trustHandled flag)", async () => {
1511
+ const sendKeysCalls: string[][] = [];
1512
+ let captureCallCount = 0;
1513
+ spawnSpy.mockImplementation((...args: unknown[]) => {
1514
+ const cmd = args[0] as string[];
1515
+ if (cmd[3] === "capture-pane") {
1516
+ captureCallCount++;
1517
+ if (captureCallCount <= 3) {
1518
+ // Multiple polls still show trust dialog (slow dialog dismissal)
1519
+ return mockSpawnResult("Do you trust this folder?", "", 0);
1520
+ }
1521
+ // Eventually TUI loads with both indicators
1522
+ return mockSpawnResult('Try "help"\nbypass permissions', "", 0);
1523
+ }
1524
+ if (cmd[3] === "send-keys") {
1525
+ sendKeysCalls.push(cmd);
1526
+ return mockSpawnResult("", "", 0);
1527
+ }
1528
+ // has-session: session is alive
1529
+ return mockSpawnResult("", "", 0);
1530
+ });
1531
+
1532
+ const ready = await waitForTuiReady("agentplate-agent", claudeDetectReady, 10_000, 500);
1533
+
1534
+ expect(ready).toBe(true);
1535
+ // sendKeys must be called exactly once — dialogHandled prevents duplicate Enter sends
1536
+ expect(sendKeysCalls).toHaveLength(1);
1537
+ });
1538
+ });
1539
+
1540
+ describe("ensureTmuxAvailable", () => {
1541
+ let spawnSpy: ReturnType<typeof spyOn>;
1542
+
1543
+ beforeEach(() => {
1544
+ spawnSpy = spyOn(Bun, "spawn");
1545
+ });
1546
+
1547
+ afterEach(() => {
1548
+ spawnSpy.mockRestore();
1549
+ });
1550
+
1551
+ test("succeeds when tmux is available", async () => {
1552
+ spawnSpy.mockImplementation(() => mockSpawnResult("tmux 3.3a\n", "", 0));
1553
+
1554
+ // Should not throw
1555
+ await ensureTmuxAvailable();
1556
+
1557
+ expect(spawnSpy).toHaveBeenCalledTimes(1);
1558
+ const callArgs = spawnSpy.mock.calls[0] as unknown[];
1559
+ const cmd = callArgs[0] as string[];
1560
+ expect(cmd).toEqual(["tmux", "-V"]);
1561
+ });
1562
+
1563
+ test("throws AgentError when tmux is not installed", async () => {
1564
+ spawnSpy.mockImplementation(() => mockSpawnResult("", "tmux: command not found", 1));
1565
+
1566
+ await expect(ensureTmuxAvailable()).rejects.toThrow(AgentError);
1567
+ });
1568
+
1569
+ test("AgentError message mentions tmux not installed", async () => {
1570
+ spawnSpy.mockImplementation(() => mockSpawnResult("", "", 127));
1571
+
1572
+ try {
1573
+ await ensureTmuxAvailable();
1574
+ expect(true).toBe(false); // Should have thrown
1575
+ } catch (err: unknown) {
1576
+ expect(err).toBeInstanceOf(AgentError);
1577
+ const agentErr = err as AgentError;
1578
+ expect(agentErr.message).toContain("tmux is not installed");
1579
+ }
1580
+ });
1581
+ });
1582
+
1583
+ describe("sanitizeTmuxName", () => {
1584
+ test("replaces dots with underscores", () => {
1585
+ expect(sanitizeTmuxName("consulting.hgoudat.com")).toBe("consulting_hgoudat_com");
1586
+ });
1587
+
1588
+ test("replaces colons with underscores", () => {
1589
+ expect(sanitizeTmuxName("host:8080")).toBe("host_8080");
1590
+ });
1591
+
1592
+ test("replaces mixed dots and colons", () => {
1593
+ expect(sanitizeTmuxName("my.project:v2.0")).toBe("my_project_v2_0");
1594
+ });
1595
+
1596
+ test("leaves names without special characters unchanged", () => {
1597
+ expect(sanitizeTmuxName("my-project")).toBe("my-project");
1598
+ });
1599
+
1600
+ test("handles empty string", () => {
1601
+ expect(sanitizeTmuxName("")).toBe("");
1602
+ });
1603
+
1604
+ test("handles name with only dots", () => {
1605
+ expect(sanitizeTmuxName("...")).toBe("___");
1606
+ });
1607
+
1608
+ test("produces valid tmux session name components", () => {
1609
+ // A real-world project name that would break tmux target parsing
1610
+ const projectName = "consulting.hgoudat.com";
1611
+ const sessionName = `agentplate-${sanitizeTmuxName(projectName)}-coordinator`;
1612
+ expect(sessionName).toBe("agentplate-consulting_hgoudat_com-coordinator");
1613
+ // No dots or colons that tmux would interpret as separators
1614
+ expect(sessionName).not.toMatch(/[.:]/);
1615
+ });
1616
+ });