@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,1212 @@
1
+ /**
2
+ * CLI command: ap dashboard [--interval <ms>] [--all]
3
+ *
4
+ * Rich terminal dashboard using raw ANSI escape codes (zero runtime deps).
5
+ * Polls existing data sources and renders multi-panel layout with agent status,
6
+ * mail activity, merge queue, metrics, tasks, and recent event feed.
7
+ *
8
+ * Layout:
9
+ * Row 1-2: Header
10
+ * Row 3-N: Agents (60% width, dynamic height) | Tasks (upper-right 40%) + Feed (lower-right 40%)
11
+ * Row N+1: Mail (50%) | Merge Queue (50%)
12
+ * Row M: Metrics
13
+ *
14
+ * By default, all panels are scoped to the current run (current-run.txt).
15
+ * Use --all to show data across all runs.
16
+ */
17
+
18
+ import { existsSync } from "node:fs";
19
+ import { join, resolve } from "node:path";
20
+ import { Command } from "commander";
21
+ import { loadConfig } from "../config.ts";
22
+ import { ValidationError } from "../errors.ts";
23
+ import { createEventStore } from "../events/store.ts";
24
+ import { accent, brand, color, visibleLength } from "../logging/color.ts";
25
+ import {
26
+ buildAgentColorMap,
27
+ extendAgentColorMap,
28
+ formatDuration,
29
+ formatEventLine,
30
+ formatRelativeTime,
31
+ mergeStatusColor,
32
+ numericPriorityColor,
33
+ priorityColor,
34
+ } from "../logging/format.ts";
35
+ import { stateColor, stateIcon } from "../logging/theme.ts";
36
+ import { createMailStore, type MailStore } from "../mail/store.ts";
37
+ import { createMergeQueue, type MergeQueue } from "../merge/queue.ts";
38
+ import { createMetricsStore, type MetricsStore } from "../metrics/store.ts";
39
+ import { openSessionStore } from "../sessions/compat.ts";
40
+ import type { SessionStore } from "../sessions/store.ts";
41
+ import { createTrackerClient, resolveBackend } from "../tracker/factory.ts";
42
+ import type { TrackerIssue } from "../tracker/types.ts";
43
+ import type {
44
+ AgentplateConfig,
45
+ AgentSession,
46
+ EventStore,
47
+ MailMessage,
48
+ StoredEvent,
49
+ TaskTrackerBackend,
50
+ } from "../types.ts";
51
+ import { openBrowser } from "../utils/browser.ts";
52
+ import { evaluateHealth } from "../watchdog/health.ts";
53
+ import { isProcessAlive } from "../worktree/tmux.ts";
54
+ import { DEFAULT_SERVE_PORT, runServe } from "./serve.ts";
55
+ import { getCachedTmuxSessions, getCachedWorktrees, type StatusData } from "./status.ts";
56
+
57
+ const pkgPath = resolve(import.meta.dir, "../../package.json");
58
+ const PKG_VERSION: string = JSON.parse(await Bun.file(pkgPath).text()).version ?? "unknown";
59
+
60
+ /**
61
+ * Terminal control codes (cursor movement, screen clearing).
62
+ * These are not colors, so they stay separate from the color module.
63
+ */
64
+ const CURSOR = {
65
+ clear: "\x1b[H\x1b[J", // Home cursor then clear from cursor to end
66
+ home: "\x1b[H", // Home cursor only (for redraw without full clear)
67
+ cursorTo: (row: number, col: number) => `\x1b[${row};${col}H`,
68
+ hideCursor: "\x1b[?25l",
69
+ showCursor: "\x1b[?25h",
70
+ enterAltScreen: "\x1b[?1049h", // Enter alternate screen buffer
71
+ leaveAltScreen: "\x1b[?1049l", // Leave alternate screen buffer
72
+ } as const;
73
+
74
+ /**
75
+ * Box drawing characters for panel borders (plain — not used for rendering,
76
+ * kept for backward compat with tests and horizontalLine helper).
77
+ */
78
+ const BOX = {
79
+ topLeft: "┌",
80
+ topRight: "┐",
81
+ bottomLeft: "└",
82
+ bottomRight: "┘",
83
+ horizontal: "─",
84
+ vertical: "│",
85
+ tee: "├",
86
+ teeRight: "┤",
87
+ cross: "┼",
88
+ };
89
+
90
+ /**
91
+ * Dimmed version of BOX characters — for subdued borders that do not
92
+ * compete visually with panel content.
93
+ */
94
+ export const dimBox = {
95
+ topLeft: color.dim("┌"),
96
+ topRight: color.dim("┐"),
97
+ bottomLeft: color.dim("└"),
98
+ bottomRight: color.dim("┘"),
99
+ horizontal: color.dim("─"),
100
+ vertical: color.dim("│"),
101
+ tee: color.dim("├"),
102
+ teeRight: color.dim("┤"),
103
+ cross: color.dim("┼"),
104
+ } as const;
105
+
106
+ /**
107
+ * Truncate a string to fit within maxLen characters, adding ellipsis if needed.
108
+ */
109
+ function truncate(str: string, maxLen: number): string {
110
+ if (maxLen <= 0) return "";
111
+ if (str.length <= maxLen) return str;
112
+ return `${str.slice(0, maxLen - 1)}…`;
113
+ }
114
+
115
+ /**
116
+ * Pad or truncate a string to exactly the given width.
117
+ */
118
+ function pad(str: string, width: number): string {
119
+ if (width <= 0) return "";
120
+ if (str.length >= width) return str.slice(0, width);
121
+ return str + " ".repeat(width - str.length);
122
+ }
123
+
124
+ /**
125
+ * Draw a horizontal line with left/right connectors using plain BOX chars.
126
+ * Exported for backward compat in tests.
127
+ */
128
+ function horizontalLine(width: number, left: string, _middle: string, right: string): string {
129
+ return left + BOX.horizontal.repeat(Math.max(0, width - 2)) + right;
130
+ }
131
+
132
+ /**
133
+ * Draw a horizontal line using dimmed border characters.
134
+ * ANSI-aware: uses visibleLength() for padding calculations.
135
+ */
136
+ function dimHorizontalLine(width: number, left: string, right: string): string {
137
+ const fillCount = Math.max(0, width - visibleLength(left) - visibleLength(right));
138
+ return left + dimBox.horizontal.repeat(fillCount) + right;
139
+ }
140
+
141
+ export { pad, truncate, horizontalLine };
142
+
143
+ /**
144
+ * Compute agent panel height from screen height and agent count.
145
+ * min 8 rows, max floor(height * 0.35), grows with agent count (+4 for chrome).
146
+ */
147
+ export function computeAgentPanelHeight(height: number, agentCount: number): number {
148
+ return Math.max(8, Math.min(Math.floor(height * 0.35), agentCount + 4));
149
+ }
150
+
151
+ /**
152
+ * Filter agents by run ID. When run-scoped, also includes sessions with null
153
+ * runId (e.g. coordinator) because SQL WHERE run_id = ? never matches NULL.
154
+ */
155
+ export function filterAgentsByRun<T extends { runId: string | null }>(
156
+ agents: T[],
157
+ runId: string | null | undefined,
158
+ ): T[] {
159
+ if (!runId) return agents;
160
+ return agents.filter((a) => a.runId === runId || a.runId === null);
161
+ }
162
+
163
+ /**
164
+ * Pre-opened database handles for the dashboard poll loop.
165
+ * Stores are opened once and reused across ticks to avoid
166
+ * repeated open/close/PRAGMA/WAL checkpoint overhead.
167
+ */
168
+ export interface DashboardStores {
169
+ sessionStore: SessionStore;
170
+ mailStore: MailStore | null;
171
+ mergeQueue: MergeQueue | null;
172
+ metricsStore: MetricsStore | null;
173
+ eventStore: EventStore | null;
174
+ }
175
+
176
+ /**
177
+ * Open all database connections needed by the dashboard.
178
+ * Returns null handles for databases that do not exist on disk.
179
+ */
180
+ export function openDashboardStores(root: string): DashboardStores {
181
+ const agentplateDir = join(root, ".agentplate");
182
+ const { store: sessionStore } = openSessionStore(agentplateDir);
183
+
184
+ let mailStore: MailStore | null = null;
185
+ try {
186
+ const mailDbPath = join(agentplateDir, "mail.db");
187
+ if (existsSync(mailDbPath)) {
188
+ mailStore = createMailStore(mailDbPath);
189
+ }
190
+ } catch {
191
+ // mail db might not be openable
192
+ }
193
+
194
+ let mergeQueue: MergeQueue | null = null;
195
+ try {
196
+ const queuePath = join(agentplateDir, "merge-queue.db");
197
+ if (existsSync(queuePath)) {
198
+ mergeQueue = createMergeQueue(queuePath);
199
+ }
200
+ } catch {
201
+ // queue db might not be openable
202
+ }
203
+
204
+ let metricsStore: MetricsStore | null = null;
205
+ try {
206
+ const metricsDbPath = join(agentplateDir, "metrics.db");
207
+ if (existsSync(metricsDbPath)) {
208
+ metricsStore = createMetricsStore(metricsDbPath);
209
+ }
210
+ } catch {
211
+ // metrics db might not be openable
212
+ }
213
+
214
+ let eventStore: EventStore | null = null;
215
+ try {
216
+ const eventsDbPath = join(agentplateDir, "events.db");
217
+ if (existsSync(eventsDbPath)) {
218
+ eventStore = createEventStore(eventsDbPath);
219
+ }
220
+ } catch {
221
+ // events db might not be openable
222
+ }
223
+
224
+ return { sessionStore, mailStore, mergeQueue, metricsStore, eventStore };
225
+ }
226
+
227
+ /**
228
+ * Close all dashboard database connections.
229
+ */
230
+ export function closeDashboardStores(stores: DashboardStores): void {
231
+ try {
232
+ stores.sessionStore.close();
233
+ } catch {
234
+ /* best effort */
235
+ }
236
+ try {
237
+ stores.mailStore?.close();
238
+ } catch {
239
+ /* best effort */
240
+ }
241
+ try {
242
+ stores.mergeQueue?.close();
243
+ } catch {
244
+ /* best effort */
245
+ }
246
+ try {
247
+ stores.metricsStore?.close();
248
+ } catch {
249
+ /* best effort */
250
+ }
251
+ try {
252
+ stores.eventStore?.close();
253
+ } catch {
254
+ /* best effort */
255
+ }
256
+ }
257
+
258
+ /**
259
+ * Rolling event buffer with incremental dedup by lastSeenId.
260
+ * Maintains a fixed-size window of the most recent events.
261
+ */
262
+ export class EventBuffer {
263
+ private events: StoredEvent[] = [];
264
+ private lastSeenId = 0;
265
+ private colorMap: Map<string, (s: string) => string> = new Map();
266
+ private readonly maxSize: number;
267
+
268
+ constructor(maxSize = 100) {
269
+ this.maxSize = maxSize;
270
+ }
271
+
272
+ poll(eventStore: EventStore): void {
273
+ const since = new Date(Date.now() - 60 * 1000).toISOString();
274
+ const allEvents = eventStore.getTimeline({ since, limit: 1000 });
275
+ const newEvents = allEvents.filter((e) => e.id > this.lastSeenId);
276
+
277
+ if (newEvents.length === 0) return;
278
+
279
+ extendAgentColorMap(this.colorMap, newEvents);
280
+ this.events = [...this.events, ...newEvents].slice(-this.maxSize);
281
+
282
+ const lastEvent = newEvents[newEvents.length - 1];
283
+ if (lastEvent) {
284
+ this.lastSeenId = lastEvent.id;
285
+ }
286
+ }
287
+
288
+ getEvents(): StoredEvent[] {
289
+ return this.events;
290
+ }
291
+
292
+ getColorMap(): Map<string, (s: string) => string> {
293
+ return this.colorMap;
294
+ }
295
+
296
+ get size(): number {
297
+ return this.events.length;
298
+ }
299
+ }
300
+
301
+ /** Tracker data cached between dashboard ticks (10s TTL). */
302
+ interface TrackerCache {
303
+ tasks: TrackerIssue[];
304
+ fetchedAt: number; // Date.now() ms
305
+ }
306
+
307
+ /** Module-level tracker cache (persists across poll ticks). */
308
+ let trackerCache: TrackerCache | null = null;
309
+ const TRACKER_CACHE_TTL_MS = 10_000; // 10 seconds
310
+
311
+ /** Session data cached between ticks — stale-on-error fallback. */
312
+ interface SessionDataCache {
313
+ sessions: AgentSession[];
314
+ }
315
+
316
+ /** Module-level session cache (persists across poll ticks, used as fallback on SQLite errors). */
317
+ let sessionDataCache: SessionDataCache | null = null;
318
+
319
+ interface DashboardData {
320
+ currentRunId?: string | null;
321
+ status: StatusData;
322
+ recentMail: MailMessage[];
323
+ mergeQueue: Array<{ branchName: string; agentName: string; status: string }>;
324
+ metrics: {
325
+ totalSessions: number;
326
+ avgDuration: number;
327
+ byCapability: Record<string, number>;
328
+ };
329
+ tasks: TrackerIssue[];
330
+ recentEvents: StoredEvent[];
331
+ feedColorMap: Map<string, (s: string) => string>;
332
+ /** Runtime config for resolving per-capability runtime names in the agent panel. */
333
+ runtimeConfig?: AgentplateConfig["runtime"];
334
+ }
335
+
336
+ /**
337
+ * Read the current run ID from current-run.txt, or null if no active run.
338
+ */
339
+ async function readCurrentRunId(agentplateDir: string): Promise<string | null> {
340
+ const path = join(agentplateDir, "current-run.txt");
341
+ const file = Bun.file(path);
342
+ if (!(await file.exists())) {
343
+ return null;
344
+ }
345
+ const text = await file.text();
346
+ const trimmed = text.trim();
347
+ return trimmed.length > 0 ? trimmed : null;
348
+ }
349
+
350
+ /**
351
+ * Load all data sources for the dashboard using pre-opened store handles.
352
+ * When runId is provided, all panels are scoped to agents in that run.
353
+ * No stores are opened or closed here — that is the caller's responsibility.
354
+ */
355
+ async function loadDashboardData(
356
+ root: string,
357
+ stores: DashboardStores,
358
+ runId?: string | null,
359
+ thresholds?: { staleMs: number; zombieMs: number },
360
+ eventBuffer?: EventBuffer,
361
+ runtimeConfig?: AgentplateConfig["runtime"],
362
+ taskTrackerBackend?: TaskTrackerBackend,
363
+ ): Promise<DashboardData> {
364
+ // Get all sessions from the pre-opened session store — fall back to cache on SQLite errors.
365
+ let allSessions: AgentSession[];
366
+ try {
367
+ allSessions = stores.sessionStore.getAll();
368
+ sessionDataCache = { sessions: allSessions };
369
+ } catch {
370
+ // SQLite lock contention or I/O error — use last known sessions
371
+ allSessions = sessionDataCache?.sessions ?? [];
372
+ }
373
+
374
+ // Get worktrees and tmux sessions via cached subprocess helpers
375
+ const worktrees = await getCachedWorktrees(root);
376
+ const tmuxSessions = await getCachedTmuxSessions();
377
+
378
+ // Evaluate health for active agents using the same logic as the watchdog.
379
+ const tmuxSessionNames = new Set(tmuxSessions.map((s) => s.name));
380
+ const healthThresholds = thresholds ?? { staleMs: 300_000, zombieMs: 600_000 };
381
+ try {
382
+ for (const session of allSessions) {
383
+ if (session.state === "completed") continue;
384
+ const tmuxAlive = tmuxSessionNames.has(session.tmuxSession);
385
+ const check = evaluateHealth(session, tmuxAlive, healthThresholds);
386
+ if (check.state !== session.state) {
387
+ try {
388
+ stores.sessionStore.updateState(session.agentName, check.state);
389
+ session.state = check.state;
390
+ } catch {
391
+ // Best effort: don't fail dashboard if update fails
392
+ }
393
+ }
394
+ }
395
+ } catch {
396
+ // Best effort: evaluateHealth loop should not crash the dashboard
397
+ }
398
+
399
+ // If run-scoped, filter agents to only those belonging to the current run.
400
+ const filteredAgents = filterAgentsByRun(allSessions, runId);
401
+
402
+ // Count unread mail
403
+ let unreadMailCount = 0;
404
+ if (stores.mailStore) {
405
+ try {
406
+ const unread = stores.mailStore.getAll({ to: "orchestrator", unread: true });
407
+ unreadMailCount = unread.length;
408
+ } catch {
409
+ // best effort
410
+ }
411
+ }
412
+
413
+ // Count merge queue pending entries
414
+ let mergeQueueCount = 0;
415
+ if (stores.mergeQueue) {
416
+ try {
417
+ mergeQueueCount = stores.mergeQueue.list("pending").length;
418
+ } catch {
419
+ // best effort
420
+ }
421
+ }
422
+
423
+ // Count recent metrics sessions
424
+ let recentMetricsCount = 0;
425
+ if (stores.metricsStore) {
426
+ try {
427
+ recentMetricsCount = stores.metricsStore.countSessions();
428
+ } catch {
429
+ // best effort
430
+ }
431
+ }
432
+
433
+ const status: StatusData = {
434
+ currentRunId: runId,
435
+ agents: filteredAgents,
436
+ worktrees,
437
+ tmuxSessions,
438
+ unreadMailCount,
439
+ unreadMailScope: "orchestrator",
440
+ mergeQueueCount,
441
+ recentMetricsCount,
442
+ };
443
+
444
+ // Load recent mail from pre-opened mail store
445
+ let recentMail: MailMessage[] = [];
446
+ if (stores.mailStore) {
447
+ try {
448
+ if (runId && filteredAgents.length > 0) {
449
+ const agentNames = new Set(filteredAgents.map((a) => a.agentName));
450
+ const allMail = stores.mailStore.getAll({ limit: 50 });
451
+ recentMail = allMail
452
+ .filter((m) => agentNames.has(m.from) || agentNames.has(m.to))
453
+ .slice(0, 5);
454
+ } else {
455
+ recentMail = stores.mailStore.getAll({ limit: 5 });
456
+ }
457
+ } catch {
458
+ // best effort
459
+ }
460
+ }
461
+
462
+ // Load merge queue entries from pre-opened merge queue
463
+ let mergeQueueEntries: Array<{ branchName: string; agentName: string; status: string }> = [];
464
+ if (stores.mergeQueue) {
465
+ try {
466
+ let entries = stores.mergeQueue.list();
467
+ if (runId && filteredAgents.length > 0) {
468
+ const agentNames = new Set(filteredAgents.map((a) => a.agentName));
469
+ entries = entries.filter((e) => agentNames.has(e.agentName));
470
+ }
471
+ mergeQueueEntries = entries.map((e) => ({
472
+ branchName: e.branchName,
473
+ agentName: e.agentName,
474
+ status: e.status,
475
+ }));
476
+ } catch {
477
+ // best effort
478
+ }
479
+ }
480
+
481
+ // Load metrics from pre-opened metrics store
482
+ let totalSessions = 0;
483
+ let avgDuration = 0;
484
+ const byCapability: Record<string, number> = {};
485
+ if (stores.metricsStore) {
486
+ try {
487
+ if (runId && filteredAgents.length > 0) {
488
+ const agentNames = new Set(filteredAgents.map((a) => a.agentName));
489
+ const sessions = stores.metricsStore.getRecentSessions(100);
490
+ const filtered = sessions.filter((s) => agentNames.has(s.agentName));
491
+
492
+ totalSessions = filtered.length;
493
+
494
+ const completedSessions = filtered.filter((s) => s.completedAt !== null);
495
+ if (completedSessions.length > 0) {
496
+ avgDuration =
497
+ completedSessions.reduce((sum, s) => sum + s.durationMs, 0) / completedSessions.length;
498
+ }
499
+
500
+ for (const session of filtered) {
501
+ const cap = session.capability;
502
+ byCapability[cap] = (byCapability[cap] ?? 0) + 1;
503
+ }
504
+ } else {
505
+ totalSessions = stores.metricsStore.countSessions();
506
+ avgDuration = stores.metricsStore.getAverageDuration();
507
+
508
+ const sessions = stores.metricsStore.getRecentSessions(100);
509
+ for (const session of sessions) {
510
+ const cap = session.capability;
511
+ byCapability[cap] = (byCapability[cap] ?? 0) + 1;
512
+ }
513
+ }
514
+ } catch {
515
+ // best effort
516
+ }
517
+ }
518
+
519
+ // Load tasks from tracker with cache
520
+ let tasks: TrackerIssue[] = [];
521
+ const now2 = Date.now();
522
+ if (!trackerCache || now2 - trackerCache.fetchedAt > TRACKER_CACHE_TTL_MS) {
523
+ try {
524
+ const backend = await resolveBackend(taskTrackerBackend ?? "auto", root);
525
+ const tracker = createTrackerClient(backend, root);
526
+ tasks = await tracker.list({ limit: 10 });
527
+ trackerCache = { tasks, fetchedAt: now2 };
528
+ } catch {
529
+ // tracker unavailable — graceful degradation
530
+ tasks = trackerCache?.tasks ?? [];
531
+ }
532
+ } else {
533
+ tasks = trackerCache.tasks;
534
+ }
535
+
536
+ // Load recent events via incremental buffer (or fallback to empty)
537
+ let recentEvents: StoredEvent[] = [];
538
+ let feedColorMap: Map<string, (s: string) => string> = new Map();
539
+ if (eventBuffer && stores.eventStore) {
540
+ try {
541
+ eventBuffer.poll(stores.eventStore);
542
+ recentEvents = [...eventBuffer.getEvents()].reverse();
543
+ feedColorMap = eventBuffer.getColorMap();
544
+ } catch {
545
+ /* best effort */
546
+ }
547
+ }
548
+
549
+ return {
550
+ currentRunId: runId,
551
+ status,
552
+ recentMail,
553
+ mergeQueue: mergeQueueEntries,
554
+ metrics: { totalSessions, avgDuration, byCapability },
555
+ tasks,
556
+ recentEvents,
557
+ feedColorMap,
558
+ runtimeConfig,
559
+ };
560
+ }
561
+
562
+ /**
563
+ * Render the header bar (line 1).
564
+ */
565
+ function renderHeader(width: number, interval: number, currentRunId?: string | null): string {
566
+ const left = brand.bold(`ap dashboard v${PKG_VERSION}`);
567
+ const now = new Date().toLocaleTimeString();
568
+ const scope = currentRunId ? ` [run: ${accent(currentRunId.slice(0, 8))}]` : " [all runs]";
569
+ const right = `${now}${scope} | refresh: ${interval}ms`;
570
+ const padding = width - visibleLength(left) - right.length;
571
+ const line = left + " ".repeat(Math.max(0, padding)) + right;
572
+ const separator = horizontalLine(width, BOX.topLeft, BOX.horizontal, BOX.topRight);
573
+ return `${line}\n${separator}`;
574
+ }
575
+
576
+ /**
577
+ * Resolve the runtime name for a given capability from config.
578
+ * Mirrors the lookup chain in runtimes/registry.ts getRuntime():
579
+ * capabilities[cap] > runtime.default > "claude"
580
+ */
581
+ function resolveRuntimeName(
582
+ capability: string,
583
+ runtimeConfig?: AgentplateConfig["runtime"],
584
+ ): string {
585
+ return runtimeConfig?.capabilities?.[capability] ?? runtimeConfig?.default ?? "claude";
586
+ }
587
+
588
+ /**
589
+ * Render the agent panel (left 60%, dynamic height).
590
+ */
591
+ export function renderAgentPanel(
592
+ data: DashboardData,
593
+ fullWidth: number,
594
+ panelHeight: number,
595
+ startRow: number,
596
+ ): string {
597
+ const leftWidth = fullWidth;
598
+ let output = "";
599
+
600
+ // Panel header
601
+ const headerLine = `${dimBox.vertical} ${brand.bold("Agents")} (${data.status.agents.length})`;
602
+ const headerPadding = " ".repeat(
603
+ Math.max(0, leftWidth - visibleLength(headerLine) - visibleLength(dimBox.vertical)),
604
+ );
605
+ output += `${CURSOR.cursorTo(startRow, 1)}${headerLine}${headerPadding}${dimBox.vertical}\n`;
606
+
607
+ // Column headers
608
+ const colStr = `${dimBox.vertical} St Name Capability Runtime State Task ID Duration Live `;
609
+ const colPadding = " ".repeat(
610
+ Math.max(0, leftWidth - visibleLength(colStr) - visibleLength(dimBox.vertical)),
611
+ );
612
+ output += `${CURSOR.cursorTo(startRow + 1, 1)}${colStr}${colPadding}${dimBox.vertical}\n`;
613
+
614
+ // Separator
615
+ const separator = dimHorizontalLine(leftWidth, dimBox.tee, dimBox.teeRight);
616
+ output += `${CURSOR.cursorTo(startRow + 2, 1)}${separator}\n`;
617
+
618
+ // Sort agents: active first, then completed, then zombie
619
+ const agents = [...data.status.agents].sort((a, b) => {
620
+ const activeStates = ["working", "in_turn", "between_turns", "booting", "stalled"];
621
+ const aActive = activeStates.includes(a.state);
622
+ const bActive = activeStates.includes(b.state);
623
+ if (aActive && !bActive) return -1;
624
+ if (!aActive && bActive) return 1;
625
+ return 0;
626
+ });
627
+
628
+ const now = Date.now();
629
+ const maxRows = panelHeight - 4; // header + col headers + separator + border
630
+ const visibleAgents = agents.slice(0, maxRows);
631
+
632
+ for (let i = 0; i < visibleAgents.length; i++) {
633
+ const agent = visibleAgents[i];
634
+ if (!agent) continue;
635
+
636
+ const icon = stateIcon(agent.state);
637
+ const stateColorFn = stateColor(agent.state);
638
+ const name = accent(pad(truncate(agent.agentName, 15), 15));
639
+ const capability = pad(truncate(agent.capability, 12), 12);
640
+ const runtimeName = resolveRuntimeName(agent.capability, data.runtimeConfig);
641
+ const runtime = pad(truncate(runtimeName, 8), 8);
642
+ const state = pad(agent.state, 10);
643
+ const taskId = accent(pad(truncate(agent.taskId, 16), 16));
644
+ const endTime =
645
+ agent.state === "completed" || agent.state === "zombie"
646
+ ? new Date(agent.lastActivity).getTime()
647
+ : now;
648
+ const duration = formatDuration(endTime - new Date(agent.startedAt).getTime());
649
+ const durationPadded = pad(duration, 9);
650
+ // Three liveness topologies (agentplate-7a34):
651
+ // tmux: tmuxSession !== "" → tmux session must exist
652
+ // long-lived headless: tmuxSession === "" && pid !== null → PID must be alive
653
+ // spawn-per-turn: tmuxSession === "" && pid === null → no process between
654
+ // turns is normal, so liveness reduces to "state is non-terminal".
655
+ // Time-based stale/zombie classification is handled in evaluateHealth.
656
+ const isHeadless = agent.tmuxSession === "" && agent.pid !== null;
657
+ const isSpawnPerTurn = agent.tmuxSession === "" && agent.pid === null;
658
+ const alive = isSpawnPerTurn
659
+ ? agent.state !== "zombie" && agent.state !== "completed"
660
+ : isHeadless
661
+ ? agent.pid !== null && isProcessAlive(agent.pid)
662
+ : data.status.tmuxSessions.some((s) => s.name === agent.tmuxSession);
663
+ const aliveDot = alive ? color.green(">") : color.red("x");
664
+
665
+ const lineContent = `${dimBox.vertical} ${stateColorFn(icon)} ${name} ${capability} ${color.dim(runtime)} ${stateColorFn(state)} ${taskId} ${durationPadded} ${aliveDot} `;
666
+ const linePadding = " ".repeat(
667
+ Math.max(0, leftWidth - visibleLength(lineContent) - visibleLength(dimBox.vertical)),
668
+ );
669
+ output += `${CURSOR.cursorTo(startRow + 3 + i, 1)}${lineContent}${linePadding}${dimBox.vertical}\n`;
670
+ }
671
+
672
+ // Fill remaining rows with empty lines
673
+ for (let i = visibleAgents.length; i < maxRows; i++) {
674
+ const emptyLine = `${dimBox.vertical}${" ".repeat(Math.max(0, leftWidth - 2))}${dimBox.vertical}`;
675
+ output += `${CURSOR.cursorTo(startRow + 3 + i, 1)}${emptyLine}\n`;
676
+ }
677
+
678
+ // Bottom border (joins the right column)
679
+ const bottomBorder = dimHorizontalLine(leftWidth, dimBox.tee, dimBox.teeRight);
680
+ output += `${CURSOR.cursorTo(startRow + 3 + maxRows, 1)}${bottomBorder}\n`;
681
+
682
+ return output;
683
+ }
684
+
685
+ /**
686
+ * Render the tasks panel (upper-right quadrant).
687
+ */
688
+ export function renderTasksPanel(
689
+ data: DashboardData,
690
+ startCol: number,
691
+ panelWidth: number,
692
+ panelHeight: number,
693
+ startRow: number,
694
+ ): string {
695
+ let output = "";
696
+
697
+ // Header
698
+ const headerLine = `${dimBox.vertical} ${brand.bold("Tasks")} (${data.tasks.length})`;
699
+ const headerPadding = " ".repeat(
700
+ Math.max(0, panelWidth - visibleLength(headerLine) - visibleLength(dimBox.vertical)),
701
+ );
702
+ output += `${CURSOR.cursorTo(startRow, startCol)}${headerLine}${headerPadding}${dimBox.vertical}\n`;
703
+
704
+ // Separator
705
+ const separator = dimHorizontalLine(panelWidth, dimBox.tee, dimBox.teeRight);
706
+ output += `${CURSOR.cursorTo(startRow + 1, startCol)}${separator}\n`;
707
+
708
+ const maxRows = panelHeight - 2; // header + separator
709
+ const visibleTasks = data.tasks.slice(0, maxRows);
710
+
711
+ if (visibleTasks.length === 0) {
712
+ const emptyMsg = color.dim("No tracker data");
713
+ const emptyLine = `${dimBox.vertical} ${emptyMsg}`;
714
+ const emptyPadding = " ".repeat(
715
+ Math.max(0, panelWidth - visibleLength(emptyLine) - visibleLength(dimBox.vertical)),
716
+ );
717
+ output += `${CURSOR.cursorTo(startRow + 2, startCol)}${emptyLine}${emptyPadding}${dimBox.vertical}\n`;
718
+ // Fill remaining rows
719
+ for (let i = 1; i < maxRows; i++) {
720
+ const blankLine = `${dimBox.vertical}${" ".repeat(Math.max(0, panelWidth - 2))}${dimBox.vertical}`;
721
+ output += `${CURSOR.cursorTo(startRow + 2 + i, startCol)}${blankLine}\n`;
722
+ }
723
+ return output;
724
+ }
725
+
726
+ for (let i = 0; i < visibleTasks.length; i++) {
727
+ const task = visibleTasks[i];
728
+ if (!task) continue;
729
+
730
+ const idStr = accent(pad(truncate(task.id, 14), 14));
731
+ const priorityStr = numericPriorityColor(task.priority)(`P${task.priority}`);
732
+ const statusStr = pad(task.status, 12);
733
+ const titleMaxLen = Math.max(4, panelWidth - 44);
734
+ const titleStr = truncate(task.title, titleMaxLen);
735
+
736
+ const lineContent = `${dimBox.vertical} ${idStr} ${titleStr} ${priorityStr} ${statusStr}`;
737
+ const linePadding = " ".repeat(
738
+ Math.max(0, panelWidth - visibleLength(lineContent) - visibleLength(dimBox.vertical)),
739
+ );
740
+ output += `${CURSOR.cursorTo(startRow + 2 + i, startCol)}${lineContent}${linePadding}${dimBox.vertical}\n`;
741
+ }
742
+
743
+ // Fill remaining rows
744
+ for (let i = visibleTasks.length; i < maxRows; i++) {
745
+ const blankLine = `${dimBox.vertical}${" ".repeat(Math.max(0, panelWidth - 2))}${dimBox.vertical}`;
746
+ output += `${CURSOR.cursorTo(startRow + 2 + i, startCol)}${blankLine}\n`;
747
+ }
748
+
749
+ return output;
750
+ }
751
+
752
+ /**
753
+ * Render the feed panel (lower-right quadrant).
754
+ */
755
+ export function renderFeedPanel(
756
+ data: DashboardData,
757
+ startCol: number,
758
+ panelWidth: number,
759
+ panelHeight: number,
760
+ startRow: number,
761
+ ): string {
762
+ let output = "";
763
+
764
+ // Header
765
+ const headerLine = `${dimBox.vertical} ${brand.bold("Feed")} (live)`;
766
+ const headerPadding = " ".repeat(
767
+ Math.max(0, panelWidth - visibleLength(headerLine) - visibleLength(dimBox.vertical)),
768
+ );
769
+ output += `${CURSOR.cursorTo(startRow, startCol)}${headerLine}${headerPadding}${dimBox.vertical}\n`;
770
+
771
+ // Separator
772
+ const separator = dimHorizontalLine(panelWidth, dimBox.tee, dimBox.teeRight);
773
+ output += `${CURSOR.cursorTo(startRow + 1, startCol)}${separator}\n`;
774
+
775
+ const maxRows = panelHeight - 2; // header + separator
776
+
777
+ if (data.recentEvents.length === 0) {
778
+ const emptyMsg = color.dim("No recent events");
779
+ const emptyLine = `${dimBox.vertical} ${emptyMsg}`;
780
+ const emptyPadding = " ".repeat(
781
+ Math.max(0, panelWidth - visibleLength(emptyLine) - visibleLength(dimBox.vertical)),
782
+ );
783
+ output += `${CURSOR.cursorTo(startRow + 2, startCol)}${emptyLine}${emptyPadding}${dimBox.vertical}\n`;
784
+ for (let i = 1; i < maxRows; i++) {
785
+ const blankLine = `${dimBox.vertical}${" ".repeat(Math.max(0, panelWidth - 2))}${dimBox.vertical}`;
786
+ output += `${CURSOR.cursorTo(startRow + 2 + i, startCol)}${blankLine}\n`;
787
+ }
788
+ return output;
789
+ }
790
+
791
+ const colorMap =
792
+ data.feedColorMap.size > 0 ? data.feedColorMap : buildAgentColorMap(data.recentEvents);
793
+ const visibleEvents = data.recentEvents.slice(0, maxRows);
794
+
795
+ for (let i = 0; i < visibleEvents.length; i++) {
796
+ const event = visibleEvents[i];
797
+ if (!event) continue;
798
+
799
+ const formatted = formatEventLine(event, colorMap);
800
+ // ANSI-safe truncation: trim to panelWidth - 4 (border + space each side)
801
+ const maxLineLen = panelWidth - 4;
802
+ let displayLine = formatted;
803
+ if (visibleLength(displayLine) > maxLineLen) {
804
+ // Truncate by stripping to visible characters
805
+ let count = 0;
806
+ let end = 0;
807
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: needed for ANSI
808
+ const ANSI = /\x1b\[[0-9;]*m/g;
809
+ let lastIndex = 0;
810
+ let match = ANSI.exec(displayLine);
811
+ while (match !== null) {
812
+ const plainSegLen = match.index - lastIndex;
813
+ if (count + plainSegLen >= maxLineLen - 1) {
814
+ end = lastIndex + (maxLineLen - 1 - count);
815
+ count = maxLineLen - 1;
816
+ break;
817
+ }
818
+ count += plainSegLen;
819
+ lastIndex = match.index + match[0].length;
820
+ end = lastIndex;
821
+ match = ANSI.exec(displayLine);
822
+ }
823
+ if (count < maxLineLen - 1) {
824
+ end = displayLine.length;
825
+ }
826
+ displayLine = `${displayLine.slice(0, end)}…`;
827
+ }
828
+
829
+ const lineContent = `${dimBox.vertical} ${displayLine}`;
830
+ const contentLen = visibleLength(lineContent) + visibleLength(dimBox.vertical);
831
+ const linePadding = " ".repeat(Math.max(0, panelWidth - contentLen));
832
+ output += `${CURSOR.cursorTo(startRow + 2 + i, startCol)}${lineContent}${linePadding}${dimBox.vertical}\n`;
833
+ }
834
+
835
+ // Fill remaining rows
836
+ for (let i = visibleEvents.length; i < maxRows; i++) {
837
+ const blankLine = `${dimBox.vertical}${" ".repeat(Math.max(0, panelWidth - 2))}${dimBox.vertical}`;
838
+ output += `${CURSOR.cursorTo(startRow + 2 + i, startCol)}${blankLine}\n`;
839
+ }
840
+
841
+ return output;
842
+ }
843
+
844
+ /**
845
+ * Render the mail panel (bottom-left 50%).
846
+ */
847
+ function renderMailPanel(
848
+ data: DashboardData,
849
+ panelWidth: number,
850
+ panelHeight: number,
851
+ startRow: number,
852
+ ): string {
853
+ let output = "";
854
+
855
+ const unreadCount = data.status.unreadMailCount;
856
+ const headerLine = `${dimBox.vertical} ${brand.bold("Mail")} (${unreadCount} unread)`;
857
+ const headerPadding = " ".repeat(
858
+ Math.max(0, panelWidth - visibleLength(headerLine) - visibleLength(dimBox.vertical)),
859
+ );
860
+ output += `${CURSOR.cursorTo(startRow, 1)}${headerLine}${headerPadding}${dimBox.vertical}\n`;
861
+
862
+ const separator = dimHorizontalLine(panelWidth, dimBox.tee, dimBox.cross);
863
+ output += `${CURSOR.cursorTo(startRow + 1, 1)}${separator}\n`;
864
+
865
+ const maxRows = panelHeight - 3; // header + separator + border
866
+ const messages = data.recentMail.slice(0, maxRows);
867
+
868
+ for (let i = 0; i < messages.length; i++) {
869
+ const msg = messages[i];
870
+ if (!msg) continue;
871
+
872
+ const priorityColorFn = priorityColor(msg.priority);
873
+ const priority = msg.priority === "normal" ? "" : `[${msg.priority}] `;
874
+ const from = accent(truncate(msg.from, 12));
875
+ const to = accent(truncate(msg.to, 12));
876
+ const subject = truncate(msg.subject, panelWidth - 40);
877
+ const time = formatRelativeTime(msg.createdAt);
878
+
879
+ const coloredPriority = priority ? priorityColorFn(priority) : "";
880
+ const lineContent = `${dimBox.vertical} ${coloredPriority}${from} → ${to}: ${subject} (${time})`;
881
+ const padding = " ".repeat(
882
+ Math.max(0, panelWidth - visibleLength(lineContent) - visibleLength(dimBox.vertical)),
883
+ );
884
+ output += `${CURSOR.cursorTo(startRow + 2 + i, 1)}${lineContent}${padding}${dimBox.vertical}\n`;
885
+ }
886
+
887
+ // Fill remaining rows with empty lines
888
+ for (let i = messages.length; i < maxRows; i++) {
889
+ const emptyLine = `${dimBox.vertical}${" ".repeat(Math.max(0, panelWidth - 2))}${dimBox.vertical}`;
890
+ output += `${CURSOR.cursorTo(startRow + 2 + i, 1)}${emptyLine}\n`;
891
+ }
892
+
893
+ return output;
894
+ }
895
+
896
+ /**
897
+ * Render the merge queue panel (bottom-right 50%).
898
+ */
899
+ function renderMergeQueuePanel(
900
+ data: DashboardData,
901
+ panelWidth: number,
902
+ panelHeight: number,
903
+ startRow: number,
904
+ startCol: number,
905
+ ): string {
906
+ let output = "";
907
+
908
+ const headerLine = `${dimBox.vertical} ${brand.bold("Merge Queue")} (${data.mergeQueue.length})`;
909
+ const headerPadding = " ".repeat(
910
+ Math.max(0, panelWidth - visibleLength(headerLine) - visibleLength(dimBox.vertical)),
911
+ );
912
+ output += `${CURSOR.cursorTo(startRow, startCol)}${headerLine}${headerPadding}${dimBox.vertical}\n`;
913
+
914
+ const separator = dimHorizontalLine(panelWidth, dimBox.cross, dimBox.teeRight);
915
+ output += `${CURSOR.cursorTo(startRow + 1, startCol)}${separator}\n`;
916
+
917
+ const maxRows = panelHeight - 3; // header + separator + border
918
+ const entries = data.mergeQueue.slice(0, maxRows);
919
+
920
+ for (let i = 0; i < entries.length; i++) {
921
+ const entry = entries[i];
922
+ if (!entry) continue;
923
+
924
+ const statusColorFn = mergeStatusColor(entry.status);
925
+ const status = pad(entry.status, 10);
926
+ const agent = accent(truncate(entry.agentName, 15));
927
+ const branch = truncate(entry.branchName, panelWidth - 30);
928
+
929
+ const lineContent = `${dimBox.vertical} ${statusColorFn(status)} ${agent} ${branch}`;
930
+ const padding = " ".repeat(
931
+ Math.max(0, panelWidth - visibleLength(lineContent) - visibleLength(dimBox.vertical)),
932
+ );
933
+ output += `${CURSOR.cursorTo(startRow + 2 + i, startCol)}${lineContent}${padding}${dimBox.vertical}\n`;
934
+ }
935
+
936
+ // Fill remaining rows with empty lines
937
+ for (let i = entries.length; i < maxRows; i++) {
938
+ const emptyLine = `${dimBox.vertical}${" ".repeat(Math.max(0, panelWidth - 2))}${dimBox.vertical}`;
939
+ output += `${CURSOR.cursorTo(startRow + 2 + i, startCol)}${emptyLine}\n`;
940
+ }
941
+
942
+ return output;
943
+ }
944
+
945
+ /**
946
+ * Render the metrics panel (bottom strip).
947
+ */
948
+ function renderMetricsPanel(
949
+ data: DashboardData,
950
+ width: number,
951
+ _height: number,
952
+ startRow: number,
953
+ ): string {
954
+ let output = "";
955
+
956
+ const separator = dimHorizontalLine(width, dimBox.tee, dimBox.teeRight);
957
+ output += `${CURSOR.cursorTo(startRow, 1)}${separator}\n`;
958
+
959
+ const totalSessions = data.metrics.totalSessions;
960
+ const avgDur = formatDuration(data.metrics.avgDuration);
961
+ const byCapability = Object.entries(data.metrics.byCapability)
962
+ .map(([cap, count]) => `${cap}:${count}`)
963
+ .join(", ");
964
+
965
+ const metricsLine = `${dimBox.vertical} ${brand.bold("Metrics")} Total: ${totalSessions} | Avg: ${avgDur} | ${byCapability}`;
966
+ const metricsPadding = " ".repeat(
967
+ Math.max(0, width - visibleLength(metricsLine) - visibleLength(dimBox.vertical)),
968
+ );
969
+ output += `${CURSOR.cursorTo(startRow + 1, 1)}${metricsLine}${metricsPadding}${dimBox.vertical}\n`;
970
+
971
+ const bottomBorder = dimHorizontalLine(width, dimBox.bottomLeft, dimBox.bottomRight);
972
+ output += `${CURSOR.cursorTo(startRow + 2, 1)}${bottomBorder}\n`;
973
+
974
+ return output;
975
+ }
976
+
977
+ /**
978
+ * Render the full dashboard.
979
+ */
980
+ function renderDashboard(data: DashboardData, interval: number, isFirstRender: boolean): void {
981
+ const width = process.stdout.columns ?? 100;
982
+ const height = process.stdout.rows ?? 30;
983
+
984
+ // First render: clear entire alt screen. Subsequent: just home cursor
985
+ // and overwrite in-place (avoids Warp's block-per-clear issue).
986
+ let output = isFirstRender ? CURSOR.clear : CURSOR.home;
987
+
988
+ // Header (rows 1-2)
989
+ output += renderHeader(width, interval, data.currentRunId);
990
+
991
+ // Agent panel: full width, capped at 35% of height
992
+ const agentPanelStart = 3;
993
+ const agentCount = data.status.agents.length;
994
+ const agentPanelHeight = computeAgentPanelHeight(height, agentCount);
995
+ output += renderAgentPanel(data, width, agentPanelHeight, agentPanelStart);
996
+
997
+ // Middle zone: Feed (left 60%) | Tasks (right 40%)
998
+ const middleStart = agentPanelStart + agentPanelHeight + 1;
999
+ const compactPanelHeight = 5; // fixed for mail/merge panels
1000
+ const metricsHeight = 3; // separator + data + border
1001
+ const middleHeight = Math.max(6, height - middleStart - compactPanelHeight - metricsHeight);
1002
+
1003
+ const feedWidth = Math.floor(width * 0.6);
1004
+ output += renderFeedPanel(data, 1, feedWidth, middleHeight, middleStart);
1005
+
1006
+ const taskWidth = width - feedWidth;
1007
+ const taskStartCol = feedWidth + 1;
1008
+ output += renderTasksPanel(data, taskStartCol, taskWidth, middleHeight, middleStart);
1009
+
1010
+ // Compact panels: Mail (left 50%) | Merge Queue (right 50%) — fixed 5 rows
1011
+ const compactStart = middleStart + middleHeight;
1012
+ const mailWidth = Math.floor(width * 0.5);
1013
+ output += renderMailPanel(data, mailWidth, compactPanelHeight, compactStart);
1014
+
1015
+ const mergeStartCol = mailWidth + 1;
1016
+ const mergeWidth = width - mailWidth;
1017
+ output += renderMergeQueuePanel(
1018
+ data,
1019
+ mergeWidth,
1020
+ compactPanelHeight,
1021
+ compactStart,
1022
+ mergeStartCol,
1023
+ );
1024
+
1025
+ // Metrics footer
1026
+ const metricsStart = compactStart + compactPanelHeight;
1027
+ output += renderMetricsPanel(data, width, height, metricsStart);
1028
+
1029
+ process.stdout.write(output);
1030
+ }
1031
+
1032
+ interface DashboardOpts {
1033
+ interval?: string;
1034
+ all?: boolean;
1035
+ }
1036
+
1037
+ async function executeDashboard(opts: DashboardOpts): Promise<void> {
1038
+ const intervalStr = opts.interval;
1039
+ const interval = intervalStr ? Number.parseInt(intervalStr, 10) : 2000;
1040
+ const showAll = opts.all ?? false;
1041
+
1042
+ if (Number.isNaN(interval) || interval < 500) {
1043
+ throw new ValidationError("--interval must be a number >= 500 (milliseconds)", {
1044
+ field: "interval",
1045
+ value: intervalStr,
1046
+ });
1047
+ }
1048
+
1049
+ const cwd = process.cwd();
1050
+ const config = await loadConfig(cwd);
1051
+ const root = config.project.root;
1052
+
1053
+ // Read current run ID unless --all flag is set
1054
+ let runId: string | null | undefined;
1055
+ if (!showAll) {
1056
+ const agentplateDir = join(root, ".agentplate");
1057
+ runId = await readCurrentRunId(agentplateDir);
1058
+ }
1059
+
1060
+ // Open stores once for the entire poll loop lifetime
1061
+ const stores = openDashboardStores(root);
1062
+
1063
+ // Create rolling event buffer (persisted across poll ticks)
1064
+ const eventBuffer = new EventBuffer(100);
1065
+
1066
+ // Compute health thresholds once from config (reused across poll ticks)
1067
+ const thresholds = {
1068
+ staleMs: config.watchdog.staleThresholdMs,
1069
+ zombieMs: config.watchdog.zombieThresholdMs,
1070
+ };
1071
+
1072
+ // Enter alternate screen buffer (like vim/htop) + hide cursor + raw stdin
1073
+ process.stdout.write(CURSOR.enterAltScreen);
1074
+ process.stdout.write(CURSOR.hideCursor);
1075
+ if (process.stdin.isTTY) {
1076
+ process.stdin.setRawMode(true);
1077
+ process.stdin.resume();
1078
+ }
1079
+
1080
+ // Clean exit on Ctrl+C or 'q': restore original screen
1081
+ let running = true;
1082
+ const cleanup = () => {
1083
+ running = false;
1084
+ if (process.stdin.isTTY) {
1085
+ process.stdin.setRawMode(false);
1086
+ process.stdin.pause();
1087
+ }
1088
+ closeDashboardStores(stores);
1089
+ process.stdout.write(CURSOR.showCursor);
1090
+ process.stdout.write(CURSOR.leaveAltScreen);
1091
+ };
1092
+
1093
+ process.on("SIGINT", () => {
1094
+ cleanup();
1095
+ process.exitCode = 0;
1096
+ });
1097
+
1098
+ // Allow 'q' to quit the dashboard
1099
+ process.stdin.on("data", (data: Buffer) => {
1100
+ const key = data.toString();
1101
+ if (key === "q" || key === "\x03") {
1102
+ // 'q' or Ctrl+C
1103
+ cleanup();
1104
+ process.exitCode = 0;
1105
+ }
1106
+ });
1107
+
1108
+ // Poll loop — errors are caught per-tick so transient DB failures never crash the dashboard.
1109
+ let isFirstRender = true;
1110
+ let lastGoodData: DashboardData | null = null;
1111
+ let lastErrorMsg: string | null = null;
1112
+ while (running) {
1113
+ try {
1114
+ const data = await loadDashboardData(
1115
+ root,
1116
+ stores,
1117
+ runId,
1118
+ thresholds,
1119
+ eventBuffer,
1120
+ config.runtime,
1121
+ config.taskTracker.backend,
1122
+ );
1123
+ lastGoodData = data;
1124
+ // If recovering from an error, clear the stale error line at the bottom
1125
+ if (lastErrorMsg !== null) {
1126
+ const w = process.stdout.columns ?? 100;
1127
+ const h = process.stdout.rows ?? 30;
1128
+ process.stdout.write(`${CURSOR.cursorTo(h, 1)}${" ".repeat(w)}`);
1129
+ }
1130
+ lastErrorMsg = null;
1131
+ renderDashboard(data, interval, isFirstRender);
1132
+ isFirstRender = false;
1133
+ } catch (err) {
1134
+ // Render last good frame so the TUI stays alive, then show the error inline.
1135
+ if (lastGoodData) {
1136
+ renderDashboard(lastGoodData, interval, isFirstRender);
1137
+ isFirstRender = false;
1138
+ }
1139
+ lastErrorMsg = err instanceof Error ? err.message : String(err);
1140
+ const w = process.stdout.columns ?? 100;
1141
+ const h = process.stdout.rows ?? 30;
1142
+ const errLine = `${CURSOR.cursorTo(h, 1)}\x1b[31m⚠ DB error (retrying):\x1b[0m ${truncate(lastErrorMsg, w - 30)}`;
1143
+ process.stdout.write(errLine);
1144
+ }
1145
+ await Bun.sleep(interval);
1146
+ }
1147
+ }
1148
+
1149
+ /**
1150
+ * `ap dashboard ui` — launch the web dashboard instead of the terminal TUI.
1151
+ *
1152
+ * Builds the SPA (via `runServe` → `ensureUiBuild`), starts the HTTP +
1153
+ * WebSocket server, and opens a browser at the served URL. `--no-open`
1154
+ * suppresses the browser launch (useful for remote/headless hosts).
1155
+ */
1156
+ export function createDashboardUiCommand(): Command {
1157
+ return new Command("ui")
1158
+ .description("Launch the web dashboard (build SPA, start server, open browser)")
1159
+ .option("--port <n>", "TCP port to listen on", String(DEFAULT_SERVE_PORT))
1160
+ .option("--host <addr>", "Host/address to bind", "127.0.0.1")
1161
+ .option("--no-open", "Do not open a browser automatically")
1162
+ .option("--json", "Output startup info as JSON")
1163
+ .action(async (opts: { port?: string; host?: string; open?: boolean; json?: boolean }) => {
1164
+ const port = opts.port !== undefined ? Number.parseInt(opts.port, 10) : DEFAULT_SERVE_PORT;
1165
+ if (Number.isNaN(port) || port < 1 || port > 65535) {
1166
+ throw new ValidationError(`Invalid port: ${opts.port ?? "undefined"}`, {
1167
+ field: "port",
1168
+ value: opts.port,
1169
+ });
1170
+ }
1171
+ const shouldOpen = opts.open !== false;
1172
+ await runServe({
1173
+ port,
1174
+ host: opts.host ?? "127.0.0.1",
1175
+ json: opts.json,
1176
+ onReady: (url) => {
1177
+ if (shouldOpen) openBrowser(url);
1178
+ },
1179
+ });
1180
+ });
1181
+ }
1182
+
1183
+ export function createDashboardCommand(): Command {
1184
+ return new Command("dashboard")
1185
+ .description("Live TUI dashboard for agent monitoring (Ctrl+C to stop)")
1186
+ .option("--interval <ms>", "Poll interval in milliseconds (default: 2000, min: 500)")
1187
+ .option("--all", "Show data from all runs (default: current run only)")
1188
+ .addCommand(createDashboardUiCommand())
1189
+ .action(async (opts: DashboardOpts) => {
1190
+ await executeDashboard(opts);
1191
+ });
1192
+ }
1193
+
1194
+ export async function dashboardCommand(args: string[]): Promise<void> {
1195
+ const cmd = createDashboardCommand();
1196
+ cmd.exitOverride();
1197
+ try {
1198
+ await cmd.parseAsync(args, { from: "user" });
1199
+ } catch (err: unknown) {
1200
+ if (err && typeof err === "object" && "code" in err) {
1201
+ const code = (err as { code: string }).code;
1202
+ if (code === "commander.helpDisplayed" || code === "commander.version") {
1203
+ return;
1204
+ }
1205
+ if (code.startsWith("commander.")) {
1206
+ const message = err instanceof Error ? err.message : String(err);
1207
+ throw new ValidationError(message, { field: "args" });
1208
+ }
1209
+ }
1210
+ throw err;
1211
+ }
1212
+ }