chainlesschain 0.163.8 → 0.165.1

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 (240) hide show
  1. package/package.json +7 -4
  2. package/scripts/macos-mcp-launcher-build.mjs +317 -0
  3. package/scripts/macos-mcp-launcher-postinstall.sh +54 -0
  4. package/scripts/macos-mcp-launcher-preinstall.sh +61 -0
  5. package/src/assets/web-panel/.build-hash +1 -1
  6. package/src/assets/web-panel/assets/{AIOps-CavM7W3k.js → AIOps-CYxFDrcm.js} +1 -1
  7. package/src/assets/web-panel/assets/{ActionButton-CUvZGabQ.js → ActionButton-CPBtdiU2.js} +1 -1
  8. package/src/assets/web-panel/assets/{Analytics-DeBnWnbe.js → Analytics-Dayq7Dp7.js} +3 -3
  9. package/src/assets/web-panel/assets/{AppLayout-DbI5kxvd.js → AppLayout-6U7bPb2s.js} +4 -4
  10. package/src/assets/web-panel/assets/{Artifacts-D93bigKm.js → Artifacts-D2Iqg_3D.js} +1 -1
  11. package/src/assets/web-panel/assets/{Audit-CBNvfUZa.js → Audit-CdoOB7WV.js} +1 -1
  12. package/src/assets/web-panel/assets/{BackgroundAgents-CJLyh_YU.js → BackgroundAgents-DrnAVB8w.js} +1 -1
  13. package/src/assets/web-panel/assets/{Backup-D_0yoehD.js → Backup-CesMi5cy.js} +1 -1
  14. package/src/assets/web-panel/assets/{BaseInput-TJxmbObW.js → BaseInput-DaX9ptax.js} +1 -1
  15. package/src/assets/web-panel/assets/{Chat-CE0dto1q.js → Chat-OrwOPxDS.js} +5 -5
  16. package/src/assets/web-panel/assets/{ChatBubbleRenderer-CZ5-yYiD.js → ChatBubbleRenderer-CvPZFRxM.js} +1 -1
  17. package/src/assets/web-panel/assets/{Checkbox-Ck1RVIVo.js → Checkbox-DiHbcysC.js} +1 -1
  18. package/src/assets/web-panel/assets/{Codegen-qpoyf6DK.js → Codegen-CbgCkaBJ.js} +1 -1
  19. package/src/assets/web-panel/assets/{Col-DUyPWjIE.js → Col-Du5hgrh4.js} +1 -1
  20. package/src/assets/web-panel/assets/{Community-D_S1BemS.js → Community-DsPOBW-p.js} +1 -1
  21. package/src/assets/web-panel/assets/{Compact-BjxeK-Gc.js → Compact-BwHIFkNC.js} +1 -1
  22. package/src/assets/web-panel/assets/{Compliance-Dd3CEJsu.js → Compliance-BvrDQSgd.js} +1 -1
  23. package/src/assets/web-panel/assets/{Cowork-B4QG7-ad.js → Cowork-gwAE6arx.js} +3 -3
  24. package/src/assets/web-panel/assets/{Cron-BtZnids5.js → Cron-DW-0l4uF.js} +2 -2
  25. package/src/assets/web-panel/assets/{Crosschain-CHPfCBFD.js → Crosschain-j-NsLesB.js} +1 -1
  26. package/src/assets/web-panel/assets/{DID-DT8DCEOK.js → DID-CibVEBOI.js} +2 -2
  27. package/src/assets/web-panel/assets/{Dashboard-COQab5_U.js → Dashboard-W4z9vSfe.js} +2 -2
  28. package/src/assets/web-panel/assets/{Dropdown-BY1PsQuU.js → Dropdown-DAcrkKxG.js} +1 -1
  29. package/src/assets/web-panel/assets/{EmailListRenderer-CsuAuaq3.js → EmailListRenderer-6GXah-89.js} +1 -1
  30. package/src/assets/web-panel/assets/{FamilyGuardDashboard-BqpinD-j.js → FamilyGuardDashboard-TKDCq8Ku.js} +1 -1
  31. package/src/assets/web-panel/assets/{Federation-D0gkfQFv.js → Federation-BXBLlcNm.js} +1 -1
  32. package/src/assets/web-panel/assets/{FormItemContext-BqgE-axK.js → FormItemContext-BoJJBkG3.js} +1 -1
  33. package/src/assets/web-panel/assets/{GenericCardRenderer-D0hJ2KvC.js → GenericCardRenderer-okjsl46v.js} +1 -1
  34. package/src/assets/web-panel/assets/{Git-CmMB0uPY.js → Git-Rvs7i6nn.js} +2 -2
  35. package/src/assets/web-panel/assets/{Governance-BnVEI0Dk.js → Governance-CHSUxXXS.js} +1 -1
  36. package/src/assets/web-panel/assets/{Inference-YTAMihRP.js → Inference-V3BlfxsB.js} +1 -1
  37. package/src/assets/web-panel/assets/{KnowledgeGraph-0ax7VHop.js → KnowledgeGraph-C_NlD4aR.js} +1 -1
  38. package/src/assets/web-panel/assets/{Logs-B9TRcokL.js → Logs-Nn4LNxhA.js} +2 -2
  39. package/src/assets/web-panel/assets/MarkdownRenderer-SVAgNnkt.js +1 -0
  40. package/src/assets/web-panel/assets/{Marketplace-B6CSuD_H.js → Marketplace-DEmjFcg-.js} +1 -1
  41. package/src/assets/web-panel/assets/{McpTools-CjMXvOVU.js → McpTools-DlopXipI.js} +3 -3
  42. package/src/assets/web-panel/assets/{Memory-CXRF71U3.js → Memory-BG3ynX60.js} +2 -2
  43. package/src/assets/web-panel/assets/{MobileBridge-DQgNp_oS.js → MobileBridge-DeRzWOfT.js} +1 -1
  44. package/src/assets/web-panel/assets/{MobileProjects-DlPwcCbq.js → MobileProjects-Tyd3o2A0.js} +1 -1
  45. package/src/assets/web-panel/assets/{Mtc-6hDTRPe1.js → Mtc-BoPbOv1C.js} +2 -2
  46. package/src/assets/web-panel/assets/{MtcAudit-BlVHU1DS.js → MtcAudit-un-rTHp7.js} +2 -2
  47. package/src/assets/web-panel/assets/{Multisig-DP9pzEbV.js → Multisig-Ds0oveEB.js} +3 -3
  48. package/src/assets/web-panel/assets/{NLProgramming-Djorh8EV.js → NLProgramming-D09JI-yY.js} +1 -1
  49. package/src/assets/web-panel/assets/{Notes-oLZ-JkUg.js → Notes-DTRhiKJn.js} +4 -4
  50. package/src/assets/web-panel/assets/{NotificationSettings-Cg288VH3.js → NotificationSettings-BAnnNCim.js} +1 -1
  51. package/src/assets/web-panel/assets/OrderTableRenderer-Yr-1fSYi.js +1 -0
  52. package/src/assets/web-panel/assets/{Organization-CBdo-bHs.js → Organization-S9EAolpj.js} +4 -4
  53. package/src/assets/web-panel/assets/{Overflow-qZdnVumx.js → Overflow-CqcLKqJ8.js} +1 -1
  54. package/src/assets/web-panel/assets/{P2P-D4kNfGNQ.js → P2P-Dw0F9EcU.js} +2 -2
  55. package/src/assets/web-panel/assets/{PdhVaultBrowser-DnlMemM7.js → PdhVaultBrowser-BDRwUevN.js} +5 -5
  56. package/src/assets/web-panel/assets/{Permissions-DHucHFBJ.js → Permissions-D3_c3nKB.js} +4 -4
  57. package/src/assets/web-panel/assets/{PersonalDataHub-BovlOIPR.js → PersonalDataHub-CxdMV98x.js} +4 -4
  58. package/src/assets/web-panel/assets/{Pipeline-Deru3CQY.js → Pipeline-CgjNQFLI.js} +1 -1
  59. package/src/assets/web-panel/assets/{Privacy-Bq5oTTbU.js → Privacy-BHghIIht.js} +1 -1
  60. package/src/assets/web-panel/assets/{ProjectInit-BfzxVWAz.js → ProjectInit-De6pqaKM.js} +2 -2
  61. package/src/assets/web-panel/assets/{ProjectSettings-CKC6XBtH.js → ProjectSettings-Bg4ZkNYQ.js} +2 -2
  62. package/src/assets/web-panel/assets/Projects-CP8vPvzs.js +1 -0
  63. package/src/assets/web-panel/assets/{Providers-C_jx4TWX.js → Providers-CUnHbNf-.js} +1 -1
  64. package/src/assets/web-panel/assets/{QrScannerModal-BdIHeVGC.js → QrScannerModal-DA2f3Wts.js} +1 -1
  65. package/src/assets/web-panel/assets/{QuickAsk-BwZwAcPD.js → QuickAsk-C1FeHDqp.js} +1 -1
  66. package/src/assets/web-panel/assets/{Recommend-BNTt0L-m.js → Recommend-B5guaUMN.js} +1 -1
  67. package/src/assets/web-panel/assets/RemoteSession-PDADSds3.js +5 -0
  68. package/src/assets/web-panel/assets/{Reputation-D6VpG32B.js → Reputation-eTlRhgvK.js} +1 -1
  69. package/src/assets/web-panel/assets/{Row-D_74OvQQ.js → Row-D7AqSGY0.js} +1 -1
  70. package/src/assets/web-panel/assets/{RssFeed-BTr0a-BQ.js → RssFeed-JiuLm4Ja.js} +3 -3
  71. package/src/assets/web-panel/assets/{Search-C3MCFoNU.js → Search-CwruahW3.js} +1 -1
  72. package/src/assets/web-panel/assets/{Security-CqW9aJ6s.js → Security-CdhqaR0b.js} +4 -4
  73. package/src/assets/web-panel/assets/{Services-Cw5GNaHF.js → Services-CU0ZIlcT.js} +2 -2
  74. package/src/assets/web-panel/assets/{Skeleton-DZps5qeY.js → Skeleton-CtduPfAb.js} +1 -1
  75. package/src/assets/web-panel/assets/{Skills-C6NQ1znT.js → Skills-CROXm6AY.js} +1 -1
  76. package/src/assets/web-panel/assets/{Sla-DFP8G2Sh.js → Sla-DiZiABFD.js} +1 -1
  77. package/src/assets/web-panel/assets/{SpeechSettings-ESt6MB6v.js → SpeechSettings-B5pLcXUw.js} +1 -1
  78. package/src/assets/web-panel/assets/{SyncSettings-BHnN98aM.js → SyncSettings-DBX6J1Oe.js} +2 -2
  79. package/src/assets/web-panel/assets/{Tasks-NiSCaOf-.js → Tasks-BLyRJd3-.js} +1 -1
  80. package/src/assets/web-panel/assets/{Templates-DsKFhW2C.js → Templates-BKa5qzmk.js} +1 -1
  81. package/src/assets/web-panel/assets/{Tenant-CQ0E0ze5.js → Tenant-DnD3a4M6.js} +1 -1
  82. package/src/assets/web-panel/assets/{Terminal-D_rBu_by.js → Terminal-DEitQeTb.js} +2 -2
  83. package/src/assets/web-panel/assets/{TimelineRenderer-D3Vm4Po4.js → TimelineRenderer-CjHggTDq.js} +1 -1
  84. package/src/assets/web-panel/assets/{Tokens-DNTxRH6q.js → Tokens-CsGT7sfl.js} +1 -1
  85. package/src/assets/web-panel/assets/{Trigger-Dgprz1ms.js → Trigger-yr4rFDrW.js} +1 -1
  86. package/src/assets/web-panel/assets/{Trust-B2rUnVBD.js → Trust-CLvu1Sjp.js} +1 -1
  87. package/src/assets/web-panel/assets/{UkeySign-DEGQNc6_.js → UkeySign-B0m7yUP8.js} +1 -1
  88. package/src/assets/web-panel/assets/{VideoEditing-jlsAx3Eh.js → VideoEditing-C0VvP4lc.js} +1 -1
  89. package/src/assets/web-panel/assets/{Wallet-8jQih1ij.js → Wallet-kllBAG1M.js} +4 -4
  90. package/src/assets/web-panel/assets/{WebAuthn-XLeO6xIB.js → WebAuthn-C54UQNLx.js} +3 -3
  91. package/src/assets/web-panel/assets/{WorkflowEditor-De_dgDAG.js → WorkflowEditor-B7y8qSvU.js} +1 -1
  92. package/src/assets/web-panel/assets/{chat-CI19R_Sk.js → chat-DRzTh8CJ.js} +1 -1
  93. package/src/assets/web-panel/assets/{colors-DsYTOYN3.js → colors-BSMwSnCx.js} +1 -1
  94. package/src/assets/web-panel/assets/{compact-item-CM3-t_Qr.js → compact-item-BTg602H5.js} +1 -1
  95. package/src/assets/web-panel/assets/{createContext-BjxlBRZv.js → createContext-B6EGsdvp.js} +1 -1
  96. package/src/assets/web-panel/assets/devWarning-Dol8jLEP.js +1 -0
  97. package/src/assets/web-panel/assets/{hasIn-RDaWX_4i.js → hasIn-DDKqJ8RD.js} +1 -1
  98. package/src/assets/web-panel/assets/{index-nAH87mQP.js → index-323Hhxxt.js} +1 -1
  99. package/src/assets/web-panel/assets/{index-DIUnNOjG.js → index-BICn3Qh1.js} +1 -1
  100. package/src/assets/web-panel/assets/{index-DyIWssPC.js → index-BMuGd2--.js} +1 -1
  101. package/src/assets/web-panel/assets/{index-DX7tuWVJ.js → index-BRqt4fO-.js} +1 -1
  102. package/src/assets/web-panel/assets/{index-Cd3euP_4.js → index-BVTAl5N2.js} +1 -1
  103. package/src/assets/web-panel/assets/{index-Cpqm1qfU.js → index-BsR7Y2NG.js} +1 -1
  104. package/src/assets/web-panel/assets/{index-DcPFB8qJ.js → index-Bx044qDj.js} +1 -1
  105. package/src/assets/web-panel/assets/{index-Cd9OJhHG.js → index-C1GFfuqN.js} +1 -1
  106. package/src/assets/web-panel/assets/index-C3QX5tOl.js +1 -0
  107. package/src/assets/web-panel/assets/{index-B-Z8eFUJ.js → index-C66NpWy5.js} +1 -1
  108. package/src/assets/web-panel/assets/{index-BlT2VAEK.js → index-CDTvsX8t.js} +1 -1
  109. package/src/assets/web-panel/assets/{index-Dsij_KsN.js → index-CGNklbP6.js} +1 -1
  110. package/src/assets/web-panel/assets/{index-C4gkzxkw.js → index-CHfLYwcW.js} +1 -1
  111. package/src/assets/web-panel/assets/{index-D6sm5-Xq.js → index-CNxg61YP.js} +1 -1
  112. package/src/assets/web-panel/assets/{index-C0yx-jtQ.js → index-CQlHFN_g.js} +1 -1
  113. package/src/assets/web-panel/assets/{index-CjTrRt13.js → index-CUEU48o1.js} +1 -1
  114. package/src/assets/web-panel/assets/{index-B7WBt_4T.js → index-CWGax2QW.js} +1 -1
  115. package/src/assets/web-panel/assets/{index-IeiL9RKc.js → index-C_JzIXUq.js} +1 -1
  116. package/src/assets/web-panel/assets/{index-CnHH7bnD.js → index-Cf23wVMs.js} +1 -1
  117. package/src/assets/web-panel/assets/{index-BKJr-TZp.js → index-CmAInUkX.js} +1 -1
  118. package/src/assets/web-panel/assets/{index-CiECJi-Q.js → index-CmAubjDi.js} +1 -1
  119. package/src/assets/web-panel/assets/{index-BCnZyIMA.js → index-Co7oM1qr.js} +1 -1
  120. package/src/assets/web-panel/assets/{index-Drlnwr8F.js → index-CwPUZbMI.js} +1 -1
  121. package/src/assets/web-panel/assets/{index-9R6BxAr0.js → index-D1naNj4U.js} +1 -1
  122. package/src/assets/web-panel/assets/{index-BGqqJfL0.js → index-D5CsBShd.js} +1 -1
  123. package/src/assets/web-panel/assets/{index-Bi46Xu4u.js → index-DBT3YeQ9.js} +1 -1
  124. package/src/assets/web-panel/assets/{index-DXXtGMUJ.js → index-DLdrHhML.js} +1 -1
  125. package/src/assets/web-panel/assets/{index-B0PMlbMs.js → index-Dc77VR3H.js} +1 -1
  126. package/src/assets/web-panel/assets/{index-D0zcy54x.js → index-Dfv2lKu3.js} +1 -1
  127. package/src/assets/web-panel/assets/{index-BN6UPD2E.js → index-DiW0sDkz.js} +1 -1
  128. package/src/assets/web-panel/assets/{index-Dxge-xWl.js → index-DpTHIRfZ.js} +1 -1
  129. package/src/assets/web-panel/assets/{index-CyOGN6a5.js → index-DqFXGzzC.js} +1 -1
  130. package/src/assets/web-panel/assets/{index-B9O9YyWE.js → index-XFe-1twF.js} +1 -1
  131. package/src/assets/web-panel/assets/{index-BHNYPO0b.js → index-p2HYn89m.js} +3 -3
  132. package/src/assets/web-panel/assets/{index-CVQMeiwH.js → index-pqfux8vZ.js} +1 -1
  133. package/src/assets/web-panel/assets/{index-pPL66J85.js → index-rjzk9z1Q.js} +1 -1
  134. package/src/assets/web-panel/assets/{index-DoQA0-52.js → index-th2ihDc0.js} +1 -1
  135. package/src/assets/web-panel/assets/{index-B5REnKt8.js → index-u2YVld9W.js} +1 -1
  136. package/src/assets/web-panel/assets/index-xYybvEic.js +1 -0
  137. package/src/assets/web-panel/assets/{initDefaultProps-BTL88of4.js → initDefaultProps-C3Djh9fa.js} +1 -1
  138. package/src/assets/web-panel/assets/{motion-BUB2pXwB.js → motion-BzqFmbSe.js} +1 -1
  139. package/src/assets/web-panel/assets/{move-egCkkOMm.js → move-DJo_FFaW.js} +1 -1
  140. package/src/assets/web-panel/assets/{mtc-parser-DAWhZC8c.js → mtc-parser-_tlTiBvk.js} +1 -1
  141. package/src/assets/web-panel/assets/{omit-CvOxe_6d.js → omit-CB2ZyxUg.js} +1 -1
  142. package/src/assets/web-panel/assets/{pickAttrs-DZUSLZ5Z.js → pickAttrs-DZwIFD4u.js} +1 -1
  143. package/src/assets/web-panel/assets/{placementArrow-BERxGNMY.js → placementArrow-DxZY0dgk.js} +1 -1
  144. package/src/assets/web-panel/assets/{responsiveObserve-DZuDfZ9n.js → responsiveObserve-CzzGt7GL.js} +1 -1
  145. package/src/assets/web-panel/assets/{slide-DO756sgI.js → slide-BaIK52yz.js} +1 -1
  146. package/src/assets/web-panel/assets/{statusUtils-BRQlIz28.js → statusUtils-3-FHu9_M.js} +1 -1
  147. package/src/assets/web-panel/assets/{styleChecker-DCSfdybl.js → styleChecker-BkRT68fK.js} +1 -1
  148. package/src/assets/web-panel/assets/{useFlexGapSupport-LD8dWVve.js → useFlexGapSupport-DHYwKS-s.js} +1 -1
  149. package/src/assets/web-panel/assets/{useFs-BX9uW2LK.js → useFs-DVVUhV8p.js} +1 -1
  150. package/src/assets/web-panel/assets/{usePersonalDataHub-BPSlPuHU.js → usePersonalDataHub-CkrFKAzQ.js} +1 -1
  151. package/src/assets/web-panel/assets/{vnode-BmOILC9V.js → vnode-BuBLFz0c.js} +1 -1
  152. package/src/assets/web-panel/assets/{zoom-1iKowKGg.js → zoom-kpm6LtXU.js} +1 -1
  153. package/src/assets/web-panel/index.html +1 -1
  154. package/src/command-help-index.json +3 -3
  155. package/src/commands/cowork.js +418 -41
  156. package/src/commands/permissions.js +240 -16
  157. package/src/commands/plugin.js +1293 -56
  158. package/src/commands/session-location.js +276 -0
  159. package/src/commands/session.js +2 -0
  160. package/src/data/changelog.json +28 -2
  161. package/src/gateways/ws/action-protocol.js +316 -17
  162. package/src/gateways/ws/message-dispatcher.js +30 -0
  163. package/src/gateways/ws/remote-session-protocol.js +565 -35
  164. package/src/gateways/ws/ws-agent-handler.js +12 -0
  165. package/src/gateways/ws/ws-approval-gate.js +5 -0
  166. package/src/gateways/ws/ws-server.js +510 -50
  167. package/src/harness/jsonl-session-store.js +341 -24
  168. package/src/harness/mcp-client.js +8 -1
  169. package/src/harness/remote-session-audit.js +2 -0
  170. package/src/harness/remote-session-crypto.js +929 -21
  171. package/src/harness/remote-session-registry.js +945 -24
  172. package/src/harness/session-list-index.js +233 -14
  173. package/src/lib/agent-authority.js +6 -0
  174. package/src/lib/auto-mode-config.js +98 -3
  175. package/src/lib/background-agent-keeper-client.js +248 -0
  176. package/src/lib/background-agent-keeper-protocol.js +291 -0
  177. package/src/lib/background-agent-supervisor.js +1357 -85
  178. package/src/lib/background-interaction-resolver.js +12 -0
  179. package/src/lib/background-session-transport.js +23 -6
  180. package/src/lib/background-turn-bootstrap-protocol.js +190 -0
  181. package/src/lib/cowork-workflow.js +1188 -76
  182. package/src/lib/delivery-production-adapter.js +38 -2
  183. package/src/lib/dynamic-workflow-facade.js +1736 -0
  184. package/src/lib/execution-location-contract.js +551 -0
  185. package/src/lib/execution-location-runtime.js +130 -0
  186. package/src/lib/ide-context.js +94 -6
  187. package/src/lib/mcp-stdio-package-materialization.js +166 -14
  188. package/src/lib/packer/pkg-config-generator.js +2 -0
  189. package/src/lib/permission-authority.js +77 -0
  190. package/src/lib/permission-side-effect-center.js +495 -0
  191. package/src/lib/plugin-runtime/install.js +514 -13
  192. package/src/lib/plugin-runtime/marketplace-artifact-readback.js +900 -0
  193. package/src/lib/plugin-runtime/marketplace-catalog.js +1181 -0
  194. package/src/lib/plugin-runtime/marketplace-impact.js +408 -0
  195. package/src/lib/plugin-runtime/marketplace-remote-artifacts.js +1567 -0
  196. package/src/lib/plugin-security.js +60 -3
  197. package/src/lib/process-execution-broker/index.js +1095 -57
  198. package/src/lib/process-execution-broker/linux-bwrap-descriptor-launch.js +375 -0
  199. package/src/lib/process-execution-broker/linux-generic-bwrap-runtime.js +90 -16
  200. package/src/lib/process-execution-broker/linux-generic-bwrap.js +159 -18
  201. package/src/lib/process-execution-broker/macos-mcp-launcher-contract.js +278 -0
  202. package/src/lib/process-execution-broker/macos-mcp-launcher-protocol.json +23 -0
  203. package/src/lib/process-execution-broker/macos-mcp-launcher.c +1497 -0
  204. package/src/lib/process-execution-broker/mcp-fd-entry-bootstrap.js +37 -0
  205. package/src/lib/process-execution-broker/platform-sandbox.js +2330 -279
  206. package/src/lib/process-execution-broker/windows-sandbox-helper.dll +0 -0
  207. package/src/lib/process-execution-broker/windows-sandbox-helper.exe +0 -0
  208. package/src/lib/process-execution-broker/windows-sandbox.cs +50 -4
  209. package/src/lib/public-ide-capability-manifest.js +3 -0
  210. package/src/lib/remote-approval-bridge.js +777 -34
  211. package/src/lib/remote-control.js +2 -0
  212. package/src/lib/remote-membership-authority.js +632 -0
  213. package/src/lib/remote-membership-coordinator.js +2973 -0
  214. package/src/lib/remote-membership-host-store.js +1799 -0
  215. package/src/lib/runtime-provenance-ledger.js +171 -46
  216. package/src/lib/scoped-permission-store.js +369 -0
  217. package/src/lib/session-anti-rollback-anchor.js +262 -5
  218. package/src/lib/shell-approval.js +169 -6
  219. package/src/lib/sub-agent-context.js +4 -0
  220. package/src/lib/with-file-lock.js +73 -38
  221. package/src/lib/workflow-definition-contract.js +149 -0
  222. package/src/repl/agent-repl.js +111 -36
  223. package/src/repl/chat-repl.js +2 -0
  224. package/src/repl/clipboard-image.js +735 -16
  225. package/src/repl/ide-status.js +1 -1
  226. package/src/repl/prompt-interactions.js +59 -6
  227. package/src/repl/remote-approval.js +29 -7
  228. package/src/runtime/agent-core.js +1182 -155
  229. package/src/runtime/headless-runner.js +52 -30
  230. package/src/runtime/headless-stream.js +51 -28
  231. package/src/workers/background-agent-keeper.js +689 -0
  232. package/src/workers/background-agent-worker.js +365 -13
  233. package/src/workers/background-turn-bootstrap.js +170 -0
  234. package/src/assets/web-panel/assets/MarkdownRenderer-yySN5YNM.js +0 -1
  235. package/src/assets/web-panel/assets/OrderTableRenderer-Bb1O3Ofr.js +0 -1
  236. package/src/assets/web-panel/assets/Projects-CMolGhae.js +0 -1
  237. package/src/assets/web-panel/assets/RemoteSession-Biyn2-Ro.js +0 -5
  238. package/src/assets/web-panel/assets/devWarning-S1ld2LwU.js +0 -1
  239. package/src/assets/web-panel/assets/index-Drz6yq0m.js +0 -1
  240. package/src/assets/web-panel/assets/index-uWiUs7S_.js +0 -1
@@ -50,6 +50,14 @@ import {
50
50
  stripBackgroundLaunchSecrets,
51
51
  verifyBackgroundLaunchProfileSources,
52
52
  } from "./background-launch-profile.js";
53
+ import {
54
+ BACKGROUND_AGENT_KEEPER_POWERSHELL_TIMEOUT_MS,
55
+ BACKGROUND_AGENT_KEEPER_TASKKILL_TIMEOUT_MS,
56
+ BACKGROUND_AGENT_KEEPER_WMIC_TIMEOUT_MS,
57
+ backgroundAgentKeeperPipePath,
58
+ cleanupBackgroundAgentKeeperPipeDirectory,
59
+ prepareBackgroundAgentKeeperPipePath,
60
+ } from "./background-agent-keeper-protocol.js";
53
61
 
54
62
  export const DEFAULT_HEARTBEAT_INTERVAL_MS = 5000;
55
63
  export const DEFAULT_HEARTBEAT_STALE_MS = 120000;
@@ -68,8 +76,88 @@ export const DEFAULT_HEARTBEAT_STALE_MS = 120000;
68
76
  */
69
77
  export const PID_IDENTITY_TOLERANCE_MS = 60000;
70
78
 
71
- const START_TIME_CACHE_TTL_MS = 10000;
72
- const _startTimeCache = new Map();
79
+ export const PROCESS_START_TIME_CACHE_TTL_MS = 10_000;
80
+ export const PROCESS_START_TIME_CACHE_MAX_ENTRIES = 4_096;
81
+
82
+ /**
83
+ * A bounded FIFO/TTL cache for process creation-time probes.
84
+ *
85
+ * Probe timestamps use a monotonic clock. Deleting and re-inserting an
86
+ * existing pid refreshes its FIFO position without increasing the number of
87
+ * retained keys. Expired entries are removed from the oldest edge, making
88
+ * cleanup amortized O(1) because insertion timestamps are monotonic.
89
+ */
90
+ export class ProcessStartTimeCache {
91
+ constructor({
92
+ ttlMs = PROCESS_START_TIME_CACHE_TTL_MS,
93
+ maxEntries = PROCESS_START_TIME_CACHE_MAX_ENTRIES,
94
+ now = () => performance.now(),
95
+ } = {}) {
96
+ if (!Number.isFinite(Number(ttlMs)) || Number(ttlMs) <= 0) {
97
+ throw new TypeError("process start-time cache ttlMs must be positive");
98
+ }
99
+ if (!Number.isSafeInteger(Number(maxEntries)) || Number(maxEntries) <= 0) {
100
+ throw new TypeError(
101
+ "process start-time cache maxEntries must be a positive integer",
102
+ );
103
+ }
104
+ if (typeof now !== "function") {
105
+ throw new TypeError("process start-time cache now must be a function");
106
+ }
107
+ this.ttlMs = Number(ttlMs);
108
+ this.maxEntries = Number(maxEntries);
109
+ this._now = now;
110
+ this._entries = new Map();
111
+ }
112
+
113
+ _at(value) {
114
+ const at = value === undefined ? Number(this._now()) : Number(value);
115
+ if (!Number.isFinite(at)) {
116
+ throw new TypeError("process start-time cache time must be finite");
117
+ }
118
+ return at;
119
+ }
120
+
121
+ _evictExpired(at) {
122
+ for (const [key, entry] of this._entries) {
123
+ if (at - entry.at < this.ttlMs) break;
124
+ this._entries.delete(key);
125
+ }
126
+ }
127
+
128
+ read(key, atValue) {
129
+ const at = this._at(atValue);
130
+ this._evictExpired(at);
131
+ const entry = this._entries.get(key);
132
+ return entry ? { hit: true, value: entry.value } : { hit: false };
133
+ }
134
+
135
+ set(key, value, atValue) {
136
+ const at = this._at(atValue);
137
+ this._evictExpired(at);
138
+ // Map preserves insertion order. Refresh the same pid at the newest edge
139
+ // so capacity eviction cannot discard a just-probed identity.
140
+ this._entries.delete(key);
141
+ while (this._entries.size >= this.maxEntries) {
142
+ const oldest = this._entries.keys().next().value;
143
+ this._entries.delete(oldest);
144
+ }
145
+ this._entries.set(key, { at, value });
146
+ return value;
147
+ }
148
+
149
+ get size() {
150
+ return this._entries.size;
151
+ }
152
+
153
+ clear() {
154
+ this._entries.clear();
155
+ }
156
+ }
157
+
158
+ const _startTimeCache = new ProcessStartTimeCache();
159
+ const PROCESS_START_TIME_ABSENT = Symbol("process-start-time-absent");
160
+ const WINDOWS_PROCESS_ABSENT_MARKER = "__CC_PROCESS_ABSENT__";
73
161
 
74
162
  function runSupervisorCommand(file, args, options, origin) {
75
163
  return _deps.spawnSync(file, args, {
@@ -102,7 +190,8 @@ export function parseCimDateToMs(raw) {
102
190
  }
103
191
 
104
192
  /**
105
- * Read a process's creation time (epoch ms) with ONE synchronous probe, or
193
+ * Read a process's creation time (epoch ms) with ONE synchronous probe,
194
+ * `{status:"absent"}` when the OS authoritatively reports no such process, or
106
195
  * null when it cannot be determined (missing tools, permissions, platform
107
196
  * quirks). Callers must treat null as "unknown" and FAIL OPEN to the legacy
108
197
  * kill(pid, 0) answer — a broken probe must never declare a live worker dead
@@ -126,13 +215,21 @@ function defaultReadProcessStartTimeMs(pid) {
126
215
  "CreationDate",
127
216
  "/value",
128
217
  ],
129
- { windowsHide: true, encoding: "utf8", timeout: 5000 },
218
+ {
219
+ windowsHide: true,
220
+ encoding: "utf8",
221
+ timeout: BACKGROUND_AGENT_KEEPER_WMIC_TIMEOUT_MS,
222
+ },
130
223
  "background-agent:process-start-time",
131
224
  );
132
225
  if (!r.error && r.status === 0) {
133
- const m = /CreationDate=([^\r\n]+)/.exec(r.stdout || "");
226
+ const stdout = String(r.stdout || "");
227
+ const m = /CreationDate=([^\r\n]+)/.exec(stdout);
134
228
  const ms = m ? parseCimDateToMs(m[1]) : null;
135
229
  if (ms !== null) return ms;
230
+ if (/No Instance\(s\) Available/u.test(stdout)) {
231
+ return { status: "absent" };
232
+ }
136
233
  }
137
234
  } catch {
138
235
  /* fall through to PowerShell */
@@ -140,15 +237,25 @@ function defaultReadProcessStartTimeMs(pid) {
140
237
  try {
141
238
  const script =
142
239
  `$p = Get-CimInstance Win32_Process -Filter 'ProcessId=${target}' -ErrorAction SilentlyContinue; ` +
143
- "if ($p -and $p.CreationDate) { [DateTimeOffset]::new($p.CreationDate).ToUnixTimeMilliseconds() }";
240
+ `if (-not $p) { '${WINDOWS_PROCESS_ABSENT_MARKER}' } ` +
241
+ "elseif ($p.CreationDate) { [DateTimeOffset]::new($p.CreationDate).ToUnixTimeMilliseconds() } " +
242
+ "else { exit 2 }";
144
243
  const r = runSupervisorCommand(
145
244
  "powershell",
146
245
  ["-NoProfile", "-NonInteractive", "-Command", script],
147
- { windowsHide: true, encoding: "utf8", timeout: 10000 },
246
+ {
247
+ windowsHide: true,
248
+ encoding: "utf8",
249
+ timeout: BACKGROUND_AGENT_KEEPER_POWERSHELL_TIMEOUT_MS,
250
+ },
148
251
  "background-agent:process-start-time",
149
252
  );
150
253
  if (!r.error && r.status === 0) {
151
- const n = Number(String(r.stdout || "").trim());
254
+ const output = String(r.stdout || "").trim();
255
+ if (output === WINDOWS_PROCESS_ABSENT_MARKER) {
256
+ return { status: "absent" };
257
+ }
258
+ const n = Number(output);
152
259
  if (Number.isFinite(n) && n > 0) return n;
153
260
  }
154
261
  } catch {
@@ -276,6 +383,7 @@ function defaultKillProcessTree(pid, signal = "SIGKILL") {
276
383
  {
277
384
  windowsHide: true,
278
385
  encoding: "utf8",
386
+ timeout: BACKGROUND_AGENT_KEEPER_TASKKILL_TIMEOUT_MS,
279
387
  },
280
388
  "background-agent:process-tree-kill",
281
389
  );
@@ -307,8 +415,11 @@ export const _deps = {
307
415
  readProcessState: defaultReadProcessState,
308
416
  readProcessGroupStates: defaultReadProcessGroupStates,
309
417
  processExitWaitDeadlineMs: 2_000,
418
+ posixPermissionRetryNowMs: () => performance.now(),
419
+ posixPermissionRetrySleep: sleepSyncMs,
310
420
  killProcessTree: defaultKillProcessTree,
311
421
  terminateOwnedProcessTree,
422
+ withSessionHostRecoveryLease,
312
423
  };
313
424
 
314
425
  /**
@@ -318,7 +429,14 @@ export const _deps = {
318
429
  */
319
430
  export function stopBackgroundAgentChildTree(
320
431
  pid,
321
- { platform = process.platform, signal = "SIGTERM" } = {},
432
+ {
433
+ platform = process.platform,
434
+ signal = "SIGTERM",
435
+ expectedStartedAt,
436
+ processGroup = true,
437
+ strictIdentity = false,
438
+ allowDirectFallback = true,
439
+ } = {},
322
440
  ) {
323
441
  const target = Number(pid);
324
442
  if (!Number.isInteger(target) || target <= 0 || target === process.pid) {
@@ -326,6 +444,15 @@ export function stopBackgroundAgentChildTree(
326
444
  error.code = "ERR_BACKGROUND_AGENT_CHILD_PID_INVALID";
327
445
  throw error;
328
446
  }
447
+ if (strictIdentity === true) {
448
+ signalExactBackgroundProcessTree(target, signal, {
449
+ platform,
450
+ expectedStartedAt,
451
+ processGroup,
452
+ allowDirectFallback,
453
+ });
454
+ return true;
455
+ }
329
456
  if (platform === "win32") {
330
457
  const result = runSupervisorCommand(
331
458
  "taskkill",
@@ -333,6 +460,7 @@ export function stopBackgroundAgentChildTree(
333
460
  {
334
461
  windowsHide: true,
335
462
  encoding: "utf8",
463
+ timeout: BACKGROUND_AGENT_KEEPER_TASKKILL_TIMEOUT_MS,
336
464
  },
337
465
  "background-agent:session-stop-tree",
338
466
  );
@@ -349,32 +477,38 @@ export function stopBackgroundAgentChildTree(
349
477
  }
350
478
  return true;
351
479
  }
352
- try {
353
- _deps.kill(-target, signal);
354
- } catch {
355
- _deps.kill(target, signal);
356
- }
480
+ // Preserve the legacy caller contract while sharing the safe POSIX tree
481
+ // primitive: permission/invalid-signal failures remain fatal, and only a
482
+ // genuinely absent process group may use the historical direct fallback.
483
+ signalBackgroundProcessTree(target, signal, {
484
+ platform,
485
+ processGroup,
486
+ allowDirectFallback,
487
+ });
357
488
  return true;
358
489
  }
359
490
 
360
491
  /** Cached probe (default impl only — injected probes run uncached for tests). */
361
492
  function processStartTimeMs(pid, options = {}) {
493
+ const normalize = (raw) => {
494
+ if (raw?.status === "absent") return PROCESS_START_TIME_ABSENT;
495
+ const value = Number(raw);
496
+ return Number.isFinite(value) && value > 0 ? value : null;
497
+ };
362
498
  if (_deps.readProcessStartTimeMs !== defaultReadProcessStartTimeMs) {
363
- const raw = _deps.readProcessStartTimeMs(pid);
364
- const n = Number(raw);
365
- return Number.isFinite(n) && n > 0 ? n : null;
499
+ return normalize(_deps.readProcessStartTimeMs(pid));
366
500
  }
367
501
  const key = Number(pid);
368
- const at = Date.now();
502
+ const at = performance.now();
369
503
  if (options.fresh === true) {
370
- const value = defaultReadProcessStartTimeMs(key);
371
- _startTimeCache.set(key, { at, value });
504
+ const value = normalize(defaultReadProcessStartTimeMs(key));
505
+ _startTimeCache.set(key, value, at);
372
506
  return value;
373
507
  }
374
- const hit = _startTimeCache.get(key);
375
- if (hit && at - hit.at < START_TIME_CACHE_TTL_MS) return hit.value;
376
- const value = defaultReadProcessStartTimeMs(key);
377
- _startTimeCache.set(key, { at, value });
508
+ const hit = _startTimeCache.read(key, at);
509
+ if (hit.hit) return hit.value;
510
+ const value = normalize(defaultReadProcessStartTimeMs(key));
511
+ _startTimeCache.set(key, value, at);
378
512
  return value;
379
513
  }
380
514
 
@@ -421,6 +555,190 @@ export function logPath(id) {
421
555
 
422
556
  const TERMINAL_STATUSES = new Set(["completed", "failed", "stopped", "lost"]);
423
557
 
558
+ const KEEPER_TERMINAL_STATUSES = new Set([
559
+ "closed",
560
+ "worker-disconnected",
561
+ "cleanup-unconfirmed",
562
+ "startup-timeout",
563
+ "spawn-error",
564
+ "exited",
565
+ ]);
566
+
567
+ export function hasValidBackgroundAgentStopCleanupProof(state) {
568
+ const requestedAt = Number(state?.stopRequestedAt);
569
+ const confirmedAt = Number(state?.stopCleanupConfirmedAt);
570
+ return Boolean(
571
+ Number.isFinite(requestedAt) &&
572
+ requestedAt > 0 &&
573
+ Number.isFinite(confirmedAt) &&
574
+ confirmedAt >= requestedAt,
575
+ );
576
+ }
577
+
578
+ function hasBackgroundAgentStopFenceWithoutCleanupProof(state) {
579
+ return Boolean(
580
+ Number.isFinite(Number(state?.stopRequestedAt)) &&
581
+ Number(state.stopRequestedAt) > 0 &&
582
+ !hasValidBackgroundAgentStopCleanupProof(state),
583
+ );
584
+ }
585
+
586
+ function isBackgroundAgentStopCleanupUnconfirmed(state) {
587
+ return Boolean(
588
+ state?.status === "stopped" &&
589
+ hasBackgroundAgentStopFenceWithoutCleanupProof(state),
590
+ );
591
+ }
592
+
593
+ function selectBackgroundAgentStopCleanupProof(current, requested) {
594
+ if (hasValidBackgroundAgentStopCleanupProof(current)) {
595
+ return current.stopCleanupConfirmedAt;
596
+ }
597
+ const requestedAt = Number(
598
+ Number.isFinite(Number(current?.stopRequestedAt)) &&
599
+ Number(current.stopRequestedAt) > 0
600
+ ? current.stopRequestedAt
601
+ : requested?.stopRequestedAt,
602
+ );
603
+ const requestedProof = Number(requested?.stopCleanupConfirmedAt);
604
+ if (
605
+ Number.isFinite(requestedAt) &&
606
+ requestedAt > 0 &&
607
+ Number.isFinite(requestedProof) &&
608
+ requestedProof >= requestedAt
609
+ ) {
610
+ return requestedProof;
611
+ }
612
+ return current?.stopCleanupConfirmedAt;
613
+ }
614
+
615
+ function backgroundAgentStopCleanupConfirmationTimestamp(
616
+ state,
617
+ now = Date.now(),
618
+ ) {
619
+ const requestedAt = Number(state?.stopRequestedAt);
620
+ return Number.isFinite(requestedAt) && requestedAt > 0
621
+ ? Math.max(now, requestedAt)
622
+ : now;
623
+ }
624
+
625
+ const TURN_PROCESS_PROJECTION_FIELDS = [
626
+ "agentPid",
627
+ "agentStartedAt",
628
+ "agentRuntimePid",
629
+ "agentRuntimeStartedAt",
630
+ "turnBootstrapStatus",
631
+ "turnBootstrapCommittedAt",
632
+ "turnKeeperStatus",
633
+ "turnKeeperPid",
634
+ "turnKeeperArmedAt",
635
+ "turnKeeperCleanupReason",
636
+ "turnKeeperCleanupRequestedAt",
637
+ "turnKeeperCleanupConfirmedAt",
638
+ "turnKeeperCleanupError",
639
+ ];
640
+
641
+ function keeperLifecycleStage(state) {
642
+ if (KEEPER_TERMINAL_STATUSES.has(state?.keeperStatus)) return 4;
643
+ if (state?.keeperStatus === "ready") return 3;
644
+ if (state?.keeperStatus === "listening") return 2;
645
+ if (state?.keeperStatus === "starting") return 1;
646
+ return 0;
647
+ }
648
+
649
+ function keeperLifecycleClock(state) {
650
+ return Math.max(
651
+ 0,
652
+ Number(state?.keeperStartedAt || 0),
653
+ Number(state?.keeperHeartbeatAt || 0),
654
+ Number(state?.keeperReadyAt || 0),
655
+ Number(state?.keeperEndedAt || 0),
656
+ );
657
+ }
658
+
659
+ function turnProcessProjectionStage(state) {
660
+ if (
661
+ ["terminated", "not-spawned"].includes(state?.turnLaunchResolution?.outcome)
662
+ ) {
663
+ return 100;
664
+ }
665
+ if (
666
+ state?.turnLaunchResolution?.outcome === "spawned" &&
667
+ state?.agentPid == null &&
668
+ state?.agentRuntimePid == null &&
669
+ state?.turnBootstrapStatus == null &&
670
+ state?.turnKeeperStatus == null &&
671
+ ["idle", "needs_input"].includes(state?.phase)
672
+ ) {
673
+ return 90;
674
+ }
675
+ if (state?.turnKeeperStatus === "cleanup-unconfirmed") return 85;
676
+ if (state?.turnKeeperStatus === "retired") return 80;
677
+ if (state?.turnKeeperStatus === "cleanup-requested") return 70;
678
+ if (state?.turnBootstrapStatus === "released") return 60;
679
+ if (state?.turnKeeperStatus === "armed") return 50;
680
+ if (
681
+ state?.turnKeeperStatus === "arming" ||
682
+ state?.turnBootstrapStatus === "awaiting-keeper"
683
+ ) {
684
+ return 40;
685
+ }
686
+ if (Number(state?.agentRuntimePid) > 0) return 30;
687
+ if (
688
+ state?.turnBootstrapStatus === "awaiting-ready" ||
689
+ state?.turnKeeperStatus === "waiting-for-runtime"
690
+ ) {
691
+ return 20;
692
+ }
693
+ if (Number(state?.agentPid) > 0) return 10;
694
+ return 0;
695
+ }
696
+
697
+ function turnProcessProjectionClock(state) {
698
+ return Math.max(
699
+ 0,
700
+ Number(state?.agentStartedAt || 0),
701
+ Number(state?.agentRuntimeStartedAt || 0),
702
+ Number(state?.turnBootstrapCommittedAt || 0),
703
+ Number(state?.turnKeeperArmedAt || 0),
704
+ Number(state?.turnKeeperCleanupRequestedAt || 0),
705
+ Number(state?.turnKeeperCleanupConfirmedAt || 0),
706
+ );
707
+ }
708
+
709
+ function turnProjectionIdentity(state) {
710
+ const resolvedRecord = state?.turnLaunchResolution;
711
+ if (
712
+ turnProcessProjectionStage(state) > 0 &&
713
+ Number.isInteger(Number(resolvedRecord?.attempt))
714
+ ) {
715
+ return {
716
+ attempt: Number(resolvedRecord.attempt),
717
+ token: resolvedRecord.token || null,
718
+ };
719
+ }
720
+ const records = [state?.turnLaunchIntent, state?.turnLaunchResolution].filter(
721
+ (record) => Number.isInteger(Number(record?.attempt)),
722
+ );
723
+ const record = records.sort(
724
+ (left, right) => Number(right.attempt) - Number(left.attempt),
725
+ )[0];
726
+ const attempt = Number(record?.attempt ?? state?.turnLaunchAttempt);
727
+ if (!Number.isInteger(attempt) || attempt <= 0) return null;
728
+ return { attempt, token: record?.token || null };
729
+ }
730
+
731
+ function sameTurnProjectionIdentity(leftIdentity, rightIdentity) {
732
+ return Boolean(
733
+ leftIdentity &&
734
+ rightIdentity &&
735
+ leftIdentity.attempt === rightIdentity.attempt &&
736
+ (!leftIdentity.token ||
737
+ !rightIdentity.token ||
738
+ leftIdentity.token === rightIdentity.token),
739
+ );
740
+ }
741
+
424
742
  function readBackgroundAgentStateResult(id) {
425
743
  const target = statePath(id);
426
744
  if (!existsSync(target)) return { state: null, missing: true, error: null };
@@ -464,10 +782,17 @@ function mergeBackgroundAgentState(current, requested) {
464
782
  current.stopRequestedAt &&
465
783
  requested.stopRequestedAt === current.stopRequestedAt;
466
784
  if (TERMINAL_STATUSES.has(current.status) && !explicitHeartbeatStop) {
785
+ const stopCleanupConfirmedAt = selectBackgroundAgentStopCleanupProof(
786
+ current,
787
+ requested,
788
+ );
467
789
  next = {
468
790
  ...next,
469
791
  status: current.status,
470
792
  endedAt: current.endedAt ?? next.endedAt ?? null,
793
+ ...(stopCleanupConfirmedAt !== undefined
794
+ ? { stopCleanupConfirmedAt }
795
+ : {}),
471
796
  exitCode: current.exitCode ?? next.exitCode ?? null,
472
797
  ...(current.signal !== undefined ? { signal: current.signal } : {}),
473
798
  ...(current.error !== undefined ? { error: current.error } : {}),
@@ -533,6 +858,9 @@ function mergeBackgroundAgentState(current, requested) {
533
858
  if (current.workerGeneration) {
534
859
  next = { ...next, workerGeneration: current.workerGeneration };
535
860
  }
861
+ if (current.keeperGeneration) {
862
+ next = { ...next, keeperGeneration: current.keeperGeneration };
863
+ }
536
864
  const claimedWorkerPid = Number(current.workerClaimedPid);
537
865
  if (
538
866
  current.workerClaimedPid != null &&
@@ -545,6 +873,43 @@ function mergeBackgroundAgentState(current, requested) {
545
873
  workerClaimedAt: current.workerClaimedAt,
546
874
  };
547
875
  }
876
+ // Wrapper and keeper identities are write-once ownership evidence for this
877
+ // generation. In particular, a title/pin writer holding the pre-claim
878
+ // launch snapshot must not drop the independent process that owns cleanup.
879
+ if (Number(current.workerWrapperPid) > 0) {
880
+ next = { ...next, workerWrapperPid: Number(current.workerWrapperPid) };
881
+ }
882
+ if (Number(current.keeperPid) > 0) {
883
+ next = {
884
+ ...next,
885
+ keeperPid: Number(current.keeperPid),
886
+ keeperStartedAt: current.keeperStartedAt,
887
+ };
888
+ }
889
+ if (Number(current.keeperWrapperPid) > 0) {
890
+ next = {
891
+ ...next,
892
+ keeperWrapperPid: Number(current.keeperWrapperPid),
893
+ };
894
+ }
895
+ const currentKeeperStage = keeperLifecycleStage(current);
896
+ const requestedKeeperStage = keeperLifecycleStage(next);
897
+ if (
898
+ currentKeeperStage > requestedKeeperStage ||
899
+ (currentKeeperStage === requestedKeeperStage &&
900
+ keeperLifecycleClock(current) > keeperLifecycleClock(next))
901
+ ) {
902
+ next = {
903
+ ...next,
904
+ keeperStatus: current.keeperStatus,
905
+ keeperHeartbeatAt: current.keeperHeartbeatAt,
906
+ keeperReadyAt: current.keeperReadyAt,
907
+ keeperEndedAt: current.keeperEndedAt,
908
+ keeperExitCode: current.keeperExitCode,
909
+ keeperSignal: current.keeperSignal,
910
+ keeperError: current.keeperError,
911
+ };
912
+ }
548
913
  // A turn launch uses a durable prepare/resolve record. The prepare must
549
914
  // reach disk before native spawn so stop/remove can distinguish "no turn"
550
915
  // from "a child may exist but its pid commit failed". Only the owner that
@@ -557,9 +922,25 @@ function mergeBackgroundAgentState(current, requested) {
557
922
  : 0,
558
923
  );
559
924
  const requestedTurnAttempt = Number(next.turnLaunchAttempt);
925
+ // Capture each writer's process projection identity before the durable
926
+ // launch record below is reconciled. Otherwise replacing a stale
927
+ // resolution with the current token can accidentally relabel old PIDs as
928
+ // belonging to the newest turn.
929
+ const currentProcessProjectionIdentity = turnProjectionIdentity(current);
930
+ const requestedProcessProjectionIdentity = turnProjectionIdentity(next);
931
+ const currentProcessProjectionStage = turnProcessProjectionStage(current);
932
+ const requestedProcessProjectionStage = turnProcessProjectionStage(next);
933
+ const currentProcessProjectionClock = turnProcessProjectionClock(current);
934
+ const requestedProcessProjectionClock = turnProcessProjectionClock(next);
560
935
  const currentTurnIntent = current.turnLaunchIntent;
561
936
  const requestedTurnIntent = next.turnLaunchIntent;
562
937
  const requestedTurnResolution = next.turnLaunchResolution;
938
+ const requestedCarriesCurrentTurnIntent = Boolean(
939
+ currentTurnIntent &&
940
+ requestedTurnIntent?.token === currentTurnIntent.token &&
941
+ Number(requestedTurnIntent?.attempt) ===
942
+ Number(currentTurnIntent.attempt),
943
+ );
563
944
  const resolvesCurrentTurnIntent =
564
945
  currentTurnIntent &&
565
946
  requestedTurnIntent === null &&
@@ -591,6 +972,9 @@ function mergeBackgroundAgentState(current, requested) {
591
972
  ...(current.turnLaunchResolution !== undefined
592
973
  ? { turnLaunchResolution: current.turnLaunchResolution }
593
974
  : {}),
975
+ ...(!requestedCarriesCurrentTurnIntent && current.phase !== undefined
976
+ ? { phase: current.phase }
977
+ : {}),
594
978
  };
595
979
  } else if (
596
980
  !currentTurnIntent &&
@@ -612,6 +996,59 @@ function mergeBackgroundAgentState(current, requested) {
612
996
  turnLaunchIntent: null,
613
997
  };
614
998
  }
999
+ // The target cannot execute Agent main until these fields reach `released`,
1000
+ // and the keeper cannot relinquish ownership until cleanup is confirmed.
1001
+ // Keep that same-turn projection monotonic across stale generic writers.
1002
+ // `retired` -> idle is the only intentional projection clear; once idle,
1003
+ // an old active snapshot may not resurrect already-reaped process ids.
1004
+ const sameProcessProjection = sameTurnProjectionIdentity(
1005
+ currentProcessProjectionIdentity,
1006
+ requestedProcessProjectionIdentity,
1007
+ );
1008
+ const currentProjectionIsNewer = Boolean(
1009
+ currentProcessProjectionIdentity &&
1010
+ (!requestedProcessProjectionIdentity ||
1011
+ currentProcessProjectionIdentity.attempt >
1012
+ requestedProcessProjectionIdentity.attempt ||
1013
+ (currentProcessProjectionIdentity.attempt ===
1014
+ requestedProcessProjectionIdentity.attempt &&
1015
+ currentProcessProjectionIdentity.token &&
1016
+ requestedProcessProjectionIdentity.token &&
1017
+ currentProcessProjectionIdentity.token !==
1018
+ requestedProcessProjectionIdentity.token)),
1019
+ );
1020
+ if (sameProcessProjection || currentProjectionIsNewer) {
1021
+ const intentionalIdleClear =
1022
+ sameProcessProjection &&
1023
+ requestedProcessProjectionStage === 90 &&
1024
+ currentProcessProjectionStage === 80 &&
1025
+ current.turnKeeperStatus === "retired" &&
1026
+ Number(current.turnKeeperCleanupConfirmedAt) > 0;
1027
+ const preserveCurrentProjection =
1028
+ currentProjectionIsNewer ||
1029
+ (!intentionalIdleClear &&
1030
+ (currentProcessProjectionStage > requestedProcessProjectionStage ||
1031
+ (currentProcessProjectionStage ===
1032
+ requestedProcessProjectionStage &&
1033
+ currentProcessProjectionClock >
1034
+ requestedProcessProjectionClock)));
1035
+ if (preserveCurrentProjection) {
1036
+ next = {
1037
+ ...next,
1038
+ ...((currentProjectionIsNewer ||
1039
+ currentProcessProjectionStage === 90) &&
1040
+ next.status === "running"
1041
+ ? { phase: current.phase }
1042
+ : {}),
1043
+ ...Object.fromEntries(
1044
+ TURN_PROCESS_PROJECTION_FIELDS.map((field) => [
1045
+ field,
1046
+ current[field],
1047
+ ]),
1048
+ ),
1049
+ };
1050
+ }
1051
+ }
615
1052
  const turnTerminationConfirmed =
616
1053
  next.turnLaunchFinalizationUncertain === false &&
617
1054
  next.turnLaunchTermination?.confirmed === true;
@@ -666,6 +1103,26 @@ function mergeBackgroundAgentState(current, requested) {
666
1103
  transport: null,
667
1104
  };
668
1105
  }
1106
+ if (
1107
+ current.status === "running" &&
1108
+ next.status === "stopped" &&
1109
+ !hasValidBackgroundAgentStopCleanupProof(next)
1110
+ ) {
1111
+ // A worker or read-side reconciliation can publish a terminal outcome
1112
+ // after an explicit stop fence wins but before the stopper has observed
1113
+ // every exact-owned process retire. Keep that record durably fenced
1114
+ // and retryable; only stopBackgroundAgent may publish `stopped`
1115
+ // together with cleanup proof.
1116
+ next = {
1117
+ ...next,
1118
+ status: "running",
1119
+ stoppedByUser: false,
1120
+ phase: "stop_waiting_for_exit",
1121
+ transport: null,
1122
+ stopPending: true,
1123
+ stopPendingReason: "process-exit",
1124
+ };
1125
+ }
669
1126
  }
670
1127
  }
671
1128
  return next;
@@ -698,6 +1155,20 @@ function persistBackgroundAgentState(target, next) {
698
1155
  return next;
699
1156
  }
700
1157
 
1158
+ function isBackgroundAgentStateLockOwnerAlive(owner) {
1159
+ const target = Number(owner?.pid);
1160
+ if (!isProcessExecutionAlive(target)) return false;
1161
+ const acquiredAt = Number(owner?.startedAt);
1162
+ if (!Number.isFinite(acquiredAt) || acquiredAt <= 0) return true;
1163
+ const actualStartedAt = processStartTimeMs(target, { fresh: true });
1164
+ if (actualStartedAt === PROCESS_START_TIME_ABSENT) return false;
1165
+ if (!Number.isFinite(actualStartedAt)) return true;
1166
+ // owner.startedAt is written only after this process acquired the lock. A
1167
+ // live process created later therefore proves PID reuse; retaining that
1168
+ // stale lock would permanently fence the state after high-churn workers.
1169
+ return actualStartedAt <= acquiredAt;
1170
+ }
1171
+
701
1172
  /**
702
1173
  * Strict cross-process read/mutate/write transaction for one supervisor state.
703
1174
  * Returning null from `updater` leaves the record untouched. Missing and
@@ -727,7 +1198,25 @@ export function mutateBackgroundAgentState(id, updater, options = {}) {
727
1198
  },
728
1199
  {
729
1200
  failIfUnavailable: true,
730
- timeoutMs: options.timeoutMs ?? 5_000,
1201
+ // Startup has three independent writers (launcher, worker and keeper),
1202
+ // while stop/turn settlement adds another contender. Hosted Windows can
1203
+ // otherwise starve a first heartbeat for the old five-second budget when
1204
+ // the full strict matrix is saturating the runner. Keep the lock strict
1205
+ // and bounded, but give default callers one full ten-second contention
1206
+ // window; keeper cleanup keeps its explicit protocol-bound five seconds.
1207
+ timeoutMs: options.timeoutMs ?? 10_000,
1208
+ // Release before yielding so an already-waiting process gets a scheduling
1209
+ // opportunity instead of letting a heartbeat loop immediately reacquire.
1210
+ yieldAfterReleaseMs: 2,
1211
+ // A stopped POSIX worker can remain as a zombie long enough for
1212
+ // kill(pid, 0) to report it as alive. Such a process cannot still own
1213
+ // the critical section, so use the supervisor's execution-state probe
1214
+ // (which treats only Z/X as dead and keeps unknown states fail-closed).
1215
+ _isProcessAlive: isProcessExecutionAlive,
1216
+ // A live numeric pid can belong to a later process after the lock owner
1217
+ // was killed. Use the lock acquisition timestamp as a one-sided identity
1218
+ // fence so only an owner which existed before acquisition still blocks.
1219
+ _isOwnerAlive: isBackgroundAgentStateLockOwnerAlive,
731
1220
  },
732
1221
  );
733
1222
  }
@@ -954,6 +1443,7 @@ export function isSameProcess(pid, expectedStartedAtMs, options = {}) {
954
1443
  const expected = Number(expectedStartedAtMs);
955
1444
  if (!Number.isFinite(expected) || expected <= 0) return true;
956
1445
  const actual = processStartTimeMs(pid);
1446
+ if (actual === PROCESS_START_TIME_ABSENT) return false;
957
1447
  if (actual === null) return true;
958
1448
  const tolerance = Number.isFinite(Number(options.toleranceMs))
959
1449
  ? Number(options.toleranceMs)
@@ -988,7 +1478,7 @@ function inspectDestructiveProcessIdentity(
988
1478
  const leaderCanExecute =
989
1479
  leaderAlive && leaderState !== "Z" && leaderState !== "X";
990
1480
  if (!leaderCanExecute) {
991
- if (process.platform === "win32") {
1481
+ if (process.platform === "win32" || options.processGroup === false) {
992
1482
  return { status: "dead", reason: "not-executing", pid: target };
993
1483
  }
994
1484
  const group = inspectProcessGroupExecution(target);
@@ -1026,6 +1516,13 @@ function inspectDestructiveProcessIdentity(
1026
1516
  };
1027
1517
  }
1028
1518
  const actual = processStartTimeMs(target, { fresh: true });
1519
+ if (actual === PROCESS_START_TIME_ABSENT) {
1520
+ return {
1521
+ status: "dead",
1522
+ reason: "process-not-observed",
1523
+ pid: target,
1524
+ };
1525
+ }
1029
1526
  if (actual === null) {
1030
1527
  return {
1031
1528
  status: "unverifiable",
@@ -1038,7 +1535,7 @@ function inspectDestructiveProcessIdentity(
1038
1535
  ? Number(options.toleranceMs)
1039
1536
  : PID_IDENTITY_TOLERANCE_MS;
1040
1537
  if (Math.abs(actual - expected) > tolerance) {
1041
- if (process.platform !== "win32") {
1538
+ if (process.platform !== "win32" && options.processGroup !== false) {
1042
1539
  const group = inspectProcessGroupExecution(target);
1043
1540
  // The leader PID can be reused while descendants from its detached
1044
1541
  // process group still execute under the old PGID. We can prove the new
@@ -1087,15 +1584,36 @@ function inspectDestructiveProcessIdentity(
1087
1584
  * @returns {boolean} true when a kill was actually issued
1088
1585
  */
1089
1586
  export function reclaimOrphanAgentProcess(state) {
1090
- const agentPid = Number(state?.agentPid);
1091
- if (!Number.isInteger(agentPid) || agentPid <= 0) return false;
1092
- if (agentPid === Number(state?.pid)) return false; // never the worker itself
1093
- const anchor = Number(state?.agentStartedAt);
1094
- if (!Number.isFinite(anchor) || anchor <= 0) return false; // fail closed
1095
- if (inspectDestructiveProcessIdentity(agentPid, anchor).status !== "match") {
1096
- return false;
1587
+ const targets = [
1588
+ {
1589
+ pid: Number(state?.agentRuntimePid),
1590
+ startedAt: Number(state?.agentRuntimeStartedAt),
1591
+ },
1592
+ {
1593
+ pid: Number(state?.agentPid),
1594
+ startedAt: Number(state?.agentStartedAt),
1595
+ },
1596
+ ].filter(
1597
+ (target, index, values) =>
1598
+ Number.isInteger(target.pid) &&
1599
+ target.pid > 0 &&
1600
+ target.pid !== Number(state?.pid) &&
1601
+ values.findIndex((candidate) => candidate.pid === target.pid) === index,
1602
+ );
1603
+ let killed = false;
1604
+ for (const target of targets) {
1605
+ // Destructive recovery never falls back to a naked PID. Every target must
1606
+ // retain a positive creation-time anchor and pass the fresh OS probe.
1607
+ if (!Number.isFinite(target.startedAt) || target.startedAt <= 0) continue;
1608
+ if (
1609
+ inspectDestructiveProcessIdentity(target.pid, target.startedAt).status !==
1610
+ "match"
1611
+ ) {
1612
+ continue;
1613
+ }
1614
+ killed = _deps.killProcessTree(target.pid, "SIGKILL") === true || killed;
1097
1615
  }
1098
- return _deps.killProcessTree(agentPid, "SIGKILL") === true;
1616
+ return killed;
1099
1617
  }
1100
1618
 
1101
1619
  export function normalizeBackgroundAgentTitle(title) {
@@ -1259,7 +1777,7 @@ function recoverTerminalBackgroundInteractions(
1259
1777
 
1260
1778
  const fallbackRequest = backgroundInteractionFallback(state);
1261
1779
  try {
1262
- const recovery = withSessionHostRecoveryLease(state.sessionId, () =>
1780
+ const recovery = _deps.withSessionHostRecoveryLease(state.sessionId, () =>
1263
1781
  rejectPendingBackgroundInteractions(state.sessionId, state.id, {
1264
1782
  fallbackRequest,
1265
1783
  code,
@@ -1303,6 +1821,16 @@ export function effectiveBackgroundAgentState(state, options = {}) {
1303
1821
  }
1304
1822
  return state;
1305
1823
  }
1824
+ if (
1825
+ Number.isFinite(Number(state.stopRequestedAt)) &&
1826
+ Number(state.stopRequestedAt) > 0 &&
1827
+ !hasValidBackgroundAgentStopCleanupProof(state)
1828
+ ) {
1829
+ // A durable stop fence transfers terminal reconciliation to the explicit
1830
+ // stopper. A concurrent list/view must not persist `lost` before that path
1831
+ // has verified every exact-owned process and published cleanup proof.
1832
+ return state;
1833
+ }
1306
1834
  // OS identity probes can take seconds on Windows. Perform them outside the
1307
1835
  // state lock, then use the exact observed identity/heartbeat as a CAS fence
1308
1836
  // inside the short persistence transaction.
@@ -1677,6 +2205,13 @@ export function resumeBackgroundAgent(id, prompt, options = {}) {
1677
2205
  heartbeatStaleMs: options.heartbeatStaleMs,
1678
2206
  });
1679
2207
  if (!state) throw new Error(`Background agent not found: ${id}`);
2208
+ if (hasBackgroundAgentStopFenceWithoutCleanupProof(state)) {
2209
+ const error = new Error(
2210
+ `Background agent ${id} stop cleanup is still pending; retry stop and wait for durable cleanup confirmation before resuming`,
2211
+ );
2212
+ error.code = "BACKGROUND_AGENT_STOP_CLEANUP_PENDING";
2213
+ throw error;
2214
+ }
1680
2215
  if (state.status === "running") {
1681
2216
  throw new Error(
1682
2217
  `Background agent ${id} is still running — use cc attach ${id} to send follow-up prompts instead`,
@@ -1859,20 +2394,33 @@ export function isBackgroundWorkerStartedError(error) {
1859
2394
  async function settleUncertainBackgroundLaunch({
1860
2395
  id,
1861
2396
  jobFile,
2397
+ jobFiles,
1862
2398
  spawnedProcess,
2399
+ spawnedProcesses,
1863
2400
  error,
1864
2401
  }) {
1865
- let termination;
2402
+ const owned = (spawnedProcesses || [spawnedProcess]).filter(Boolean);
2403
+ let terminations;
1866
2404
  try {
1867
- termination = await _deps.terminateOwnedProcessTree(spawnedProcess, {
1868
- treeMode: process.platform === "win32" ? "windows-tree" : "posix-group",
1869
- });
2405
+ terminations = await Promise.all(
2406
+ owned.map((processHandle) =>
2407
+ _deps.terminateOwnedProcessTree(processHandle, {
2408
+ treeMode:
2409
+ process.platform === "win32" ? "windows-tree" : "posix-group",
2410
+ }),
2411
+ ),
2412
+ );
1870
2413
  } catch {
1871
2414
  return;
1872
2415
  }
1873
- if (termination?.confirmed !== true) return;
2416
+ if (
2417
+ terminations.length !== owned.length ||
2418
+ terminations.some((termination) => termination?.confirmed !== true)
2419
+ ) {
2420
+ return;
2421
+ }
1874
2422
  try {
1875
- const workerPid = Number(spawnedProcess?.pid);
2423
+ const workerPid = Number(owned[0]?.pid);
1876
2424
  const current = readBackgroundAgentState(id);
1877
2425
  if (!current || current.launchFinalizationUncertain !== true) return;
1878
2426
  if (
@@ -1901,15 +2449,25 @@ async function settleUncertainBackgroundLaunch({
1901
2449
  launchFinalizationUncertain: false,
1902
2450
  launchTermination: {
1903
2451
  confirmed: true,
1904
- treeMode: termination.treeMode || null,
1905
- closed: termination.closed === true,
1906
- treeTerminated: termination.treeTerminated === true,
2452
+ treeMode:
2453
+ terminations.length === 1
2454
+ ? terminations[0].treeMode || null
2455
+ : "multiple-owned-trees",
2456
+ closed: terminations.every(
2457
+ (termination) => termination.closed === true,
2458
+ ),
2459
+ treeTerminated: terminations.every(
2460
+ (termination) => termination.treeTerminated === true,
2461
+ ),
2462
+ ownedTreeCount: terminations.length,
1907
2463
  settledAt: Date.now(),
1908
2464
  },
1909
2465
  });
1910
2466
  // State is the durable cleanup authority; remove the one-shot job only
1911
2467
  // after the terminal/ownership evidence is safely persisted.
1912
- rmSync(jobFile, { force: true });
2468
+ for (const file of jobFiles || [jobFile]) {
2469
+ if (file) rmSync(file, { force: true });
2470
+ }
1913
2471
  } catch {
1914
2472
  // Persistence failure must retain the original uncertainty fence and must
1915
2473
  // never become an unhandled rejection in the launching CLI process.
@@ -1972,24 +2530,43 @@ export function launchBackgroundAgent({
1972
2530
  omitted: [...launchProfileState.omitted, "apiKey"],
1973
2531
  });
1974
2532
  }
2533
+ const launchCwd = launchProfileState.workspace.cwd;
2534
+ if (canonicalPath(cwd) !== canonicalPath(launchCwd)) {
2535
+ throw new Error(
2536
+ "Cannot launch background agent: launch profile cwd does not match the requested cwd",
2537
+ );
2538
+ }
1975
2539
  const configFingerprint =
1976
2540
  fingerprintBackgroundLaunchProfile(launchProfileState);
1977
2541
  const dir = backgroundAgentsDir();
1978
2542
  const workerGeneration = randomBytes(16).toString("hex");
2543
+ const keeperGeneration = randomBytes(16).toString("hex");
2544
+ const keeperToken = randomBytes(32).toString("hex");
2545
+ const keeperPipePath = backgroundAgentKeeperPipePath(id, dir);
2546
+ prepareBackgroundAgentKeeperPipePath(keeperPipePath);
1979
2547
  const jobFile = join(dir, `${id}.job.${process.pid}.json`);
2548
+ const keeperJobFile = join(dir, `${id}.keeper.${process.pid}.json`);
1980
2549
  const worker = fileURLToPath(
1981
2550
  new URL("../workers/background-agent-worker.js", import.meta.url),
1982
2551
  );
2552
+ const keeper = fileURLToPath(
2553
+ new URL("../workers/background-agent-keeper.js", import.meta.url),
2554
+ );
1983
2555
  const job = {
1984
2556
  id,
1985
2557
  workerGeneration,
1986
2558
  argv: initialSecrets.argv,
1987
- cwd,
2559
+ cwd: launchCwd,
1988
2560
  sessionId,
1989
2561
  sessionBootstrapExpected,
1990
2562
  title: title || "Background agent",
1991
2563
  cliEntry: cliEntry || process.argv[1],
1992
2564
  logFile: logPath(id),
2565
+ keeper: {
2566
+ generation: keeperGeneration,
2567
+ pipePath: keeperPipePath,
2568
+ token: keeperToken,
2569
+ },
1993
2570
  ...(worktreeState || {}),
1994
2571
  governance: governanceState,
1995
2572
  // Present = interactive attach can start follow-up turns; absent = the
@@ -1998,7 +2575,25 @@ export function launchBackgroundAgent({
1998
2575
  ? { followUpArgv: followUpSecrets.argv }
1999
2576
  : {}),
2000
2577
  };
2001
- writeFileSync(jobFile, JSON.stringify(job), { mode: 0o600 });
2578
+ try {
2579
+ writeFileSync(jobFile, JSON.stringify(job), { mode: 0o600 });
2580
+ writeFileSync(
2581
+ keeperJobFile,
2582
+ JSON.stringify({
2583
+ id,
2584
+ workerGeneration,
2585
+ keeperGeneration,
2586
+ pipePath: keeperPipePath,
2587
+ token: keeperToken,
2588
+ }),
2589
+ { mode: 0o600 },
2590
+ );
2591
+ } catch (error) {
2592
+ rmSync(jobFile, { force: true });
2593
+ rmSync(keeperJobFile, { force: true });
2594
+ cleanupBackgroundAgentKeeperPipeDirectory(keeperPipePath);
2595
+ throw error;
2596
+ }
2002
2597
  // Write the initial state BEFORE spawning so the worker's own merges (the
2003
2598
  // transport endpoint lands within its first ~100ms) can never be clobbered
2004
2599
  // by a late launcher write racing the worker.
@@ -2008,11 +2603,18 @@ export function launchBackgroundAgent({
2008
2603
  sessionId,
2009
2604
  sessionBootstrapExpected,
2010
2605
  title: job.title,
2011
- cwd,
2606
+ cwd: launchCwd,
2012
2607
  pid: null,
2013
2608
  workerPid: null,
2014
2609
  workerClaimedPid: null,
2015
2610
  workerClaimedAt: null,
2611
+ workerWrapperPid: null,
2612
+ keeperGeneration,
2613
+ keeperPid: null,
2614
+ keeperStartedAt: null,
2615
+ keeperWrapperPid: null,
2616
+ keeperStatus: "launching",
2617
+ keeperHeartbeatAt: null,
2016
2618
  turnLaunchAttempt: 0,
2017
2619
  turnLaunchIntent: null,
2018
2620
  turnLaunchResolution: null,
@@ -2036,10 +2638,14 @@ export function launchBackgroundAgent({
2036
2638
  writeBackgroundAgentState(state, { createIfMissing: true });
2037
2639
  } catch (error) {
2038
2640
  rmSync(jobFile, { force: true });
2641
+ rmSync(keeperJobFile, { force: true });
2039
2642
  rmSync(statePath(id), { force: true });
2643
+ cleanupBackgroundAgentKeeperPipeDirectory(keeperPipePath);
2040
2644
  throw error;
2041
2645
  }
2042
2646
  let child;
2647
+ let keeperChild;
2648
+ let keeperStartedAt = null;
2043
2649
  let workerLogFd;
2044
2650
  let postSpawnError = null;
2045
2651
  try {
@@ -2049,7 +2655,7 @@ export function launchBackgroundAgent({
2049
2655
  // hosted process' possibly absent standard handles.
2050
2656
  workerLogFd = openSync(job.logFile, "a");
2051
2657
  child = _deps.spawn(process.execPath, [worker, jobFile], {
2052
- cwd,
2658
+ cwd: launchCwd,
2053
2659
  detached: true,
2054
2660
  stdio: ["ignore", workerLogFd, workerLogFd],
2055
2661
  windowsHide: true,
@@ -2061,13 +2667,29 @@ export function launchBackgroundAgent({
2061
2667
  scope: "background-agent",
2062
2668
  shell: false,
2063
2669
  });
2670
+ keeperStartedAt = Date.now();
2671
+ keeperChild = _deps.spawn(process.execPath, [keeper, keeperJobFile], {
2672
+ cwd: launchCwd,
2673
+ detached: true,
2674
+ stdio: ["ignore", workerLogFd, workerLogFd],
2675
+ windowsHide: true,
2676
+ origin: "background-agent:keeper",
2677
+ policy: "allow",
2678
+ scope: "background-agent",
2679
+ shell: false,
2680
+ });
2064
2681
  } catch (error) {
2065
- if (error?.spawnedProcess) {
2682
+ const ownedProcesses = [child, keeperChild, error?.spawnedProcess].filter(
2683
+ (processHandle, index, values) =>
2684
+ processHandle && values.indexOf(processHandle) === index,
2685
+ );
2686
+ if (ownedProcesses.length > 0) {
2066
2687
  // The broker can fail a post-spawn sandbox handshake only after native
2067
2688
  // spawn returned. It requests termination, but this synchronous caller
2068
- // cannot prove the close fence has settled; retain state/job/worktree so
2069
- // cleanup never races a process that may still hold cwd.
2070
- const workerPid = Number(error.spawnedProcess.pid);
2689
+ // cannot prove every close fence has settled; retain state/jobs/worktree
2690
+ // so cleanup never races a process that may still hold cwd.
2691
+ const workerPid = Number(child?.pid || error?.spawnedProcess?.pid);
2692
+ const keeperPid = Number(keeperChild?.pid);
2071
2693
  try {
2072
2694
  const current = readBackgroundAgentState(id) || state;
2073
2695
  writeBackgroundAgentState({
@@ -2075,6 +2697,9 @@ export function launchBackgroundAgent({
2075
2697
  pid: Number.isInteger(workerPid) && workerPid > 0 ? workerPid : null,
2076
2698
  workerPid:
2077
2699
  Number.isInteger(workerPid) && workerPid > 0 ? workerPid : null,
2700
+ keeperPid:
2701
+ Number.isInteger(keeperPid) && keeperPid > 0 ? keeperPid : null,
2702
+ keeperStartedAt,
2078
2703
  launchFinalizationUncertain: true,
2079
2704
  });
2080
2705
  } catch {
@@ -2083,8 +2708,8 @@ export function launchBackgroundAgent({
2083
2708
  }
2084
2709
  void settleUncertainBackgroundLaunch({
2085
2710
  id,
2086
- jobFile,
2087
- spawnedProcess: error.spawnedProcess,
2711
+ jobFiles: [jobFile, keeperJobFile],
2712
+ spawnedProcesses: ownedProcesses,
2088
2713
  error,
2089
2714
  });
2090
2715
  throw new BackgroundWorkerStartedError(error, {
@@ -2093,7 +2718,9 @@ export function launchBackgroundAgent({
2093
2718
  });
2094
2719
  }
2095
2720
  rmSync(jobFile, { force: true });
2721
+ rmSync(keeperJobFile, { force: true });
2096
2722
  rmSync(statePath(id), { force: true });
2723
+ cleanupBackgroundAgentKeeperPipeDirectory(keeperPipePath);
2097
2724
  throw error;
2098
2725
  } finally {
2099
2726
  if (Number.isInteger(workerLogFd)) {
@@ -2102,7 +2729,7 @@ export function launchBackgroundAgent({
2102
2729
  } catch (error) {
2103
2730
  // Once spawn returned a child, even fd-finalization errors are
2104
2731
  // post-transfer: the worker may already be using the worktree.
2105
- if (child) postSpawnError = error;
2732
+ if (child || keeperChild) postSpawnError = error;
2106
2733
  }
2107
2734
  }
2108
2735
  }
@@ -2173,6 +2800,44 @@ export function launchBackgroundAgent({
2173
2800
  }
2174
2801
  });
2175
2802
  }
2803
+ if (typeof keeperChild.on === "function") {
2804
+ keeperChild.on("error", (error) => {
2805
+ try {
2806
+ mutateBackgroundAgentState(id, (current) =>
2807
+ current?.status === "running" &&
2808
+ current.keeperGeneration === keeperGeneration
2809
+ ? {
2810
+ ...current,
2811
+ keeperStatus: "spawn-error",
2812
+ keeperError: error?.message || String(error),
2813
+ keeperEndedAt: Date.now(),
2814
+ }
2815
+ : null,
2816
+ );
2817
+ } catch {
2818
+ /* worker-side channel failure remains authoritative */
2819
+ }
2820
+ });
2821
+ keeperChild.on("exit", (code, signal) => {
2822
+ try {
2823
+ mutateBackgroundAgentState(id, (current) =>
2824
+ current?.status === "running" &&
2825
+ current.keeperGeneration === keeperGeneration
2826
+ ? {
2827
+ ...current,
2828
+ keeperStatus:
2829
+ current.keeperStatus === "closed" ? "closed" : "exited",
2830
+ keeperExitCode: Number.isInteger(code) ? code : null,
2831
+ keeperSignal: signal || null,
2832
+ keeperEndedAt: Date.now(),
2833
+ }
2834
+ : null,
2835
+ );
2836
+ } catch {
2837
+ /* worker-side channel failure remains authoritative */
2838
+ }
2839
+ });
2840
+ }
2176
2841
  // Read child.pid before entering the state transaction. A platform wrapper
2177
2842
  // (notably the Windows Job Object adapter) can return from spawn just as the
2178
2843
  // real worker claims the state record. Finalize the wrapper pid against the
@@ -2180,6 +2845,7 @@ export function launchBackgroundAgent({
2180
2845
  // worker already published (agentPid, pendingQuestion, recovery marker,
2181
2846
  // turnCount, ...).
2182
2847
  const spawnedWorkerPid = child.pid;
2848
+ const spawnedKeeperPid = keeperChild.pid;
2183
2849
  const pidFinalization = mutateBackgroundAgentState(id, (current) => {
2184
2850
  if (
2185
2851
  !current ||
@@ -2195,6 +2861,17 @@ export function launchBackgroundAgent({
2195
2861
  // platform child returned by spawn.
2196
2862
  pid: current.pid ?? spawnedWorkerPid,
2197
2863
  workerPid: current.workerPid ?? spawnedWorkerPid,
2864
+ workerWrapperPid:
2865
+ current.workerWrapperPid ?? child.sandboxWrapperPid ?? null,
2866
+ keeperPid: current.keeperPid ?? spawnedKeeperPid,
2867
+ keeperStartedAt: current.keeperStartedAt ?? keeperStartedAt,
2868
+ keeperWrapperPid:
2869
+ current.keeperWrapperPid ?? keeperChild.sandboxWrapperPid ?? null,
2870
+ keeperStatus:
2871
+ current.keeperStatus === "listening" ||
2872
+ current.keeperStatus === "ready"
2873
+ ? current.keeperStatus
2874
+ : "starting",
2198
2875
  launchFinalizationUncertain: false,
2199
2876
  };
2200
2877
  });
@@ -2205,6 +2882,7 @@ export function launchBackgroundAgent({
2205
2882
  }
2206
2883
  withPid = pidFinalization.state;
2207
2884
  child.unref();
2885
+ keeperChild.unref();
2208
2886
  } catch (error) {
2209
2887
  postSpawnError ||= error;
2210
2888
  }
@@ -2227,13 +2905,50 @@ export function readBackgroundAgentLog(id, options = {}) {
2227
2905
  }
2228
2906
 
2229
2907
  function recordedBackgroundProcessIdentities(state) {
2908
+ const hasRecordedAgentTree =
2909
+ Number(state?.agentPid) > 0 || Number(state?.agentRuntimePid) > 0;
2230
2910
  const records = [
2231
- { role: "agent", pid: state?.agentPid, startedAt: state?.agentStartedAt },
2232
- { role: "worker", pid: state?.pid, startedAt: state?.startedAt },
2233
2911
  {
2234
- role: "worker-wrapper",
2912
+ role: "agent",
2913
+ pid: state?.agentPid,
2914
+ startedAt: state?.agentStartedAt,
2915
+ signalAsGroup: true,
2916
+ },
2917
+ {
2918
+ role: "agent-runtime",
2919
+ pid: state?.agentRuntimePid,
2920
+ startedAt: state?.agentRuntimeStartedAt,
2921
+ signalAsGroup: false,
2922
+ },
2923
+ {
2924
+ role: "worker",
2925
+ pid: state?.pid,
2926
+ startedAt: state?.startedAt,
2927
+ signalAsGroup: !hasRecordedAgentTree,
2928
+ },
2929
+ {
2930
+ role: "worker-published",
2235
2931
  pid: state?.workerPid,
2236
2932
  startedAt: state?.startedAt,
2933
+ signalAsGroup: !hasRecordedAgentTree,
2934
+ },
2935
+ {
2936
+ role: "worker-wrapper",
2937
+ pid: state?.workerWrapperPid,
2938
+ startedAt: state?.startedAt,
2939
+ signalAsGroup: true,
2940
+ },
2941
+ {
2942
+ role: "keeper",
2943
+ pid: state?.keeperPid,
2944
+ startedAt: state?.keeperStartedAt,
2945
+ signalAsGroup: false,
2946
+ },
2947
+ {
2948
+ role: "keeper-wrapper",
2949
+ pid: state?.keeperWrapperPid,
2950
+ startedAt: state?.keeperStartedAt,
2951
+ signalAsGroup: true,
2237
2952
  },
2238
2953
  ];
2239
2954
  const seen = new Set();
@@ -2249,7 +2964,9 @@ function recordedBackgroundProcessIdentities(state) {
2249
2964
  function inspectRecordedBackgroundProcesses(state) {
2250
2965
  return recordedBackgroundProcessIdentities(state).map((record) => ({
2251
2966
  ...record,
2252
- identity: inspectDestructiveProcessIdentity(record.pid, record.startedAt),
2967
+ identity: inspectDestructiveProcessIdentity(record.pid, record.startedAt, {
2968
+ processGroup: record.signalAsGroup,
2969
+ }),
2253
2970
  }));
2254
2971
  }
2255
2972
 
@@ -2259,7 +2976,7 @@ function recordedBackgroundProcessesAlive(state) {
2259
2976
  );
2260
2977
  }
2261
2978
 
2262
- function isRecordedProcessTreeExecutionAlive(pid, startedAt) {
2979
+ export function isBackgroundProcessTreeExecutionAlive(pid, startedAt) {
2263
2980
  const identity = inspectDestructiveProcessIdentity(pid, startedAt);
2264
2981
  // `unverifiable` includes a vanished/zombie POSIX leader whose process group
2265
2982
  // is still executable or whose group snapshot failed. Both must block
@@ -2267,13 +2984,25 @@ function isRecordedProcessTreeExecutionAlive(pid, startedAt) {
2267
2984
  return ["match", "unverifiable", "self"].includes(identity.status);
2268
2985
  }
2269
2986
 
2270
- function signalBackgroundProcessTree(pid, signal = "SIGTERM") {
2987
+ export function signalBackgroundProcessTree(
2988
+ pid,
2989
+ signal = "SIGTERM",
2990
+ {
2991
+ platform = process.platform,
2992
+ processGroup = true,
2993
+ allowDirectFallback = true,
2994
+ } = {},
2995
+ ) {
2271
2996
  const target = Number(pid);
2272
- if (process.platform === "win32") {
2997
+ if (platform === "win32") {
2273
2998
  const killed = runSupervisorCommand(
2274
2999
  "taskkill",
2275
3000
  ["/PID", String(target), "/T", "/F"],
2276
- { windowsHide: true, encoding: "utf8" },
3001
+ {
3002
+ windowsHide: true,
3003
+ encoding: "utf8",
3004
+ timeout: BACKGROUND_AGENT_KEEPER_TASKKILL_TIMEOUT_MS,
3005
+ },
2277
3006
  "background-agent:stop-tree",
2278
3007
  );
2279
3008
  if (killed.error || (killed.status !== 0 && isProcessAlive(target))) {
@@ -2285,12 +3014,387 @@ function signalBackgroundProcessTree(pid, signal = "SIGTERM") {
2285
3014
  }
2286
3015
  return "process-tree";
2287
3016
  }
3017
+ if (processGroup !== true) {
3018
+ try {
3019
+ _deps.kill(target, signal);
3020
+ return "process";
3021
+ } catch (error) {
3022
+ if (error?.code === "ESRCH") return "already-exited";
3023
+ throw error;
3024
+ }
3025
+ }
2288
3026
  try {
2289
3027
  _deps.kill(-target, signal);
2290
3028
  return "process-group";
2291
- } catch {
2292
- _deps.kill(target, signal);
2293
- return "process";
3029
+ } catch (groupError) {
3030
+ // A denied/invalid group signal can leave descendants executing even if a
3031
+ // direct leader signal would succeed. Only ESRCH proves that there is no
3032
+ // process group to terminate and permits the single-process fallback.
3033
+ if (groupError?.code !== "ESRCH" || allowDirectFallback !== true) {
3034
+ throw groupError;
3035
+ }
3036
+ try {
3037
+ _deps.kill(target, signal);
3038
+ return "process";
3039
+ } catch (error) {
3040
+ // The exact-identity probe and signal are necessarily separate syscalls.
3041
+ // A previously signalled group (or a naturally exiting child) can reap
3042
+ // this target between them. ESRCH therefore proves there is no process
3043
+ // left to terminate; permission and all other failures remain fatal.
3044
+ if (error?.code === "ESRCH") return "already-exited";
3045
+ throw error;
3046
+ }
3047
+ }
3048
+ }
3049
+
3050
+ function isDestructiveIdentityRetired(identity) {
3051
+ return ["dead", "reused"].includes(identity?.status);
3052
+ }
3053
+
3054
+ function isRecoverableCreationTimeIdentityProbe(identity) {
3055
+ // A one-shot `ps -o lstart=` failure is observational: it says nothing about
3056
+ // whether the exact target is still present. Other unverifiable outcomes can
3057
+ // mean a missing durable anchor, a vanished group owner, PID reuse with a
3058
+ // surviving group, or an untrusted group snapshot; those remain immediately
3059
+ // fail-closed rather than being promoted into the resend path.
3060
+ return (
3061
+ identity?.status === "unverifiable" &&
3062
+ identity?.reason === "creation-time-probe-failed"
3063
+ );
3064
+ }
3065
+
3066
+ function isRecoverableInitialDestructiveIdentityProbe(identity) {
3067
+ // These outcomes come only from read-only OS observations. Retrying them
3068
+ // cannot address a process, and the caller still requires two consecutive
3069
+ // fresh exact matches before it may issue the first signal.
3070
+ return (
3071
+ identity?.status === "unverifiable" &&
3072
+ ["creation-time-probe-failed", "process-group-state-probe-failed"].includes(
3073
+ identity?.reason,
3074
+ )
3075
+ );
3076
+ }
3077
+
3078
+ function createPosixProbeRetryBudget(deadline) {
3079
+ const configuredRetryWindow = Math.max(
3080
+ 0,
3081
+ Number(_deps.processExitWaitDeadlineMs) || 0,
3082
+ );
3083
+ let remainingSleepBudget = Math.min(
3084
+ configuredRetryWindow,
3085
+ Math.max(0, deadline - _deps.posixPermissionRetryNowMs()),
3086
+ );
3087
+ let retryWaits = 0;
3088
+
3089
+ return {
3090
+ wait() {
3091
+ const remaining = Math.min(
3092
+ remainingSleepBudget,
3093
+ deadline - _deps.posixPermissionRetryNowMs(),
3094
+ );
3095
+ if (!(remaining > 0)) return false;
3096
+ const retryDelayMs = Math.min(
3097
+ remaining,
3098
+ 10 * 2 ** Math.min(retryWaits, 4),
3099
+ );
3100
+ _deps.posixPermissionRetrySleep(retryDelayMs);
3101
+ remainingSleepBudget -= retryDelayMs;
3102
+ retryWaits += 1;
3103
+ return true;
3104
+ },
3105
+ hasRemaining() {
3106
+ return (
3107
+ remainingSleepBudget > 0 && _deps.posixPermissionRetryNowMs() < deadline
3108
+ );
3109
+ },
3110
+ };
3111
+ }
3112
+
3113
+ /**
3114
+ * Retry an observational failure before the first POSIX signal. Unknown
3115
+ * identity never authorizes a signal: only two consecutive delayed fresh
3116
+ * matches do. A dead/reused target converges without signalling, while every
3117
+ * non-observational identity failure remains immediately fail-closed.
3118
+ */
3119
+ function retryRecoverableInitialPosixIdentity({
3120
+ inspectIdentity,
3121
+ initialIdentity,
3122
+ deadline,
3123
+ }) {
3124
+ let lastIdentity = initialIdentity;
3125
+ let identityProbeRetries = 0;
3126
+ const retryBudget = createPosixProbeRetryBudget(deadline);
3127
+ const outcome = (status, retryStoppedBy) => ({
3128
+ status,
3129
+ lastIdentity,
3130
+ identityProbeRetries,
3131
+ retryStoppedBy,
3132
+ });
3133
+
3134
+ for (;;) {
3135
+ if (isDestructiveIdentityRetired(lastIdentity)) {
3136
+ return outcome("retired", "retired");
3137
+ }
3138
+ if (
3139
+ lastIdentity?.status !== "match" &&
3140
+ !isRecoverableInitialDestructiveIdentityProbe(lastIdentity)
3141
+ ) {
3142
+ return outcome("failed", "preflight-identity");
3143
+ }
3144
+ if (!retryBudget.wait()) {
3145
+ return outcome("failed", "initial-identity-probe-deadline");
3146
+ }
3147
+
3148
+ const identityBeforeDelay = lastIdentity;
3149
+ identityProbeRetries += 1;
3150
+ lastIdentity = inspectIdentity();
3151
+
3152
+ if (isDestructiveIdentityRetired(lastIdentity)) {
3153
+ return outcome("retired", "retired");
3154
+ }
3155
+ if (
3156
+ lastIdentity?.status !== "match" &&
3157
+ !isRecoverableInitialDestructiveIdentityProbe(lastIdentity)
3158
+ ) {
3159
+ return outcome("failed", "preflight-identity");
3160
+ }
3161
+ if (
3162
+ identityBeforeDelay?.status === "match" &&
3163
+ lastIdentity?.status === "match"
3164
+ ) {
3165
+ if (!retryBudget.hasRemaining()) {
3166
+ return outcome("failed", "initial-identity-probe-deadline");
3167
+ }
3168
+ return outcome("matched", "consecutive-match");
3169
+ }
3170
+ }
3171
+ }
3172
+
3173
+ /**
3174
+ * Recover from a transient POSIX EPERM without ever signalling from unknown
3175
+ * identity evidence. Probe-only waits may bridge a transient creation-time
3176
+ * lookup failure, but a resend requires two consecutive fresh exact matches.
3177
+ */
3178
+ function retryExactPosixSignalAfterPermissionError({
3179
+ inspectIdentity,
3180
+ sendSignal,
3181
+ deadline,
3182
+ initialCause,
3183
+ }) {
3184
+ let cause = initialCause;
3185
+ let lastIdentity = inspectIdentity();
3186
+ let signalResends = 0;
3187
+ let identityProbeRetries = 0;
3188
+ const retryBudget = createPosixProbeRetryBudget(deadline);
3189
+
3190
+ const outcome = (status, retryStoppedBy, extra = {}) => ({
3191
+ status,
3192
+ cause,
3193
+ lastIdentity,
3194
+ signalResends,
3195
+ identityProbeRetries,
3196
+ retryStoppedBy,
3197
+ ...extra,
3198
+ });
3199
+
3200
+ for (;;) {
3201
+ if (isDestructiveIdentityRetired(lastIdentity)) {
3202
+ return outcome("retired", "retired");
3203
+ }
3204
+ if (
3205
+ lastIdentity?.status !== "match" &&
3206
+ !isRecoverableCreationTimeIdentityProbe(lastIdentity)
3207
+ ) {
3208
+ return outcome("failed", "identity");
3209
+ }
3210
+
3211
+ if (!retryBudget.wait()) {
3212
+ return outcome(
3213
+ "failed",
3214
+ lastIdentity?.status === "match"
3215
+ ? "signal-retry-deadline"
3216
+ : "identity-probe-deadline",
3217
+ );
3218
+ }
3219
+
3220
+ const identityBeforeDelay = lastIdentity;
3221
+ identityProbeRetries += 1;
3222
+ lastIdentity = inspectIdentity();
3223
+
3224
+ if (isDestructiveIdentityRetired(lastIdentity)) {
3225
+ return outcome("retired", "retired");
3226
+ }
3227
+ if (
3228
+ lastIdentity?.status !== "match" &&
3229
+ !isRecoverableCreationTimeIdentityProbe(lastIdentity)
3230
+ ) {
3231
+ return outcome("failed", "identity");
3232
+ }
3233
+
3234
+ // A target that moved from unknown to match gets one more probe-only
3235
+ // interval. Only match -> fresh match authorizes the next signal syscall.
3236
+ if (
3237
+ identityBeforeDelay?.status !== "match" ||
3238
+ lastIdentity?.status !== "match"
3239
+ ) {
3240
+ continue;
3241
+ }
3242
+ if (!retryBudget.hasRemaining()) {
3243
+ return outcome("failed", "signal-retry-deadline");
3244
+ }
3245
+ signalResends += 1;
3246
+ try {
3247
+ const signalResult = sendSignal();
3248
+ return outcome(
3249
+ signalResult === "already-exited" ? "retired" : "signalled",
3250
+ "completed",
3251
+ { signalResult },
3252
+ );
3253
+ } catch (error) {
3254
+ cause = error;
3255
+ if (error?.code !== "EPERM") {
3256
+ return outcome("failed", "signal");
3257
+ }
3258
+ // The failed resend has no authority value. Start the next decision from
3259
+ // a fresh probe, then require another delayed fresh match before signalling.
3260
+ lastIdentity = inspectIdentity();
3261
+ }
3262
+ }
3263
+ }
3264
+
3265
+ function signalExactBackgroundProcessTree(
3266
+ pid,
3267
+ signal,
3268
+ {
3269
+ platform = process.platform,
3270
+ expectedStartedAt,
3271
+ processGroup = true,
3272
+ allowDirectFallback = false,
3273
+ } = {},
3274
+ ) {
3275
+ const target = Number(pid);
3276
+ const mode = processGroup === true ? "process-group" : "direct";
3277
+ const posixRetryDeadline =
3278
+ _deps.posixPermissionRetryNowMs() +
3279
+ Math.max(0, Number(_deps.processExitWaitDeadlineMs) || 0);
3280
+ let initialSignalAttempts = 0;
3281
+ let signalResends = 0;
3282
+ let identityProbeRetries = 0;
3283
+ let lastIdentity = null;
3284
+ let retryStoppedBy = "preflight";
3285
+ const inspectIdentity = () =>
3286
+ inspectDestructiveProcessIdentity(target, expectedStartedAt, {
3287
+ processGroup,
3288
+ });
3289
+ const failure = (cause) => {
3290
+ const reason = cause?.message || String(cause);
3291
+ const error = new Error(
3292
+ `background_agent_child_tree_stop_failed: ${reason} (${mode} pid ${target}, initial signal attempts ${initialSignalAttempts}, signal resends ${signalResends}, identity probe retries ${identityProbeRetries}, last identity ${lastIdentity?.status || "not-probed"}/${lastIdentity?.reason || "no-reason"}, retry stopped by ${retryStoppedBy})`,
3293
+ { cause },
3294
+ );
3295
+ error.code = "ERR_BACKGROUND_AGENT_CHILD_TREE_STOP_FAILED";
3296
+ return error;
3297
+ };
3298
+
3299
+ // Unlike the legacy tree helper, the keeper's destructive path owns a
3300
+ // persisted creation-time anchor. Re-authorize the initial signal from a
3301
+ // fresh exact snapshot so a stale/reused PID can never address a stranger.
3302
+ lastIdentity = inspectIdentity();
3303
+ if (isDestructiveIdentityRetired(lastIdentity)) {
3304
+ return { alreadyRetired: true, signalledAsGroup: processGroup === true };
3305
+ }
3306
+ if (lastIdentity.status !== "match") {
3307
+ if (
3308
+ platform === "win32" ||
3309
+ !isRecoverableInitialDestructiveIdentityProbe(lastIdentity)
3310
+ ) {
3311
+ retryStoppedBy = "preflight-identity";
3312
+ throw failure(
3313
+ new Error(
3314
+ `destructive process identity is ${lastIdentity.status}: ${lastIdentity.reason || "unknown"}`,
3315
+ ),
3316
+ );
3317
+ }
3318
+ const retry = retryRecoverableInitialPosixIdentity({
3319
+ inspectIdentity,
3320
+ initialIdentity: lastIdentity,
3321
+ deadline: posixRetryDeadline,
3322
+ });
3323
+ identityProbeRetries += retry.identityProbeRetries;
3324
+ lastIdentity = retry.lastIdentity;
3325
+ retryStoppedBy = retry.retryStoppedBy;
3326
+ if (retry.status === "retired") {
3327
+ return { alreadyRetired: true, signalledAsGroup: processGroup === true };
3328
+ }
3329
+ if (retry.status !== "matched") {
3330
+ throw failure(
3331
+ new Error(
3332
+ `destructive process identity is ${lastIdentity.status}: ${lastIdentity.reason || "unknown"}`,
3333
+ ),
3334
+ );
3335
+ }
3336
+ }
3337
+
3338
+ const sendSignal = () =>
3339
+ signalBackgroundProcessTree(target, signal, {
3340
+ platform,
3341
+ processGroup,
3342
+ allowDirectFallback,
3343
+ });
3344
+
3345
+ retryStoppedBy = "initial-signal";
3346
+ try {
3347
+ initialSignalAttempts += 1;
3348
+ const result = sendSignal();
3349
+ return {
3350
+ alreadyRetired: result === "already-exited",
3351
+ signalledAsGroup: result === "process-group",
3352
+ };
3353
+ } catch (initialCause) {
3354
+ let cause = initialCause;
3355
+ if (platform !== "win32" && cause?.code === "EPERM") {
3356
+ const retry = retryExactPosixSignalAfterPermissionError({
3357
+ inspectIdentity,
3358
+ sendSignal,
3359
+ deadline: posixRetryDeadline,
3360
+ initialCause: cause,
3361
+ });
3362
+ signalResends = retry.signalResends;
3363
+ identityProbeRetries += retry.identityProbeRetries;
3364
+ lastIdentity = retry.lastIdentity;
3365
+ retryStoppedBy = retry.retryStoppedBy;
3366
+ cause = retry.cause;
3367
+ if (retry.status === "retired") {
3368
+ return {
3369
+ alreadyRetired: true,
3370
+ signalledAsGroup: processGroup === true,
3371
+ };
3372
+ }
3373
+ if (retry.status === "signalled") {
3374
+ return {
3375
+ alreadyRetired: retry.signalResult === "already-exited",
3376
+ signalledAsGroup: retry.signalResult === "process-group",
3377
+ };
3378
+ }
3379
+ }
3380
+ if (
3381
+ processGroup === true &&
3382
+ allowDirectFallback !== true &&
3383
+ cause?.code === "ESRCH"
3384
+ ) {
3385
+ // A group may disappear between the exact preflight and signal. It is
3386
+ // safe to accept only a fresh dead/reused result; a still-live leader
3387
+ // is never converted to a direct kill on the keeper path.
3388
+ lastIdentity = inspectIdentity();
3389
+ if (isDestructiveIdentityRetired(lastIdentity)) {
3390
+ return {
3391
+ alreadyRetired: true,
3392
+ signalledAsGroup: true,
3393
+ };
3394
+ }
3395
+ retryStoppedBy = "identity";
3396
+ }
3397
+ throw failure(cause);
2294
3398
  }
2295
3399
  }
2296
3400
 
@@ -2355,6 +3459,7 @@ function waitForSignalledProcessesToExit(
2355
3459
  const identity = inspectDestructiveProcessIdentity(
2356
3460
  target.pid,
2357
3461
  target.startedAt,
3462
+ { processGroup: target.signalledAsGroup },
2358
3463
  );
2359
3464
  return ["match", "unverifiable", "self"].includes(identity.status);
2360
3465
  });
@@ -2362,7 +3467,7 @@ function waitForSignalledProcessesToExit(
2362
3467
 
2363
3468
  function recoverStoppedInteractions(state) {
2364
3469
  if (!state?.sessionId) return { state, recovery: null };
2365
- const recovery = withSessionHostRecoveryLease(state.sessionId, () =>
3470
+ const recovery = _deps.withSessionHostRecoveryLease(state.sessionId, () =>
2366
3471
  rejectPendingBackgroundInteractions(state.sessionId, state.id, {
2367
3472
  fallbackRequest: backgroundInteractionFallback(state),
2368
3473
  code: "INTERACTION_STOPPED",
@@ -2390,7 +3495,13 @@ export function stopBackgroundAgent(id) {
2390
3495
  if (!state) throw new Error(`Background agent not found: ${id}`);
2391
3496
  const staleHeartbeatOwner =
2392
3497
  state.status === "lost" && state.lostReason === "heartbeat-stale";
2393
- if (state.status !== "running" && !staleHeartbeatOwner) {
3498
+ const preexistingStopCleanupRepair =
3499
+ hasBackgroundAgentStopFenceWithoutCleanupProof(state);
3500
+ if (
3501
+ state.status !== "running" &&
3502
+ !staleHeartbeatOwner &&
3503
+ !preexistingStopCleanupRepair
3504
+ ) {
2394
3505
  // Gap 2: stopping an already-lost session still reaps a leaked agent
2395
3506
  // child recorded before the worker died (identity-guarded inside).
2396
3507
  if (state.status === "lost") {
@@ -2415,7 +3526,8 @@ export function stopBackgroundAgent(id) {
2415
3526
  (current.status !== "running" &&
2416
3527
  !(
2417
3528
  current.status === "lost" && current.lostReason === "heartbeat-stale"
2418
- ))
3529
+ ) &&
3530
+ !hasBackgroundAgentStopFenceWithoutCleanupProof(current))
2419
3531
  ) {
2420
3532
  return null;
2421
3533
  }
@@ -2434,6 +3546,10 @@ export function stopBackgroundAgent(id) {
2434
3546
  return { ...fence.state, stopped: false };
2435
3547
  }
2436
3548
  state = fence.state;
3549
+ const repairingUnconfirmedStop =
3550
+ preexistingStopCleanupRepair ||
3551
+ hasBackgroundAgentStopFenceWithoutCleanupProof(fence.previous) ||
3552
+ isBackgroundAgentStopCleanupUnconfirmed(state);
2437
3553
  if (state.launchFinalizationUncertain === true || state.turnLaunchIntent) {
2438
3554
  // The worker has durably announced that native spawn is prepared or that
2439
3555
  // post-spawn pid persistence is unresolved. Killing the worker from this
@@ -2472,6 +3588,7 @@ export function stopBackgroundAgent(id) {
2472
3588
  let postKill = state;
2473
3589
  let workerPidReused = false;
2474
3590
  const signalledPids = new Set();
3591
+ let activeTerminationContext = null;
2475
3592
  // A worker may publish a just-spawned child identity while the stop fence is
2476
3593
  // being observed. Re-read once after the first termination round so that a
2477
3594
  // newly durable, exact-owned agent tree is also terminated before recovery.
@@ -2493,8 +3610,19 @@ export function stopBackgroundAgent(id) {
2493
3610
  }
2494
3611
  const primaryWorker =
2495
3612
  inspected.find(({ role }) => role === "worker") ||
3613
+ inspected.find(({ role }) => role === "worker-published") ||
2496
3614
  inspected.find(({ role }) => role === "worker-wrapper");
2497
- if (primaryWorker?.identity.status === "reused") workerPidReused = true;
3615
+ if (
3616
+ primaryWorker?.identity.status === "reused" &&
3617
+ !signalledPids.has(primaryWorker.pid)
3618
+ ) {
3619
+ // A fresh stop must refuse a worker pid that was already reused at the
3620
+ // first strict probe. If the exact worker matched and was signalled in an
3621
+ // earlier round, however, observing that numeric pid reused afterward is
3622
+ // evidence that the owned process retired, not grounds to discard the
3623
+ // durable cleanup proof.
3624
+ workerPidReused = true;
3625
+ }
2498
3626
  const targets = inspected.filter(
2499
3627
  ({ pid, identity }) =>
2500
3628
  identity.status === "match" && !signalledPids.has(pid),
@@ -2502,14 +3630,122 @@ export function stopBackgroundAgent(id) {
2502
3630
  if (targets.length === 0) break;
2503
3631
  try {
2504
3632
  // The child is detached into its own group and therefore gets its own
2505
- // exact-identity signal before the worker tree is terminated.
3633
+ // exact-identity signal before the worker tree is terminated. The
3634
+ // turn's detached agent root owns its tool descendants; the worker and
3635
+ // independent keeper do not. Signal those single processes directly so
3636
+ // a rapidly recycled pid cannot address an unrelated POSIX group.
2506
3637
  for (const target of targets) {
2507
- target.signalledAsGroup =
2508
- signalBackgroundProcessTree(target.pid, "SIGTERM") ===
2509
- "process-group";
2510
- signalledPids.add(target.pid);
3638
+ let permissionRetryDeadline = 0;
3639
+ const inspectIdentity = () =>
3640
+ inspectDestructiveProcessIdentity(target.pid, target.startedAt, {
3641
+ processGroup: target.signalAsGroup,
3642
+ });
3643
+ const sendInitialSignal = () =>
3644
+ signalBackgroundProcessTree(target.pid, "SIGTERM", {
3645
+ processGroup: target.signalAsGroup,
3646
+ // Preserve the pre-existing ESRCH behavior. EPERM itself never
3647
+ // enters this fallback because the tree primitive rethrows it.
3648
+ allowDirectFallback: true,
3649
+ });
3650
+ const resendSignal = () =>
3651
+ signalBackgroundProcessTree(target.pid, "SIGTERM", {
3652
+ processGroup: target.signalAsGroup,
3653
+ // After EPERM, every resend is a newly authorized action. A group
3654
+ // ESRCH must be resolved by fresh identity evidence, never by
3655
+ // changing that action into a direct leader signal.
3656
+ allowDirectFallback: false,
3657
+ });
3658
+ activeTerminationContext = {
3659
+ role: target.role,
3660
+ pid: target.pid,
3661
+ mode: target.signalAsGroup ? "process-group" : "direct",
3662
+ signalResends: 0,
3663
+ identityProbeRetries: 0,
3664
+ };
3665
+ try {
3666
+ const preflightIdentity = inspectIdentity();
3667
+ activeTerminationContext.lastIdentityStatus =
3668
+ preflightIdentity.status;
3669
+ activeTerminationContext.lastIdentityReason =
3670
+ preflightIdentity.reason || null;
3671
+ if (isDestructiveIdentityRetired(preflightIdentity)) {
3672
+ if (
3673
+ preflightIdentity.status === "reused" &&
3674
+ primaryWorker?.pid === target.pid &&
3675
+ !signalledPids.has(target.pid)
3676
+ ) {
3677
+ workerPidReused = true;
3678
+ }
3679
+ target.retiredBeforeWait = true;
3680
+ signalledPids.add(target.pid);
3681
+ continue;
3682
+ }
3683
+ if (preflightIdentity.status !== "match") {
3684
+ activeTerminationContext.retryStoppedBy = "preflight-identity";
3685
+ throw new Error(
3686
+ `destructive process identity is ${preflightIdentity.status}: ${preflightIdentity.reason || "unknown"}`,
3687
+ );
3688
+ }
3689
+ permissionRetryDeadline =
3690
+ _deps.posixPermissionRetryNowMs() +
3691
+ Math.max(0, Number(_deps.processExitWaitDeadlineMs) || 0);
3692
+ target.signalledAsGroup = sendInitialSignal() === "process-group";
3693
+ signalledPids.add(target.pid);
3694
+ } catch (error) {
3695
+ if (process.platform !== "win32" && error?.code === "EPERM") {
3696
+ const retry = retryExactPosixSignalAfterPermissionError({
3697
+ inspectIdentity,
3698
+ sendSignal: resendSignal,
3699
+ deadline: permissionRetryDeadline,
3700
+ initialCause: error,
3701
+ });
3702
+ activeTerminationContext.signalResends = retry.signalResends;
3703
+ activeTerminationContext.identityProbeRetries =
3704
+ retry.identityProbeRetries;
3705
+ activeTerminationContext.lastIdentityStatus =
3706
+ retry.lastIdentity?.status;
3707
+ activeTerminationContext.lastIdentityReason =
3708
+ retry.lastIdentity?.reason || null;
3709
+ activeTerminationContext.retryStoppedBy = retry.retryStoppedBy;
3710
+ if (retry.status === "retired") {
3711
+ target.signalledAsGroup = retry.signalResult
3712
+ ? retry.signalResult === "process-group"
3713
+ : target.signalAsGroup === true;
3714
+ signalledPids.add(target.pid);
3715
+ continue;
3716
+ }
3717
+ if (retry.status === "signalled") {
3718
+ target.signalledAsGroup = retry.signalResult === "process-group";
3719
+ signalledPids.add(target.pid);
3720
+ continue;
3721
+ }
3722
+ if (
3723
+ target.signalAsGroup === true &&
3724
+ retry.cause?.code === "ESRCH"
3725
+ ) {
3726
+ const refreshedIdentity = inspectIdentity();
3727
+ activeTerminationContext.lastIdentityStatus =
3728
+ refreshedIdentity.status;
3729
+ activeTerminationContext.lastIdentityReason =
3730
+ refreshedIdentity.reason || null;
3731
+ activeTerminationContext.retryStoppedBy = "identity";
3732
+ if (isDestructiveIdentityRetired(refreshedIdentity)) {
3733
+ target.signalledAsGroup = true;
3734
+ signalledPids.add(target.pid);
3735
+ continue;
3736
+ }
3737
+ }
3738
+ throw retry.cause;
3739
+ }
3740
+ throw error;
3741
+ }
2511
3742
  }
2512
3743
  } catch (error) {
3744
+ const failureMessage = `Failed to stop background agent ${id}${
3745
+ activeTerminationContext
3746
+ ? ` (${activeTerminationContext.role} pid ${activeTerminationContext.pid}, ${activeTerminationContext.mode}, signal resends ${activeTerminationContext.signalResends}, identity probe retries ${activeTerminationContext.identityProbeRetries}, last identity ${activeTerminationContext.lastIdentityStatus || "not-probed"}/${activeTerminationContext.lastIdentityReason || "no-reason"}, retry stopped by ${activeTerminationContext.retryStoppedBy || "non-permission-error"})`
3747
+ : ""
3748
+ }: ${error.message}`;
2513
3749
  try {
2514
3750
  mutateBackgroundAgentState(id, (current) =>
2515
3751
  current
@@ -2518,18 +3754,18 @@ export function stopBackgroundAgent(id) {
2518
3754
  phase: "stop_failed",
2519
3755
  stopPending: true,
2520
3756
  stopPendingReason: "process-termination",
2521
- stopError: error?.message || String(error),
3757
+ stopError: failureMessage,
2522
3758
  }
2523
3759
  : null,
2524
3760
  );
2525
3761
  } catch {
2526
3762
  /* the durable stopRequestedAt fence already blocks new turns */
2527
3763
  }
2528
- throw new Error(
2529
- `Failed to stop background agent ${id}: ${error.message}`,
2530
- );
3764
+ throw new Error(failureMessage);
2531
3765
  }
2532
- const stillAlive = waitForSignalledProcessesToExit(targets);
3766
+ const stillAlive = waitForSignalledProcessesToExit(
3767
+ targets.filter((target) => target.retiredBeforeWait !== true),
3768
+ );
2533
3769
  postKill = readBackgroundAgentState(id) || postKill;
2534
3770
  if (stillAlive.length > 0) {
2535
3771
  return persistStopPending(postKill, "process-exit", {
@@ -2544,7 +3780,7 @@ export function stopBackgroundAgent(id) {
2544
3780
  return persistStopPending(postKill, "process-exit");
2545
3781
  }
2546
3782
 
2547
- if (workerPidReused) {
3783
+ if (workerPidReused && !repairingUnconfirmedStop) {
2548
3784
  const lostMutation = mutateBackgroundAgentState(id, (current) =>
2549
3785
  current
2550
3786
  ? {
@@ -2602,6 +3838,12 @@ export function stopBackgroundAgent(id) {
2602
3838
  ...current,
2603
3839
  status: "stopped",
2604
3840
  endedAt: current.endedAt || Date.now(),
3841
+ // `endedAt` belongs to the worker/session lifecycle and may predate
3842
+ // retirement of a detached child. Publish a distinct durable proof
3843
+ // only after every exact-owned process identity has been observed
3844
+ // non-executable above.
3845
+ stopCleanupConfirmedAt:
3846
+ backgroundAgentStopCleanupConfirmationTimestamp(current),
2605
3847
  stoppedByUser: true,
2606
3848
  lostReason: null,
2607
3849
  phase: null,
@@ -2624,17 +3866,47 @@ function activeWorktreeProcesses(state) {
2624
3866
  if (
2625
3867
  Number.isInteger(Number(state?.pid)) &&
2626
3868
  Number(state.pid) > 0 &&
2627
- isRecordedProcessTreeExecutionAlive(state.pid, state.startedAt)
3869
+ isBackgroundProcessTreeExecutionAlive(state.pid, state.startedAt)
2628
3870
  ) {
2629
3871
  active.push("worker");
2630
3872
  }
2631
3873
  if (
2632
3874
  Number.isInteger(Number(state?.agentPid)) &&
2633
3875
  Number(state.agentPid) > 0 &&
2634
- isRecordedProcessTreeExecutionAlive(state.agentPid, state.agentStartedAt)
3876
+ isBackgroundProcessTreeExecutionAlive(state.agentPid, state.agentStartedAt)
2635
3877
  ) {
2636
3878
  active.push("agent");
2637
3879
  }
3880
+ if (
3881
+ Number.isInteger(Number(state?.keeperPid)) &&
3882
+ Number(state.keeperPid) > 0 &&
3883
+ isBackgroundProcessTreeExecutionAlive(
3884
+ state.keeperPid,
3885
+ state.keeperStartedAt,
3886
+ )
3887
+ ) {
3888
+ active.push("keeper");
3889
+ }
3890
+ if (
3891
+ Number.isInteger(Number(state?.workerWrapperPid)) &&
3892
+ Number(state.workerWrapperPid) > 0 &&
3893
+ isBackgroundProcessTreeExecutionAlive(
3894
+ state.workerWrapperPid,
3895
+ state.startedAt,
3896
+ )
3897
+ ) {
3898
+ active.push("worker-wrapper");
3899
+ }
3900
+ if (
3901
+ Number.isInteger(Number(state?.keeperWrapperPid)) &&
3902
+ Number(state.keeperWrapperPid) > 0 &&
3903
+ isBackgroundProcessTreeExecutionAlive(
3904
+ state.keeperWrapperPid,
3905
+ state.keeperStartedAt,
3906
+ )
3907
+ ) {
3908
+ active.push("keeper-wrapper");
3909
+ }
2638
3910
  return active;
2639
3911
  }
2640
3912