@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,10 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { resolveAgentplateBin } from "./bin.ts";
3
+
4
+ describe("resolveAgentplateBin", () => {
5
+ test("returns a non-empty string", async () => {
6
+ const bin = await resolveAgentplateBin();
7
+ expect(typeof bin).toBe("string");
8
+ expect(bin.length).toBeGreaterThan(0);
9
+ });
10
+ });
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Binary resolution utilities.
3
+ */
4
+ import { AgentplateError } from "../errors.ts";
5
+
6
+ /**
7
+ * Resolve the path to the agentplate binary for re-launching.
8
+ * Uses `which ap` first, then falls back to process.argv.
9
+ */
10
+ export async function resolveAgentplateBin(): Promise<string> {
11
+ try {
12
+ const proc = Bun.spawn(["which", "ap"], {
13
+ stdout: "pipe",
14
+ stderr: "pipe",
15
+ });
16
+ const exitCode = await proc.exited;
17
+ if (exitCode === 0) {
18
+ const binPath = (await new Response(proc.stdout).text()).trim();
19
+ if (binPath.length > 0) {
20
+ return binPath;
21
+ }
22
+ }
23
+ } catch {
24
+ // which not available or agentplate not on PATH
25
+ }
26
+
27
+ // Fallback: use the script that's currently running (process.argv[1])
28
+ const scriptPath = process.argv[1];
29
+ if (scriptPath) {
30
+ return scriptPath;
31
+ }
32
+
33
+ throw new AgentplateError(
34
+ "Cannot resolve agentplate binary path for background launch",
35
+ "WATCH_ERROR",
36
+ );
37
+ }
@@ -0,0 +1,49 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { browserOpenCommand, openBrowser } from "./browser.ts";
3
+
4
+ describe("browserOpenCommand", () => {
5
+ test("uses `open` on darwin", () => {
6
+ expect(browserOpenCommand("http://localhost:7321", "darwin")).toEqual([
7
+ "open",
8
+ "http://localhost:7321",
9
+ ]);
10
+ });
11
+
12
+ test("uses `cmd /c start` with empty title on win32", () => {
13
+ expect(browserOpenCommand("http://localhost:7321", "win32")).toEqual([
14
+ "cmd",
15
+ "/c",
16
+ "start",
17
+ "",
18
+ "http://localhost:7321",
19
+ ]);
20
+ });
21
+
22
+ test("uses `xdg-open` on linux/other", () => {
23
+ expect(browserOpenCommand("http://localhost:7321", "linux")).toEqual([
24
+ "xdg-open",
25
+ "http://localhost:7321",
26
+ ]);
27
+ });
28
+ });
29
+
30
+ describe("openBrowser", () => {
31
+ test("spawns the resolved command and returns true", () => {
32
+ let received: string[] | null = null;
33
+ const fakeSpawn = ((cmd: string[]) => {
34
+ received = cmd;
35
+ return { unref() {} } as unknown as ReturnType<typeof Bun.spawn>;
36
+ }) as typeof Bun.spawn;
37
+
38
+ const ok = openBrowser("http://localhost:7321", fakeSpawn, "darwin");
39
+ expect(ok).toBe(true);
40
+ expect(received as string[] | null).toEqual(["open", "http://localhost:7321"]);
41
+ });
42
+
43
+ test("returns false (never throws) when spawn fails", () => {
44
+ const throwingSpawn = (() => {
45
+ throw new Error("no GUI");
46
+ }) as typeof Bun.spawn;
47
+ expect(openBrowser("http://localhost:7321", throwingSpawn, "linux")).toBe(false);
48
+ });
49
+ });
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Best-effort cross-platform browser launcher for `ap dashboard ui`.
3
+ *
4
+ * Resolves the platform's "open a URL in the default browser" command and
5
+ * spawns it detached. Never throws — a failed launch (headless CI, no GUI,
6
+ * missing `xdg-open`) just means the operator opens the printed URL manually.
7
+ */
8
+
9
+ type SpawnFn = typeof Bun.spawn;
10
+
11
+ /**
12
+ * Resolve the argv used to open `url` in the default browser on `platform`.
13
+ * - darwin: `open <url>`
14
+ * - win32: `cmd /c start "" <url>` (empty title arg so a quoted URL isn't
15
+ * mistaken for the window title)
16
+ * - else: `xdg-open <url>` (Linux/BSD desktops)
17
+ */
18
+ export function browserOpenCommand(
19
+ url: string,
20
+ platform: NodeJS.Platform = process.platform,
21
+ ): string[] {
22
+ if (platform === "darwin") return ["open", url];
23
+ if (platform === "win32") return ["cmd", "/c", "start", "", url];
24
+ return ["xdg-open", url];
25
+ }
26
+
27
+ /**
28
+ * Open `url` in the default browser. Best-effort: returns `true` when the
29
+ * launcher was spawned, `false` on any failure. `spawn`/`platform` are
30
+ * injectable for tests.
31
+ */
32
+ export function openBrowser(
33
+ url: string,
34
+ spawn: SpawnFn = Bun.spawn,
35
+ platform: NodeJS.Platform = process.platform,
36
+ ): boolean {
37
+ try {
38
+ const proc = spawn(browserOpenCommand(url, platform), {
39
+ stdout: "ignore",
40
+ stderr: "ignore",
41
+ stdin: "ignore",
42
+ });
43
+ proc.unref();
44
+ return true;
45
+ } catch {
46
+ return false;
47
+ }
48
+ }
@@ -0,0 +1,119 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
+ import { existsSync } from "node:fs";
3
+ import { mkdir, mkdtemp, readdir, writeFile } from "node:fs/promises";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+ import { cleanupTempDir } from "../test-helpers.ts";
7
+ import { clearDirectory, deleteFile, resetJsonFile, wipeSqliteDb } from "./fs.ts";
8
+
9
+ let tempDir: string;
10
+
11
+ beforeEach(async () => {
12
+ tempDir = await mkdtemp(join(tmpdir(), "ap-fs-test-"));
13
+ });
14
+
15
+ afterEach(async () => {
16
+ await cleanupTempDir(tempDir);
17
+ });
18
+
19
+ describe("wipeSqliteDb", () => {
20
+ test("deletes main db and WAL/SHM companion files", async () => {
21
+ const dbPath = join(tempDir, "test-wipe.db");
22
+ const { Database } = await import("bun:sqlite");
23
+ const db = new Database(dbPath);
24
+ db.exec("PRAGMA journal_mode=WAL");
25
+ db.exec("CREATE TABLE t (id INTEGER PRIMARY KEY)");
26
+ db.exec("INSERT INTO t VALUES (1)");
27
+ db.close();
28
+
29
+ expect(existsSync(dbPath)).toBe(true);
30
+
31
+ const result = await wipeSqliteDb(dbPath);
32
+ expect(result).toBe(true);
33
+
34
+ expect(existsSync(dbPath)).toBe(false);
35
+ expect(existsSync(`${dbPath}-wal`)).toBe(false);
36
+ expect(existsSync(`${dbPath}-shm`)).toBe(false);
37
+ });
38
+
39
+ test("returns false when db file does not exist", async () => {
40
+ const dbPath = join(tempDir, "nonexistent.db");
41
+ const result = await wipeSqliteDb(dbPath);
42
+ expect(result).toBe(false);
43
+ });
44
+ });
45
+
46
+ describe("resetJsonFile", () => {
47
+ test("resets existing JSON file to empty array", async () => {
48
+ const filePath = join(tempDir, "test-reset.json");
49
+ await Bun.write(filePath, '[{"id":"1"},{"id":"2"}]');
50
+
51
+ const result = await resetJsonFile(filePath);
52
+ expect(result).toBe(true);
53
+
54
+ const content = await Bun.file(filePath).text();
55
+ expect(content).toBe("[]\n");
56
+ });
57
+
58
+ test("returns false for nonexistent file", async () => {
59
+ const filePath = join(tempDir, "nonexistent.json");
60
+ const result = await resetJsonFile(filePath);
61
+ expect(result).toBe(false);
62
+ });
63
+ });
64
+
65
+ describe("clearDirectory", () => {
66
+ test("clears files from a directory", async () => {
67
+ const dirPath = join(tempDir, "clear-test");
68
+ await mkdir(dirPath, { recursive: true });
69
+ await writeFile(join(dirPath, "file1.txt"), "hello");
70
+ await writeFile(join(dirPath, "file2.txt"), "world");
71
+
72
+ const result = await clearDirectory(dirPath);
73
+ expect(result).toBe(true);
74
+
75
+ const entries = await readdir(dirPath);
76
+ expect(entries).toHaveLength(0);
77
+ });
78
+
79
+ test("returns false for empty directory", async () => {
80
+ const dirPath = join(tempDir, "empty-dir");
81
+ await mkdir(dirPath, { recursive: true });
82
+
83
+ const result = await clearDirectory(dirPath);
84
+ expect(result).toBe(false);
85
+ });
86
+
87
+ test("returns false for nonexistent directory", async () => {
88
+ const result = await clearDirectory(join(tempDir, "no-such-dir"));
89
+ expect(result).toBe(false);
90
+ });
91
+
92
+ test("recursively removes subdirectories", async () => {
93
+ const dirPath = join(tempDir, "nested-clear");
94
+ await mkdir(join(dirPath, "sub", "deep"), { recursive: true });
95
+ await writeFile(join(dirPath, "sub", "deep", "file.txt"), "data");
96
+
97
+ const result = await clearDirectory(dirPath);
98
+ expect(result).toBe(true);
99
+
100
+ const entries = await readdir(dirPath);
101
+ expect(entries).toHaveLength(0);
102
+ });
103
+ });
104
+
105
+ describe("deleteFile", () => {
106
+ test("deletes an existing file", async () => {
107
+ const filePath = join(tempDir, "to-delete.txt");
108
+ await writeFile(filePath, "delete me");
109
+
110
+ const result = await deleteFile(filePath);
111
+ expect(result).toBe(true);
112
+ expect(existsSync(filePath)).toBe(false);
113
+ });
114
+
115
+ test("returns false for nonexistent file", async () => {
116
+ const result = await deleteFile(join(tempDir, "no-such-file.txt"));
117
+ expect(result).toBe(false);
118
+ });
119
+ });
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Filesystem utility functions for cleanup and state management.
3
+ */
4
+ import { readdir, rm, unlink } from "node:fs/promises";
5
+ import { join } from "node:path";
6
+
7
+ /**
8
+ * Delete a SQLite database file and its WAL/SHM companions.
9
+ */
10
+ export async function wipeSqliteDb(dbPath: string): Promise<boolean> {
11
+ const extensions = ["", "-wal", "-shm"];
12
+ let wiped = false;
13
+ for (const ext of extensions) {
14
+ try {
15
+ await unlink(`${dbPath}${ext}`);
16
+ if (ext === "") wiped = true;
17
+ } catch {
18
+ // File may not exist
19
+ }
20
+ }
21
+ return wiped;
22
+ }
23
+
24
+ /**
25
+ * Reset a JSON file to an empty array.
26
+ */
27
+ export async function resetJsonFile(path: string): Promise<boolean> {
28
+ const file = Bun.file(path);
29
+ if (await file.exists()) {
30
+ await Bun.write(path, "[]\n");
31
+ return true;
32
+ }
33
+ return false;
34
+ }
35
+
36
+ /**
37
+ * Clear all entries inside a directory but keep the directory itself.
38
+ */
39
+ export async function clearDirectory(dirPath: string): Promise<boolean> {
40
+ try {
41
+ const entries = await readdir(dirPath);
42
+ for (const entry of entries) {
43
+ await rm(join(dirPath, entry), { recursive: true, force: true });
44
+ }
45
+ return entries.length > 0;
46
+ } catch {
47
+ // Directory may not exist
48
+ return false;
49
+ }
50
+ }
51
+
52
+ /**
53
+ * Delete a single file if it exists.
54
+ */
55
+ export async function deleteFile(path: string): Promise<boolean> {
56
+ try {
57
+ await unlink(path);
58
+ return true;
59
+ } catch {
60
+ return false;
61
+ }
62
+ }
@@ -0,0 +1,152 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
+ import { mkdtemp } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { cleanupTempDir } from "../test-helpers.ts";
6
+ import { acquirePidLock, readPidFile, removePidFile, writePidFile } from "./pid.ts";
7
+
8
+ let tempDir: string;
9
+
10
+ beforeEach(async () => {
11
+ tempDir = await mkdtemp(join(tmpdir(), "ap-pid-test-"));
12
+ });
13
+
14
+ afterEach(async () => {
15
+ await cleanupTempDir(tempDir);
16
+ });
17
+
18
+ describe("readPidFile", () => {
19
+ test("returns pid from valid file", async () => {
20
+ const pidPath = join(tempDir, "test.pid");
21
+ await Bun.write(pidPath, "12345\n");
22
+ const pid = await readPidFile(pidPath);
23
+ expect(pid).toBe(12345);
24
+ });
25
+
26
+ test("returns null for nonexistent file", async () => {
27
+ const pid = await readPidFile(join(tempDir, "missing.pid"));
28
+ expect(pid).toBeNull();
29
+ });
30
+
31
+ test("returns null for non-numeric content", async () => {
32
+ const pidPath = join(tempDir, "bad.pid");
33
+ await Bun.write(pidPath, "not-a-number\n");
34
+ const pid = await readPidFile(pidPath);
35
+ expect(pid).toBeNull();
36
+ });
37
+
38
+ test("returns null for negative pid", async () => {
39
+ const pidPath = join(tempDir, "neg.pid");
40
+ await Bun.write(pidPath, "-1\n");
41
+ const pid = await readPidFile(pidPath);
42
+ expect(pid).toBeNull();
43
+ });
44
+ });
45
+
46
+ describe("writePidFile", () => {
47
+ test("roundtrip write then read", async () => {
48
+ const pidPath = join(tempDir, "roundtrip.pid");
49
+ await writePidFile(pidPath, 42);
50
+ const pid = await readPidFile(pidPath);
51
+ expect(pid).toBe(42);
52
+ });
53
+ });
54
+
55
+ describe("removePidFile", () => {
56
+ test("removes existing file", async () => {
57
+ const pidPath = join(tempDir, "remove.pid");
58
+ await Bun.write(pidPath, "99\n");
59
+ expect(await Bun.file(pidPath).exists()).toBe(true);
60
+ await removePidFile(pidPath);
61
+ expect(await Bun.file(pidPath).exists()).toBe(false);
62
+ });
63
+
64
+ test("does not throw for nonexistent file", async () => {
65
+ await removePidFile(join(tempDir, "nope.pid"));
66
+ // No throw = pass
67
+ });
68
+ });
69
+
70
+ describe("acquirePidLock", () => {
71
+ const alwaysAlive = (_pid: number) => true;
72
+ const alwaysDead = (_pid: number) => false;
73
+
74
+ test("acquires when no lock file exists", async () => {
75
+ const pidPath = join(tempDir, "lock.pid");
76
+ const result = await acquirePidLock(pidPath, 1234, alwaysAlive);
77
+ expect(result.acquired).toBe(true);
78
+ expect(await readPidFile(pidPath)).toBe(1234);
79
+ });
80
+
81
+ test("creates parent directory if missing", async () => {
82
+ const pidPath = join(tempDir, "nested", "deeper", "lock.pid");
83
+ const result = await acquirePidLock(pidPath, 555, alwaysAlive);
84
+ expect(result.acquired).toBe(true);
85
+ expect(await readPidFile(pidPath)).toBe(555);
86
+ });
87
+
88
+ test("refuses when a live foreign PID owns the lock", async () => {
89
+ const pidPath = join(tempDir, "lock.pid");
90
+ await Bun.write(pidPath, "9999\n");
91
+ const result = await acquirePidLock(pidPath, 1234, alwaysAlive);
92
+ expect(result.acquired).toBe(false);
93
+ if (!result.acquired) {
94
+ expect(result.existingPid).toBe(9999);
95
+ }
96
+ // File untouched.
97
+ expect(await readPidFile(pidPath)).toBe(9999);
98
+ });
99
+
100
+ test("idempotent when file already contains caller's own PID", async () => {
101
+ const pidPath = join(tempDir, "lock.pid");
102
+ await Bun.write(pidPath, "1234\n");
103
+ // alwaysAlive would say 1234 is alive, but acquirePidLock should detect
104
+ // own-PID first and accept.
105
+ const result = await acquirePidLock(pidPath, 1234, alwaysAlive);
106
+ expect(result.acquired).toBe(true);
107
+ expect(await readPidFile(pidPath)).toBe(1234);
108
+ });
109
+
110
+ test("reclaims stale lock with dead PID", async () => {
111
+ const pidPath = join(tempDir, "lock.pid");
112
+ await Bun.write(pidPath, "9999\n");
113
+ const result = await acquirePidLock(pidPath, 1234, alwaysDead);
114
+ expect(result.acquired).toBe(true);
115
+ expect(await readPidFile(pidPath)).toBe(1234);
116
+ });
117
+
118
+ test("reclaims unreadable/corrupted lock file", async () => {
119
+ const pidPath = join(tempDir, "lock.pid");
120
+ await Bun.write(pidPath, "garbage-not-a-pid\n");
121
+ const result = await acquirePidLock(pidPath, 1234, alwaysAlive);
122
+ expect(result.acquired).toBe(true);
123
+ expect(await readPidFile(pidPath)).toBe(1234);
124
+ });
125
+
126
+ test("two simultaneous acquirers — only one wins", async () => {
127
+ const pidPath = join(tempDir, "lock.pid");
128
+ const [a, b] = await Promise.all([
129
+ acquirePidLock(pidPath, 1111, alwaysAlive),
130
+ acquirePidLock(pidPath, 2222, alwaysAlive),
131
+ ]);
132
+ const winners = [a, b].filter((r) => r.acquired);
133
+ const losers = [a, b].filter((r) => !r.acquired);
134
+ expect(winners.length).toBe(1);
135
+ expect(losers.length).toBe(1);
136
+ const loser = losers[0];
137
+ if (loser && !loser.acquired) {
138
+ expect([1111, 2222]).toContain(loser.existingPid);
139
+ }
140
+ });
141
+
142
+ test("two simultaneous acquirers — file content matches the winner", async () => {
143
+ const pidPath = join(tempDir, "lock.pid");
144
+ const [a, b] = await Promise.all([
145
+ acquirePidLock(pidPath, 1111, alwaysAlive),
146
+ acquirePidLock(pidPath, 2222, alwaysAlive),
147
+ ]);
148
+ const fileContent = await readPidFile(pidPath);
149
+ const winnerPid = a.acquired ? 1111 : b.acquired ? 2222 : -1;
150
+ expect(fileContent).toBe(winnerPid);
151
+ });
152
+ });
@@ -0,0 +1,130 @@
1
+ /**
2
+ * PID file management for daemon processes.
3
+ */
4
+ import { randomUUID } from "node:crypto";
5
+ import { link, mkdir, unlink, writeFile } from "node:fs/promises";
6
+ import { dirname } from "node:path";
7
+
8
+ /**
9
+ * Read the PID from a PID file.
10
+ * Returns null if the file doesn't exist or can't be parsed.
11
+ */
12
+ export async function readPidFile(pidFilePath: string): Promise<number | null> {
13
+ const file = Bun.file(pidFilePath);
14
+ const exists = await file.exists();
15
+ if (!exists) {
16
+ return null;
17
+ }
18
+
19
+ try {
20
+ const text = await file.text();
21
+ const pid = Number.parseInt(text.trim(), 10);
22
+ if (Number.isNaN(pid) || pid <= 0) {
23
+ return null;
24
+ }
25
+ return pid;
26
+ } catch {
27
+ return null;
28
+ }
29
+ }
30
+
31
+ /**
32
+ * Write a PID to a PID file.
33
+ */
34
+ export async function writePidFile(pidFilePath: string, pid: number): Promise<void> {
35
+ await Bun.write(pidFilePath, `${pid}\n`);
36
+ }
37
+
38
+ /**
39
+ * Remove a PID file.
40
+ */
41
+ export async function removePidFile(pidFilePath: string): Promise<void> {
42
+ try {
43
+ await unlink(pidFilePath);
44
+ } catch {
45
+ // File may already be gone — not an error
46
+ }
47
+ }
48
+
49
+ /**
50
+ * Result of acquirePidLock.
51
+ *
52
+ * `acquired: true` — caller owns the lock and is responsible for removing the
53
+ * PID file on shutdown.
54
+ *
55
+ * `acquired: false` — a live foreign process already owns the lock; caller
56
+ * must not start. `existingPid` is the live owner. `existingPid === -1` means
57
+ * the lock file existed but was unreadable and could not be reclaimed.
58
+ */
59
+ export type AcquirePidLockResult = { acquired: true } | { acquired: false; existingPid: number };
60
+
61
+ /**
62
+ * Atomically acquire a PID-file lock.
63
+ *
64
+ * Uses the write-temp-then-link pattern so the lock file appears at its final
65
+ * path with PID contents already present (no empty-file window): a competing
66
+ * reader can never observe an in-flight write. Behavior:
67
+ *
68
+ * - Lock file does not exist → atomic create via link(). Caller owns the lock.
69
+ * - Lock file exists, contains the caller's own PID → idempotent acquire
70
+ * (caller already owns it; e.g. background-mode parent wrote child.pid
71
+ * before spawn).
72
+ * - Lock file exists with a live foreign PID → refuse; return existingPid.
73
+ * - Lock file exists with a dead PID (or unreadable) → reclaim by unlinking
74
+ * and retrying once. If the retry races and loses to a live foreign
75
+ * watchdog, the call returns acquired=false with that foreign PID.
76
+ *
77
+ * Parent directory is created if missing (matches the implicit Bun.write
78
+ * behavior the legacy writePidFile relied on).
79
+ */
80
+ export async function acquirePidLock(
81
+ pidFilePath: string,
82
+ pid: number,
83
+ isAlive: (pid: number) => boolean,
84
+ ): Promise<AcquirePidLockResult> {
85
+ await mkdir(dirname(pidFilePath), { recursive: true });
86
+
87
+ // Stage the PID content at a unique temp path. After link() succeeds, the
88
+ // lock path appears with full content already present.
89
+ const tempPath = `${pidFilePath}.tmp.${pid}.${randomUUID()}`;
90
+ await writeFile(tempPath, `${pid}\n`);
91
+
92
+ try {
93
+ // Two attempts: first try, then one stale-lock reclaim retry. A second
94
+ // EEXIST after reclaim means a live foreign process raced in.
95
+ for (let attempt = 0; attempt < 2; attempt++) {
96
+ try {
97
+ await link(tempPath, pidFilePath);
98
+ return { acquired: true };
99
+ } catch (err: unknown) {
100
+ const code = (err as NodeJS.ErrnoException | undefined)?.code;
101
+ if (code !== "EEXIST") {
102
+ throw err;
103
+ }
104
+ const existing = await readPidFile(pidFilePath);
105
+ if (existing === null) {
106
+ // Unreadable/corrupted lock file — treat as stale.
107
+ await removePidFile(pidFilePath);
108
+ continue;
109
+ }
110
+ if (existing === pid) {
111
+ // Idempotent: caller already owns it (parent pre-wrote child PID).
112
+ return { acquired: true };
113
+ }
114
+ if (isAlive(existing)) {
115
+ return { acquired: false, existingPid: existing };
116
+ }
117
+ // Stale: reclaim and retry once.
118
+ await removePidFile(pidFilePath);
119
+ }
120
+ }
121
+
122
+ // Two stale-then-retry attempts both failed. Another writer raced in
123
+ // between our reclaim and our retry — they own the lock now.
124
+ const existing = await readPidFile(pidFilePath);
125
+ return { acquired: false, existingPid: existing ?? -1 };
126
+ } finally {
127
+ // Drop the temp inode link (lock path retains the data via the second link).
128
+ await unlink(tempPath).catch(() => {});
129
+ }
130
+ }
@@ -0,0 +1,53 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { findRunningWatchdogProcesses } from "./process-scan.ts";
3
+
4
+ describe("findRunningWatchdogProcesses", () => {
5
+ test("returns an array (does not throw)", async () => {
6
+ const results = await findRunningWatchdogProcesses();
7
+ expect(Array.isArray(results)).toBe(true);
8
+ // We can't assert specifics — depends on what's running on the host —
9
+ // but each entry should have a numeric pid and string command.
10
+ for (const proc of results) {
11
+ expect(typeof proc.pid).toBe("number");
12
+ expect(proc.pid).toBeGreaterThan(0);
13
+ expect(typeof proc.command).toBe("string");
14
+ }
15
+ });
16
+
17
+ test("excludes own process even if command matches", async () => {
18
+ // The test process itself runs `bun test ...` not `ap watch`, so it
19
+ // would not match anyway. But we still verify own-pid is filtered out
20
+ // by checking no result has our PID.
21
+ const results = await findRunningWatchdogProcesses();
22
+ const ownPid = process.pid;
23
+ for (const proc of results) {
24
+ expect(proc.pid).not.toBe(ownPid);
25
+ }
26
+ });
27
+
28
+ test("matches `ap watch` and `bun run ap watch` invocations", async () => {
29
+ // Spawn a sleeper whose command line contains the `ap watch` substring,
30
+ // then verify the scanner finds it. We use `sh -c` so the argv string
31
+ // passed to ps contains our marker tokens.
32
+ const sleeper = Bun.spawn(["sh", "-c", "exec -a 'bun run ap watch' sleep 30"], {
33
+ stdout: "ignore",
34
+ stderr: "ignore",
35
+ });
36
+ try {
37
+ // Give ps a moment to see the new process.
38
+ await Bun.sleep(150);
39
+ const results = await findRunningWatchdogProcesses();
40
+ const found = results.find((p) => p.pid === sleeper.pid);
41
+ // On macOS BSD ps, `exec -a` may or may not change the displayed
42
+ // argv depending on shell version. We accept either: if the
43
+ // command is detected, it must look right; if not, we don't fail
44
+ // the test (env-dependent).
45
+ if (found) {
46
+ expect(found.command).toMatch(/\b(ap|agentplate)\b.*\bwatch\b/);
47
+ }
48
+ } finally {
49
+ sleeper.kill("SIGTERM");
50
+ await sleeper.exited.catch(() => {});
51
+ }
52
+ });
53
+ });