@overdeck/desktop 0.45.13 → 0.45.16

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 (336) hide show
  1. package/package.json +1 -1
  2. package/server/{acceptance-criteria-CfujPRrz.js → acceptance-criteria-BZ_nQS1f.js} +2 -2
  3. package/server/{advancing-selfheal-Bn5QiLtn.js → advancing-selfheal-DJYAvGv-.js} +6 -5
  4. package/server/{agent-gc-Cl9Nm0Zu.js → agent-gc-D0wDIeV1.js} +3 -3
  5. package/server/{agent-state-C6Rm6FPR.js → agent-state-lGvgPP4m.js} +1 -1
  6. package/server/{agent-state-service-U8feBf8t.js → agent-state-service-CZrayD_G.js} +1 -1
  7. package/server/{agent-state-service-JhFzYCP_.js → agent-state-service-DFslS5go.js} +9 -52
  8. package/server/{agents-DqQa4Dq_.js → agents-B9GwfCyp.js} +155 -153
  9. package/server/{agents-CxbuBjID.js → agents-CEcaxSkR.js} +1 -1
  10. package/server/{archive-planning-CPitEZo4.js → archive-planning-CbghE9-o.js} +5 -5
  11. package/server/{archive-planning-DmpT1_14.js → archive-planning-CoyIbgGK.js} +1 -1
  12. package/server/{artifact-index-jobs-sMnNtPhy.js → artifact-index-jobs-CcSrUoSE.js} +2 -2
  13. package/server/auto-commit-IW-eik_b.js +3 -0
  14. package/server/{backlog-auto-trigger-Cf9C8fwD.js → backlog-auto-trigger-BfgDsII0.js} +3 -3
  15. package/server/{bd-process-lock-D1aJxeYv.js → bd-process-lock-XAXAet9O.js} +32 -11
  16. package/server/{beads-CivpXztb.js → beads-B1u04n3o.js} +1 -1
  17. package/server/{beads-DRoj6-e1.js → beads-BeSMEjGT.js} +96 -196
  18. package/server/{beads-query-tOsAYP5h.js → beads-query-Dl4FhT8J.js} +24 -99
  19. package/server/beads-restore-BUItPOjI.js +23 -0
  20. package/server/behavior-BTpYbAv4.js +10 -0
  21. package/server/{briefing-freshness-BL3Q6p7R.js → briefing-freshness-DESTCWo-.js} +4 -4
  22. package/server/{checkpoint-manager-DCxjQvP_.js → checkpoint-manager-BwFdegJF.js} +2 -2
  23. package/server/{checkpoint-manager-j5URlFY7.js → checkpoint-manager-DWSXooZV.js} +1 -1
  24. package/server/checkpoint-worker.js +1 -1
  25. package/server/{checkpoints-BoX1H2Hf.js → checkpoints-BtUonbs3.js} +2 -2
  26. package/server/{checkpoints-Bu_M_Q9W.js → checkpoints-fjadfOKF.js} +1 -1
  27. package/server/{claude-hooks-provision-D-jGXmu1.js → claude-hooks-provision-DAtxutOs.js} +132 -114
  28. package/server/claude-hooks-provision-DiK62u0k.js +3 -0
  29. package/server/{claude-permissions-DxIjkOVl.js → claude-permissions-CThLgf8d.js} +2 -2
  30. package/server/{claude-settings-overlay-BPDS6GfE.js → claude-settings-overlay-BF9MPIwx.js} +2 -2
  31. package/server/{claude-settings-overlay-5b9X2S0C.js → claude-settings-overlay-Dx_Uqcmv.js} +1 -1
  32. package/server/{cli-s7bPWy-E.js → cli-ByKIIMfT.js} +9 -9
  33. package/server/cli-D9cUTbBr.js +3 -0
  34. package/server/{cliproxy-BituqWtR.js → cliproxy-B4c1BUvs.js} +2 -2
  35. package/server/{cliproxy-D3sHB0sI.js → cliproxy-C_GuT5DU.js} +1 -1
  36. package/server/{close-issue-CrBE7voz.js → close-issue-BOuLKr6e.js} +5 -5
  37. package/server/{closed-issue-reaper-BRQBc7Xa.js → closed-issue-reaper-CksfuiFD.js} +1 -1
  38. package/server/{closed-issue-reaper-DX9mYUji.js → closed-issue-reaper-DCAYzNXW.js} +102 -30
  39. package/server/{codex-Bi1ITSOo.js → codex-BYPz6ZdF.js} +1 -1
  40. package/server/{codex-CO0HrK3j.js → codex-BbqIRZjd.js} +6 -5
  41. package/server/{behavior-D3d2mVyD.js → codex-parser-mFYZh8vR.js} +3 -9
  42. package/server/{compact-beads-DUkCUFxA.js → compact-beads-CyJtoXJi.js} +15 -8
  43. package/server/{compact-beads-CrA8-k10.js → compact-beads-N00E4EVa.js} +1 -1
  44. package/server/{config-DsCxzjXK.js → config-DVLdmnMJ.js} +2 -2
  45. package/server/{config-B4_d7z7z.js → config-Dj6N8EP9.js} +1 -1
  46. package/server/{config-yaml-C_djSurJ.js → config-yaml-cNOZcEww.js} +118 -23
  47. package/server/{conflict-gate-D47Kl78u.js → conflict-gate-BMKm_5DV.js} +3 -3
  48. package/server/{conflict-gate-CpOn9KC-.js → conflict-gate-CuXUv-bP.js} +1 -1
  49. package/server/{context-layers-CHl3Nroz.js → context-layers-CeAwSYNt.js} +2 -2
  50. package/server/{context-layers-BNhUBTkf.js → context-layers-c0JqYHcS.js} +3 -3
  51. package/server/{control-settings-KyYNXZLv.js → control-settings-D4EFsRBd.js} +4 -4
  52. package/server/{conversation-compaction-D3aS5PnS.js → conversation-compaction-BCggyih4.js} +8 -8
  53. package/server/{conversation-compaction-D4Wed1BJ.js → conversation-compaction-kHVfVRg3.js} +1 -1
  54. package/server/{conversation-service-CFZIjs-M.js → conversation-service-B8LFQQm1.js} +6 -5
  55. package/server/{conversation-service-DcDB5usR.js → conversation-service-BTzObqGi.js} +1 -1
  56. package/server/{conversations-BSOU5qnu.js → conversations-B-lcHv-5.js} +4 -4
  57. package/server/{conversations-BbDPrz89.js → conversations-BM7zlXjr.js} +1 -1
  58. package/server/{cost-BE_ulOrw.js → cost-BcpRQ-7o.js} +3 -3
  59. package/server/{cost-CaiG63FG.js → cost-D9kPIY_5.js} +2 -2
  60. package/server/{cost-sync-D6AX8jv-.js → cost-sync-DF6odxK9.js} +3 -3
  61. package/server/{dag-BQFtXOuJ.js → dag-DKI3sOmX.js} +3 -3
  62. package/server/{dangerous-git-ops-04Ba-Qu3.js → dangerous-git-ops-Bn18PEcN.js} +1 -1
  63. package/server/dashboard-db-worker.js +6 -6
  64. package/server/{deacon-Bz-6Wjmn.js → deacon-DONuopff.js} +3 -3
  65. package/server/{deacon-2EvA9Ci3.js → deacon-DdARMjZq.js} +188 -1601
  66. package/server/{deacon-swarm-DYYOzHRH.js → deacon-swarm-BNGkmYR_.js} +12 -105
  67. package/server/deacon-swarm-CvdFekIO.js +2 -0
  68. package/server/deacon.js +7 -7
  69. package/server/{devcontainer-renderer-Dx0C7VWJ.js → devcontainer-renderer-C-34Sxyz.js} +3 -3
  70. package/server/{dist-ya66D9ze.js → dist-BMXvKiZP.js} +1 -1
  71. package/server/dist-BNwrgsUO.js +3 -0
  72. package/server/{dist-src-Byxt1ifL.js → dist-src-BgGUblXg.js} +1 -1
  73. package/server/{docker-D2TbXDO6.js → docker-apZ9pBYw.js} +67 -28
  74. package/server/{ensure-devcontainer-BTiMfFp5.js → ensure-devcontainer-Bqi1EF1P.js} +4 -4
  75. package/server/{ensure-devcontainer-BIShzs3M.js → ensure-devcontainer-Dq3KoOIl.js} +1 -1
  76. package/server/{esm-shims-DiV9jHa_.js → esm-shims-BEjvINX3.js} +1 -1
  77. package/server/{event-store-C02Gprug.js → event-store-CqDgfbAs.js} +1 -1
  78. package/server/{event-store-C71x6h0i.js → event-store-D89fMfXS.js} +3 -3
  79. package/server/{events-BFDuPV6n.js → events-S_C0pvl6.js} +3 -3
  80. package/server/{events-D2gP2-Yv.js → events-rIGncmP1.js} +1 -1
  81. package/server/export-BJHSVwKP.js +87 -0
  82. package/server/{factory-ByfbrCab.js → factory-DV18m0rc.js} +5 -5
  83. package/server/{feature-registry-population-CSH66UO8.js → feature-registry-population-CqZSnqQi.js} +4 -4
  84. package/server/{feature-registry-storage-DW7dUefc.js → feature-registry-storage-BeXmNBwK.js} +2 -2
  85. package/server/{features-x_aLR7U9.js → features-u5XeMaOK.js} +2 -2
  86. package/server/{feedback-target-BFo9fScH.js → feedback-target-CwyWf0Dg.js} +6 -6
  87. package/server/{feedback-target-C4VMPTZu.js → feedback-target-DDG1PWl3.js} +1 -1
  88. package/server/{feedback-writer-DCyd75yJ.js → feedback-writer-BsJiw5aT.js} +1 -1
  89. package/server/{feedback-writer-B9ZqnnX3.js → feedback-writer-D20HSXre.js} +4 -4
  90. package/server/{flywheel-DwQCoZQG.js → flywheel-9Y1X7IM-.js} +13 -13
  91. package/server/{flywheel-D9NKvWqb.js → flywheel-Dczmgchd.js} +23 -15
  92. package/server/{flywheel-DB48X7Nn.js → flywheel-RCnHAgc4.js} +1 -1
  93. package/server/{flywheel-merge-order-DrjmFXg4.js → flywheel-merge-order-CtCYsFJP.js} +7 -7
  94. package/server/{flywheel-merge-order-f1bi5XIf.js → flywheel-merge-order-DEtEd3aK.js} +1 -1
  95. package/server/forge-8y6aEPTB.js +2 -0
  96. package/server/{forge-C-SoKy8U.js → forge-DFDThFil.js} +2 -2
  97. package/server/{from-fu7ZumnY.js → from-NSJypkI8.js} +1 -1
  98. package/server/{fts-db-CL-YzSRG.js → fts-db-C5A_TTOM.js} +2 -2
  99. package/server/{fts-operations-Bylu5NjM.js → fts-operations-DvCSimfU.js} +2 -2
  100. package/server/{git-utils-Ym380hJZ.js → git-utils-C0neHvQx.js} +1 -1
  101. package/server/{git-utils-C8koBs8j.js → git-utils-Dr0L7mFk.js} +2 -2
  102. package/server/{github-app-DIr70YLg.js → github-app-Bwt9lK4A.js} +1 -1
  103. package/server/{github-app-DhycHVLF.js → github-app-u9-SX7Gc.js} +2 -2
  104. package/server/{github-client-7gkcXcBS.js → github-client-Btyy3uan.js} +3 -3
  105. package/server/{github-client-Ji9lUTNF.js → github-client-Do3k-N1W.js} +1 -1
  106. package/server/{github-pr-lookup-rt7T9FwA.js → github-pr-lookup-DOF1U2WW.js} +2 -2
  107. package/server/{harness-resolve-CvGXUnAF.js → harness-resolve-C6g2R0Cd.js} +3 -3
  108. package/server/{health-events-D4E1DLN-.js → health-events-B4u-2a9m.js} +2 -2
  109. package/server/{health-events-DDfkoO5a.js → health-events-Bos3sk5g.js} +1 -1
  110. package/server/{hooks-D_6pAbuJ.js → hooks-BCoOjL_R.js} +2 -2
  111. package/server/{hooks-DvdnCVv6.js → hooks-uEr8WTx_.js} +1 -1
  112. package/server/{hume-DkVOs1Qi.js → hume-BqxYKbUf.js} +2 -2
  113. package/server/hume-CerGi2RH.js +3 -0
  114. package/server/{idle-stack-reaper-cDRXDpgE.js → idle-stack-reaper-DbZ8ukMd.js} +2 -2
  115. package/server/{idle-stack-reaper-DNbV9-fj.js → idle-stack-reaper-H0W6XtHh.js} +1 -1
  116. package/server/{infra-Clew3DpF.js → infra-Ba9mS6e6.js} +3 -3
  117. package/server/{injection-QCIHOpk6.js → injection-C1DKXeIZ.js} +1 -1
  118. package/server/{injection-ql0Nddcn.js → injection-yrMBxzVq.js} +7 -7
  119. package/server/{inspect-agent-DY18KEx4.js → inspect-agent-B-mnb4uh.js} +9 -9
  120. package/server/inspect-agent-CWkeAB7J.js +2 -0
  121. package/server/{internal-token-C90jBp-y.js → internal-token-BPYEaXFh.js} +1 -1
  122. package/server/{internal-token-BeXjPLKy.js → internal-token-BtqSbKTx.js} +2 -2
  123. package/server/{io-CbAFXQge.js → io-VEGSAqsd.js} +15 -22
  124. package/server/{issue-closed-BYC2TCk-.js → issue-closed-BxcePXL-.js} +4 -4
  125. package/server/{issue-lifecycle-BoJa6qIt.js → issue-lifecycle--8MRxrG1.js} +22 -22
  126. package/server/{issue-lifecycle-CLHbj8RS.js → issue-lifecycle-CdMUauyb.js} +1 -1
  127. package/server/{issue-service-singleton-BS9NENuR.js → issue-service-singleton-CkTUI-eu.js} +10 -10
  128. package/server/{issue-service-singleton-3g_CCY-1.js → issue-service-singleton-MZsDhAob.js} +1 -1
  129. package/server/{issues-BPcqPqfV.js → issues-CBAsuoAO.js} +2 -2
  130. package/server/jsonl-parser-BUW5_sSK.js +245 -0
  131. package/server/{label-cleanup-CwiCSZrb.js → label-cleanup-htK6fBRU.js} +3 -3
  132. package/server/{layers-OHHLphl0.js → layers-jjL16At6.js} +2 -2
  133. package/server/{lifecycle-B8IdvUbt.js → lifecycle-Ck2UR27D.js} +7 -7
  134. package/server/{lifecycle-io-BMCH1Bxg.js → lifecycle-io-DXv8Utct.js} +1 -1
  135. package/server/{lifecycle-io-C73m5e6R.js → lifecycle-io-IehcRt2v.js} +36 -47
  136. package/server/{linear-client-BMxgycoy.js → linear-client-By7hjwuN.js} +1 -1
  137. package/server/{linear-client-CgQlM4aT.js → linear-client-D9cSQfvD.js} +3 -3
  138. package/server/memory-fts-worker.js +1 -1
  139. package/server/{merge-BRkVaIx-.js → merge-BLcEZNXz.js} +1 -1
  140. package/server/{merge-B7Oi-g6k.js → merge-BN8FGJ2q.js} +3 -3
  141. package/server/{merge-agent-DNFFsiEm.js → merge-agent-B3u2szOL.js} +29 -29
  142. package/server/{merge-agent-Bwm34MZ5.js → merge-agent-ejC7Wos8.js} +1 -1
  143. package/server/{merge-batch-BpYXUaqJ.js → merge-batch-DyqlgYGp.js} +1 -1
  144. package/server/{merge-blockers-DIOZmqdK.js → merge-blockers-cq-aS2KG.js} +2 -2
  145. package/server/merge-ops-CFkQtXEj.js +2 -0
  146. package/server/{merge-rebase-C8WH39MB.js → merge-rebase-Dl7yIxtV.js} +2 -2
  147. package/server/{merge-set-CsEE2CyQ.js → merge-set-CZ9Nbay0.js} +3 -3
  148. package/server/{merge-set-DOsBTBQc.js → merge-set-Dy0D0r3i.js} +1 -1
  149. package/server/{merge-sync-CcLM2vwW.js → merge-sync-CG9kDCdT.js} +1 -1
  150. package/server/{merge-sync-C2B322Ot.js → merge-sync-D7jWLgCC.js} +2 -2
  151. package/server/{merge-train-BIm7-7cy.js → merge-train-CH33XJOH.js} +3 -3
  152. package/server/{merge-train-deps-DG1oQ2DT.js → merge-train-deps-JoVdGnOg.js} +4 -4
  153. package/server/{merge-verification-BAbjxEd5.js → merge-verification-D4wTChdX.js} +4 -4
  154. package/server/{multipart-parser-jKtKAXtl.js → multipart-parser-Csn8U-K9.js} +2 -2
  155. package/server/{no-resume-mode-yrPEQ_pC.js → no-resume-mode-Gh5R_YLF.js} +1 -1
  156. package/server/node_modules/.package-lock.json +1 -1
  157. package/server/{orphan-proposed-reconciler-BqVGaOIj.js → orphan-proposed-reconciler-BIcNu4tl.js} +1 -1
  158. package/server/{orphan-proposed-reconciler-zHrwJ3xr.js → orphan-proposed-reconciler-DI_IS1p_.js} +15 -39
  159. package/server/package-lock.json +2 -2
  160. package/server/package.json +1 -1
  161. package/server/{pan-dir-BlNrLzW7.js → pan-dir-CQIfKG8c.js} +4 -4
  162. package/server/{paths-CiVeo4N_.js → paths-Blj6A9AJ.js} +2 -2
  163. package/server/{pickup-KLRUhThD.js → pickup-BtvRBbvI.js} +1 -1
  164. package/server/{pipeline-notifier-nkWCe042.js → pipeline-notifier-BH4PAi-w.js} +1 -1
  165. package/server/{pipeline-notifier-DBF6dHxH.js → pipeline-notifier-D_WYtEwl.js} +2 -2
  166. package/server/project-pipelines-jMTitpeO.js +162 -0
  167. package/server/projects-CrBE6IhN.js +3 -0
  168. package/server/{prompts-CXbQvevY.js → prompts-B3njaImU.js} +2 -2
  169. package/server/{providers-CUdAb5Qu.js → providers-BbEyFhPX.js} +7 -7
  170. package/server/providers-DnPxkHoG.js +3 -0
  171. package/server/public/assets/chunk-BO2N2NFS-3qh9TUJi.js +103 -0
  172. package/server/public/assets/{diagram-OG6HWLK6-NACO1xW5.js → diagram-OG6HWLK6-D8UVTPj7.js} +1 -1
  173. package/server/public/assets/{dist-DQD-ZjQY.js → dist-BvLLn1YC.js} +3 -3
  174. package/server/public/assets/{dist-D1FjqGgm.js → dist-CyubL7Wj.js} +1 -1
  175. package/server/public/assets/{ganttDiagram-6RSMTGT7-CEMAKgmE.js → ganttDiagram-6RSMTGT7-BI4E1LbQ.js} +1 -1
  176. package/server/public/assets/{highlighted-body-OFNGDK62-D5lC7FW8.js → highlighted-body-OFNGDK62-D_bapWkF.js} +1 -1
  177. package/server/public/assets/index-BAZxZ-8I.js +796 -0
  178. package/server/public/assets/index-FBG3JBfq.css +1 -0
  179. package/server/public/assets/{linear-DDHAMRPB.js → linear-CEdobObr.js} +1 -1
  180. package/server/public/assets/mermaid-GHXKKRXX-BYP8MI37.js +1 -0
  181. package/server/public/assets/{pieDiagram-4H26LBE5-DwBdeKBb.js → pieDiagram-4H26LBE5-DT79L01f.js} +1 -1
  182. package/server/public/assets/{prod-DAZt0c4G.js → prod-CR_K2DFV.js} +4 -4
  183. package/server/public/assets/{quadrantDiagram-W4KKPZXB-CNDLp-_m.js → quadrantDiagram-W4KKPZXB-BEkKXXrL.js} +1 -1
  184. package/server/public/assets/{sankeyDiagram-5OEKKPKP-oIUL_uO1.js → sankeyDiagram-5OEKKPKP-BxSCqn-j.js} +1 -1
  185. package/server/public/assets/{xychartDiagram-2RQKCTM6-B-IM6Cck.js → xychartDiagram-2RQKCTM6-DicwJGG_.js} +1 -1
  186. package/server/public/index.html +2 -2
  187. package/server/{pull-requests-DZ-V7_LB.js → pull-requests-43LPDfM8.js} +1 -1
  188. package/server/{pull-requests-kTRl7cWC.js → pull-requests-DyUkQl4W.js} +3 -3
  189. package/server/{rally-xCTZ-UcI.js → rally-BtWjybOa.js} +3 -3
  190. package/server/{rally-api-jOk4vit_.js → rally-api-B-YA_oXx.js} +1 -1
  191. package/server/{rally-api-DpZo3WwF.js → rally-api-B-osULNL.js} +2 -2
  192. package/server/{rally-client-BQjm3aaq.js → rally-client-CzFymP7P.js} +1 -1
  193. package/server/{rally-client-CoWZeVEj.js → rally-client-sErNQBO0.js} +5 -5
  194. package/server/rally-oggyp4cs.js +3 -0
  195. package/server/{reap-terminal-sessions-CAmURC2h.js → reap-terminal-sessions-2s-fvilz.js} +1 -1
  196. package/server/{reap-terminal-sessions-BwJb2DLJ.js → reap-terminal-sessions-DnDQ7pf9.js} +1 -1
  197. package/server/{rebuild-stack-BZ7j27xY.js → rebuild-stack-CoU-HqZj.js} +6 -6
  198. package/server/{rebuild-stack-r-cxcr26.js → rebuild-stack-nGD_nPZo.js} +1 -1
  199. package/server/{reconstruct-cache-13EkMg_i.js → reconstruct-cache-Wj8GrYB0.js} +6 -6
  200. package/server/record-lEYK__LD.js +3 -0
  201. package/server/recovery-trip-BT7nb9OV.js +98 -0
  202. package/server/{release-engine-YLTnEO_K.js → release-engine-CBLQXDlH.js} +5 -5
  203. package/server/{release-set-DwvT_Opu.js → release-set-BNiqi7hy.js} +3 -3
  204. package/server/{remote-B062S1Kc.js → remote-DW_TWQnr.js} +3 -3
  205. package/server/{remote-9u9WL0NJ.js → remote-DcO9An_f.js} +3 -3
  206. package/server/{remote-agents-BBTg-vLT.js → remote-agents-C4cyxHQS.js} +6 -6
  207. package/server/{remote-agents-B4eWgwNd.js → remote-agents-Cv-U-_V8.js} +1 -1
  208. package/server/{remote-completion-B0rmBnbL.js → remote-completion-PhXGGFQy.js} +13 -13
  209. package/server/resolver-C7dLn8OK.js +158 -0
  210. package/server/resume-BYZBIab-.js +3 -0
  211. package/server/{review-agent-tfR5RDzG.js → review-agent-Dn-yn7Zj.js} +23 -23
  212. package/server/{review-agent-Bf9XjLnl.js → review-agent-u5q3ip7H.js} +1 -1
  213. package/server/{review-artifacts-D0_IE6DQ.js → review-artifacts-D4T04aYI.js} +6 -6
  214. package/server/{review-context-CXO5ml5D.js → review-context-Rc0VDLG_.js} +8 -8
  215. package/server/{review-monitor-DfFUSx0J.js → review-monitor-BC--miCO.js} +2 -2
  216. package/server/{review-status-B0Axo-Ma.js → review-status-CG0iDuWa.js} +1 -1
  217. package/server/{review-status-BcfwcAcO.js → review-status-Dc1l4dVz.js} +18 -18
  218. package/server/{review-status-enrichment-Cp5kFTGl.js → review-status-enrichment-C1a1OG_1.js} +3 -3
  219. package/server/{review-status-sync-CEFty1pW.js → review-status-sync-CAr-hfnJ.js} +2 -2
  220. package/server/{review-status-sync-Bc0c_u12.js → review-status-sync-CfSixhhX.js} +1 -1
  221. package/server/{review-verdict-feedback-BOKRLLDf.js → review-verdict-feedback-CVDbIDBe.js} +7 -7
  222. package/server/{review-verdict-feedback-BnGtT0vN.js → review-verdict-feedback-D2Q960wX.js} +1 -1
  223. package/server/{rollup-BVqFuiS9.js → rollup-CakjztPl.js} +1 -1
  224. package/server/{rollup-C22QDVxh.js → rollup-DctoIu-A.js} +8 -8
  225. package/server/{scanner-BLCnuXqY.js → scanner-BTM1jc_u.js} +7 -7
  226. package/server/{sequence-io-BozlUb6f.js → sequence-io-pnoIDGv4.js} +4 -4
  227. package/server/{sequencer-agent-BEDEHfd2.js → sequencer-agent-9Soi-WL-.js} +10 -6
  228. package/server/server.js +1239 -1836
  229. package/server/{service-C8jKYZ0w.js → service-DfTVoqJg.js} +1668 -1408
  230. package/server/{session-fork-BDGksK4e.js → session-fork-C4jAFHj8.js} +2 -2
  231. package/server/session-history-DanhhiCw.js +168 -0
  232. package/server/{settings-CSLhh6qR.js → settings-Cuc12oP_.js} +2 -2
  233. package/server/settings-Cy7SCFO9.js +2 -0
  234. package/server/{settings-DrRQCren.js → settings-DfT3f86D.js} +3 -3
  235. package/server/{shadow-engineering-BMcBN-Of.js → shadow-engineering-bKuKTCn-.js} +3 -3
  236. package/server/{shadow-state-CHqWHD3T.js → shadow-state-CVHpRWlb.js} +1 -1
  237. package/server/{shadow-state-B4ayC7nx.js → shadow-state-DJz6_pb_.js} +2 -2
  238. package/server/{ship-log-CDVmOlZP.js → ship-log-CwqPFfqx.js} +1 -1
  239. package/server/{smart-compaction-V9DBrGcr.js → smart-compaction-B4KlpZGN.js} +1 -1
  240. package/server/{smart-compaction-C-F5sP9P.js → smart-compaction-CiWfGfRZ.js} +7 -7
  241. package/server/{spawn-planning-session-dSpOU0lP.js → spawn-planning-session-CU-DwyDl.js} +15 -15
  242. package/server/{spawn-planning-session-Bz4_JES2.js → spawn-planning-session-D5N3RuLG.js} +1 -1
  243. package/server/{specialist-completion-Coz3eTSr.js → specialist-completion-CJcv-NXH.js} +1 -1
  244. package/server/{specialist-context-fmb03vXQ.js → specialist-context-B2HlbNxS.js} +5 -5
  245. package/server/{specialist-handoff-logger-B9HK_lRu.js → specialist-handoff-logger-CQyF1ygu.js} +2 -2
  246. package/server/{specialist-logs-B24R2B9Y.js → specialist-logs-CdcUpfjj.js} +3 -3
  247. package/server/{specialists-CL7E8yUg.js → specialists-CFgw_0Q6.js} +1657 -113
  248. package/server/{specialists-BGNZ0_ZW.js → specialists-CwzFdapR.js} +20 -256
  249. package/server/{specialists-B6f5-5nG.js → specialists-D7AcFi1u.js} +1 -1
  250. package/server/{specialists-Chvvwf5z.js → specialists-k22_Btaq.js} +1 -1
  251. package/server/specs-RzcDgozr.js +3 -0
  252. package/server/{src-DGAEtABV.js → src-B4TTzNSr.js} +84 -2
  253. package/server/{src-BVxZuHMg.js → src-BvpMg4EB.js} +3 -3
  254. package/server/{src-D-VXCC9C.js → src-D4YUZrwR.js} +6 -6
  255. package/server/{stack-health-C-le5erA.js → stack-health-CSgcYcr8.js} +2 -2
  256. package/server/{stack-health-BKbTTOT2.js → stack-health-DVN7zRnL.js} +1 -1
  257. package/server/{state-home-CoyWsECg.js → state-home-CEvD_v5F.js} +6 -6
  258. package/server/{state-mapping-BvAgFK8n.js → state-mapping-C13UaCL7.js} +1 -1
  259. package/server/stuck-remediation-CxsyxwFO.js +1513 -0
  260. package/server/stuck-remediation-nTIvhGOR.js +2 -0
  261. package/server/{summary-fork-BF_0vxrq.js → summary-fork-CnRMPCYd.js} +2 -2
  262. package/server/{summary-fork-BlE30Q-I.js → summary-fork-gKSoFAnK.js} +8 -8
  263. package/server/{supervisor-BSeML_p0.js → supervisor-CZMs8s0M.js} +2 -2
  264. package/server/supervisor-CfX192ma.js +2 -0
  265. package/server/{swarm-readiness-CQ4sjrXc.js → swarm-readiness-h6-pwY8w.js} +3 -3
  266. package/server/{system-prerequisites-DnmLYOnU.js → system-prerequisites-DZZTi2G6.js} +1 -1
  267. package/server/{teardown-workspace-CpcjDrZ4.js → teardown-workspace-B4D-oCVg.js} +37 -68
  268. package/server/{test-agent-queue-Bq298zZl.js → test-agent-queue-CnuYku4B.js} +6 -6
  269. package/server/{test-verdict-CCDd25uN.js → test-verdict-CRsPpByy.js} +1 -1
  270. package/server/{test-verdict-Bm4uv_AA.js → test-verdict-CXMLATGc.js} +1 -1
  271. package/server/{tldr-daemon-Rn1Zue0P.js → tldr-daemon-CgZQ3jmZ.js} +1 -1
  272. package/server/{tldr-daemon-CaSUXd9r.js → tldr-daemon-CuA8hz21.js} +2 -2
  273. package/server/{tmux-CbcrkUx5.js → tmux-By8E_Yv_.js} +1 -1
  274. package/server/{tmux-BVc-NeKM.js → tmux-C3g9VFcg.js} +2 -2
  275. package/server/{token-D2OC_9ms.js → token-g_sJ19g3.js} +2 -2
  276. package/server/token-util-C9u4An3K.js +3 -0
  277. package/server/{token-util-CROoF5ic.js → token-util-DiwnnbJo.js} +1 -1
  278. package/server/{tracker-config-EsBoOWEy.js → tracker-config-BzfVj41Z.js} +1 -1
  279. package/server/{tracker-config-C1Y_2CIo.js → tracker-config-dQxC15vH.js} +2 -2
  280. package/server/{tracker-utils-lph9C3Pw.js → tracker-utils--z8FLSWn.js} +2 -2
  281. package/server/{tracker-utils-DSb9cfrF.js → tracker-utils-DkKyRT0H.js} +1 -1
  282. package/server/{traefik-connect-5EP1WS_Z.js → traefik-connect-CZYB3dlm.js} +1 -1
  283. package/server/{tunnel-BFBCeo97.js → tunnel-BpWZs0Jy.js} +3 -3
  284. package/server/{tunnel-DDe-ATFF.js → tunnel-CN2px87h.js} +1 -1
  285. package/server/{typed-errors-DSPn_xWP.js → typed-errors-CrN1AByg.js} +1 -1
  286. package/server/{typed-errors-BEJhbjJg.js → typed-errors-sLpGfegz.js} +1 -1
  287. package/server/{types-CE1m_daD.js → types-BLS_Zbmd.js} +1 -1
  288. package/server/{types-B5u3NxMl.js → types-tlXf80x3.js} +1 -1
  289. package/server/{uat-train-DtKHHy1U.js → uat-train-BRo6DM7p.js} +24 -24
  290. package/server/{uat-train-8T24rPzc.js → uat-train-utGVmqdm.js} +1 -1
  291. package/server/{validation-lyudzPvD.js → validation-CsPVsoA0.js} +4 -4
  292. package/server/{validation-COOXwNXU.js → validation-Dq_wwcw3.js} +1 -1
  293. package/server/{verification-runner-DO0nYTJx.js → verification-runner-CMT5WRMS.js} +12 -12
  294. package/server/{verification-runner-Dd1FX8Qk.js → verification-runner-D0Uh-9jc.js} +1 -1
  295. package/server/{vtt-parser-C57tEF7-.js → vtt-parser-C_XPlifj.js} +1 -1
  296. package/server/{work-agent-prompt-B2F3ZwKm.js → work-agent-prompt-ZTAb2Hyf.js} +12 -12
  297. package/server/workflows-BLwxUMP7.js +2 -0
  298. package/server/{workflows-Clz_AFwR.js → workflows-CXRwhw_o.js} +14 -14
  299. package/server/{workspace-D0rCFemz.js → workspace-9UEsKuiP.js} +1 -1
  300. package/server/{workspace-CY_2-Ddv.js → workspace-CNfuj1-5.js} +3 -3
  301. package/server/{workspace-config-DA8U97k9.js → workspace-config-ChWOECy0.js} +1 -1
  302. package/server/{workspace-manager-BIfpzqdU.js → workspace-manager-BO-6k3sP.js} +1 -1
  303. package/server/{workspace-manager-D6NgGkiF.js → workspace-manager-D8AByFIT.js} +13 -13
  304. package/server/{workspace-metadata-C8yn8kAH.js → workspace-metadata-CvC268MN.js} +2 -2
  305. package/server/{workspace-metadata-DfgR40YU.js → workspace-metadata-DZiJtnuF.js} +1 -1
  306. package/server/{workspaces-DOeHyKmA.js → workspaces-D-uIoqbg.js} +104 -122
  307. package/server/writer-BUweuRPx.js +3 -0
  308. package/server/writer-CrP2xuBn.js +251 -0
  309. package/sync-sources/hooks/ask-user-question-hook +7 -1
  310. package/sync-sources/hooks/record-cost-event.js +4 -3
  311. package/sync-sources/hooks/record-cost-event.js.map +1 -1
  312. package/server/agent-runtime-mirror-DrWxopNA.js +0 -37
  313. package/server/auto-commit-wZ9X7Isd.js +0 -3
  314. package/server/bd-mutex-BItGvLQ8.js +0 -35
  315. package/server/beads-restore-CMbDSB5R.js +0 -60
  316. package/server/cli-CqgiSAU8.js +0 -3
  317. package/server/deacon-swarm-B8pgK2a7.js +0 -2
  318. package/server/dist-BYSj075e.js +0 -3
  319. package/server/forge-CUCYK7h_.js +0 -2
  320. package/server/hume-x9voULey.js +0 -3
  321. package/server/inspect-agent-D8UBDgfR.js +0 -2
  322. package/server/merge-ops-B7m-hBEZ.js +0 -2
  323. package/server/projects-BqRMlDOv.js +0 -3
  324. package/server/providers-gZ0cUgQM.js +0 -3
  325. package/server/public/assets/chunk-BO2N2NFS-C-6y8VFI.js +0 -103
  326. package/server/public/assets/index-BDqWyuZQ.js +0 -796
  327. package/server/public/assets/index-CZiADHcL.css +0 -1
  328. package/server/public/assets/mermaid-GHXKKRXX-DRM2ANcR.js +0 -1
  329. package/server/rally-CRteqXe0.js +0 -3
  330. package/server/record-CJsjvRO4.js +0 -3
  331. package/server/resume-R7xkSmLW.js +0 -3
  332. package/server/settings-CSaXtkoA.js +0 -2
  333. package/server/specs-oaaYaA5c.js +0 -3
  334. package/server/supervisor-03Tj_Wra.js +0 -2
  335. package/server/token-util-xNaV-00f.js +0 -3
  336. package/server/workflows-B4fIN8VP.js +0 -2
@@ -1,25 +1,26 @@
1
- import { Dt as getProjectConfigFromWorkspacePath, Ft as resolveProjectForIssue, Gn as resolveProjectFromIssueSync, Hr as init_paths, Nn as init_projects, Ot as init_record, Pt as readRecordContinueViewSync, Vr as getOverdeckHome, br as AGENTS_DIR, kr as OVERDECK_HOME, yr as init_errors } from "./config-yaml-C_djSurJ.js";
1
+ import { Fn as init_projects, Ft as readRecordContinueViewSync, It as resolveProjectForIssue, Jr as init_paths, Ot as getProjectConfigFromWorkspacePath, Sr as AGENTS_DIR, jr as OVERDECK_HOME, kt as init_record, qn as resolveProjectFromIssueSync, qr as getOverdeckHome, xr as init_errors } from "./config-yaml-cNOZcEww.js";
2
2
  import { a as init_activity_logger, n as emitActivityEntrySync } from "./activity-logger-pcbf-e30.js";
3
- import { D as sql, E as like, S as gte, _ as integer, a as EventBus, b as and, f as init_infra, g as real, h as text, i as DbLive, m as sqliteTable, n as CostArchiveLive, o as EventBusLive, p as init_sqlite_core, r as Db, t as CostArchive, v as init_drizzle_orm, x as eq, y as desc } from "./infra-Clew3DpF.js";
4
- import { L as sessionExists, R as sessionExistsSync, w as killSession, x as init_tmux } from "./tmux-BVc-NeKM.js";
5
- import { c as init_review_status, u as loadReviewStatuses, y as setReviewStatusSync } from "./review-status-BcfwcAcO.js";
6
- import { n as init_issues, t as IssueId } from "./issues-BPcqPqfV.js";
7
- import { a as getAllBudgetsSync, i as deleteBudgetSync, n as checkBudgetSync, r as createBudgetSync, s as init_cost } from "./cost-CaiG63FG.js";
8
- import { An as getAgentDir, D as init_context_overflow, Jn as saveAgentStateSync, Mn as getAgentState, Pn as getAgentStateSync, Q as listRunningAgentsSync, Sr as parseOhmypiSessionCostResultSync, U as spawnAgent, _t as init_config, gr as getAgentRuntimeStateSync, gt as getHealthThresholdsMs, k as isContextOverflowTail, q as stopAgentSync, t as init_agents, vr as saveAgentRuntimeState, xr as init_ohmypi_parser, yt as loadCloisterConfigSync } from "./agents-DqQa4Dq_.js";
9
- import { i as parseCodexSessionSync, n as init_codex_parser, r as parseCodexSessionCostEventsSync } from "./behavior-D3d2mVyD.js";
10
- import { _ as init_tier_metrics, c as getDailyTrendsSync, d as init_cost_sync, h as deriveTieredAgentCostRole, t as getAgentDailyCostSync } from "./cost-sync-D6AX8jv-.js";
11
- import { c as requireModelOverrideSync, o as init_model_validation } from "./state-home-CoyWsECg.js";
12
- import { _ as setDeaconGloballyPaused, c as isCloisterSpawnsPausedSync, g as setCloisterSpawnsPausedSync, s as init_control_settings, x as setFlywheelGloballyPaused } from "./control-settings-KyYNXZLv.js";
13
- import { n as checkHookSync, p as init_hooks } from "./hooks-D_6pAbuJ.js";
14
- import { r as isRoleTerminal, t as init_reap_terminal_sessions } from "./reap-terminal-sessions-CAmURC2h.js";
15
- import { C as getLastPatrolResult, D as isDeaconRunning, Lt as getRuntimeForAgent, N as runPatrol, R as startDeacon, S as getDeaconStatus, n as assessDeaconPatrolFreshness, x as getDeaconLogs, z as stopDeacon } from "./deacon-2EvA9Ci3.js";
16
- import "./flywheel-D9NKvWqb.js";
17
- import { n as renderPrompt, t as init_prompts } from "./prompts-CXbQvevY.js";
18
- import { r as writeHealthEvent } from "./health-events-D4E1DLN-.js";
19
- import { O as init_specialists, T as getTmuxSessionName } from "./specialists-BGNZ0_ZW.js";
20
- import { t as withBdMutex } from "./bd-mutex-BItGvLQ8.js";
21
- import { n as init_issue_closed, r as isIssueClosed } from "./issue-closed-BYC2TCk-.js";
22
- import { n as shouldSkipDispatchAsMerged, t as init_merge_verification } from "./merge-verification-BAbjxEd5.js";
3
+ import { D as sql, E as like, S as gte, _ as integer, a as EventBus, b as and, f as init_infra, g as real, h as text, i as DbLive, m as sqliteTable, n as CostArchiveLive, o as EventBusLive, p as init_sqlite_core, r as Db, t as CostArchive, v as init_drizzle_orm, x as eq, y as desc } from "./infra-Ba9mS6e6.js";
4
+ import { L as sessionExists, R as sessionExistsSync, d as exactPaneTarget, w as killSession, x as init_tmux } from "./tmux-C3g9VFcg.js";
5
+ import { c as init_review_status, u as loadReviewStatuses, y as setReviewStatusSync } from "./review-status-Dc1l4dVz.js";
6
+ import { n as init_issues, t as IssueId } from "./issues-CBAsuoAO.js";
7
+ import { a as getAllBudgetsSync, i as deleteBudgetSync, n as checkBudgetSync, r as createBudgetSync, s as init_cost } from "./cost-D9kPIY_5.js";
8
+ import { An as getAgentDir, D as init_context_overflow, Jn as saveAgentStateSync, Mn as getAgentState, Pn as getAgentStateSync, Q as listRunningAgentsSync, U as spawnAgent, _t as init_config, gt as getHealthThresholdsMs, hr as saveAgentRuntimeState, k as isContextOverflowTail, pr as getAgentRuntimeStateSync, q as stopAgentSync, t as init_agents, vr as init_ohmypi_parser, yr as parseOhmypiSessionCostResultSync, yt as loadCloisterConfigSync } from "./agents-B9GwfCyp.js";
9
+ import { n as parseCodexSessionCostEventsSync, r as parseCodexSessionSync, t as init_codex_parser } from "./codex-parser-mFYZh8vR.js";
10
+ import { _ as init_tier_metrics, c as getDailyTrendsSync, d as init_cost_sync, h as deriveTieredAgentCostRole, t as getAgentDailyCostSync } from "./cost-sync-DF6odxK9.js";
11
+ import { c as requireModelOverrideSync, o as init_model_validation } from "./state-home-CEvD_v5F.js";
12
+ import { _ as setDeaconGloballyPaused, c as isCloisterSpawnsPausedSync, g as setCloisterSpawnsPausedSync, s as init_control_settings, x as setFlywheelGloballyPaused } from "./control-settings-D4EFsRBd.js";
13
+ import { n as init_resolver, t as createBeadsResolver } from "./resolver-C7dLn8OK.js";
14
+ import { n as checkHookSync, p as init_hooks } from "./hooks-BCoOjL_R.js";
15
+ import { r as isRoleTerminal, t as init_reap_terminal_sessions } from "./reap-terminal-sessions-2s-fvilz.js";
16
+ import { o as getRuntimeForAgent } from "./stuck-remediation-CxsyxwFO.js";
17
+ import "./flywheel-Dczmgchd.js";
18
+ import { C as getLastPatrolResult, D as isDeaconRunning, N as runPatrol, R as startDeacon, S as getDeaconStatus, n as assessDeaconPatrolFreshness, x as getDeaconLogs, z as stopDeacon } from "./deacon-DdARMjZq.js";
19
+ import { n as renderPrompt, t as init_prompts } from "./prompts-B3njaImU.js";
20
+ import { O as init_specialists, T as getTmuxSessionName } from "./specialists-CwzFdapR.js";
21
+ import { n as init_issue_closed, r as isIssueClosed } from "./issue-closed-BxcePXL-.js";
22
+ import { n as shouldSkipDispatchAsMerged, t as init_merge_verification } from "./merge-verification-D4wTChdX.js";
23
+ import { r as writeHealthEvent } from "./health-events-B4u-2a9m.js";
23
24
  import { join } from "node:path";
24
25
  import { Context, Data, Effect, Layer, Schema } from "effect";
25
26
  import { appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, statSync, unlinkSync, writeFileSync } from "fs";
@@ -27,7 +28,7 @@ import { dirname as dirname$1, join as join$1 } from "path";
27
28
  import { existsSync as existsSync$1, readdirSync as readdirSync$1 } from "node:fs";
28
29
  import { createHash } from "crypto";
29
30
  import { rm } from "fs/promises";
30
- import { exec } from "node:child_process";
31
+ import { exec, execFile } from "node:child_process";
31
32
  import { promisify } from "node:util";
32
33
  import { exec as exec$1 } from "child_process";
33
34
  import { promisify as promisify$1 } from "util";
@@ -726,7 +727,10 @@ HttpApiGroup.make("costs").add(HttpApiEndpoint.get("summary", "/costs/summary",
726
727
  }));
727
728
  //#endregion
728
729
  //#region ../../lib/cloister/health.ts
729
- init_context_overflow();
730
+ init_cost_sync();
731
+ init_review_status();
732
+ init_control_settings();
733
+ init_hooks();
730
734
  init_agents();
731
735
  init_config();
732
736
  /**
@@ -831,9 +835,6 @@ function getAgentsNeedingAttention(agentHealths) {
831
835
  }
832
836
  //#endregion
833
837
  //#region ../../lib/cloister/handoff-context.ts
834
- init_review_status();
835
- init_control_settings();
836
- init_hooks();
837
838
  /**
838
839
  * Handoff Context Module
839
840
  *
@@ -1168,310 +1169,595 @@ const performHandoff = (agentId, options) => Effect.tryPromise({
1168
1169
  })
1169
1170
  });
1170
1171
  //#endregion
1171
- //#region ../../lib/cloister/triggers.ts
1172
+ //#region ../../lib/cloister/cost-monitor.ts
1172
1173
  /**
1173
- * Handoff Triggers
1174
+ * Cost Monitor
1174
1175
  *
1175
- * Detects conditions that should trigger model handoffs:
1176
- * 1. Stuck escalation - Agent inactive for too long
1177
- * 2. Planning complete - Planning phase finished, ready for implementation
1178
- * 3. Test failure - Tests failing, need more powerful model
1179
- * 4. Task completion - Implementation done, ready for specialist testing
1176
+ * Monitors agent costs against configured limits and emits alerts.
1177
+ * Does NOT automatically stop agents - just provides visibility and warnings.
1180
1178
  */
1179
+ init_paths();
1181
1180
  init_config();
1182
- const execAsync$2 = promisify$1(exec$1);
1183
- const taskCompletionCache = /* @__PURE__ */ new Map();
1184
- const taskCompletionInflight = /* @__PURE__ */ new Map();
1185
1181
  /**
1186
- * Check if agent should be handed off due to stuck escalation
1187
- *
1188
- * Model-specific thresholds:
1189
- * - Haiku: Stuck after 10 minutes → escalate to Sonnet
1190
- * - Sonnet: Stuck after 20 minutes → escalate to Opus
1191
- * - Opus: Stuck after 30 minutes → alert user (no auto-escalation)
1192
- *
1193
- * @param health - Agent health state
1194
- * @param currentModel - Current model
1195
- * @param config - Cloister configuration
1196
- * @returns Trigger detection result
1182
+ * Path to cost data file
1197
1183
  */
1198
- function checkStuckEscalation(health, currentModel, config) {
1199
- const stuckConfig = (config || loadCloisterConfigSync()).handoffs?.auto_triggers?.stuck_escalation;
1200
- if (!stuckConfig?.enabled) return {
1201
- triggered: false,
1202
- type: "stuck_escalation",
1203
- reason: "Stuck escalation disabled in config",
1204
- confidence: "high"
1205
- };
1206
- if (health.state !== "stuck") return {
1207
- triggered: false,
1208
- type: "stuck_escalation",
1209
- reason: `Agent is ${health.state}, not stuck`,
1210
- confidence: "high"
1211
- };
1212
- if (!health.lastActivity) return {
1213
- triggered: false,
1214
- type: "stuck_escalation",
1215
- reason: "No last activity timestamp available",
1216
- confidence: "low"
1217
- };
1218
- const minutesSinceActivity = (Date.now() - health.lastActivity.getTime()) / (1e3 * 60);
1219
- if (currentModel === "haiku" && minutesSinceActivity >= stuckConfig.haiku_to_sonnet_minutes) return {
1220
- triggered: true,
1221
- type: "stuck_escalation",
1222
- reason: `Haiku agent stuck for ${Math.round(minutesSinceActivity)} minutes`,
1223
- suggestedModel: "sonnet",
1224
- confidence: "high"
1225
- };
1226
- if (currentModel === "sonnet" && minutesSinceActivity >= stuckConfig.sonnet_to_opus_minutes) return {
1227
- triggered: true,
1228
- type: "stuck_escalation",
1229
- reason: `Sonnet agent stuck for ${Math.round(minutesSinceActivity)} minutes`,
1230
- suggestedModel: "opus",
1231
- confidence: "high"
1232
- };
1233
- if (currentModel === "opus") return {
1234
- triggered: false,
1235
- type: "stuck_escalation",
1236
- reason: "Opus agent stuck - no higher model available, manual intervention needed",
1237
- confidence: "high"
1238
- };
1239
- return {
1240
- triggered: false,
1241
- type: "stuck_escalation",
1242
- reason: `Agent stuck but threshold not reached (${Math.round(minutesSinceActivity)} minutes)`,
1243
- confidence: "medium"
1244
- };
1245
- }
1184
+ const COST_DATA_FILE = join$1(OVERDECK_HOME, "cost-data.json");
1246
1185
  /**
1247
- * Check if test failures should trigger escalation
1248
- *
1249
- * Aggressive escalation: Any test failure from Haiku escalates to Sonnet
1250
- * Reasoning: Haiku is for simple tasks - if tests fail, the task isn't simple
1251
- *
1252
- * @param workspace - Workspace path
1253
- * @param currentModel - Current model
1254
- * @param config - Cloister configuration
1255
- * @returns Trigger detection result
1186
+ * Load cost data from file
1256
1187
  */
1257
- function checkTestFailure(workspace, currentModel, config) {
1258
- const testConfig = (config || loadCloisterConfigSync()).handoffs?.auto_triggers?.test_failure;
1259
- if (!testConfig?.enabled) return {
1260
- triggered: false,
1261
- type: "test_failure",
1262
- reason: "Test failure escalation disabled in config",
1263
- confidence: "high"
1264
- };
1265
- if (currentModel !== testConfig.from_model) return {
1266
- triggered: false,
1267
- type: "test_failure",
1268
- reason: `Test failure escalation only applies to ${testConfig.from_model} model`,
1269
- confidence: "high"
1270
- };
1271
- const testFailure = detectTestFailure(workspace);
1272
- if (testFailure.failed) return {
1273
- triggered: true,
1274
- type: "test_failure",
1275
- reason: `Test failures detected: ${testFailure.reason}`,
1276
- suggestedModel: testConfig.to_model,
1277
- confidence: testFailure.confidence
1278
- };
1279
- return {
1280
- triggered: false,
1281
- type: "test_failure",
1282
- reason: "No test failures detected",
1283
- confidence: "medium"
1188
+ function loadCostData() {
1189
+ if (!existsSync(COST_DATA_FILE)) return {
1190
+ perAgent: /* @__PURE__ */ new Map(),
1191
+ perIssue: /* @__PURE__ */ new Map(),
1192
+ dailyTotal: 0,
1193
+ lastResetDate: (/* @__PURE__ */ new Date()).toISOString().split("T")[0]
1284
1194
  };
1195
+ try {
1196
+ const fileContent = readFileSync(COST_DATA_FILE, "utf-8");
1197
+ const persisted = JSON.parse(fileContent);
1198
+ return {
1199
+ perAgent: new Map(Object.entries(persisted.perAgent || {})),
1200
+ perIssue: new Map(Object.entries(persisted.perIssue || {})),
1201
+ dailyTotal: persisted.dailyTotal || 0,
1202
+ lastResetDate: persisted.lastResetDate || (/* @__PURE__ */ new Date()).toISOString().split("T")[0]
1203
+ };
1204
+ } catch (error) {
1205
+ console.error("Failed to load cost data, starting fresh:", error);
1206
+ return {
1207
+ perAgent: /* @__PURE__ */ new Map(),
1208
+ perIssue: /* @__PURE__ */ new Map(),
1209
+ dailyTotal: 0,
1210
+ lastResetDate: (/* @__PURE__ */ new Date()).toISOString().split("T")[0]
1211
+ };
1212
+ }
1285
1213
  }
1286
1214
  /**
1287
- * Detect test failures in workspace
1288
- *
1289
- * Checks for:
1290
- * - npm test output
1291
- * - Jest results
1292
- * - pytest results
1293
- * - cargo test results
1294
- *
1295
- * @param workspace - Workspace path
1296
- * @returns Test failure detection
1215
+ * Save cost data to file (atomic write)
1297
1216
  */
1298
- function detectTestFailure(workspace) {
1299
- return {
1300
- failed: false,
1301
- reason: "Test result detection not yet implemented",
1302
- confidence: "low"
1303
- };
1304
- }
1305
- async function checkTaskCompletionPromise(issueId, config, workspace) {
1306
- const completionConfig = (config || loadCloisterConfigSync()).handoffs?.auto_triggers?.implementation_complete;
1307
- if (!completionConfig?.enabled) return {
1308
- triggered: false,
1309
- type: "task_complete",
1310
- reason: "Task completion detection disabled in config",
1311
- confidence: "high"
1312
- };
1313
- const cacheKey = `${workspace ?? ""}::${issueId.toLowerCase()}`;
1314
- let mtimeMs = 0;
1315
- if (workspace) {
1316
- const beadsFile = join$1(workspace, ".beads", "issues.jsonl");
1317
- try {
1318
- mtimeMs = statSync(beadsFile).mtimeMs;
1319
- } catch {}
1320
- if (mtimeMs > 0) {
1321
- const cached = taskCompletionCache.get(cacheKey);
1322
- if (cached && cached.mtimeMs === mtimeMs) return cached.result;
1323
- const inflight = taskCompletionInflight.get(cacheKey);
1324
- if (inflight) return inflight;
1325
- }
1326
- }
1327
- const computePromise = (async () => {
1328
- if (!workspace) return {
1329
- triggered: false,
1330
- type: "task_complete",
1331
- reason: "No workspace available to check task completion",
1332
- confidence: "high"
1333
- };
1334
- const bdOptions = {
1335
- encoding: "utf-8",
1336
- cwd: workspace
1337
- };
1338
- const issueLower = issueId.toLowerCase();
1339
- try {
1340
- const { stdout: output } = await Effect.runPromise(withBdMutex(() => Effect.tryPromise({
1341
- try: () => execAsync$2(`bd list --json --title-contains ${issueLower} --status closed`, bdOptions),
1342
- catch: (cause) => cause
1343
- })));
1344
- if (JSON.parse(output).find((t) => t.title.toLowerCase().includes("implement") || t.labels?.includes("implementation"))) {
1345
- const { stdout: openOutput } = await execAsync$2(`bd list --json --title-contains ${issueLower} --status open`, bdOptions);
1346
- const openTasks = JSON.parse(openOutput);
1347
- if (openTasks.length === 0) return {
1348
- triggered: true,
1349
- type: "task_complete",
1350
- reason: "Implementation task closed, no remaining tasks",
1351
- suggestedModel: completionConfig.to_specialist,
1352
- confidence: "high"
1353
- };
1354
- return {
1355
- triggered: false,
1356
- type: "task_complete",
1357
- reason: `Implementation task closed but ${openTasks.length} tasks remain`,
1358
- confidence: "medium"
1359
- };
1360
- }
1361
- } catch {}
1362
- return {
1363
- triggered: false,
1364
- type: "task_complete",
1365
- reason: "No implementation completion signals detected",
1366
- confidence: "high"
1217
+ function saveCostData(data) {
1218
+ try {
1219
+ const dir = dirname$1(COST_DATA_FILE);
1220
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
1221
+ const persisted = {
1222
+ perAgent: Object.fromEntries(data.perAgent),
1223
+ perIssue: Object.fromEntries(data.perIssue),
1224
+ dailyTotal: data.dailyTotal,
1225
+ lastResetDate: data.lastResetDate
1367
1226
  };
1368
- })();
1369
- if (workspace && mtimeMs > 0) {
1370
- taskCompletionInflight.set(cacheKey, computePromise);
1227
+ const tempFile = `${COST_DATA_FILE}.tmp`;
1228
+ writeFileSync(tempFile, JSON.stringify(persisted, null, 2));
1229
+ writeFileSync(COST_DATA_FILE, readFileSync(tempFile));
1371
1230
  try {
1372
- const result = await computePromise;
1373
- taskCompletionCache.set(cacheKey, {
1374
- mtimeMs,
1375
- result
1376
- });
1377
- return result;
1378
- } finally {
1379
- taskCompletionInflight.delete(cacheKey);
1231
+ unlinkSync(tempFile);
1232
+ } catch (unlinkError) {
1233
+ console.debug("Failed to cleanup temp file:", unlinkError instanceof Error ? unlinkError.message : unlinkError);
1380
1234
  }
1235
+ } catch (error) {
1236
+ console.error("Failed to save cost data:", error);
1381
1237
  }
1382
- return computePromise;
1383
- }
1384
- async function checkAllTriggersPromise(agentId, workspace, issueId, currentModel, health, config) {
1385
- const triggers = [];
1386
- const stuckCheck = checkStuckEscalation(health, currentModel, config);
1387
- if (stuckCheck.triggered) triggers.push(stuckCheck);
1388
- const testCheck = checkTestFailure(workspace, currentModel, config);
1389
- if (testCheck.triggered) triggers.push(testCheck);
1390
- const completionCheck = await Effect.runPromise(checkTaskCompletion(issueId, config, workspace));
1391
- if (completionCheck.triggered) triggers.push(completionCheck);
1392
- return triggers;
1393
1238
  }
1239
+ let costData = loadCostData();
1394
1240
  /**
1395
- * Effect-typed variant of {@link checkTaskCompletion}. Wraps the Promise-based
1396
- * implementation; never fails (the underlying function swallows errors and
1397
- * returns a "not triggered" detection on failure).
1241
+ * Get today's date as ISO string (YYYY-MM-DD)
1398
1242
  */
1399
- function checkTaskCompletion(issueId, config, workspace) {
1400
- return Effect.promise(() => checkTaskCompletionPromise(issueId, config, workspace));
1243
+ function getTodayDate() {
1244
+ return (/* @__PURE__ */ new Date()).toISOString().split("T")[0];
1401
1245
  }
1402
1246
  /**
1403
- * Effect-typed variant of {@link checkAllTriggers}. Never fails — uses
1404
- * `Effect.promise` because the underlying async path absorbs bd / fs errors.
1247
+ * Reset daily totals if it's a new day
1405
1248
  */
1406
- function checkAllTriggers(agentId, workspace, issueId, currentModel, health, config) {
1407
- return Effect.promise(() => checkAllTriggersPromise(agentId, workspace, issueId, currentModel, health, config));
1249
+ function checkDailyReset() {
1250
+ const today = getTodayDate();
1251
+ if (costData.lastResetDate !== today) {
1252
+ costData.dailyTotal = 0;
1253
+ costData.lastResetDate = today;
1254
+ console.log(`🔔 Cost monitor: Daily totals reset for ${today}`);
1255
+ saveCostData(costData);
1256
+ }
1408
1257
  }
1409
- //#endregion
1410
- //#region ../../lib/cloister/handoff-logger.ts
1411
1258
  /**
1412
- * Handoff Event Logger
1259
+ * Check if any cost limits are being approached or exceeded
1413
1260
  *
1414
- * Logs handoff events to JSONL file for tracking and analysis.
1415
- */
1416
- init_paths();
1261
+ * @param agentId - Agent ID to check
1262
+ * @param issueId - Optional issue ID to check
1263
+ * @param config - Cost limits configuration
1264
+ * @returns Array of alerts (empty if no limits exceeded)
1265
+ */
1266
+ function checkCostLimits(agentId, issueId, config = loadCloisterConfigSync().cost_limits || {
1267
+ per_agent_usd: 10,
1268
+ per_issue_usd: 25,
1269
+ daily_total_usd: 100,
1270
+ alert_threshold: .8
1271
+ }) {
1272
+ const alerts = [];
1273
+ const now = (/* @__PURE__ */ new Date()).toISOString();
1274
+ if (config.per_agent_usd > 0) {
1275
+ const agentCost = getAgentDailyCostSync(agentId);
1276
+ const agentPercent = agentCost / config.per_agent_usd;
1277
+ if (agentPercent >= 1) alerts.push({
1278
+ type: "per_agent",
1279
+ level: "limit_reached",
1280
+ agentId,
1281
+ currentCost: agentCost,
1282
+ limit: config.per_agent_usd,
1283
+ percentUsed: agentPercent * 100,
1284
+ timestamp: now
1285
+ });
1286
+ else if (agentPercent >= config.alert_threshold) alerts.push({
1287
+ type: "per_agent",
1288
+ level: "warning",
1289
+ agentId,
1290
+ currentCost: agentCost,
1291
+ limit: config.per_agent_usd,
1292
+ percentUsed: agentPercent * 100,
1293
+ timestamp: now
1294
+ });
1295
+ }
1296
+ if (issueId && config.per_issue_usd > 0) {
1297
+ const issueCost = getDailyTrendsSync({
1298
+ days: 1,
1299
+ issueId
1300
+ }).reduce((sum, t) => sum + t.totalCost, 0);
1301
+ const issuePercent = issueCost / config.per_issue_usd;
1302
+ if (issuePercent >= 1) alerts.push({
1303
+ type: "per_issue",
1304
+ level: "limit_reached",
1305
+ issueId,
1306
+ currentCost: issueCost,
1307
+ limit: config.per_issue_usd,
1308
+ percentUsed: issuePercent * 100,
1309
+ timestamp: now
1310
+ });
1311
+ else if (issuePercent >= config.alert_threshold) alerts.push({
1312
+ type: "per_issue",
1313
+ level: "warning",
1314
+ issueId,
1315
+ currentCost: issueCost,
1316
+ limit: config.per_issue_usd,
1317
+ percentUsed: issuePercent * 100,
1318
+ timestamp: now
1319
+ });
1320
+ }
1321
+ if (config.daily_total_usd > 0) {
1322
+ const dailyTotal = getDailyTrendsSync({ days: 1 }).reduce((sum, t) => sum + t.totalCost, 0);
1323
+ const dailyPercent = dailyTotal / config.daily_total_usd;
1324
+ if (dailyPercent >= 1) alerts.push({
1325
+ type: "daily_total",
1326
+ level: "limit_reached",
1327
+ currentCost: dailyTotal,
1328
+ limit: config.daily_total_usd,
1329
+ percentUsed: dailyPercent * 100,
1330
+ timestamp: now
1331
+ });
1332
+ else if (dailyPercent >= config.alert_threshold) alerts.push({
1333
+ type: "daily_total",
1334
+ level: "warning",
1335
+ currentCost: dailyTotal,
1336
+ limit: config.daily_total_usd,
1337
+ percentUsed: dailyPercent * 100,
1338
+ timestamp: now
1339
+ });
1340
+ }
1341
+ return alerts;
1342
+ }
1417
1343
  /**
1418
- * Handoff log file path
1344
+ * Get cost summary
1345
+ *
1346
+ * @returns Cost summary with top spenders
1419
1347
  */
1420
- const HANDOFF_LOG_FILE = join$1(OVERDECK_HOME, "logs", "handoffs.jsonl");
1348
+ function getCostSummary() {
1349
+ checkDailyReset();
1350
+ const topAgents = Array.from(costData.perAgent.entries()).map(([agentId, cost]) => ({
1351
+ agentId,
1352
+ cost
1353
+ })).sort((a, b) => b.cost - a.cost).slice(0, 10);
1354
+ const topIssues = Array.from(costData.perIssue.entries()).map(([issueId, cost]) => ({
1355
+ issueId,
1356
+ cost
1357
+ })).sort((a, b) => b.cost - a.cost).slice(0, 10);
1358
+ return {
1359
+ dailyTotal: costData.dailyTotal,
1360
+ topAgents,
1361
+ topIssues
1362
+ };
1363
+ }
1364
+ //#endregion
1365
+ //#region ../../lib/cloister/service-reactive.ts
1366
+ /** Cloister reactive lifecycle scheduler. */
1367
+ init_agents();
1368
+ init_tmux();
1369
+ init_issue_closed();
1370
+ init_merge_verification();
1371
+ const execAsync$2 = promisify(exec);
1372
+ /** Return issues orphaned in reviewStatus='reviewing' with no active reviewer. */
1373
+ function identifyOrphanedReviewingIssues(statuses, activeReviewIssues) {
1374
+ const orphaned = [];
1375
+ for (const [issueId, status] of Object.entries(statuses)) {
1376
+ if (status.reviewStatus !== "reviewing") continue;
1377
+ if (status.history?.some((h) => h.type === "review" && h.status === "passed")) continue;
1378
+ if (activeReviewIssues.has(issueId.toUpperCase())) continue;
1379
+ orphaned.push(issueId);
1380
+ }
1381
+ return orphaned;
1382
+ }
1383
+ function parseSpecialistAgentSession(name) {
1384
+ const issueScoped = name.match(/^specialist-(.+)-([A-Z]+-\d+)-(review-agent|test-agent|merge-agent)$/);
1385
+ if (issueScoped) return {
1386
+ projectKey: issueScoped[1],
1387
+ issueId: issueScoped[2],
1388
+ specialistType: issueScoped[3]
1389
+ };
1390
+ const legacy = name.match(/^specialist-(.+)-(review-agent|test-agent|merge-agent)$/);
1391
+ if (legacy) return {
1392
+ projectKey: legacy[1],
1393
+ specialistType: legacy[2]
1394
+ };
1395
+ return null;
1396
+ }
1397
+ const ROLE_RUN_STATES = {
1398
+ todo: null,
1399
+ open: null,
1400
+ in_planning: "plan",
1401
+ in_progress: "work",
1402
+ in_review: "review",
1403
+ testing: "test",
1404
+ shipping: null,
1405
+ closed: null,
1406
+ canceled: null
1407
+ };
1421
1408
  /**
1422
- * Ensure log directory exists
1409
+ * Map issue lifecycle state to the role that should own that state.
1423
1410
  */
1424
- function ensureLogDir() {
1425
- const logDir = join$1(OVERDECK_HOME, "logs");
1426
- if (!existsSync(logDir)) mkdirSync(logDir, { recursive: true });
1411
+ function stateToRole(state) {
1412
+ return ROLE_RUN_STATES[state.toLowerCase().replace(/[ -]/g, "_")] ?? null;
1413
+ }
1414
+ function normalizeIssueId(issueId) {
1415
+ return issueId.trim().toUpperCase();
1416
+ }
1417
+ function roleFromAgentId(agentId, issueId) {
1418
+ const base = `agent-${issueId.toLowerCase()}`;
1419
+ if (agentId === base) return "work";
1420
+ const role = agentId.slice(base.length + 1);
1421
+ return [
1422
+ "plan",
1423
+ "review",
1424
+ "test"
1425
+ ].includes(role) ? role : null;
1426
+ }
1427
+ /**
1428
+ * Grace window for `starting` states without a tmux session yet. The
1429
+ * start-planning route deliberately writes state.json BEFORE the lifecycle
1430
+ * transition (PAN-1048) so this guard can see the planner coming — but the
1431
+ * tmux session is only created after the transition. Treating any
1432
+ * starting-without-session as dead (the original S1 unstick heuristic)
1433
+ * re-opened the PAN-1048 race and spawned a duplicate `agent-<issue>-plan`
1434
+ * twin on EVERY `pan plan` (PAN-2159, 100% repro). A fresh `starting` state
1435
+ * is a startup in progress; only a stale one is a crashed spawn to unstick.
1436
+ */
1437
+ const STARTING_WITHOUT_SESSION_GRACE_MS = 12e4;
1438
+ function isFreshStarting(state) {
1439
+ const startedAt = Date.parse(state.startedAt ?? "");
1440
+ return Number.isFinite(startedAt) && Date.now() - startedAt < STARTING_WITHOUT_SESSION_GRACE_MS;
1441
+ }
1442
+ async function activeRoleRunExists(issueId, role, workspacePath) {
1443
+ const issueLower = issueId.toLowerCase();
1444
+ if (role === "plan") {
1445
+ const legacyId = `planning-${issueLower}`;
1446
+ const legacyState = await Effect.runPromise(getAgentState(legacyId));
1447
+ if (legacyState?.role === "plan" && legacyState.status !== "stopped" && legacyState.status !== "error") {
1448
+ if (legacyState.status === "starting" && !await Effect.runPromise(sessionExists(legacyId))) return isFreshStarting(legacyState);
1449
+ return true;
1450
+ }
1451
+ }
1452
+ const candidateId = role === "work" ? `agent-${issueLower}` : `agent-${issueLower}-${role}`;
1453
+ const state = await Effect.runPromise(getAgentState(candidateId));
1454
+ if (!state) return false;
1455
+ const stateRole = state.role ?? roleFromAgentId(candidateId, issueId);
1456
+ if (stateRole === role && state.status === "starting" && !await Effect.runPromise(sessionExists(candidateId))) return isFreshStarting(state);
1457
+ if (!(stateRole === role && state.status !== "stopped" && state.status !== "error")) return false;
1458
+ if (workspacePath && state.roleRunHead) try {
1459
+ const { stdout } = await execAsync$2("git rev-parse --short=8 HEAD", { cwd: workspacePath });
1460
+ const currentHead = stdout.trim();
1461
+ if (currentHead && currentHead !== state.roleRunHead) {
1462
+ console.log(`[cloister] ${issueId}: ${role} session ${candidateId} is stale (ran against ${state.roleRunHead}, HEAD is now ${currentHead}) — not active`);
1463
+ return false;
1464
+ }
1465
+ } catch {}
1466
+ return true;
1467
+ }
1468
+ function buildReactiveRolePrompt(issueId, state, role) {
1469
+ return `${role.toUpperCase()} TASK for ${issueId}:
1470
+
1471
+ The issue lifecycle transitioned to ${state}. Run the ${role} role for this issue.
1472
+
1473
+ Required steps:
1474
+ 1. Work only in the workspace configured for ${issueId}.
1475
+ 2. Read .pan/continue.json, .pan/spec.vbrief.json, project instructions, and issue context.
1476
+ 3. Follow the boundaries and success criteria in roles/${role}.md exactly.
1477
+ 4. Report the role-specific terminal status when done.`;
1478
+ }
1479
+ /**
1480
+ * Resolve the workspace path for an issue from agent state, then fall back
1481
+ * to the canonical `<projectPath>/workspaces/feature-<issueLower>` layout.
1482
+ * Mirrors the resolution used by startup recovery (service.ts:583-609) so
1483
+ * the reactive scheduler dispatches review/test wrappers with the same
1484
+ * workspace contract those wrappers receive on the manual code path.
1485
+ */
1486
+ async function resolveWorkspaceForIssue(issueId) {
1487
+ const issueLower = issueId.toLowerCase();
1488
+ const agentState = await Effect.runPromise(getAgentState(`agent-${issueLower}`));
1489
+ if (agentState?.workspace) return agentState.workspace;
1490
+ const resolved = resolveProjectFromIssueSync(issueId);
1491
+ if (!resolved) return null;
1492
+ return `${resolved.projectPath}/workspaces/feature-${issueLower}`;
1493
+ }
1494
+ async function onIssueStateChangePromise(issueId, newState) {
1495
+ const normalizedIssueId = normalizeIssueId(issueId);
1496
+ const role = stateToRole(newState);
1497
+ if (!role) {
1498
+ console.log(`[cloister] ${normalizedIssueId}: no role for issue state '${newState}'`);
1499
+ return;
1500
+ }
1501
+ if (await isIssueClosed(normalizedIssueId)) {
1502
+ const message = `${normalizedIssueId}: skipping ${role} dispatch — issue is closed`;
1503
+ console.log(`[cloister] ${message}`);
1504
+ emitActivityEntrySync({
1505
+ source: "cloister",
1506
+ level: "info",
1507
+ message,
1508
+ issueId: normalizedIssueId
1509
+ });
1510
+ return;
1511
+ }
1512
+ const { getReviewStatusSync } = await import("./review-status-CG0iDuWa.js");
1513
+ if (getReviewStatusSync(normalizedIssueId)?.mergeStatus === "merged") {
1514
+ const message = `${normalizedIssueId}: skipping ${role} dispatch — merge already landed (merge_status='merged' is terminal)`;
1515
+ console.log(`[cloister] ${message}`);
1516
+ emitActivityEntrySync({
1517
+ source: "cloister",
1518
+ level: "info",
1519
+ message,
1520
+ issueId: normalizedIssueId
1521
+ });
1522
+ return;
1523
+ }
1524
+ const mergedGuard = await shouldSkipDispatchAsMerged(normalizedIssueId);
1525
+ if (mergedGuard.skip) {
1526
+ const message = `${normalizedIssueId}: skipping ${role} dispatch — ${mergedGuard.reason}`;
1527
+ console.log(`[cloister] ${message}`);
1528
+ emitActivityEntrySync({
1529
+ source: "cloister",
1530
+ level: "info",
1531
+ message,
1532
+ issueId: normalizedIssueId
1533
+ });
1534
+ return;
1535
+ }
1536
+ const workspace = await resolveWorkspaceForIssue(normalizedIssueId);
1537
+ if (await activeRoleRunExists(normalizedIssueId, role, workspace ?? void 0)) {
1538
+ const message = `${normalizedIssueId}: ${role} role already active; skipping lifecycle spawn`;
1539
+ console.log(`[cloister] ${message}`);
1540
+ emitActivityEntrySync({
1541
+ source: "cloister",
1542
+ level: "info",
1543
+ message,
1544
+ issueId: normalizedIssueId
1545
+ });
1546
+ return;
1547
+ }
1548
+ const issueLower = normalizedIssueId.toLowerCase();
1549
+ const roleSessionId = role === "work" ? `agent-${issueLower}` : `agent-${issueLower}-${role}`;
1550
+ if (await Effect.runPromise(sessionExists(roleSessionId))) {
1551
+ const message = `${normalizedIssueId}: killing stale ${role} session ${roleSessionId} before re-dispatch`;
1552
+ console.log(`[cloister] ${message}`);
1553
+ emitActivityEntrySync({
1554
+ source: "cloister",
1555
+ level: "info",
1556
+ message,
1557
+ issueId: normalizedIssueId
1558
+ });
1559
+ try {
1560
+ await Effect.runPromise(killSession(roleSessionId));
1561
+ } catch (err) {
1562
+ console.error(`[cloister] failed to kill stale session ${roleSessionId}:`, err instanceof Error ? err.message : String(err));
1563
+ }
1564
+ }
1565
+ try {
1566
+ if (role === "review") {
1567
+ if (!workspace) {
1568
+ const failure = `${normalizedIssueId}: cannot dispatch review role — no workspace or project resolved`;
1569
+ console.error(`[cloister] ${failure}`);
1570
+ emitActivityEntrySync({
1571
+ source: "cloister",
1572
+ level: "error",
1573
+ message: failure,
1574
+ issueId: normalizedIssueId
1575
+ });
1576
+ return;
1577
+ }
1578
+ const branch = `feature/${normalizedIssueId.toLowerCase()}`;
1579
+ const { spawnReviewRoleForIssue } = await import("./review-agent-u5q3ip7H.js");
1580
+ const result = await Effect.runPromise(spawnReviewRoleForIssue({
1581
+ issueId: normalizedIssueId,
1582
+ workspace,
1583
+ branch
1584
+ }));
1585
+ const message = `${normalizedIssueId}: review role dispatched from lifecycle state '${newState}' (${result.message})`;
1586
+ console.log(`[cloister] ${message}`);
1587
+ emitActivityEntrySync({
1588
+ source: "cloister",
1589
+ level: result.success ? "info" : "error",
1590
+ message,
1591
+ issueId: normalizedIssueId
1592
+ });
1593
+ return;
1594
+ }
1595
+ if (role === "test") {
1596
+ const branch = `feature/${normalizedIssueId.toLowerCase()}`;
1597
+ const { dispatchTestAgentAndNotify } = await import("./test-agent-queue-CnuYku4B.js");
1598
+ await Effect.runPromise(dispatchTestAgentAndNotify(normalizedIssueId, workspace ?? void 0, branch));
1599
+ const message = `${normalizedIssueId}: test role dispatched from lifecycle state '${newState}'`;
1600
+ console.log(`[cloister] ${message}`);
1601
+ emitActivityEntrySync({
1602
+ source: "cloister",
1603
+ level: "info",
1604
+ message,
1605
+ issueId: normalizedIssueId
1606
+ });
1607
+ return;
1608
+ }
1609
+ const { spawnRun } = await import("./agents-CEcaxSkR.js");
1610
+ const message = `${normalizedIssueId}: ${role} role started from lifecycle state '${newState}' as ${(await spawnRun(normalizedIssueId, role, { prompt: buildReactiveRolePrompt(normalizedIssueId, newState, role) })).id}`;
1611
+ console.log(`[cloister] ${message}`);
1612
+ emitActivityEntrySync({
1613
+ source: "cloister",
1614
+ level: "info",
1615
+ message,
1616
+ issueId: normalizedIssueId
1617
+ });
1618
+ } catch (error) {
1619
+ const message = error instanceof Error ? error.message : String(error);
1620
+ if (message.includes("already running")) {
1621
+ const skipMessage = `${normalizedIssueId}: ${role} role already running; skipping lifecycle spawn`;
1622
+ console.log(`[cloister] ${skipMessage}`);
1623
+ emitActivityEntrySync({
1624
+ source: "cloister",
1625
+ level: "info",
1626
+ message: skipMessage,
1627
+ issueId: normalizedIssueId
1628
+ });
1629
+ return;
1630
+ }
1631
+ console.error(`[cloister] Failed to start ${role} role for ${normalizedIssueId}:`, error);
1632
+ emitActivityEntrySync({
1633
+ source: "cloister",
1634
+ level: "error",
1635
+ message: `${normalizedIssueId}: failed to start ${role} role: ${message}`,
1636
+ issueId: normalizedIssueId
1637
+ });
1638
+ }
1639
+ }
1640
+ function payloadRecord(event) {
1641
+ return event.payload && typeof event.payload === "object" ? event.payload : {};
1642
+ }
1643
+ function issueStateChangeFromDomainEvent(event) {
1644
+ const payload = payloadRecord(event);
1645
+ const issueId = typeof payload.issueId === "string" ? payload.issueId : null;
1646
+ if (!issueId) return null;
1647
+ switch (event.type) {
1648
+ case "issue.transitioned": return typeof payload.state === "string" ? {
1649
+ issueId,
1650
+ state: payload.state
1651
+ } : null;
1652
+ case "issue.statusChanged": return typeof payload.canonicalStatus === "string" ? {
1653
+ issueId,
1654
+ state: payload.canonicalStatus
1655
+ } : null;
1656
+ case "issue.closed": return {
1657
+ issueId,
1658
+ state: "closed"
1659
+ };
1660
+ case "agent.completed": {
1661
+ const role = typeof payload.role === "string" ? payload.role : void 0;
1662
+ if (role === void 0 || role === "work") return {
1663
+ issueId,
1664
+ state: "in_review"
1665
+ };
1666
+ return null;
1667
+ }
1668
+ case "work.completed": return {
1669
+ issueId,
1670
+ state: "in_review"
1671
+ };
1672
+ case "review.approved": return {
1673
+ issueId,
1674
+ state: "testing"
1675
+ };
1676
+ case "test.passed": return {
1677
+ issueId,
1678
+ state: "shipping"
1679
+ };
1680
+ default: return null;
1681
+ }
1682
+ }
1683
+ async function handleCloisterDomainEventPromise(event) {
1684
+ if (event.type === "agent.stopped") {
1685
+ const agentId = event.payload?.agentId;
1686
+ if (agentId) {
1687
+ const { handleAgentStoppedEvent, handleAgentStoppedForOrphanReviewerSessions } = await import("./deacon-DONuopff.js");
1688
+ const { handleAgentLifecycleEventForIdleStack } = await import("./idle-stack-reaper-H0W6XtHh.js");
1689
+ handleAgentLifecycleEventForIdleStack(agentId);
1690
+ const slotMatch = /^agent-(.+)-slot-\d+$/.exec(agentId);
1691
+ await Promise.all([
1692
+ handleAgentStoppedEvent(agentId),
1693
+ handleAgentStoppedForOrphanReviewerSessions(agentId),
1694
+ slotMatch ? (await import("./deacon-swarm-CvdFekIO.js")).coordinateSwarmSlots({ issueId: slotMatch[1].toUpperCase() }) : Promise.resolve([])
1695
+ ]);
1696
+ }
1697
+ return;
1698
+ }
1699
+ if (event.type === "agent.started") {
1700
+ const agentId = event.payload?.agentId;
1701
+ if (agentId) (await import("./idle-stack-reaper-H0W6XtHh.js")).handleAgentLifecycleEventForIdleStack(agentId);
1702
+ return;
1703
+ }
1704
+ if (event.type === "agent.heartbeat_dead") {
1705
+ const agentId = event.payload?.agentId;
1706
+ if (agentId) {
1707
+ const { handleAgentHeartbeatDeadEvent } = await import("./deacon-DONuopff.js");
1708
+ await handleAgentHeartbeatDeadEvent(agentId, "event");
1709
+ }
1710
+ return;
1711
+ }
1712
+ if (event.type === "review.coordinator.died") {
1713
+ const payload = event.payload;
1714
+ const issueId = payload?.issueId;
1715
+ if (issueId) {
1716
+ const { handleReviewCoordinatorDied } = await import("./deacon-DONuopff.js");
1717
+ await handleReviewCoordinatorDied(issueId, payload?.sessionName ?? "", payload?.reason ?? "");
1718
+ }
1719
+ return;
1720
+ }
1721
+ if (event.type === "work.completed") {
1722
+ const issueId = event.payload?.issueId;
1723
+ if (issueId) {
1724
+ const { handleWorkCompleted } = await import("./deacon-DONuopff.js");
1725
+ await handleWorkCompleted(issueId);
1726
+ }
1727
+ }
1728
+ if (event.type === "issue.statusChanged") {
1729
+ const payload = event.payload;
1730
+ const issueId = payload?.issueId;
1731
+ const canonicalStatus = payload?.canonicalStatus?.toLowerCase();
1732
+ const status = payload?.status?.toLowerCase();
1733
+ if (issueId) {
1734
+ if (canonicalStatus === "closed" || status === "closed") {
1735
+ const { handleIssueStatusChangedClosed } = await import("./closed-issue-reaper-CksfuiFD.js");
1736
+ await handleIssueStatusChangedClosed(issueId);
1737
+ return;
1738
+ }
1739
+ if (canonicalStatus === "todo" || status === "planned" || status === "todo") {
1740
+ const { handleOrphanProposedSpec } = await import("./orphan-proposed-reconciler-BIcNu4tl.js");
1741
+ await handleOrphanProposedSpec(issueId);
1742
+ }
1743
+ }
1744
+ }
1745
+ const change = issueStateChangeFromDomainEvent(event);
1746
+ if (!change) return;
1747
+ await Effect.runPromise(onIssueStateChange(change.issueId, change.state));
1427
1748
  }
1428
1749
  /**
1429
- * Log a handoff event
1430
- *
1431
- * @param event - Handoff event to log
1750
+ * Effect-typed variant of {@link onIssueStateChange}. Never fails — failures
1751
+ * surface through `emitActivityEntry` inside the legacy implementation.
1432
1752
  */
1433
- function logHandoffEventSync(event) {
1434
- ensureLogDir();
1435
- appendFileSync(HANDOFF_LOG_FILE, JSON.stringify(event) + "\n", "utf-8");
1753
+ function onIssueStateChange(issueId, newState) {
1754
+ return Effect.promise(() => onIssueStateChangePromise(issueId, newState));
1436
1755
  }
1437
1756
  /**
1438
- * Create a handoff event from handoff result
1439
- *
1440
- * @param agentId - Agent ID
1441
- * @param issueId - Issue ID
1442
- * @param context - Handoff context
1443
- * @param trigger - Trigger type
1444
- * @param success - Whether handoff succeeded
1445
- * @param errorMessage - Error message if failed
1446
- * @returns Handoff event
1757
+ * Effect-typed variant of {@link handleCloisterDomainEvent}. Never fails.
1447
1758
  */
1448
- function createHandoffEvent(agentId, issueId, context, trigger, success, errorMessage) {
1449
- let stuckMinutes;
1450
- if (trigger === "stuck_escalation") {}
1451
- return {
1452
- timestamp: (/* @__PURE__ */ new Date()).toISOString(),
1453
- agentId,
1454
- issueId,
1455
- from: {
1456
- model: context.previousModel,
1457
- runtime: context.previousRuntime,
1458
- sessionId: context.previousSessionId
1459
- },
1460
- to: {
1461
- model: context.targetModel,
1462
- runtime: context.previousRuntime,
1463
- sessionId: void 0
1464
- },
1465
- trigger,
1466
- reason: context.reason,
1467
- context: {
1468
- costAtHandoff: context.costSoFar,
1469
- handoffCount: context.handoffCount,
1470
- stuckMinutes
1471
- },
1472
- success,
1473
- errorMessage
1474
- };
1759
+ function handleCloisterDomainEvent(event) {
1760
+ return Effect.promise(() => handleCloisterDomainEventPromise(event));
1475
1761
  }
1476
1762
  //#endregion
1477
1763
  //#region ../../lib/cloister/fpp-violations.ts
@@ -1633,198 +1919,71 @@ function clearOldViolationsSync(hoursOld = 24) {
1633
1919
  if (changed) saveViolations(activeViolations);
1634
1920
  }
1635
1921
  //#endregion
1636
- //#region ../../lib/cloister/cost-monitor.ts
1922
+ //#region ../../lib/cloister/handoff-logger.ts
1637
1923
  /**
1638
- * Cost Monitor
1924
+ * Handoff Event Logger
1639
1925
  *
1640
- * Monitors agent costs against configured limits and emits alerts.
1641
- * Does NOT automatically stop agents - just provides visibility and warnings.
1926
+ * Logs handoff events to JSONL file for tracking and analysis.
1642
1927
  */
1643
1928
  init_errors();
1644
- init_paths();
1645
- init_config();
1646
- init_cost_sync();
1647
- /**
1648
- * Path to cost data file
1649
- */
1650
- const COST_DATA_FILE = join$1(OVERDECK_HOME, "cost-data.json");
1651
- /**
1652
- * Load cost data from file
1653
- */
1654
- function loadCostData() {
1655
- if (!existsSync(COST_DATA_FILE)) return {
1656
- perAgent: /* @__PURE__ */ new Map(),
1657
- perIssue: /* @__PURE__ */ new Map(),
1658
- dailyTotal: 0,
1659
- lastResetDate: (/* @__PURE__ */ new Date()).toISOString().split("T")[0]
1660
- };
1661
- try {
1662
- const fileContent = readFileSync(COST_DATA_FILE, "utf-8");
1663
- const persisted = JSON.parse(fileContent);
1664
- return {
1665
- perAgent: new Map(Object.entries(persisted.perAgent || {})),
1666
- perIssue: new Map(Object.entries(persisted.perIssue || {})),
1667
- dailyTotal: persisted.dailyTotal || 0,
1668
- lastResetDate: persisted.lastResetDate || (/* @__PURE__ */ new Date()).toISOString().split("T")[0]
1669
- };
1670
- } catch (error) {
1671
- console.error("Failed to load cost data, starting fresh:", error);
1672
- return {
1673
- perAgent: /* @__PURE__ */ new Map(),
1674
- perIssue: /* @__PURE__ */ new Map(),
1675
- dailyTotal: 0,
1676
- lastResetDate: (/* @__PURE__ */ new Date()).toISOString().split("T")[0]
1677
- };
1678
- }
1679
- }
1680
- /**
1681
- * Save cost data to file (atomic write)
1682
- */
1683
- function saveCostData(data) {
1684
- try {
1685
- const dir = dirname$1(COST_DATA_FILE);
1686
- if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
1687
- const persisted = {
1688
- perAgent: Object.fromEntries(data.perAgent),
1689
- perIssue: Object.fromEntries(data.perIssue),
1690
- dailyTotal: data.dailyTotal,
1691
- lastResetDate: data.lastResetDate
1692
- };
1693
- const tempFile = `${COST_DATA_FILE}.tmp`;
1694
- writeFileSync(tempFile, JSON.stringify(persisted, null, 2));
1695
- writeFileSync(COST_DATA_FILE, readFileSync(tempFile));
1696
- try {
1697
- unlinkSync(tempFile);
1698
- } catch (unlinkError) {
1699
- console.debug("Failed to cleanup temp file:", unlinkError instanceof Error ? unlinkError.message : unlinkError);
1700
- }
1701
- } catch (error) {
1702
- console.error("Failed to save cost data:", error);
1703
- }
1704
- }
1705
- let costData = loadCostData();
1706
- /**
1707
- * Get today's date as ISO string (YYYY-MM-DD)
1708
- */
1709
- function getTodayDate() {
1710
- return (/* @__PURE__ */ new Date()).toISOString().split("T")[0];
1711
- }
1712
- /**
1713
- * Reset daily totals if it's a new day
1714
- */
1715
- function checkDailyReset() {
1716
- const today = getTodayDate();
1717
- if (costData.lastResetDate !== today) {
1718
- costData.dailyTotal = 0;
1719
- costData.lastResetDate = today;
1720
- console.log(`🔔 Cost monitor: Daily totals reset for ${today}`);
1721
- saveCostData(costData);
1722
- }
1723
- }
1724
- /**
1725
- * Check if any cost limits are being approached or exceeded
1726
- *
1727
- * @param agentId - Agent ID to check
1728
- * @param issueId - Optional issue ID to check
1729
- * @param config - Cost limits configuration
1730
- * @returns Array of alerts (empty if no limits exceeded)
1731
- */
1732
- function checkCostLimits(agentId, issueId, config = loadCloisterConfigSync().cost_limits || {
1733
- per_agent_usd: 10,
1734
- per_issue_usd: 25,
1735
- daily_total_usd: 100,
1736
- alert_threshold: .8
1737
- }) {
1738
- const alerts = [];
1739
- const now = (/* @__PURE__ */ new Date()).toISOString();
1740
- if (config.per_agent_usd > 0) {
1741
- const agentCost = getAgentDailyCostSync(agentId);
1742
- const agentPercent = agentCost / config.per_agent_usd;
1743
- if (agentPercent >= 1) alerts.push({
1744
- type: "per_agent",
1745
- level: "limit_reached",
1746
- agentId,
1747
- currentCost: agentCost,
1748
- limit: config.per_agent_usd,
1749
- percentUsed: agentPercent * 100,
1750
- timestamp: now
1751
- });
1752
- else if (agentPercent >= config.alert_threshold) alerts.push({
1753
- type: "per_agent",
1754
- level: "warning",
1755
- agentId,
1756
- currentCost: agentCost,
1757
- limit: config.per_agent_usd,
1758
- percentUsed: agentPercent * 100,
1759
- timestamp: now
1760
- });
1761
- }
1762
- if (issueId && config.per_issue_usd > 0) {
1763
- const issueCost = getDailyTrendsSync({
1764
- days: 1,
1765
- issueId
1766
- }).reduce((sum, t) => sum + t.totalCost, 0);
1767
- const issuePercent = issueCost / config.per_issue_usd;
1768
- if (issuePercent >= 1) alerts.push({
1769
- type: "per_issue",
1770
- level: "limit_reached",
1771
- issueId,
1772
- currentCost: issueCost,
1773
- limit: config.per_issue_usd,
1774
- percentUsed: issuePercent * 100,
1775
- timestamp: now
1776
- });
1777
- else if (issuePercent >= config.alert_threshold) alerts.push({
1778
- type: "per_issue",
1779
- level: "warning",
1780
- issueId,
1781
- currentCost: issueCost,
1782
- limit: config.per_issue_usd,
1783
- percentUsed: issuePercent * 100,
1784
- timestamp: now
1785
- });
1786
- }
1787
- if (config.daily_total_usd > 0) {
1788
- const dailyTotal = getDailyTrendsSync({ days: 1 }).reduce((sum, t) => sum + t.totalCost, 0);
1789
- const dailyPercent = dailyTotal / config.daily_total_usd;
1790
- if (dailyPercent >= 1) alerts.push({
1791
- type: "daily_total",
1792
- level: "limit_reached",
1793
- currentCost: dailyTotal,
1794
- limit: config.daily_total_usd,
1795
- percentUsed: dailyPercent * 100,
1796
- timestamp: now
1797
- });
1798
- else if (dailyPercent >= config.alert_threshold) alerts.push({
1799
- type: "daily_total",
1800
- level: "warning",
1801
- currentCost: dailyTotal,
1802
- limit: config.daily_total_usd,
1803
- percentUsed: dailyPercent * 100,
1804
- timestamp: now
1805
- });
1806
- }
1807
- return alerts;
1929
+ init_paths();
1930
+ /**
1931
+ * Handoff log file path
1932
+ */
1933
+ const HANDOFF_LOG_FILE = join$1(OVERDECK_HOME, "logs", "handoffs.jsonl");
1934
+ /**
1935
+ * Ensure log directory exists
1936
+ */
1937
+ function ensureLogDir() {
1938
+ const logDir = join$1(OVERDECK_HOME, "logs");
1939
+ if (!existsSync(logDir)) mkdirSync(logDir, { recursive: true });
1808
1940
  }
1809
1941
  /**
1810
- * Get cost summary
1942
+ * Log a handoff event
1811
1943
  *
1812
- * @returns Cost summary with top spenders
1944
+ * @param event - Handoff event to log
1813
1945
  */
1814
- function getCostSummary() {
1815
- checkDailyReset();
1816
- const topAgents = Array.from(costData.perAgent.entries()).map(([agentId, cost]) => ({
1946
+ function logHandoffEventSync(event) {
1947
+ ensureLogDir();
1948
+ appendFileSync(HANDOFF_LOG_FILE, JSON.stringify(event) + "\n", "utf-8");
1949
+ }
1950
+ /**
1951
+ * Create a handoff event from handoff result
1952
+ *
1953
+ * @param agentId - Agent ID
1954
+ * @param issueId - Issue ID
1955
+ * @param context - Handoff context
1956
+ * @param trigger - Trigger type
1957
+ * @param success - Whether handoff succeeded
1958
+ * @param errorMessage - Error message if failed
1959
+ * @returns Handoff event
1960
+ */
1961
+ function createHandoffEvent(agentId, issueId, context, trigger, success, errorMessage) {
1962
+ let stuckMinutes;
1963
+ if (trigger === "stuck_escalation") {}
1964
+ return {
1965
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
1817
1966
  agentId,
1818
- cost
1819
- })).sort((a, b) => b.cost - a.cost).slice(0, 10);
1820
- const topIssues = Array.from(costData.perIssue.entries()).map(([issueId, cost]) => ({
1821
1967
  issueId,
1822
- cost
1823
- })).sort((a, b) => b.cost - a.cost).slice(0, 10);
1824
- return {
1825
- dailyTotal: costData.dailyTotal,
1826
- topAgents,
1827
- topIssues
1968
+ from: {
1969
+ model: context.previousModel,
1970
+ runtime: context.previousRuntime,
1971
+ sessionId: context.previousSessionId
1972
+ },
1973
+ to: {
1974
+ model: context.targetModel,
1975
+ runtime: context.previousRuntime,
1976
+ sessionId: void 0
1977
+ },
1978
+ trigger,
1979
+ reason: context.reason,
1980
+ context: {
1981
+ costAtHandoff: context.costSoFar,
1982
+ handoffCount: context.handoffCount,
1983
+ stuckMinutes
1984
+ },
1985
+ success,
1986
+ errorMessage
1828
1987
  };
1829
1988
  }
1830
1989
  //#endregion
@@ -2032,404 +2191,944 @@ function checkAndRotateIfNeeded(specialistName, workingDir) {
2032
2191
  return Effect.promise(() => checkAndRotateIfNeededPromise(specialistName, workingDir));
2033
2192
  }
2034
2193
  //#endregion
2035
- //#region ../../lib/cloister/service.ts
2036
- /** Cloister service: core monitoring for all running agents. */
2194
+ //#region ../../lib/cloister/triggers.ts
2037
2195
  init_config();
2038
- init_agents();
2039
- init_paths();
2040
- init_reap_terminal_sessions();
2041
- init_tmux();
2042
- init_issue_closed();
2043
- init_merge_verification();
2044
- const execAsync = promisify(exec);
2045
- const CLOISTER_STATE_FILE = join$1(OVERDECK_HOME, "cloister.state");
2046
- const LEGACY_SPECIALISTS_DIR = join$1(OVERDECK_HOME, "specialists");
2047
- async function cleanupLegacySpecialistsDirectory() {
2048
- await rm(LEGACY_SPECIALISTS_DIR, {
2049
- recursive: true,
2050
- force: true
2051
- });
2196
+ init_resolver();
2197
+ /** Single-flight prevents concurrent dashboard polls from duplicating the canonical read. */
2198
+ const taskCompletionInflight = /* @__PURE__ */ new Map();
2199
+ /**
2200
+ * Check if agent should be handed off due to stuck escalation
2201
+ *
2202
+ * Model-specific thresholds:
2203
+ * - Haiku: Stuck after 10 minutes → escalate to Sonnet
2204
+ * - Sonnet: Stuck after 20 minutes → escalate to Opus
2205
+ * - Opus: Stuck after 30 minutes → alert user (no auto-escalation)
2206
+ *
2207
+ * @param health - Agent health state
2208
+ * @param currentModel - Current model
2209
+ * @param config - Cloister configuration
2210
+ * @returns Trigger detection result
2211
+ */
2212
+ function checkStuckEscalation(health, currentModel, config) {
2213
+ const stuckConfig = (config || loadCloisterConfigSync()).handoffs?.auto_triggers?.stuck_escalation;
2214
+ if (!stuckConfig?.enabled) return {
2215
+ triggered: false,
2216
+ type: "stuck_escalation",
2217
+ reason: "Stuck escalation disabled in config",
2218
+ confidence: "high"
2219
+ };
2220
+ if (health.state !== "stuck") return {
2221
+ triggered: false,
2222
+ type: "stuck_escalation",
2223
+ reason: `Agent is ${health.state}, not stuck`,
2224
+ confidence: "high"
2225
+ };
2226
+ if (!health.lastActivity) return {
2227
+ triggered: false,
2228
+ type: "stuck_escalation",
2229
+ reason: "No last activity timestamp available",
2230
+ confidence: "low"
2231
+ };
2232
+ const minutesSinceActivity = (Date.now() - health.lastActivity.getTime()) / (1e3 * 60);
2233
+ if (currentModel === "haiku" && minutesSinceActivity >= stuckConfig.haiku_to_sonnet_minutes) return {
2234
+ triggered: true,
2235
+ type: "stuck_escalation",
2236
+ reason: `Haiku agent stuck for ${Math.round(minutesSinceActivity)} minutes`,
2237
+ suggestedModel: "sonnet",
2238
+ confidence: "high"
2239
+ };
2240
+ if (currentModel === "sonnet" && minutesSinceActivity >= stuckConfig.sonnet_to_opus_minutes) return {
2241
+ triggered: true,
2242
+ type: "stuck_escalation",
2243
+ reason: `Sonnet agent stuck for ${Math.round(minutesSinceActivity)} minutes`,
2244
+ suggestedModel: "opus",
2245
+ confidence: "high"
2246
+ };
2247
+ if (currentModel === "opus") return {
2248
+ triggered: false,
2249
+ type: "stuck_escalation",
2250
+ reason: "Opus agent stuck - no higher model available, manual intervention needed",
2251
+ confidence: "high"
2252
+ };
2253
+ return {
2254
+ triggered: false,
2255
+ type: "stuck_escalation",
2256
+ reason: `Agent stuck but threshold not reached (${Math.round(minutesSinceActivity)} minutes)`,
2257
+ confidence: "medium"
2258
+ };
2052
2259
  }
2053
- /** Return issues orphaned in reviewStatus='reviewing' with no active reviewer. */
2054
- function identifyOrphanedReviewingIssues(statuses, activeReviewIssues) {
2055
- const orphaned = [];
2056
- for (const [issueId, status] of Object.entries(statuses)) {
2057
- if (status.reviewStatus !== "reviewing") continue;
2058
- if (status.history?.some((h) => h.type === "review" && h.status === "passed")) continue;
2059
- if (activeReviewIssues.has(issueId.toUpperCase())) continue;
2060
- orphaned.push(issueId);
2061
- }
2062
- return orphaned;
2260
+ /**
2261
+ * Check if test failures should trigger escalation
2262
+ *
2263
+ * Aggressive escalation: Any test failure from Haiku escalates to Sonnet
2264
+ * Reasoning: Haiku is for simple tasks - if tests fail, the task isn't simple
2265
+ *
2266
+ * @param workspace - Workspace path
2267
+ * @param currentModel - Current model
2268
+ * @param config - Cloister configuration
2269
+ * @returns Trigger detection result
2270
+ */
2271
+ function checkTestFailure(workspace, currentModel, config) {
2272
+ const testConfig = (config || loadCloisterConfigSync()).handoffs?.auto_triggers?.test_failure;
2273
+ if (!testConfig?.enabled) return {
2274
+ triggered: false,
2275
+ type: "test_failure",
2276
+ reason: "Test failure escalation disabled in config",
2277
+ confidence: "high"
2278
+ };
2279
+ if (currentModel !== testConfig.from_model) return {
2280
+ triggered: false,
2281
+ type: "test_failure",
2282
+ reason: `Test failure escalation only applies to ${testConfig.from_model} model`,
2283
+ confidence: "high"
2284
+ };
2285
+ const testFailure = detectTestFailure(workspace);
2286
+ if (testFailure.failed) return {
2287
+ triggered: true,
2288
+ type: "test_failure",
2289
+ reason: `Test failures detected: ${testFailure.reason}`,
2290
+ suggestedModel: testConfig.to_model,
2291
+ confidence: testFailure.confidence
2292
+ };
2293
+ return {
2294
+ triggered: false,
2295
+ type: "test_failure",
2296
+ reason: "No test failures detected",
2297
+ confidence: "medium"
2298
+ };
2063
2299
  }
2064
- function parseSpecialistAgentSession(name) {
2065
- const issueScoped = name.match(/^specialist-(.+)-([A-Z]+-\d+)-(review-agent|test-agent|merge-agent)$/);
2066
- if (issueScoped) return {
2067
- projectKey: issueScoped[1],
2068
- issueId: issueScoped[2],
2069
- specialistType: issueScoped[3]
2300
+ /**
2301
+ * Detect test failures in workspace
2302
+ *
2303
+ * Checks for:
2304
+ * - npm test output
2305
+ * - Jest results
2306
+ * - pytest results
2307
+ * - cargo test results
2308
+ *
2309
+ * @param workspace - Workspace path
2310
+ * @returns Test failure detection
2311
+ */
2312
+ function detectTestFailure(workspace) {
2313
+ return {
2314
+ failed: false,
2315
+ reason: "Test result detection not yet implemented",
2316
+ confidence: "low"
2070
2317
  };
2071
- const legacy = name.match(/^specialist-(.+)-(review-agent|test-agent|merge-agent)$/);
2072
- if (legacy) return {
2073
- projectKey: legacy[1],
2074
- specialistType: legacy[2]
2318
+ }
2319
+ async function checkTaskCompletionPromise(issueId, config, workspace) {
2320
+ const completionConfig = (config || loadCloisterConfigSync()).handoffs?.auto_triggers?.implementation_complete;
2321
+ if (!completionConfig?.enabled) return {
2322
+ triggered: false,
2323
+ type: "task_complete",
2324
+ reason: "Task completion detection disabled in config",
2325
+ confidence: "high"
2075
2326
  };
2076
- return null;
2327
+ const cacheKey = `${workspace ?? ""}::${issueId.toLowerCase()}`;
2328
+ const inflight = taskCompletionInflight.get(cacheKey);
2329
+ if (inflight) return inflight;
2330
+ const computePromise = (async () => {
2331
+ if (!workspace) return {
2332
+ triggered: false,
2333
+ type: "task_complete",
2334
+ reason: "No workspace available to check task completion",
2335
+ confidence: "high"
2336
+ };
2337
+ try {
2338
+ const result = await createBeadsResolver(workspace).getBeadsForIssue(issueId);
2339
+ if (!result.ok) throw result.error;
2340
+ if (result.value.filter((task) => task.status === "closed").find((t) => t.title.toLowerCase().includes("implement") || t.labels?.includes("implementation"))) {
2341
+ const openTasks = result.value.filter((task) => task.status === "open");
2342
+ if (openTasks.length === 0) return {
2343
+ triggered: true,
2344
+ type: "task_complete",
2345
+ reason: "Implementation task closed, no remaining tasks",
2346
+ suggestedModel: completionConfig.to_specialist,
2347
+ confidence: "high"
2348
+ };
2349
+ return {
2350
+ triggered: false,
2351
+ type: "task_complete",
2352
+ reason: `Implementation task closed but ${openTasks.length} tasks remain`,
2353
+ confidence: "medium"
2354
+ };
2355
+ }
2356
+ } catch {}
2357
+ return {
2358
+ triggered: false,
2359
+ type: "task_complete",
2360
+ reason: "No implementation completion signals detected",
2361
+ confidence: "high"
2362
+ };
2363
+ })();
2364
+ taskCompletionInflight.set(cacheKey, computePromise);
2365
+ try {
2366
+ return await computePromise;
2367
+ } finally {
2368
+ taskCompletionInflight.delete(cacheKey);
2369
+ }
2077
2370
  }
2078
- let cloisterEventStoreProvider = null;
2079
- function setCloisterEventStoreProvider(provider) {
2080
- cloisterEventStoreProvider = provider;
2371
+ async function checkAllTriggersPromise(agentId, workspace, issueId, currentModel, health, config) {
2372
+ const triggers = [];
2373
+ const stuckCheck = checkStuckEscalation(health, currentModel, config);
2374
+ if (stuckCheck.triggered) triggers.push(stuckCheck);
2375
+ const testCheck = checkTestFailure(workspace, currentModel, config);
2376
+ if (testCheck.triggered) triggers.push(testCheck);
2377
+ const completionCheck = await Effect.runPromise(checkTaskCompletion(issueId, config, workspace));
2378
+ if (completionCheck.triggered) triggers.push(completionCheck);
2379
+ return triggers;
2081
2380
  }
2082
- const ROLE_RUN_STATES = {
2083
- todo: null,
2084
- open: null,
2085
- in_planning: "plan",
2086
- in_progress: "work",
2087
- in_review: "review",
2088
- testing: "test",
2089
- shipping: null,
2090
- closed: null,
2091
- canceled: null
2092
- };
2093
2381
  /**
2094
- * Map issue lifecycle state to the role that should own that state.
2382
+ * Effect-typed variant of {@link checkTaskCompletion}. Wraps the Promise-based
2383
+ * implementation; never fails (the underlying function swallows errors and
2384
+ * returns a "not triggered" detection on failure).
2095
2385
  */
2096
- function stateToRole(state) {
2097
- return ROLE_RUN_STATES[state.toLowerCase().replace(/[ -]/g, "_")] ?? null;
2098
- }
2099
- function normalizeIssueId(issueId) {
2100
- return issueId.trim().toUpperCase();
2101
- }
2102
- function roleFromAgentId(agentId, issueId) {
2103
- const base = `agent-${issueId.toLowerCase()}`;
2104
- if (agentId === base) return "work";
2105
- const role = agentId.slice(base.length + 1);
2106
- return [
2107
- "plan",
2108
- "review",
2109
- "test"
2110
- ].includes(role) ? role : null;
2386
+ function checkTaskCompletion(issueId, config, workspace) {
2387
+ return Effect.promise(() => checkTaskCompletionPromise(issueId, config, workspace));
2111
2388
  }
2112
2389
  /**
2113
- * Grace window for `starting` states without a tmux session yet. The
2114
- * start-planning route deliberately writes state.json BEFORE the lifecycle
2115
- * transition (PAN-1048) so this guard can see the planner coming — but the
2116
- * tmux session is only created after the transition. Treating any
2117
- * starting-without-session as dead (the original S1 unstick heuristic)
2118
- * re-opened the PAN-1048 race and spawned a duplicate `agent-<issue>-plan`
2119
- * twin on EVERY `pan plan` (PAN-2159, 100% repro). A fresh `starting` state
2120
- * is a startup in progress; only a stale one is a crashed spawn to unstick.
2390
+ * Effect-typed variant of {@link checkAllTriggers}. Never fails — uses
2391
+ * `Effect.promise` because the underlying async path absorbs bd / fs errors.
2121
2392
  */
2122
- const STARTING_WITHOUT_SESSION_GRACE_MS = 12e4;
2123
- function isFreshStarting(state) {
2124
- const startedAt = Date.parse(state.startedAt ?? "");
2125
- return Number.isFinite(startedAt) && Date.now() - startedAt < STARTING_WITHOUT_SESSION_GRACE_MS;
2126
- }
2127
- async function activeRoleRunExists(issueId, role, workspacePath) {
2128
- const issueLower = issueId.toLowerCase();
2129
- if (role === "plan") {
2130
- const legacyId = `planning-${issueLower}`;
2131
- const legacyState = await Effect.runPromise(getAgentState(legacyId));
2132
- if (legacyState?.role === "plan" && legacyState.status !== "stopped" && legacyState.status !== "error") {
2133
- if (legacyState.status === "starting" && !await Effect.runPromise(sessionExists(legacyId))) return isFreshStarting(legacyState);
2134
- return true;
2135
- }
2136
- }
2137
- const candidateId = role === "work" ? `agent-${issueLower}` : `agent-${issueLower}-${role}`;
2138
- const state = await Effect.runPromise(getAgentState(candidateId));
2139
- if (!state) return false;
2140
- const stateRole = state.role ?? roleFromAgentId(candidateId, issueId);
2141
- if (stateRole === role && state.status === "starting" && !await Effect.runPromise(sessionExists(candidateId))) return isFreshStarting(state);
2142
- if (!(stateRole === role && state.status !== "stopped" && state.status !== "error")) return false;
2143
- if (workspacePath && state.roleRunHead) try {
2144
- const { stdout } = await execAsync("git rev-parse --short=8 HEAD", { cwd: workspacePath });
2145
- const currentHead = stdout.trim();
2146
- if (currentHead && currentHead !== state.roleRunHead) {
2147
- console.log(`[cloister] ${issueId}: ${role} session ${candidateId} is stale (ran against ${state.roleRunHead}, HEAD is now ${currentHead}) — not active`);
2148
- return false;
2149
- }
2150
- } catch {}
2151
- return true;
2152
- }
2153
- function buildReactiveRolePrompt(issueId, state, role) {
2154
- return `${role.toUpperCase()} TASK for ${issueId}:
2155
-
2156
- The issue lifecycle transitioned to ${state}. Run the ${role} role for this issue.
2157
-
2158
- Required steps:
2159
- 1. Work only in the workspace configured for ${issueId}.
2160
- 2. Read .pan/continue.json, .pan/spec.vbrief.json, project instructions, and issue context.
2161
- 3. Follow the boundaries and success criteria in roles/${role}.md exactly.
2162
- 4. Report the role-specific terminal status when done.`;
2393
+ function checkAllTriggers(agentId, workspace, issueId, currentModel, health, config) {
2394
+ return Effect.promise(() => checkAllTriggersPromise(agentId, workspace, issueId, currentModel, health, config));
2163
2395
  }
2396
+ //#endregion
2397
+ //#region ../../lib/cloister/service-health.ts
2398
+ /** Cloister health monitoring seam. */
2399
+ init_agents();
2164
2400
  /**
2165
- * Resolve the workspace path for an issue from agent state, then fall back
2166
- * to the canonical `<projectPath>/workspaces/feature-<issueLower>` layout.
2167
- * Mirrors the resolution used by startup recovery (service.ts:583-609) so
2168
- * the reactive scheduler dispatches review/test wrappers with the same
2169
- * workspace contract those wrappers receive on the manual code path.
2401
+ * Perform a health check on all running agents
2170
2402
  */
2171
- async function resolveWorkspaceForIssue(issueId) {
2172
- const issueLower = issueId.toLowerCase();
2173
- const agentState = await Effect.runPromise(getAgentState(`agent-${issueLower}`));
2174
- if (agentState?.workspace) return agentState.workspace;
2175
- const resolved = resolveProjectFromIssueSync(issueId);
2176
- if (!resolved) return null;
2177
- return `${resolved.projectPath}/workspaces/feature-${issueLower}`;
2178
- }
2179
- async function onIssueStateChangePromise(issueId, newState) {
2180
- const normalizedIssueId = normalizeIssueId(issueId);
2181
- const role = stateToRole(newState);
2182
- if (!role) {
2183
- console.log(`[cloister] ${normalizedIssueId}: no role for issue state '${newState}'`);
2184
- return;
2185
- }
2186
- if (await isIssueClosed(normalizedIssueId)) {
2187
- const message = `${normalizedIssueId}: skipping ${role} dispatch — issue is closed`;
2188
- console.log(`[cloister] ${message}`);
2189
- emitActivityEntrySync({
2190
- source: "cloister",
2191
- level: "info",
2192
- message,
2193
- issueId: normalizedIssueId
2194
- });
2195
- return;
2196
- }
2197
- const { getReviewStatusSync } = await import("./review-status-B0Axo-Ma.js");
2198
- if (getReviewStatusSync(normalizedIssueId)?.mergeStatus === "merged") {
2199
- const message = `${normalizedIssueId}: skipping ${role} dispatch — merge already landed (merge_status='merged' is terminal)`;
2200
- console.log(`[cloister] ${message}`);
2201
- emitActivityEntrySync({
2202
- source: "cloister",
2203
- level: "info",
2204
- message,
2205
- issueId: normalizedIssueId
2403
+ async function performHealthCheck(host) {
2404
+ try {
2405
+ const runningAgents = listRunningAgentsSync().filter((a) => a.tmuxActive);
2406
+ const agentIds = runningAgents.map((a) => a.id);
2407
+ const currentRunningSet = new Set(agentIds);
2408
+ if (host.previousRunningAgents.size > 0 && host.config.auto_restart?.enabled) {
2409
+ for (const previousAgentId of host.previousRunningAgents) if (!currentRunningSet.has(previousAgentId)) await host.handleAgentCrash(previousAgentId);
2410
+ }
2411
+ host.previousRunningAgents = currentRunningSet;
2412
+ host.healthCheckCount++;
2413
+ if (host.healthCheckCount % 4 === 0) host.checkCompletionMarkers();
2414
+ if (agentIds.length === 0) {
2415
+ host.lastCheck = /* @__PURE__ */ new Date();
2416
+ return;
2417
+ }
2418
+ const agentHealths = [];
2419
+ for (const agentId of agentIds) {
2420
+ const runtime = getRuntimeForAgent(agentId);
2421
+ if (runtime) {
2422
+ const health = getAgentHealth(agentId, runtime);
2423
+ agentHealths.push(health);
2424
+ host.recordHealthEvent(health);
2425
+ }
2426
+ }
2427
+ host.lastCheck = /* @__PURE__ */ new Date();
2428
+ host.emit({
2429
+ type: "health_check",
2430
+ agentHealths
2206
2431
  });
2207
- return;
2208
- }
2209
- const mergedGuard = await shouldSkipDispatchAsMerged(normalizedIssueId);
2210
- if (mergedGuard.skip) {
2211
- const message = `${normalizedIssueId}: skipping ${role} dispatch — ${mergedGuard.reason}`;
2212
- console.log(`[cloister] ${message}`);
2213
- emitActivityEntrySync({
2214
- source: "cloister",
2215
- level: "info",
2216
- message,
2217
- issueId: normalizedIssueId
2432
+ const needsAttention = getAgentsNeedingAttention(agentHealths);
2433
+ const pokeCooldownMs = host.config.auto_actions.poke_cooldown_ms ?? 1800 * 1e3;
2434
+ const now = Date.now();
2435
+ for (const health of needsAttention) {
2436
+ const idleAgentState = getAgentStateSync(health.agentId);
2437
+ if (idleAgentState?.role === "sequencer") continue;
2438
+ const { shouldSkipIdlePokeForAgent } = await import("./stuck-remediation-nTIvhGOR.js");
2439
+ if (shouldSkipIdlePokeForAgent(idleAgentState)) {
2440
+ host.pokeProgress.delete(health.agentId);
2441
+ continue;
2442
+ }
2443
+ const cooledDown = now - (host.lastPokeTimestamps.get(health.agentId) ?? 0) >= pokeCooldownMs;
2444
+ if (health.state === "warning") {
2445
+ host.emit({
2446
+ type: "agent_warning",
2447
+ agentId: health.agentId,
2448
+ health
2449
+ });
2450
+ if (host.config.auto_actions.poke_on_warning && cooledDown) {
2451
+ host.pokeAgent(health.agentId);
2452
+ host.lastPokeTimestamps.set(health.agentId, now);
2453
+ }
2454
+ } else if (health.state === "stuck") {
2455
+ host.emit({
2456
+ type: "agent_stuck",
2457
+ agentId: health.agentId,
2458
+ health
2459
+ });
2460
+ if ((host.config.auto_actions.poke_on_stuck ?? true) && cooledDown) {
2461
+ host.pokeAgent(health.agentId);
2462
+ host.lastPokeTimestamps.set(health.agentId, now);
2463
+ }
2464
+ if (host.config.auto_actions.kill_on_stuck) host.killAgent(health.agentId);
2465
+ }
2466
+ }
2467
+ host.checkHandoffTriggers(agentHealths);
2468
+ host.checkFPPViolations(agentIds);
2469
+ await reconcilePiCostEventsForRunningAgents(runningAgents);
2470
+ host.checkCostAlerts(agentIds);
2471
+ if (Math.random() < .1) host.checkSpecialistRotations();
2472
+ if (Math.random() < .01) clearOldViolationsSync(24);
2473
+ } catch (error) {
2474
+ console.error("Cloister health check failed:", error);
2475
+ host.emit({
2476
+ type: "error",
2477
+ error
2218
2478
  });
2219
- return;
2220
2479
  }
2221
- const workspace = await resolveWorkspaceForIssue(normalizedIssueId);
2222
- if (await activeRoleRunExists(normalizedIssueId, role, workspace ?? void 0)) {
2223
- const message = `${normalizedIssueId}: ${role} role already active; skipping lifecycle spawn`;
2224
- console.log(`[cloister] ${message}`);
2225
- emitActivityEntrySync({
2226
- source: "cloister",
2227
- level: "info",
2228
- message,
2229
- issueId: normalizedIssueId
2480
+ }
2481
+ /**
2482
+ * Check if any specialists need session rotation
2483
+ */
2484
+ async function checkSpecialistRotations(host) {
2485
+ const mergeAgentResult = await Effect.runPromise(checkAndRotateIfNeeded("merge-agent", process.cwd()));
2486
+ if (mergeAgentResult) {
2487
+ host.emit({
2488
+ type: "session_rotated",
2489
+ specialistName: "merge-agent",
2490
+ result: mergeAgentResult
2230
2491
  });
2231
- return;
2492
+ if (mergeAgentResult.success) console.log(`🔔 Rotated merge-agent session: ${mergeAgentResult.oldSessionId.substring(0, 8)} → ${mergeAgentResult.newSessionId?.substring(0, 8)}`);
2493
+ else console.error(`🔔 Failed to rotate merge-agent: ${mergeAgentResult.error}`);
2232
2494
  }
2233
- const issueLower = normalizedIssueId.toLowerCase();
2234
- const roleSessionId = role === "work" ? `agent-${issueLower}` : `agent-${issueLower}-${role}`;
2235
- if (await Effect.runPromise(sessionExists(roleSessionId))) {
2236
- const message = `${normalizedIssueId}: killing stale ${role} session ${roleSessionId} before re-dispatch`;
2237
- console.log(`[cloister] ${message}`);
2238
- emitActivityEntrySync({
2239
- source: "cloister",
2240
- level: "info",
2241
- message,
2242
- issueId: normalizedIssueId
2243
- });
2244
- try {
2245
- await Effect.runPromise(killSession(roleSessionId));
2246
- } catch (err) {
2247
- console.error(`[cloister] failed to kill stale session ${roleSessionId}:`, err instanceof Error ? err.message : String(err));
2495
+ }
2496
+ /**
2497
+ * Record health event to database
2498
+ *
2499
+ * Only writes events when state changes or on first check.
2500
+ */
2501
+ function recordHealthEvent(host, health) {
2502
+ try {
2503
+ const currentState = health.state;
2504
+ const previousState = host.previousStates.get(health.agentId);
2505
+ if (previousState === void 0 || previousState !== currentState) {
2506
+ const source = health.heartbeat?.source ? host.mapHeartbeatSource(health.heartbeat.source) : "unknown";
2507
+ writeHealthEvent({
2508
+ agentId: health.agentId,
2509
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
2510
+ state: currentState,
2511
+ source,
2512
+ metadata: health.heartbeat ? JSON.stringify({
2513
+ confidence: health.heartbeat.confidence,
2514
+ lastAction: health.heartbeat.lastAction,
2515
+ toolName: health.heartbeat.toolName,
2516
+ timeSinceActivity: health.timeSinceActivity
2517
+ }) : void 0
2518
+ });
2519
+ host.previousStates.set(health.agentId, currentState);
2248
2520
  }
2521
+ } catch (error) {
2522
+ console.error(`Failed to record health event for ${health.agentId}:`, error);
2249
2523
  }
2250
- try {
2251
- if (role === "review") {
2252
- if (!workspace) {
2253
- const failure = `${normalizedIssueId}: cannot dispatch review role — no workspace or project resolved`;
2254
- console.error(`[cloister] ${failure}`);
2255
- emitActivityEntrySync({
2256
- source: "cloister",
2257
- level: "error",
2258
- message: failure,
2259
- issueId: normalizedIssueId
2260
- });
2261
- return;
2524
+ }
2525
+ /**
2526
+ * Check for handoff triggers and execute handoffs (Phase 4)
2527
+ *
2528
+ * Checks all triggers for each agent and performs handoffs when triggered.
2529
+ */
2530
+ async function checkHandoffTriggers(host, agentHealths) {
2531
+ for (const health of agentHealths) try {
2532
+ const agentState = getAgentStateSync(health.agentId);
2533
+ if (!agentState) continue;
2534
+ if (!agentState.workspace) continue;
2535
+ const triggers = await Effect.runPromise(checkAllTriggers(health.agentId, agentState.workspace, agentState.issueId, agentState.model, health, host.config));
2536
+ if (triggers.length > 0) {
2537
+ const trigger = triggers[0];
2538
+ if (trigger.type === "task_complete" && [
2539
+ "review-agent",
2540
+ "test-agent",
2541
+ "merge-agent",
2542
+ "inspect-agent",
2543
+ "uat-agent"
2544
+ ].includes(trigger.suggestedModel || "")) {
2545
+ console.log(`[cloister] Skipping handoff for ${health.agentId}: task_complete triggers specialist dispatch via completion marker, not model swap`);
2546
+ continue;
2262
2547
  }
2263
- const branch = `feature/${normalizedIssueId.toLowerCase()}`;
2264
- const { spawnReviewRoleForIssue } = await import("./review-agent-Bf9XjLnl.js");
2265
- const result = await Effect.runPromise(spawnReviewRoleForIssue({
2266
- issueId: normalizedIssueId,
2267
- workspace,
2268
- branch
2269
- }));
2270
- const message = `${normalizedIssueId}: review role dispatched from lifecycle state '${newState}' (${result.message})`;
2271
- console.log(`[cloister] ${message}`);
2272
- emitActivityEntrySync({
2273
- source: "cloister",
2274
- level: result.success ? "info" : "error",
2275
- message,
2276
- issueId: normalizedIssueId
2548
+ host.emit({
2549
+ type: "handoff_triggered",
2550
+ agentId: health.agentId,
2551
+ trigger
2277
2552
  });
2278
- return;
2279
- }
2280
- if (role === "test") {
2281
- const branch = `feature/${normalizedIssueId.toLowerCase()}`;
2282
- const { dispatchTestAgentAndNotify } = await import("./test-agent-queue-Bq298zZl.js");
2283
- await Effect.runPromise(dispatchTestAgentAndNotify(normalizedIssueId, workspace ?? void 0, branch));
2284
- const message = `${normalizedIssueId}: test role dispatched from lifecycle state '${newState}'`;
2285
- console.log(`[cloister] ${message}`);
2286
- emitActivityEntrySync({
2287
- source: "cloister",
2288
- level: "info",
2289
- message,
2290
- issueId: normalizedIssueId
2553
+ console.log(`🔔 Handoff triggered for ${health.agentId}: ${trigger.reason}`);
2554
+ const result = await Effect.runPromise(performHandoff(health.agentId, {
2555
+ targetModel: trigger.suggestedModel || "sonnet",
2556
+ reason: trigger.reason
2557
+ }));
2558
+ host.emit({
2559
+ type: "handoff_completed",
2560
+ agentId: health.agentId,
2561
+ result
2291
2562
  });
2292
- return;
2563
+ if (result.context) logHandoffEventSync(createHandoffEvent(health.agentId, agentState.issueId, result.context, trigger.type, result.success, result.error));
2564
+ if (result.success) console.log(`✓ Handoff completed: ${health.agentId} → ${result.newAgentId} (${trigger.suggestedModel})`);
2565
+ else console.error(`✗ Handoff failed: ${result.error}`);
2293
2566
  }
2294
- const { spawnRun } = await import("./agents-CxbuBjID.js");
2295
- const message = `${normalizedIssueId}: ${role} role started from lifecycle state '${newState}' as ${(await spawnRun(normalizedIssueId, role, { prompt: buildReactiveRolePrompt(normalizedIssueId, newState, role) })).id}`;
2296
- console.log(`[cloister] ${message}`);
2297
- emitActivityEntrySync({
2298
- source: "cloister",
2299
- level: "info",
2300
- message,
2301
- issueId: normalizedIssueId
2302
- });
2303
2567
  } catch (error) {
2304
- const message = error instanceof Error ? error.message : String(error);
2305
- if (message.includes("already running")) {
2306
- const skipMessage = `${normalizedIssueId}: ${role} role already running; skipping lifecycle spawn`;
2307
- console.log(`[cloister] ${skipMessage}`);
2308
- emitActivityEntrySync({
2309
- source: "cloister",
2310
- level: "info",
2311
- message: skipMessage,
2312
- issueId: normalizedIssueId
2568
+ console.error(`Failed to check handoff triggers for ${health.agentId}:`, error);
2569
+ }
2570
+ }
2571
+ /**
2572
+ * Check for FPP violations and send nudges
2573
+ */
2574
+ function checkFPPViolations(host, agentIds) {
2575
+ for (const agentId of agentIds) {
2576
+ const violation = checkAgentForViolations(agentId);
2577
+ if (!violation) continue;
2578
+ if (violation.nudgeCount === 0) host.emit({
2579
+ type: "fpp_violation_detected",
2580
+ agentId,
2581
+ violation
2582
+ });
2583
+ if ((violation.lastNudgeAt ? Date.now() - new Date(violation.lastNudgeAt).getTime() : Infinity) >= 300 * 1e3 || violation.nudgeCount === 0) {
2584
+ if (hasExceededMaxNudges(violation)) {
2585
+ host.emit({
2586
+ type: "fpp_max_nudges_exceeded",
2587
+ agentId,
2588
+ violation
2589
+ });
2590
+ console.error(`🔔 Agent ${agentId} exceeded max nudges for ${violation.type} - manual intervention required`);
2591
+ } else if (sendNudge(violation)) host.emit({
2592
+ type: "fpp_nudge_sent",
2593
+ agentId,
2594
+ nudgeCount: violation.nudgeCount
2313
2595
  });
2314
- return;
2315
2596
  }
2316
- console.error(`[cloister] Failed to start ${role} role for ${normalizedIssueId}:`, error);
2317
- emitActivityEntrySync({
2318
- source: "cloister",
2319
- level: "error",
2320
- message: `${normalizedIssueId}: failed to start ${role} role: ${message}`,
2321
- issueId: normalizedIssueId
2322
- });
2323
2597
  }
2324
2598
  }
2325
- function payloadRecord(event) {
2326
- return event.payload && typeof event.payload === "object" ? event.payload : {};
2327
- }
2328
- function issueStateChangeFromDomainEvent(event) {
2329
- const payload = payloadRecord(event);
2330
- const issueId = typeof payload.issueId === "string" ? payload.issueId : null;
2331
- if (!issueId) return null;
2332
- switch (event.type) {
2333
- case "issue.transitioned": return typeof payload.state === "string" ? {
2334
- issueId,
2335
- state: payload.state
2336
- } : null;
2337
- case "issue.statusChanged": return typeof payload.canonicalStatus === "string" ? {
2338
- issueId,
2339
- state: payload.canonicalStatus
2340
- } : null;
2341
- case "issue.closed": return {
2342
- issueId,
2343
- state: "closed"
2344
- };
2345
- case "agent.completed": {
2346
- const role = typeof payload.role === "string" ? payload.role : void 0;
2347
- if (role === void 0 || role === "work") return {
2348
- issueId,
2349
- state: "in_review"
2350
- };
2351
- return null;
2599
+ /**
2600
+ * Check for cost limit alerts
2601
+ */
2602
+ function checkCostAlerts(host, agentIds) {
2603
+ const config = host.config.cost_limits;
2604
+ if (!config) return;
2605
+ for (const agentId of agentIds) {
2606
+ const alerts = checkCostLimits(agentId, agentId.startsWith("agent-") ? agentId.replace(/^agent-/, "") : agentId, config);
2607
+ for (const alert of alerts) {
2608
+ host.emit({
2609
+ type: "cost_alert",
2610
+ alert
2611
+ });
2612
+ const entityLabel = alert.agentId || alert.issueId || "(unattributed)";
2613
+ if (alert.level === "limit_reached") console.error(`🔔 COST LIMIT REACHED: ${alert.type} for ${entityLabel} - $${alert.currentCost.toFixed(2)} / $${alert.limit.toFixed(2)}`);
2614
+ else console.warn(`🔔 Cost warning: ${alert.type} for ${entityLabel} at ${alert.percentUsed.toFixed(0)}% ($${alert.currentCost.toFixed(2)} / $${alert.limit.toFixed(2)})`);
2352
2615
  }
2353
- case "work.completed": return {
2354
- issueId,
2355
- state: "in_review"
2356
- };
2357
- case "review.approved": return {
2358
- issueId,
2359
- state: "testing"
2360
- };
2361
- case "test.passed": return {
2362
- issueId,
2363
- state: "shipping"
2364
- };
2365
- default: return null;
2366
2616
  }
2367
2617
  }
2368
- async function handleCloisterDomainEventPromise(event) {
2369
- if (event.type === "agent.stopped") {
2370
- const agentId = event.payload?.agentId;
2371
- if (agentId) {
2372
- const { handleAgentStoppedEvent, handleAgentStoppedForOrphanReviewerSessions } = await import("./deacon-Bz-6Wjmn.js");
2373
- const { handleAgentLifecycleEventForIdleStack } = await import("./idle-stack-reaper-DNbV9-fj.js");
2374
- handleAgentLifecycleEventForIdleStack(agentId);
2375
- const slotMatch = /^agent-(.+)-slot-\d+$/.exec(agentId);
2376
- await Promise.all([
2377
- handleAgentStoppedEvent(agentId),
2378
- handleAgentStoppedForOrphanReviewerSessions(agentId),
2379
- slotMatch ? (await import("./deacon-swarm-B8pgK2a7.js")).coordinateSwarmSlots({ issueId: slotMatch[1].toUpperCase() }) : Promise.resolve([])
2380
- ]);
2618
+ /**
2619
+ * Map heartbeat source to database source string
2620
+ */
2621
+ function mapHeartbeatSource(_host, source) {
2622
+ switch (source) {
2623
+ case "jsonl": return "jsonl_mtime";
2624
+ case "tmux": return "tmux_activity";
2625
+ case "git": return "git_activity";
2626
+ case "active-heartbeat": return "active_heartbeat";
2627
+ default: return source;
2628
+ }
2629
+ }
2630
+ //#endregion
2631
+ //#region ../../lib/cloister/service-completion.ts
2632
+ /** Cloister completion marker fallback seam. */
2633
+ init_paths();
2634
+ /** Fallback scan for completion markers when `pan done` did not reach the dashboard. */
2635
+ async function checkCompletionMarkers(host) {
2636
+ try {
2637
+ if (!existsSync(AGENTS_DIR)) return;
2638
+ const agentDirs = readdirSync(AGENTS_DIR, { withFileTypes: true }).filter((d) => d.isDirectory() && d.name.startsWith("agent-"));
2639
+ for (const dir of agentDirs) {
2640
+ const completedFile = join$1(AGENTS_DIR, dir.name, "completed");
2641
+ const processedFile = join$1(AGENTS_DIR, dir.name, "completed.processed");
2642
+ if (!existsSync(completedFile)) continue;
2643
+ if (existsSync(processedFile)) try {
2644
+ if (statSync(completedFile).mtimeMs > statSync(processedFile).mtimeMs) {
2645
+ try {
2646
+ unlinkSync(processedFile);
2647
+ } catch {}
2648
+ host.processedCompletions.delete(dir.name);
2649
+ console.log(`🔔 Cloister: Detected re-completion for ${dir.name} (completed newer than .processed) — clearing stale marker`);
2650
+ } else continue;
2651
+ } catch {
2652
+ continue;
2653
+ }
2654
+ try {
2655
+ const content = JSON.parse(readFileSync(completedFile, "utf-8"));
2656
+ const ageMs = Date.now() - new Date(content.timestamp).getTime();
2657
+ if (ageMs > 1440 * 60 * 1e3) {
2658
+ console.log(`🔔 Cloister: Skipping stale completion marker for ${dir.name} (${Math.floor(ageMs / 36e5)}h old)`);
2659
+ host.processedCompletions.set(dir.name, Infinity);
2660
+ try {
2661
+ renameSync(completedFile, processedFile);
2662
+ } catch {}
2663
+ continue;
2664
+ }
2665
+ } catch (parseErr) {
2666
+ console.warn(` ⚠ Cloister: Could not parse completion marker for ${dir.name}, skipping`);
2667
+ continue;
2668
+ }
2669
+ const retryCount = host.processedCompletions.get(dir.name) || 0;
2670
+ if (retryCount === Infinity) host.processedCompletions.delete(dir.name);
2671
+ else if (retryCount >= 3) continue;
2672
+ const issueId = dir.name.replace("agent-", "").toUpperCase();
2673
+ const { getReviewStatusSync } = await import("./review-status-CG0iDuWa.js");
2674
+ const existingReview = getReviewStatusSync(issueId);
2675
+ if (existingReview && ["reviewing", "passed"].includes(existingReview.reviewStatus || "")) {
2676
+ console.log(`🔔 Cloister: Completion marker for ${issueId} — review already ${existingReview.reviewStatus}, marking processed`);
2677
+ try {
2678
+ renameSync(completedFile, processedFile);
2679
+ } catch {}
2680
+ host.processedCompletions.set(dir.name, Infinity);
2681
+ continue;
2682
+ }
2683
+ console.log(`🔔 Cloister: Found completion marker for ${issueId}, triggering review...${retryCount > 0 ? ` (retry ${retryCount}/3)` : ""}`);
2684
+ try {
2685
+ const result = await (async () => {
2686
+ const controller = new AbortController();
2687
+ const timer = setTimeout(() => controller.abort(), 5e3);
2688
+ try {
2689
+ const res = await fetch(`${host.getDashboardApiUrl()}/api/review/${issueId}/trigger`, {
2690
+ method: "POST",
2691
+ headers: { "Content-Type": "application/json" },
2692
+ body: JSON.stringify({}),
2693
+ signal: controller.signal
2694
+ });
2695
+ clearTimeout(timer);
2696
+ try {
2697
+ return await res.json();
2698
+ } catch {
2699
+ return {
2700
+ success: false,
2701
+ error: `Invalid response (HTTP ${res.status})`
2702
+ };
2703
+ }
2704
+ } catch (e) {
2705
+ clearTimeout(timer);
2706
+ if (e instanceof Error && e.name === "AbortError") return {
2707
+ success: false,
2708
+ error: "Timeout (5s)"
2709
+ };
2710
+ return {
2711
+ success: false,
2712
+ error: e instanceof Error ? e.message : String(e)
2713
+ };
2714
+ }
2715
+ })();
2716
+ if (result.success) {
2717
+ console.log(` ✓ Review triggered for ${issueId}`);
2718
+ renameSync(completedFile, processedFile);
2719
+ host.processedCompletions.set(dir.name, Infinity);
2720
+ } else if (result.alreadyReviewed || result.alreadyMerged) {
2721
+ console.log(` ✓ ${issueId} already ${result.alreadyMerged ? "merged" : "reviewed"}, marking processed`);
2722
+ renameSync(completedFile, processedFile);
2723
+ host.processedCompletions.set(dir.name, Infinity);
2724
+ } else {
2725
+ host.processedCompletions.set(dir.name, retryCount + 1);
2726
+ console.log(` ⚠ Review trigger failed for ${issueId}: ${result.error || "unknown"} (will retry, ${2 - retryCount} attempts left)`);
2727
+ }
2728
+ } catch (err) {
2729
+ host.processedCompletions.set(dir.name, retryCount + 1);
2730
+ const message = err instanceof Error ? err.message : String(err);
2731
+ console.error(` ✗ Failed to trigger review for ${issueId}: ${message} (will retry, ${2 - retryCount} attempts left)`);
2732
+ }
2733
+ }
2734
+ } catch (error) {}
2735
+ }
2736
+ //#endregion
2737
+ //#region ../../lib/cloister/service-crash.ts
2738
+ /** Cloister crash recovery and poke escalation seam. */
2739
+ init_context_overflow();
2740
+ init_agents();
2741
+ init_tmux();
2742
+ init_reap_terminal_sessions();
2743
+ const execAsync = promisify(exec);
2744
+ const execFileAsync = promisify(execFile);
2745
+ /**
2746
+ * One-shot convoy roles (PAN-1742). The review lenses + synthesis and the test
2747
+ * role are spawned fresh via `spawnRun` each cycle; they run once, write their
2748
+ * artifact, and exit. They never save a sessionId, so they cannot be resumed.
2749
+ */
2750
+ const ONE_SHOT_ROLES = new Set(["review", "test"]);
2751
+ /**
2752
+ * Decide whether a vanished tmux session should be treated as a crash worth
2753
+ * auto-restarting (PAN-1742). Returns a human-readable reason to SKIP the
2754
+ * crash/restart machinery, or `null` when the agent is a genuine restart
2755
+ * candidate.
2756
+ *
2757
+ * Two cases are not crashes:
2758
+ * - one-shot convoy roles, whose session-end is a normal completion; and
2759
+ * - any agent without a saved session, which `restartAgent` cannot resume
2760
+ * (it throws "No session ID found"), so counting a crash and scheduling a
2761
+ * restart is pure noise.
2762
+ *
2763
+ * Pure and exported so the rule is unit-tested independent of the service's
2764
+ * private health-check plumbing.
2765
+ */
2766
+ function nonRestartableReason(role, sessionId) {
2767
+ if (ONE_SHOT_ROLES.has(role)) return `(${role}) finished its one-shot run — completion, not a crash`;
2768
+ if (!sessionId) return "ended with no resumable session — not auto-restartable";
2769
+ return null;
2770
+ }
2771
+ /**
2772
+ * Poke an agent (send "are you stuck?" message).
2773
+ *
2774
+ * NOTE: runtime.sendMessage() is async — both ClaudeCodeRuntime and PiRuntime
2775
+ * are declared `async sendMessage(): Promise<void>`. A `throw` inside an
2776
+ * async function before any await still returns a rejected Promise, so the
2777
+ * surrounding try/catch CANNOT catch it. Without explicit `.catch()`, the
2778
+ * rejection becomes an UnhandledPromiseRejection and crashes the dashboard
2779
+ * server. We hit this in production when the deacon health-check polled a
2780
+ * dead agent (PAN-1189 wedge sweep #12-13).
2781
+ */
2782
+ function pokeAgent(host, agentId) {
2783
+ host.pokeAgentWithEscalation(agentId).catch((error) => {
2784
+ console.error(`Failed to poke ${agentId}:`, error);
2785
+ });
2786
+ }
2787
+ /** PAN-2452: fingerprint of observable progress — workspace HEAD + pane tail.
2788
+ * Unchanged fingerprint across pokes = the poke did nothing. */
2789
+ async function progressFingerprint(_host, agentId) {
2790
+ const state = getAgentStateSync(agentId);
2791
+ let head = "";
2792
+ if (state?.workspace) try {
2793
+ const { stdout } = await execAsync("git rev-parse HEAD", {
2794
+ cwd: state.workspace,
2795
+ encoding: "utf-8"
2796
+ });
2797
+ head = stdout.trim();
2798
+ } catch {}
2799
+ let pane = "";
2800
+ try {
2801
+ const { stdout } = await execFileAsync("tmux", [
2802
+ "-L",
2803
+ "overdeck",
2804
+ "capture-pane",
2805
+ "-t",
2806
+ exactPaneTarget(agentId),
2807
+ "-p",
2808
+ "-S",
2809
+ `-40`
2810
+ ], { encoding: "utf-8" });
2811
+ pane = stdout;
2812
+ } catch {}
2813
+ const paneHash = createHash("sha1").update(pane).digest("hex").slice(0, 12);
2814
+ return `${head}:${paneHash}`;
2815
+ }
2816
+ async function pokeAgentWithEscalation(host, agentId) {
2817
+ const runtime = getRuntimeForAgent(agentId);
2818
+ if (!runtime) throw new Error(`No runtime found for agent ${agentId}`);
2819
+ const fingerprint = await host.progressFingerprint(agentId);
2820
+ const prior = host.pokeProgress.get(agentId);
2821
+ const ineffective = prior && prior.fingerprint === fingerprint ? prior.ineffective + 1 : 0;
2822
+ host.pokeProgress.set(agentId, {
2823
+ fingerprint,
2824
+ ineffective
2825
+ });
2826
+ if (ineffective >= 4) {
2827
+ const { setAgentPausedSync } = await import("./agent-state-lGvgPP4m.js");
2828
+ try {
2829
+ setAgentPausedSync(agentId, `needs-you: idle-alive — no observable progress across ${ineffective + 1} pokes (idle-alive escalation)`);
2830
+ host.emit({
2831
+ type: "agent_stuck",
2832
+ agentId,
2833
+ health: void 0
2834
+ });
2835
+ console.log(`🛑 ${agentId} paused: idle-alive across ${ineffective + 1} pokes`);
2836
+ } catch (pauseErr) {
2837
+ console.error(`Failed to pause idle-alive ${agentId}:`, pauseErr);
2381
2838
  }
2382
2839
  return;
2383
2840
  }
2384
- if (event.type === "agent.started") {
2385
- const agentId = event.payload?.agentId;
2386
- if (agentId) (await import("./idle-stack-reaper-DNbV9-fj.js")).handleAgentLifecycleEventForIdleStack(agentId);
2841
+ let pokeMessage = "Hey, I noticed you haven't made progress in a while. Are you stuck? If you need help or clarification, please ask. Otherwise, please continue with your work.";
2842
+ if (ineffective >= 2) {
2843
+ let tail = "";
2844
+ try {
2845
+ const { stdout } = await execFileAsync("tmux", [
2846
+ "-L",
2847
+ "overdeck",
2848
+ "capture-pane",
2849
+ "-t",
2850
+ exactPaneTarget(agentId),
2851
+ "-p",
2852
+ "-S",
2853
+ `-40`
2854
+ ], { encoding: "utf-8" });
2855
+ tail = stdout;
2856
+ } catch {}
2857
+ if (isContextOverflowTail(tail)) {
2858
+ pokeMessage = "/compact";
2859
+ console.log(`🔔 ${agentId}: overflow tail detected — delivering /compact instead of a poke`);
2860
+ } else pokeMessage = "You appear active but have made no observable progress (no new commits, unchanged output) across multiple checks. Do exactly one of: (1) work is done — commit, push, and run pan done; (2) blocked — state the blocker in one sentence and commit what you have as WIP; (3) lost context — re-read your bead (bd show), your latest .pan/feedback/ file, and git status, then resume. Act now.";
2861
+ }
2862
+ await Promise.resolve(runtime.sendMessage(agentId, pokeMessage)).catch((sendErr) => {
2863
+ console.error(`Failed to send poke to ${agentId}:`, sendErr);
2864
+ });
2865
+ host.emit({
2866
+ type: "poked_agent",
2867
+ agentId
2868
+ });
2869
+ console.log(`🔔 Poked ${agentId}${ineffective > 0 ? ` (ineffective streak: ${ineffective})` : ""}`);
2870
+ }
2871
+ /**
2872
+ * Kill an agent
2873
+ *
2874
+ * runtime.killAgent() is also async in some runtime implementations — apply
2875
+ * the same fire-and-forget guard as pokeAgent so async rejection cannot
2876
+ * crash the dashboard from a deacon health-check timer callback.
2877
+ */
2878
+ function killAgent(host, agentId) {
2879
+ try {
2880
+ const runtime = getRuntimeForAgent(agentId);
2881
+ if (!runtime) throw new Error(`No runtime found for agent ${agentId}`);
2882
+ Promise.resolve(runtime.killAgent(agentId)).catch((killErr) => {
2883
+ console.error(`Failed to kill ${agentId}:`, killErr);
2884
+ });
2885
+ host.emit({
2886
+ type: "killed_agent",
2887
+ agentId
2888
+ });
2889
+ console.log(`🔔 Killed ${agentId}`);
2890
+ } catch (error) {
2891
+ console.error(`Failed to kill ${agentId}:`, error);
2892
+ }
2893
+ }
2894
+ /**
2895
+ * Handle agent crash with auto-restart logic
2896
+ */
2897
+ async function handleAgentCrash(host, agentId) {
2898
+ const config = host.config.auto_restart;
2899
+ if (!config?.enabled) return;
2900
+ const agentState = getAgentStateSync(agentId);
2901
+ if (!agentState || agentState.status === "stopped") {
2902
+ console.log(`🔔 Agent ${agentId} was intentionally stopped, skipping restart`);
2387
2903
  return;
2388
2904
  }
2389
- if (event.type === "agent.heartbeat_dead") {
2390
- const agentId = event.payload?.agentId;
2391
- if (agentId) {
2392
- const { handleAgentHeartbeatDeadEvent } = await import("./deacon-Bz-6Wjmn.js");
2393
- await handleAgentHeartbeatDeadEvent(agentId, "event");
2394
- }
2905
+ const runtimeState = getAgentRuntimeStateSync(agentId);
2906
+ if (runtimeState?.state === "suspended") {
2907
+ console.log(`🔔 Agent ${agentId} is suspended, skipping restart`);
2395
2908
  return;
2396
2909
  }
2397
- if (event.type === "review.coordinator.died") {
2398
- const payload = event.payload;
2399
- const issueId = payload?.issueId;
2400
- if (issueId) {
2401
- const { handleReviewCoordinatorDied } = await import("./deacon-Bz-6Wjmn.js");
2402
- await handleReviewCoordinatorDied(issueId, payload?.sessionName ?? "", payload?.reason ?? "");
2403
- }
2910
+ if (runtimeState?.state === "stopped") {
2911
+ console.log(`🔔 Agent ${agentId} runtime is stopped, skipping restart`);
2404
2912
  return;
2405
2913
  }
2406
- if (event.type === "work.completed") {
2407
- const issueId = event.payload?.issueId;
2408
- if (issueId) {
2409
- const { handleWorkCompleted } = await import("./deacon-Bz-6Wjmn.js");
2410
- await handleWorkCompleted(issueId);
2914
+ const skipReason = nonRestartableReason(agentState.role, agentState.sessionId);
2915
+ if (skipReason) {
2916
+ let recoverUnfinishedOneShot = false;
2917
+ if (ONE_SHOT_ROLES.has(agentState.role) && agentState.sessionId && agentState.issueId) try {
2918
+ const { getReviewStatusSync } = await import("./review-status-CG0iDuWa.js");
2919
+ const status = getReviewStatusSync(agentState.issueId);
2920
+ recoverUnfinishedOneShot = !(status ? isRoleTerminal(agentState.role, {
2921
+ reviewStatus: status.reviewStatus,
2922
+ testStatus: status.testStatus,
2923
+ readyForMerge: status.readyForMerge,
2924
+ mergeStatus: status.mergeStatus
2925
+ }) : false);
2926
+ } catch {
2927
+ recoverUnfinishedOneShot = false;
2928
+ }
2929
+ if (!recoverUnfinishedOneShot) {
2930
+ console.log(`🔔 Agent ${agentId} ${skipReason}; skipping restart`);
2931
+ return;
2411
2932
  }
2933
+ console.log(`🔔 Agent ${agentId} (${agentState.role}) vanished before a terminal verdict — recovering instead of treating as a one-shot completion (PAN-2007)`);
2412
2934
  }
2413
- if (event.type === "issue.statusChanged") {
2414
- const payload = event.payload;
2415
- const issueId = payload?.issueId;
2416
- const canonicalStatus = payload?.canonicalStatus?.toLowerCase();
2417
- const status = payload?.status?.toLowerCase();
2418
- if (issueId) {
2419
- if (canonicalStatus === "closed" || status === "closed") {
2420
- const { handleIssueStatusChangedClosed } = await import("./closed-issue-reaper-BRQBc7Xa.js");
2421
- await handleIssueStatusChangedClosed(issueId);
2422
- return;
2423
- }
2424
- if (canonicalStatus === "todo" || status === "planned" || status === "todo") {
2425
- const { handleOrphanProposedSpec } = await import("./orphan-proposed-reconciler-BqVGaOIj.js");
2426
- await handleOrphanProposedSpec(issueId);
2935
+ const now = /* @__PURE__ */ new Date();
2936
+ host.deathTimestamps.push(now);
2937
+ host.checkForMassDeaths();
2938
+ let tracker = host.crashTrackers.get(agentId);
2939
+ if (!tracker) {
2940
+ tracker = {
2941
+ agentId,
2942
+ crashCount: 0,
2943
+ lastCrash: now,
2944
+ gaveUp: false
2945
+ };
2946
+ host.crashTrackers.set(agentId, tracker);
2947
+ }
2948
+ if (tracker.gaveUp) return;
2949
+ tracker.crashCount++;
2950
+ tracker.lastCrash = now;
2951
+ host.emit({
2952
+ type: "agent_crashed",
2953
+ agentId,
2954
+ crashCount: tracker.crashCount
2955
+ });
2956
+ console.log(`🔔 Agent ${agentId} crashed (crash #${tracker.crashCount})`);
2957
+ if (host.eventStore) try {
2958
+ host.eventStore.append({
2959
+ type: "agent.heartbeat_dead",
2960
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
2961
+ payload: {
2962
+ agentId,
2963
+ issueId: agentState.issueId,
2964
+ sessionId: agentState.sessionId
2427
2965
  }
2966
+ });
2967
+ } catch (err) {
2968
+ console.error(`[cloister] Failed to emit heartbeat_dead for ${agentId}:`, err);
2969
+ }
2970
+ if (tracker.crashCount > config.max_retries) {
2971
+ tracker.gaveUp = true;
2972
+ host.emit({
2973
+ type: "agent_gave_up",
2974
+ agentId,
2975
+ maxRetries: config.max_retries
2976
+ });
2977
+ console.error(`🔔 Gave up on restarting ${agentId} after ${config.max_retries} attempts`);
2978
+ return;
2979
+ }
2980
+ const backoffIndex = Math.min(tracker.crashCount - 1, config.backoff_seconds.length - 1);
2981
+ const backoffSeconds = config.backoff_seconds[backoffIndex];
2982
+ const nextRetryAt = new Date(Date.now() + backoffSeconds * 1e3);
2983
+ tracker.nextRetryAt = nextRetryAt;
2984
+ host.emit({
2985
+ type: "agent_restarting",
2986
+ agentId,
2987
+ crashCount: tracker.crashCount,
2988
+ backoffSeconds
2989
+ });
2990
+ console.log(`🔔 Deacon will recover ${agentId} via heartbeat_dead (attempt ${tracker.crashCount}/${config.max_retries}, backoff ${backoffSeconds}s)`);
2991
+ }
2992
+ /**
2993
+ * Restart an agent using its saved session
2994
+ */
2995
+ async function restartAgent(_host, agentId) {
2996
+ const runtime = getRuntimeForAgent(agentId);
2997
+ if (!runtime) throw new Error(`No runtime found for agent ${agentId}`);
2998
+ const agentState = getAgentStateSync(agentId);
2999
+ if (!agentState?.sessionId) throw new Error(`No session ID found for agent ${agentId}`);
3000
+ if (!agentState.workspace) throw new Error(`No workspace found for agent ${agentId}`);
3001
+ console.log(`🔔 Restarting ${agentId} with session ${agentState.sessionId.substring(0, 8)}...`);
3002
+ runtime.spawnAgent({
3003
+ agentId,
3004
+ workspace: agentState.workspace,
3005
+ sessionId: agentState.sessionId,
3006
+ runtime: runtime.name
3007
+ });
3008
+ console.log(`🔔 Successfully restarted ${agentId}`);
3009
+ }
3010
+ /**
3011
+ * Check for mass death events
3012
+ *
3013
+ * Detects when 3+ agents die within 30 seconds and pauses spawns.
3014
+ */
3015
+ function checkForMassDeaths(host) {
3016
+ const MASS_DEATH_THRESHOLD = 3;
3017
+ const WINDOW_SECONDS = 30;
3018
+ const windowStart = Date.now() - WINDOW_SECONDS * 1e3;
3019
+ host.deathTimestamps = host.deathTimestamps.filter((timestamp) => timestamp.getTime() >= windowStart);
3020
+ if (host.deathTimestamps.length >= MASS_DEATH_THRESHOLD) {
3021
+ host.emit({
3022
+ type: "mass_death_detected",
3023
+ deathCount: host.deathTimestamps.length,
3024
+ windowSeconds: WINDOW_SECONDS
3025
+ });
3026
+ if (!host.spawnsPaused) {
3027
+ host.pauseSpawns("Mass death detected - system stability concern");
3028
+ console.error(`🔔 MASS DEATH DETECTED: ${host.deathTimestamps.length} agents died in ${WINDOW_SECONDS}s - spawns paused`);
3029
+ }
3030
+ }
3031
+ }
3032
+ /**
3033
+ * Pause new agent spawns
3034
+ */
3035
+ function pauseSpawns(host, reason) {
3036
+ host.spawnsPaused = true;
3037
+ setCloisterSpawnsPausedSync(true);
3038
+ host.emit({
3039
+ type: "spawn_paused",
3040
+ reason
3041
+ });
3042
+ console.log(`🔔 Agent spawns paused: ${reason}`);
3043
+ }
3044
+ //#endregion
3045
+ //#region ../../lib/cloister/service-status.ts
3046
+ init_agents();
3047
+ /**
3048
+ * Get current status
3049
+ *
3050
+ * Uses a 3-second TTL cache to avoid blocking the event loop on repeated
3051
+ * dashboard polls. The underlying computation does sync file I/O and tmux
3052
+ * calls for every agent, which scales poorly with agent count.
3053
+ */
3054
+ function getStatus(host) {
3055
+ const now = Date.now();
3056
+ if (host.statusCache && now - host.statusCacheAt < host.statusCacheTtlMs) return host.statusCache;
3057
+ const agentIds = listRunningAgentsSync().filter((a) => a.tmuxActive).map((a) => a.id);
3058
+ const agentHealths = [];
3059
+ for (const agentId of agentIds) {
3060
+ const runtime = getRuntimeForAgent(agentId);
3061
+ if (runtime) {
3062
+ const health = getAgentHealth(agentId, runtime);
3063
+ agentHealths.push(health);
3064
+ }
3065
+ }
3066
+ const summary = generateHealthSummary(agentHealths);
3067
+ const needsAttention = getAgentsNeedingAttention(agentHealths).map((h) => h.agentId);
3068
+ const deaconStatus = getDeaconStatus();
3069
+ const patrol = assessDeaconPatrolFreshness({
3070
+ isRunning: deaconStatus.isRunning,
3071
+ lastPatrol: deaconStatus.state.lastPatrol,
3072
+ patrolIntervalMs: deaconStatus.config.patrolIntervalMs
3073
+ });
3074
+ const status = {
3075
+ running: host.isRunning(),
3076
+ lastCheck: host.lastCheck,
3077
+ config: host.config,
3078
+ summary,
3079
+ agentsNeedingAttention: needsAttention,
3080
+ patrol: {
3081
+ ...patrol,
3082
+ loopRunning: deaconStatus.isRunning,
3083
+ patrolIntervalMs: deaconStatus.config.patrolIntervalMs
3084
+ }
3085
+ };
3086
+ host.statusCache = status;
3087
+ host.statusCacheAt = now;
3088
+ return status;
3089
+ }
3090
+ /**
3091
+ * Get health for a specific agent
3092
+ */
3093
+ function getServiceAgentHealth(_host, agentId) {
3094
+ const runtime = getRuntimeForAgent(agentId);
3095
+ if (!runtime) return null;
3096
+ return getAgentHealth(agentId, runtime);
3097
+ }
3098
+ /**
3099
+ * Get health for all running agents
3100
+ */
3101
+ function getAllAgentHealth(_host) {
3102
+ const runningAgents = listRunningAgentsSync().filter((a) => a.tmuxActive);
3103
+ const agentHealths = [];
3104
+ for (const agent of runningAgents) {
3105
+ const runtime = getRuntimeForAgent(agent.id);
3106
+ if (runtime) {
3107
+ const health = getAgentHealth(agent.id, runtime);
3108
+ agentHealths.push(health);
2428
3109
  }
2429
3110
  }
2430
- const change = issueStateChangeFromDomainEvent(event);
2431
- if (!change) return;
2432
- await Effect.runPromise(onIssueStateChange(change.issueId, change.state));
3111
+ return agentHealths;
3112
+ }
3113
+ //#endregion
3114
+ //#region ../../lib/cloister/service.ts
3115
+ init_config();
3116
+ init_agents();
3117
+ init_control_settings();
3118
+ init_paths();
3119
+ init_tmux();
3120
+ init_activity_logger();
3121
+ const CLOISTER_STATE_FILE = join$1(OVERDECK_HOME, "cloister.state");
3122
+ const LEGACY_SPECIALISTS_DIR = join$1(OVERDECK_HOME, "specialists");
3123
+ async function cleanupLegacySpecialistsDirectory() {
3124
+ await rm(LEGACY_SPECIALISTS_DIR, {
3125
+ recursive: true,
3126
+ force: true
3127
+ });
3128
+ }
3129
+ let cloisterEventStoreProvider = null;
3130
+ function setCloisterEventStoreProvider(provider) {
3131
+ cloisterEventStoreProvider = provider;
2433
3132
  }
2434
3133
  /**
2435
3134
  * Write Cloister running state to file for cross-process visibility
@@ -2466,32 +3165,6 @@ function readCloisterStateFile() {
2466
3165
  return { running: false };
2467
3166
  }
2468
3167
  /**
2469
- * One-shot convoy roles (PAN-1742). The review lenses + synthesis and the test
2470
- * role are spawned fresh via `spawnRun` each cycle; they run once, write their
2471
- * artifact, and exit. They never save a sessionId, so they cannot be resumed.
2472
- */
2473
- const ONE_SHOT_ROLES = new Set(["review", "test"]);
2474
- /**
2475
- * Decide whether a vanished tmux session should be treated as a crash worth
2476
- * auto-restarting (PAN-1742). Returns a human-readable reason to SKIP the
2477
- * crash/restart machinery, or `null` when the agent is a genuine restart
2478
- * candidate.
2479
- *
2480
- * Two cases are not crashes:
2481
- * - one-shot convoy roles, whose session-end is a normal completion; and
2482
- * - any agent without a saved session, which `restartAgent` cannot resume
2483
- * (it throws "No session ID found"), so counting a crash and scheduling a
2484
- * restart is pure noise.
2485
- *
2486
- * Pure and exported so the rule is unit-tested independent of the service's
2487
- * private health-check plumbing.
2488
- */
2489
- function nonRestartableReason(role, sessionId) {
2490
- if (ONE_SHOT_ROLES.has(role)) return `(${role}) finished its one-shot run — completion, not a crash`;
2491
- if (!sessionId) return "ended with no resumable session — not auto-restartable";
2492
- return null;
2493
- }
2494
- /**
2495
3168
  * Cloister Service
2496
3169
  *
2497
3170
  * Monitors agent health and performs auto-actions.
@@ -2520,6 +3193,122 @@ var CloisterService = class {
2520
3193
  constructor(config) {
2521
3194
  this.config = config || loadCloisterConfigSync();
2522
3195
  }
3196
+ healthHost() {
3197
+ const service = this;
3198
+ return {
3199
+ get previousRunningAgents() {
3200
+ return service.previousRunningAgents;
3201
+ },
3202
+ set previousRunningAgents(value) {
3203
+ service.previousRunningAgents = value;
3204
+ },
3205
+ get config() {
3206
+ return service.config;
3207
+ },
3208
+ get healthCheckCount() {
3209
+ return service.healthCheckCount;
3210
+ },
3211
+ set healthCheckCount(value) {
3212
+ service.healthCheckCount = value;
3213
+ },
3214
+ get lastCheck() {
3215
+ return service.lastCheck;
3216
+ },
3217
+ set lastCheck(value) {
3218
+ service.lastCheck = value;
3219
+ },
3220
+ get lastPokeTimestamps() {
3221
+ return service.lastPokeTimestamps;
3222
+ },
3223
+ get pokeProgress() {
3224
+ return service.pokeProgress;
3225
+ },
3226
+ get previousStates() {
3227
+ return service.previousStates;
3228
+ },
3229
+ handleAgentCrash: (agentId) => service.handleAgentCrash(agentId),
3230
+ checkCompletionMarkers: () => service.checkCompletionMarkers(),
3231
+ recordHealthEvent: (health) => service.recordHealthEvent(health),
3232
+ emit: (event) => service.emit(event),
3233
+ pokeAgent: (agentId) => service.pokeAgent(agentId),
3234
+ killAgent: (agentId) => service.killAgent(agentId),
3235
+ checkHandoffTriggers: (agentHealths) => service.checkHandoffTriggers(agentHealths),
3236
+ checkFPPViolations: (agentIds) => service.checkFPPViolations(agentIds),
3237
+ checkCostAlerts: (agentIds) => service.checkCostAlerts(agentIds),
3238
+ checkSpecialistRotations: () => service.checkSpecialistRotations(),
3239
+ mapHeartbeatSource: (source) => service.mapHeartbeatSource(source)
3240
+ };
3241
+ }
3242
+ completionHost() {
3243
+ const service = this;
3244
+ return {
3245
+ get processedCompletions() {
3246
+ return service.processedCompletions;
3247
+ },
3248
+ getDashboardApiUrl: () => service.getDashboardApiUrl()
3249
+ };
3250
+ }
3251
+ crashHost() {
3252
+ const service = this;
3253
+ return {
3254
+ get config() {
3255
+ return service.config;
3256
+ },
3257
+ get crashTrackers() {
3258
+ return service.crashTrackers;
3259
+ },
3260
+ get deathTimestamps() {
3261
+ return service.deathTimestamps;
3262
+ },
3263
+ set deathTimestamps(value) {
3264
+ service.deathTimestamps = value;
3265
+ },
3266
+ get spawnsPaused() {
3267
+ return service.spawnsPaused;
3268
+ },
3269
+ set spawnsPaused(value) {
3270
+ service.spawnsPaused = value;
3271
+ },
3272
+ get pokeProgress() {
3273
+ return service.pokeProgress;
3274
+ },
3275
+ get eventStore() {
3276
+ return service.eventStore;
3277
+ },
3278
+ progressFingerprint: (agentId) => service.progressFingerprint(agentId),
3279
+ pokeAgentWithEscalation: (agentId) => service.pokeAgentWithEscalation(agentId),
3280
+ checkForMassDeaths: () => service.checkForMassDeaths(),
3281
+ pauseSpawns: (reason) => service.pauseSpawns(reason),
3282
+ emit: (event) => service.emit(event)
3283
+ };
3284
+ }
3285
+ statusHost() {
3286
+ const service = this;
3287
+ return {
3288
+ get statusCache() {
3289
+ return service._statusCache;
3290
+ },
3291
+ set statusCache(value) {
3292
+ service._statusCache = value;
3293
+ },
3294
+ get statusCacheAt() {
3295
+ return service._statusCacheAt;
3296
+ },
3297
+ set statusCacheAt(value) {
3298
+ service._statusCacheAt = value;
3299
+ },
3300
+ get statusCacheTtlMs() {
3301
+ return service.STATUS_CACHE_TTL_MS;
3302
+ },
3303
+ get lastCheck() {
3304
+ return service.lastCheck;
3305
+ },
3306
+ get config() {
3307
+ return service.config;
3308
+ },
3309
+ isRunning: () => service.isRunning()
3310
+ };
3311
+ }
2523
3312
  getDashboardApiUrl() {
2524
3313
  return `http://127.0.0.1:${process.env.API_PORT || process.env.PORT || "3011"}`;
2525
3314
  }
@@ -2558,7 +3347,7 @@ var CloisterService = class {
2558
3347
  let clearedSpecialistCount = 0;
2559
3348
  try {
2560
3349
  if (existsSync(AGENTS_DIR)) {
2561
- const { isRunning: isSpecialistRunning } = await import("./specialists-Chvvwf5z.js");
3350
+ const { isRunning: isSpecialistRunning } = await import("./specialists-k22_Btaq.js");
2562
3351
  const entries = readdirSync(AGENTS_DIR, { withFileTypes: true });
2563
3352
  for (const entry of entries) {
2564
3353
  if (!entry.isDirectory()) continue;
@@ -2593,8 +3382,8 @@ var CloisterService = class {
2593
3382
  }
2594
3383
  try {
2595
3384
  const reviewStatuses = loadReviewStatuses();
2596
- const { resolveProjectFromIssueSync } = await import("./projects-BqRMlDOv.js");
2597
- const { getTmuxSessionName, getAllProjectSpecialistStatuses } = await import("./specialists-Chvvwf5z.js");
3385
+ const { resolveProjectFromIssueSync } = await import("./projects-CrBE6IhN.js");
3386
+ const { getTmuxSessionName, getAllProjectSpecialistStatuses } = await import("./specialists-k22_Btaq.js");
2598
3387
  const activeReviewIssues = /* @__PURE__ */ new Set();
2599
3388
  try {
2600
3389
  const projSpecs = await getAllProjectSpecialistStatuses();
@@ -2605,7 +3394,7 @@ var CloisterService = class {
2605
3394
  }
2606
3395
  const globalRs = getAgentRuntimeStateSync(getTmuxSessionName("review-agent"));
2607
3396
  if (globalRs?.state === "active" && globalRs.currentIssue) activeReviewIssues.add(globalRs.currentIssue.toUpperCase());
2608
- const { listRunningAgents } = await import("./agents-CxbuBjID.js");
3397
+ const { listRunningAgents } = await import("./agents-CEcaxSkR.js");
2609
3398
  const agents = await Effect.runPromise(listRunningAgents());
2610
3399
  for (const agent of agents) {
2611
3400
  if (agent.status === "stopped" || agent.status === "error") continue;
@@ -2648,7 +3437,7 @@ var CloisterService = class {
2648
3437
  continue;
2649
3438
  }
2650
3439
  const branch = `feature/${issueId.toLowerCase()}`;
2651
- const { spawnReviewRoleForIssue } = await import("./review-agent-Bf9XjLnl.js");
3440
+ const { spawnReviewRoleForIssue } = await import("./review-agent-u5q3ip7H.js");
2652
3441
  const dispatchResult = await Effect.runPromise(spawnReviewRoleForIssue({
2653
3442
  issueId,
2654
3443
  workspace,
@@ -2736,7 +3525,7 @@ var CloisterService = class {
2736
3525
  console.log(" ✓ Cloister event store provider installed");
2737
3526
  return;
2738
3527
  }
2739
- const { initEventStore } = await import("./event-store-C02Gprug.js");
3528
+ const { initEventStore } = await import("./event-store-CqDgfbAs.js");
2740
3529
  const store = await initEventStore();
2741
3530
  this.eventStore = store;
2742
3531
  this.domainEventUnsubscribe = store.subscribe((event) => {
@@ -2837,177 +3626,11 @@ var CloisterService = class {
2837
3626
  * Perform a health check on all running agents
2838
3627
  */
2839
3628
  async performHealthCheck() {
2840
- try {
2841
- const runningAgents = listRunningAgentsSync().filter((a) => a.tmuxActive);
2842
- const agentIds = runningAgents.map((a) => a.id);
2843
- const currentRunningSet = new Set(agentIds);
2844
- if (this.previousRunningAgents.size > 0 && this.config.auto_restart?.enabled) {
2845
- for (const previousAgentId of this.previousRunningAgents) if (!currentRunningSet.has(previousAgentId)) await this.handleAgentCrash(previousAgentId);
2846
- }
2847
- this.previousRunningAgents = currentRunningSet;
2848
- this.healthCheckCount++;
2849
- if (this.healthCheckCount % 4 === 0) this.checkCompletionMarkers();
2850
- if (agentIds.length === 0) {
2851
- this.lastCheck = /* @__PURE__ */ new Date();
2852
- return;
2853
- }
2854
- const agentHealths = [];
2855
- for (const agentId of agentIds) {
2856
- const runtime = getRuntimeForAgent(agentId);
2857
- if (runtime) {
2858
- const health = getAgentHealth(agentId, runtime);
2859
- agentHealths.push(health);
2860
- this.recordHealthEvent(health);
2861
- }
2862
- }
2863
- this.lastCheck = /* @__PURE__ */ new Date();
2864
- this.emit({
2865
- type: "health_check",
2866
- agentHealths
2867
- });
2868
- const needsAttention = getAgentsNeedingAttention(agentHealths);
2869
- const pokeCooldownMs = this.config.auto_actions.poke_cooldown_ms ?? 1800 * 1e3;
2870
- const now = Date.now();
2871
- for (const health of needsAttention) {
2872
- if (getAgentStateSync(health.agentId)?.role === "sequencer") continue;
2873
- const cooledDown = now - (this.lastPokeTimestamps.get(health.agentId) ?? 0) >= pokeCooldownMs;
2874
- if (health.state === "warning") {
2875
- this.emit({
2876
- type: "agent_warning",
2877
- agentId: health.agentId,
2878
- health
2879
- });
2880
- if (this.config.auto_actions.poke_on_warning && cooledDown) {
2881
- this.pokeAgent(health.agentId);
2882
- this.lastPokeTimestamps.set(health.agentId, now);
2883
- }
2884
- } else if (health.state === "stuck") {
2885
- this.emit({
2886
- type: "agent_stuck",
2887
- agentId: health.agentId,
2888
- health
2889
- });
2890
- if ((this.config.auto_actions.poke_on_stuck ?? true) && cooledDown) {
2891
- this.pokeAgent(health.agentId);
2892
- this.lastPokeTimestamps.set(health.agentId, now);
2893
- }
2894
- if (this.config.auto_actions.kill_on_stuck) this.killAgent(health.agentId);
2895
- }
2896
- }
2897
- this.checkHandoffTriggers(agentHealths);
2898
- this.checkFPPViolations(agentIds);
2899
- await reconcilePiCostEventsForRunningAgents(runningAgents);
2900
- this.checkCostAlerts(agentIds);
2901
- if (Math.random() < .1) this.checkSpecialistRotations();
2902
- if (Math.random() < .01) clearOldViolationsSync(24);
2903
- } catch (error) {
2904
- console.error("Cloister health check failed:", error);
2905
- this.emit({
2906
- type: "error",
2907
- error
2908
- });
2909
- }
3629
+ return performHealthCheck(this.healthHost());
2910
3630
  }
2911
3631
  /** Fallback scan for completion markers when `pan done` did not reach the dashboard. */
2912
3632
  async checkCompletionMarkers() {
2913
- try {
2914
- if (!existsSync(AGENTS_DIR)) return;
2915
- const agentDirs = readdirSync(AGENTS_DIR, { withFileTypes: true }).filter((d) => d.isDirectory() && d.name.startsWith("agent-"));
2916
- for (const dir of agentDirs) {
2917
- const completedFile = join$1(AGENTS_DIR, dir.name, "completed");
2918
- const processedFile = join$1(AGENTS_DIR, dir.name, "completed.processed");
2919
- if (!existsSync(completedFile)) continue;
2920
- if (existsSync(processedFile)) try {
2921
- if (statSync(completedFile).mtimeMs > statSync(processedFile).mtimeMs) {
2922
- try {
2923
- unlinkSync(processedFile);
2924
- } catch {}
2925
- this.processedCompletions.delete(dir.name);
2926
- console.log(`🔔 Cloister: Detected re-completion for ${dir.name} (completed newer than .processed) — clearing stale marker`);
2927
- } else continue;
2928
- } catch {
2929
- continue;
2930
- }
2931
- try {
2932
- const content = JSON.parse(readFileSync(completedFile, "utf-8"));
2933
- const ageMs = Date.now() - new Date(content.timestamp).getTime();
2934
- if (ageMs > 1440 * 60 * 1e3) {
2935
- console.log(`🔔 Cloister: Skipping stale completion marker for ${dir.name} (${Math.floor(ageMs / 36e5)}h old)`);
2936
- this.processedCompletions.set(dir.name, Infinity);
2937
- try {
2938
- renameSync(completedFile, processedFile);
2939
- } catch {}
2940
- continue;
2941
- }
2942
- } catch (parseErr) {
2943
- console.warn(` ⚠ Cloister: Could not parse completion marker for ${dir.name}, skipping`);
2944
- continue;
2945
- }
2946
- const retryCount = this.processedCompletions.get(dir.name) || 0;
2947
- if (retryCount === Infinity) this.processedCompletions.delete(dir.name);
2948
- else if (retryCount >= 3) continue;
2949
- const issueId = dir.name.replace("agent-", "").toUpperCase();
2950
- const { getReviewStatusSync } = await import("./review-status-B0Axo-Ma.js");
2951
- const existingReview = getReviewStatusSync(issueId);
2952
- if (existingReview && ["reviewing", "passed"].includes(existingReview.reviewStatus || "")) {
2953
- console.log(`🔔 Cloister: Completion marker for ${issueId} — review already ${existingReview.reviewStatus}, marking processed`);
2954
- try {
2955
- renameSync(completedFile, processedFile);
2956
- } catch {}
2957
- this.processedCompletions.set(dir.name, Infinity);
2958
- continue;
2959
- }
2960
- console.log(`🔔 Cloister: Found completion marker for ${issueId}, triggering review...${retryCount > 0 ? ` (retry ${retryCount}/3)` : ""}`);
2961
- try {
2962
- const result = await (async () => {
2963
- const controller = new AbortController();
2964
- const timer = setTimeout(() => controller.abort(), 5e3);
2965
- try {
2966
- const res = await fetch(`${this.getDashboardApiUrl()}/api/review/${issueId}/trigger`, {
2967
- method: "POST",
2968
- headers: { "Content-Type": "application/json" },
2969
- body: JSON.stringify({}),
2970
- signal: controller.signal
2971
- });
2972
- clearTimeout(timer);
2973
- try {
2974
- return await res.json();
2975
- } catch {
2976
- return {
2977
- success: false,
2978
- error: `Invalid response (HTTP ${res.status})`
2979
- };
2980
- }
2981
- } catch (e) {
2982
- clearTimeout(timer);
2983
- if (e?.name === "AbortError") return {
2984
- success: false,
2985
- error: "Timeout (5s)"
2986
- };
2987
- return {
2988
- success: false,
2989
- error: e?.message || String(e)
2990
- };
2991
- }
2992
- })();
2993
- if (result.success) {
2994
- console.log(` ✓ Review triggered for ${issueId}`);
2995
- renameSync(completedFile, processedFile);
2996
- this.processedCompletions.set(dir.name, Infinity);
2997
- } else if (result.alreadyReviewed || result.alreadyMerged) {
2998
- console.log(` ✓ ${issueId} already ${result.alreadyMerged ? "merged" : "reviewed"}, marking processed`);
2999
- renameSync(completedFile, processedFile);
3000
- this.processedCompletions.set(dir.name, Infinity);
3001
- } else {
3002
- this.processedCompletions.set(dir.name, retryCount + 1);
3003
- console.log(` ⚠ Review trigger failed for ${issueId}: ${result.error || "unknown"} (will retry, ${2 - retryCount} attempts left)`);
3004
- }
3005
- } catch (err) {
3006
- this.processedCompletions.set(dir.name, retryCount + 1);
3007
- console.error(` ✗ Failed to trigger review for ${issueId}: ${err.message} (will retry, ${2 - retryCount} attempts left)`);
3008
- }
3009
- }
3010
- } catch (error) {}
3633
+ return checkCompletionMarkers(this.completionHost());
3011
3634
  }
3012
3635
  /**
3013
3636
  * Poke an agent (send "are you stuck?" message).
@@ -3021,75 +3644,15 @@ var CloisterService = class {
3021
3644
  * dead agent (PAN-1189 wedge sweep #12-13).
3022
3645
  */
3023
3646
  pokeAgent(agentId) {
3024
- this.pokeAgentWithEscalation(agentId).catch((error) => {
3025
- console.error(`Failed to poke ${agentId}:`, error);
3026
- });
3647
+ return pokeAgent(this.crashHost(), agentId);
3027
3648
  }
3028
3649
  /** PAN-2452: fingerprint of observable progress — workspace HEAD + pane tail.
3029
3650
  * Unchanged fingerprint across pokes = the poke did nothing. */
3030
3651
  async progressFingerprint(agentId) {
3031
- const state = getAgentStateSync(agentId);
3032
- let head = "";
3033
- if (state?.workspace) try {
3034
- const { stdout } = await execAsync("git rev-parse HEAD", {
3035
- cwd: state.workspace,
3036
- encoding: "utf-8"
3037
- });
3038
- head = stdout.trim();
3039
- } catch {}
3040
- let pane = "";
3041
- try {
3042
- const { stdout } = await execAsync(`tmux -L overdeck capture-pane -t ${JSON.stringify(agentId)} -p -S -40`, { encoding: "utf-8" });
3043
- pane = stdout;
3044
- } catch {}
3045
- const paneHash = createHash("sha1").update(pane).digest("hex").slice(0, 12);
3046
- return `${head}:${paneHash}`;
3652
+ return progressFingerprint(this.crashHost(), agentId);
3047
3653
  }
3048
3654
  async pokeAgentWithEscalation(agentId) {
3049
- const runtime = getRuntimeForAgent(agentId);
3050
- if (!runtime) throw new Error(`No runtime found for agent ${agentId}`);
3051
- const fingerprint = await this.progressFingerprint(agentId);
3052
- const prior = this.pokeProgress.get(agentId);
3053
- const ineffective = prior && prior.fingerprint === fingerprint ? prior.ineffective + 1 : 0;
3054
- this.pokeProgress.set(agentId, {
3055
- fingerprint,
3056
- ineffective
3057
- });
3058
- if (ineffective >= 4) {
3059
- const { setAgentPausedSync } = await import("./agent-state-C6Rm6FPR.js");
3060
- try {
3061
- setAgentPausedSync(agentId, `needs-you: idle-alive — no observable progress across ${ineffective + 1} pokes (idle-alive escalation)`);
3062
- this.emit({
3063
- type: "agent_stuck",
3064
- agentId,
3065
- health: void 0
3066
- });
3067
- console.log(`🛑 ${agentId} paused: idle-alive across ${ineffective + 1} pokes`);
3068
- } catch (pauseErr) {
3069
- console.error(`Failed to pause idle-alive ${agentId}:`, pauseErr);
3070
- }
3071
- return;
3072
- }
3073
- let pokeMessage = "Hey, I noticed you haven't made progress in a while. Are you stuck? If you need help or clarification, please ask. Otherwise, please continue with your work.";
3074
- if (ineffective >= 2) {
3075
- let tail = "";
3076
- try {
3077
- const { stdout } = await execAsync(`tmux -L overdeck capture-pane -t ${JSON.stringify(agentId)} -p -S -40`, { encoding: "utf-8" });
3078
- tail = stdout;
3079
- } catch {}
3080
- if (isContextOverflowTail(tail)) {
3081
- pokeMessage = "/compact";
3082
- console.log(`🔔 ${agentId}: overflow tail detected — delivering /compact instead of a poke`);
3083
- } else pokeMessage = "You appear active but have made no observable progress (no new commits, unchanged output) across multiple checks. Do exactly one of: (1) work is done — commit, push, and run pan done; (2) blocked — state the blocker in one sentence and commit what you have as WIP; (3) lost context — re-read your bead (bd show), your latest .pan/feedback/ file, and git status, then resume. Act now.";
3084
- }
3085
- await Promise.resolve(runtime.sendMessage(agentId, pokeMessage)).catch((sendErr) => {
3086
- console.error(`Failed to send poke to ${agentId}:`, sendErr);
3087
- });
3088
- this.emit({
3089
- type: "poked_agent",
3090
- agentId
3091
- });
3092
- console.log(`🔔 Poked ${agentId}${ineffective > 0 ? ` (ineffective streak: ${ineffective})` : ""}`);
3655
+ return pokeAgentWithEscalation(this.crashHost(), agentId);
3093
3656
  }
3094
3657
  /**
3095
3658
  * Kill an agent
@@ -3099,136 +3662,19 @@ var CloisterService = class {
3099
3662
  * crash the dashboard from a deacon health-check timer callback.
3100
3663
  */
3101
3664
  killAgent(agentId) {
3102
- try {
3103
- const runtime = getRuntimeForAgent(agentId);
3104
- if (!runtime) throw new Error(`No runtime found for agent ${agentId}`);
3105
- Promise.resolve(runtime.killAgent(agentId)).catch((killErr) => {
3106
- console.error(`Failed to kill ${agentId}:`, killErr);
3107
- });
3108
- this.emit({
3109
- type: "killed_agent",
3110
- agentId
3111
- });
3112
- console.log(`🔔 Killed ${agentId}`);
3113
- } catch (error) {
3114
- console.error(`Failed to kill ${agentId}:`, error);
3115
- }
3665
+ return killAgent(this.crashHost(), agentId);
3116
3666
  }
3117
3667
  /**
3118
3668
  * Handle agent crash with auto-restart logic
3119
3669
  */
3120
3670
  async handleAgentCrash(agentId) {
3121
- const config = this.config.auto_restart;
3122
- if (!config?.enabled) return;
3123
- const agentState = getAgentStateSync(agentId);
3124
- if (!agentState || agentState.status === "stopped") {
3125
- console.log(`🔔 Agent ${agentId} was intentionally stopped, skipping restart`);
3126
- return;
3127
- }
3128
- const runtimeState = getAgentRuntimeStateSync(agentId);
3129
- if (runtimeState?.state === "suspended") {
3130
- console.log(`🔔 Agent ${agentId} is suspended, skipping restart`);
3131
- return;
3132
- }
3133
- if (runtimeState?.state === "stopped") {
3134
- console.log(`🔔 Agent ${agentId} runtime is stopped, skipping restart`);
3135
- return;
3136
- }
3137
- const skipReason = nonRestartableReason(agentState.role, agentState.sessionId);
3138
- if (skipReason) {
3139
- let recoverUnfinishedOneShot = false;
3140
- if (ONE_SHOT_ROLES.has(agentState.role) && agentState.sessionId && agentState.issueId) try {
3141
- const { getReviewStatusSync } = await import("./review-status-B0Axo-Ma.js");
3142
- const status = getReviewStatusSync(agentState.issueId);
3143
- recoverUnfinishedOneShot = !(status ? isRoleTerminal(agentState.role, {
3144
- reviewStatus: status.reviewStatus,
3145
- testStatus: status.testStatus,
3146
- readyForMerge: status.readyForMerge,
3147
- mergeStatus: status.mergeStatus
3148
- }) : false);
3149
- } catch {
3150
- recoverUnfinishedOneShot = false;
3151
- }
3152
- if (!recoverUnfinishedOneShot) {
3153
- console.log(`🔔 Agent ${agentId} ${skipReason}; skipping restart`);
3154
- return;
3155
- }
3156
- console.log(`🔔 Agent ${agentId} (${agentState.role}) vanished before a terminal verdict — recovering instead of treating as a one-shot completion (PAN-2007)`);
3157
- }
3158
- const now = /* @__PURE__ */ new Date();
3159
- this.deathTimestamps.push(now);
3160
- this.checkForMassDeaths();
3161
- let tracker = this.crashTrackers.get(agentId);
3162
- if (!tracker) {
3163
- tracker = {
3164
- agentId,
3165
- crashCount: 0,
3166
- lastCrash: now,
3167
- gaveUp: false
3168
- };
3169
- this.crashTrackers.set(agentId, tracker);
3170
- }
3171
- if (tracker.gaveUp) return;
3172
- tracker.crashCount++;
3173
- tracker.lastCrash = now;
3174
- this.emit({
3175
- type: "agent_crashed",
3176
- agentId,
3177
- crashCount: tracker.crashCount
3178
- });
3179
- console.log(`🔔 Agent ${agentId} crashed (crash #${tracker.crashCount})`);
3180
- if (this.eventStore) try {
3181
- this.eventStore.append({
3182
- type: "agent.heartbeat_dead",
3183
- timestamp: (/* @__PURE__ */ new Date()).toISOString(),
3184
- payload: {
3185
- agentId,
3186
- issueId: agentState.issueId,
3187
- sessionId: agentState.sessionId
3188
- }
3189
- });
3190
- } catch (err) {
3191
- console.error(`[cloister] Failed to emit heartbeat_dead for ${agentId}:`, err);
3192
- }
3193
- if (tracker.crashCount > config.max_retries) {
3194
- tracker.gaveUp = true;
3195
- this.emit({
3196
- type: "agent_gave_up",
3197
- agentId,
3198
- maxRetries: config.max_retries
3199
- });
3200
- console.error(`🔔 Gave up on restarting ${agentId} after ${config.max_retries} attempts`);
3201
- return;
3202
- }
3203
- const backoffIndex = Math.min(tracker.crashCount - 1, config.backoff_seconds.length - 1);
3204
- const backoffSeconds = config.backoff_seconds[backoffIndex];
3205
- const nextRetryAt = new Date(Date.now() + backoffSeconds * 1e3);
3206
- tracker.nextRetryAt = nextRetryAt;
3207
- this.emit({
3208
- type: "agent_restarting",
3209
- agentId,
3210
- crashCount: tracker.crashCount,
3211
- backoffSeconds
3212
- });
3213
- console.log(`🔔 Deacon will recover ${agentId} via heartbeat_dead (attempt ${tracker.crashCount}/${config.max_retries}, backoff ${backoffSeconds}s)`);
3671
+ return handleAgentCrash(this.crashHost(), agentId);
3214
3672
  }
3215
3673
  /**
3216
3674
  * Restart an agent using its saved session
3217
3675
  */
3218
3676
  async restartAgent(agentId) {
3219
- const runtime = getRuntimeForAgent(agentId);
3220
- if (!runtime) throw new Error(`No runtime found for agent ${agentId}`);
3221
- const agentState = getAgentStateSync(agentId);
3222
- if (!agentState?.sessionId) throw new Error(`No session ID found for agent ${agentId}`);
3223
- if (!agentState.workspace) throw new Error(`No workspace found for agent ${agentId}`);
3224
- console.log(`🔔 Restarting ${agentId} with session ${agentState.sessionId.substring(0, 8)}...`);
3225
- runtime.spawnAgent({
3226
- agentId,
3227
- workspace: agentState.workspace,
3228
- sessionId: agentState.sessionId,
3229
- runtime: runtime.name
3230
- });
3231
- console.log(`🔔 Successfully restarted ${agentId}`);
3677
+ return restartAgent(this.crashHost(), agentId);
3232
3678
  }
3233
3679
  /**
3234
3680
  * Check for mass death events
@@ -3236,33 +3682,13 @@ var CloisterService = class {
3236
3682
  * Detects when 3+ agents die within 30 seconds and pauses spawns.
3237
3683
  */
3238
3684
  checkForMassDeaths() {
3239
- const MASS_DEATH_THRESHOLD = 3;
3240
- const WINDOW_SECONDS = 30;
3241
- const windowStart = Date.now() - WINDOW_SECONDS * 1e3;
3242
- this.deathTimestamps = this.deathTimestamps.filter((timestamp) => timestamp.getTime() >= windowStart);
3243
- if (this.deathTimestamps.length >= MASS_DEATH_THRESHOLD) {
3244
- this.emit({
3245
- type: "mass_death_detected",
3246
- deathCount: this.deathTimestamps.length,
3247
- windowSeconds: WINDOW_SECONDS
3248
- });
3249
- if (!this.spawnsPaused) {
3250
- this.pauseSpawns("Mass death detected - system stability concern");
3251
- console.error(`🔔 MASS DEATH DETECTED: ${this.deathTimestamps.length} agents died in ${WINDOW_SECONDS}s - spawns paused`);
3252
- }
3253
- }
3685
+ return checkForMassDeaths(this.crashHost());
3254
3686
  }
3255
3687
  /**
3256
3688
  * Pause new agent spawns
3257
3689
  */
3258
3690
  pauseSpawns(reason) {
3259
- this.spawnsPaused = true;
3260
- setCloisterSpawnsPausedSync(true);
3261
- this.emit({
3262
- type: "spawn_paused",
3263
- reason
3264
- });
3265
- console.log(`🔔 Agent spawns paused: ${reason}`);
3691
+ return pauseSpawns(this.crashHost(), reason);
3266
3692
  }
3267
3693
  /**
3268
3694
  * Resume agent spawns
@@ -3286,48 +3712,13 @@ var CloisterService = class {
3286
3712
  * Check for FPP violations and send nudges
3287
3713
  */
3288
3714
  checkFPPViolations(agentIds) {
3289
- for (const agentId of agentIds) {
3290
- const violation = checkAgentForViolations(agentId);
3291
- if (!violation) continue;
3292
- if (violation.nudgeCount === 0) this.emit({
3293
- type: "fpp_violation_detected",
3294
- agentId,
3295
- violation
3296
- });
3297
- if ((violation.lastNudgeAt ? Date.now() - new Date(violation.lastNudgeAt).getTime() : Infinity) >= 300 * 1e3 || violation.nudgeCount === 0) {
3298
- if (hasExceededMaxNudges(violation)) {
3299
- this.emit({
3300
- type: "fpp_max_nudges_exceeded",
3301
- agentId,
3302
- violation
3303
- });
3304
- console.error(`🔔 Agent ${agentId} exceeded max nudges for ${violation.type} - manual intervention required`);
3305
- } else if (sendNudge(violation)) this.emit({
3306
- type: "fpp_nudge_sent",
3307
- agentId,
3308
- nudgeCount: violation.nudgeCount
3309
- });
3310
- }
3311
- }
3715
+ return checkFPPViolations(this.healthHost(), agentIds);
3312
3716
  }
3313
3717
  /**
3314
3718
  * Check for cost limit alerts
3315
3719
  */
3316
3720
  checkCostAlerts(agentIds) {
3317
- const config = this.config.cost_limits;
3318
- if (!config) return;
3319
- for (const agentId of agentIds) {
3320
- const alerts = checkCostLimits(agentId, agentId.startsWith("agent-") ? agentId.replace(/^agent-/, "") : agentId, config);
3321
- for (const alert of alerts) {
3322
- this.emit({
3323
- type: "cost_alert",
3324
- alert
3325
- });
3326
- const entityLabel = alert.agentId || alert.issueId || "(unattributed)";
3327
- if (alert.level === "limit_reached") console.error(`🔔 COST LIMIT REACHED: ${alert.type} for ${entityLabel} - $${alert.currentCost.toFixed(2)} / $${alert.limit.toFixed(2)}`);
3328
- else console.warn(`🔔 Cost warning: ${alert.type} for ${entityLabel} at ${alert.percentUsed.toFixed(0)}% ($${alert.currentCost.toFixed(2)} / $${alert.limit.toFixed(2)})`);
3329
- }
3330
- }
3721
+ return checkCostAlerts(this.healthHost(), agentIds);
3331
3722
  }
3332
3723
  /**
3333
3724
  * Get cost summary
@@ -3339,16 +3730,7 @@ var CloisterService = class {
3339
3730
  * Check if any specialists need session rotation
3340
3731
  */
3341
3732
  async checkSpecialistRotations() {
3342
- const mergeAgentResult = await Effect.runPromise(checkAndRotateIfNeeded("merge-agent", process.cwd()));
3343
- if (mergeAgentResult) {
3344
- this.emit({
3345
- type: "session_rotated",
3346
- specialistName: "merge-agent",
3347
- result: mergeAgentResult
3348
- });
3349
- if (mergeAgentResult.success) console.log(`🔔 Rotated merge-agent session: ${mergeAgentResult.oldSessionId.substring(0, 8)} → ${mergeAgentResult.newSessionId?.substring(0, 8)}`);
3350
- else console.error(`🔔 Failed to rotate merge-agent: ${mergeAgentResult.error}`);
3351
- }
3733
+ return checkSpecialistRotations(this.healthHost());
3352
3734
  }
3353
3735
  /**
3354
3736
  * Record health event to database
@@ -3356,28 +3738,7 @@ var CloisterService = class {
3356
3738
  * Only writes events when state changes or on first check.
3357
3739
  */
3358
3740
  recordHealthEvent(health) {
3359
- try {
3360
- const currentState = health.state;
3361
- const previousState = this.previousStates.get(health.agentId);
3362
- if (previousState === void 0 || previousState !== currentState) {
3363
- const source = health.heartbeat?.source ? this.mapHeartbeatSource(health.heartbeat.source) : "unknown";
3364
- writeHealthEvent({
3365
- agentId: health.agentId,
3366
- timestamp: (/* @__PURE__ */ new Date()).toISOString(),
3367
- state: currentState,
3368
- source,
3369
- metadata: health.heartbeat ? JSON.stringify({
3370
- confidence: health.heartbeat.confidence,
3371
- lastAction: health.heartbeat.lastAction,
3372
- toolName: health.heartbeat.toolName,
3373
- timeSinceActivity: health.timeSinceActivity
3374
- }) : void 0
3375
- });
3376
- this.previousStates.set(health.agentId, currentState);
3377
- }
3378
- } catch (error) {
3379
- console.error(`Failed to record health event for ${health.agentId}:`, error);
3380
- }
3741
+ return recordHealthEvent(this.healthHost(), health);
3381
3742
  }
3382
3743
  /**
3383
3744
  * Check for handoff triggers and execute handoffs (Phase 4)
@@ -3385,57 +3746,13 @@ var CloisterService = class {
3385
3746
  * Checks all triggers for each agent and performs handoffs when triggered.
3386
3747
  */
3387
3748
  async checkHandoffTriggers(agentHealths) {
3388
- for (const health of agentHealths) try {
3389
- const agentState = getAgentStateSync(health.agentId);
3390
- if (!agentState) continue;
3391
- if (!agentState.workspace) continue;
3392
- const triggers = await Effect.runPromise(checkAllTriggers(health.agentId, agentState.workspace, agentState.issueId, agentState.model, health, this.config));
3393
- if (triggers.length > 0) {
3394
- const trigger = triggers[0];
3395
- if (trigger.type === "task_complete" && [
3396
- "review-agent",
3397
- "test-agent",
3398
- "merge-agent",
3399
- "inspect-agent",
3400
- "uat-agent"
3401
- ].includes(trigger.suggestedModel || "")) {
3402
- console.log(`[cloister] Skipping handoff for ${health.agentId}: task_complete triggers specialist dispatch via completion marker, not model swap`);
3403
- continue;
3404
- }
3405
- this.emit({
3406
- type: "handoff_triggered",
3407
- agentId: health.agentId,
3408
- trigger
3409
- });
3410
- console.log(`🔔 Handoff triggered for ${health.agentId}: ${trigger.reason}`);
3411
- const result = await Effect.runPromise(performHandoff(health.agentId, {
3412
- targetModel: trigger.suggestedModel || "sonnet",
3413
- reason: trigger.reason
3414
- }));
3415
- this.emit({
3416
- type: "handoff_completed",
3417
- agentId: health.agentId,
3418
- result
3419
- });
3420
- if (result.context) logHandoffEventSync(createHandoffEvent(health.agentId, agentState.issueId, result.context, trigger.type, result.success, result.error));
3421
- if (result.success) console.log(`✓ Handoff completed: ${health.agentId} → ${result.newAgentId} (${trigger.suggestedModel})`);
3422
- else console.error(`✗ Handoff failed: ${result.error}`);
3423
- }
3424
- } catch (error) {
3425
- console.error(`Failed to check handoff triggers for ${health.agentId}:`, error);
3426
- }
3749
+ return checkHandoffTriggers(this.healthHost(), agentHealths);
3427
3750
  }
3428
3751
  /**
3429
3752
  * Map heartbeat source to database source string
3430
3753
  */
3431
3754
  mapHeartbeatSource(source) {
3432
- switch (source) {
3433
- case "jsonl": return "jsonl_mtime";
3434
- case "tmux": return "tmux_activity";
3435
- case "git": return "git_activity";
3436
- case "active-heartbeat": return "active_heartbeat";
3437
- default: return source;
3438
- }
3755
+ return mapHeartbeatSource(this.healthHost(), source);
3439
3756
  }
3440
3757
  /**
3441
3758
  * Get current status
@@ -3445,63 +3762,19 @@ var CloisterService = class {
3445
3762
  * calls for every agent, which scales poorly with agent count.
3446
3763
  */
3447
3764
  getStatus() {
3448
- const now = Date.now();
3449
- if (this._statusCache && now - this._statusCacheAt < this.STATUS_CACHE_TTL_MS) return this._statusCache;
3450
- const agentIds = listRunningAgentsSync().filter((a) => a.tmuxActive).map((a) => a.id);
3451
- const agentHealths = [];
3452
- for (const agentId of agentIds) {
3453
- const runtime = getRuntimeForAgent(agentId);
3454
- if (runtime) {
3455
- const health = getAgentHealth(agentId, runtime);
3456
- agentHealths.push(health);
3457
- }
3458
- }
3459
- const summary = generateHealthSummary(agentHealths);
3460
- const needsAttention = getAgentsNeedingAttention(agentHealths).map((h) => h.agentId);
3461
- const deaconStatus = getDeaconStatus();
3462
- const patrol = assessDeaconPatrolFreshness({
3463
- isRunning: deaconStatus.isRunning,
3464
- lastPatrol: deaconStatus.state.lastPatrol,
3465
- patrolIntervalMs: deaconStatus.config.patrolIntervalMs
3466
- });
3467
- const status = {
3468
- running: this.isRunning(),
3469
- lastCheck: this.lastCheck,
3470
- config: this.config,
3471
- summary,
3472
- agentsNeedingAttention: needsAttention,
3473
- patrol: {
3474
- ...patrol,
3475
- loopRunning: deaconStatus.isRunning,
3476
- patrolIntervalMs: deaconStatus.config.patrolIntervalMs
3477
- }
3478
- };
3479
- this._statusCache = status;
3480
- this._statusCacheAt = now;
3481
- return status;
3765
+ return getStatus(this.statusHost());
3482
3766
  }
3483
3767
  /**
3484
3768
  * Get health for a specific agent
3485
3769
  */
3486
3770
  getAgentHealth(agentId) {
3487
- const runtime = getRuntimeForAgent(agentId);
3488
- if (!runtime) return null;
3489
- return getAgentHealth(agentId, runtime);
3771
+ return getServiceAgentHealth(this.statusHost(), agentId);
3490
3772
  }
3491
3773
  /**
3492
3774
  * Get health for all running agents
3493
3775
  */
3494
3776
  getAllAgentHealth() {
3495
- const runningAgents = listRunningAgentsSync().filter((a) => a.tmuxActive);
3496
- const agentHealths = [];
3497
- for (const agent of runningAgents) {
3498
- const runtime = getRuntimeForAgent(agent.id);
3499
- if (runtime) {
3500
- const health = getAgentHealth(agent.id, runtime);
3501
- agentHealths.push(health);
3502
- }
3503
- }
3504
- return agentHealths;
3777
+ return getAllAgentHealth(this.statusHost());
3505
3778
  }
3506
3779
  /**
3507
3780
  * Get deacon (specialist health monitor) status
@@ -3599,20 +3872,7 @@ function getCloisterService() {
3599
3872
  if (!globalService) globalService = new CloisterService();
3600
3873
  return globalService;
3601
3874
  }
3602
- /**
3603
- * Effect-typed variant of {@link onIssueStateChange}. Never fails — failures
3604
- * surface through `emitActivityEntry` inside the legacy implementation.
3605
- */
3606
- function onIssueStateChange(issueId, newState) {
3607
- return Effect.promise(() => onIssueStateChangePromise(issueId, newState));
3608
- }
3609
- /**
3610
- * Effect-typed variant of {@link handleCloisterDomainEvent}. Never fails.
3611
- */
3612
- function handleCloisterDomainEvent(event) {
3613
- return Effect.promise(() => handleCloisterDomainEventPromise(event));
3614
- }
3615
3875
  //#endregion
3616
3876
  export { performHandoff as a, getAgentsNeedingAttention as c, CostResolverLive as d, CostWriter as f, checkAllTriggers as i, CostDoorLive as l, readCloisterStateFile as n, generateHealthSummary as o, setCloisterEventStoreProvider as r, getAgentHealth as s, getCloisterService as t, CostResolver as u };
3617
3877
 
3618
- //# sourceMappingURL=service-C8jKYZ0w.js.map
3878
+ //# sourceMappingURL=service-DfTVoqJg.js.map