chainlesschain 0.163.6 → 0.163.7

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 (218) hide show
  1. package/package.json +1 -1
  2. package/src/assets/web-panel/assets/{AIOps-DDIUofA8.js → AIOps-axqQfwv8.js} +1 -1
  3. package/src/assets/web-panel/assets/{ActionButton-Bm-5xYRn.js → ActionButton-DV-Ro0By.js} +1 -1
  4. package/src/assets/web-panel/assets/{Analytics-D5UH4ljI.js → Analytics-Bt72WEVu.js} +3 -3
  5. package/src/assets/web-panel/assets/{AppLayout-D_ifjOOP.js → AppLayout-BtepG55E.js} +5 -5
  6. package/src/assets/web-panel/assets/{Artifacts-BY1mXdC7.js → Artifacts-B7KaJ7S_.js} +1 -1
  7. package/src/assets/web-panel/assets/{Audit-Bujr4Ml3.js → Audit-FNFaUg7A.js} +1 -1
  8. package/src/assets/web-panel/assets/{BackgroundAgents-sioKSTcC.js → BackgroundAgents-DFb6J2wQ.js} +1 -1
  9. package/src/assets/web-panel/assets/{Backup-DDOAK8RK.js → Backup-CC7YBhyj.js} +1 -1
  10. package/src/assets/web-panel/assets/{BaseInput-CtCsVVke.js → BaseInput-RkgTNVtj.js} +1 -1
  11. package/src/assets/web-panel/assets/{Chat-DpNj0vnd.js → Chat-hHEmGZy-.js} +4 -4
  12. package/src/assets/web-panel/assets/{ChatBubbleRenderer-CZta3Ftr.js → ChatBubbleRenderer-CAP9YhHv.js} +1 -1
  13. package/src/assets/web-panel/assets/{Checkbox-DoHVNSXq.js → Checkbox-z5wXOQP1.js} +1 -1
  14. package/src/assets/web-panel/assets/{Codegen-DlVl7Qmv.js → Codegen-DVaDJnxy.js} +1 -1
  15. package/src/assets/web-panel/assets/{Col-GIqoPYty.js → Col-CJmaRDR5.js} +1 -1
  16. package/src/assets/web-panel/assets/{Community-CIAPEVji.js → Community-DF29_2bI.js} +1 -1
  17. package/src/assets/web-panel/assets/{Compact-BN7JiI_h.js → Compact-DtkQkBzn.js} +1 -1
  18. package/src/assets/web-panel/assets/{Compliance-CqUWCM9B.js → Compliance-xoHDxp7I.js} +1 -1
  19. package/src/assets/web-panel/assets/{Cowork-A4iPp7A4.js → Cowork-BUm-R7ht.js} +3 -3
  20. package/src/assets/web-panel/assets/{Cron-B2zeXXWb.js → Cron-Cgl14ZkB.js} +2 -2
  21. package/src/assets/web-panel/assets/{Crosschain-BR2xmWr8.js → Crosschain-BNcbmoqh.js} +1 -1
  22. package/src/assets/web-panel/assets/{DID-BzFDjrIv.js → DID-Bwja0Zo9.js} +2 -2
  23. package/src/assets/web-panel/assets/{Dashboard-DKtDo3yA.js → Dashboard-Bk5XjEdJ.js} +2 -2
  24. package/src/assets/web-panel/assets/{Dropdown-RNx43_YG.js → Dropdown-Ci36Oitq.js} +1 -1
  25. package/src/assets/web-panel/assets/{EmailListRenderer-SwMt4Rcy.js → EmailListRenderer--U04ZN4q.js} +1 -1
  26. package/src/assets/web-panel/assets/{FamilyGuardDashboard-CsYebZ4X.js → FamilyGuardDashboard-B2gNPR2s.js} +1 -1
  27. package/src/assets/web-panel/assets/{Federation-BmRKvBTy.js → Federation-C4KghYn7.js} +1 -1
  28. package/src/assets/web-panel/assets/{FormItemContext-CVxGTA-0.js → FormItemContext-QL7x_Q_0.js} +1 -1
  29. package/src/assets/web-panel/assets/{GenericCardRenderer-x2iXpyB7.js → GenericCardRenderer-F3Ryltwm.js} +1 -1
  30. package/src/assets/web-panel/assets/{Git-DfqbkiOP.js → Git-2HassK5k.js} +2 -2
  31. package/src/assets/web-panel/assets/{Governance-RuHyyj5O.js → Governance-DoBIp7oe.js} +1 -1
  32. package/src/assets/web-panel/assets/{Inference-Bn7uZ7rI.js → Inference--W01QhZ-.js} +1 -1
  33. package/src/assets/web-panel/assets/{KnowledgeGraph-DlP7XefZ.js → KnowledgeGraph-DmvwqGpT.js} +1 -1
  34. package/src/assets/web-panel/assets/{Logs-BYNdOiCd.js → Logs-BBJ4yAel.js} +2 -2
  35. package/src/assets/web-panel/assets/{MarkdownRenderer-aMtgN-R6.js → MarkdownRenderer-BLCZMzhJ.js} +1 -1
  36. package/src/assets/web-panel/assets/{Marketplace-JQYmDqM8.js → Marketplace-D5lNngSI.js} +1 -1
  37. package/src/assets/web-panel/assets/{McpTools-Cyx4yZ-9.js → McpTools-CkEVOMKH.js} +4 -4
  38. package/src/assets/web-panel/assets/{Memory-VDuGZTN2.js → Memory-B1Ow1xVE.js} +2 -2
  39. package/src/assets/web-panel/assets/{MobileBridge-_yKo97gi.js → MobileBridge-SgYbUfb6.js} +1 -1
  40. package/src/assets/web-panel/assets/{MobileProjects-CXRXW3q7.js → MobileProjects-BWdOvQAj.js} +1 -1
  41. package/src/assets/web-panel/assets/{Mtc-acTGtfLH.js → Mtc-DcTXSSma.js} +5 -5
  42. package/src/assets/web-panel/assets/{MtcAudit-ClpjXfkY.js → MtcAudit-Dt3WG4oj.js} +4 -4
  43. package/src/assets/web-panel/assets/{Multisig-_6Tn3o96.js → Multisig-B9B38UZx.js} +3 -3
  44. package/src/assets/web-panel/assets/{NLProgramming-CK7pbZzP.js → NLProgramming-B0S4GGvG.js} +1 -1
  45. package/src/assets/web-panel/assets/{Notes-Czy6V_P8.js → Notes-DZ5U58Ng.js} +3 -3
  46. package/src/assets/web-panel/assets/{NotificationSettings-Cqx-UVv3.js → NotificationSettings-PjL5ZW34.js} +1 -1
  47. package/src/assets/web-panel/assets/{OrderTableRenderer-C69WDxGy.js → OrderTableRenderer-DrAX2ESt.js} +1 -1
  48. package/src/assets/web-panel/assets/{Organization-DFnA9vLQ.js → Organization-Cv1GYs-c.js} +4 -4
  49. package/src/assets/web-panel/assets/{Overflow-ReZkk2yT.js → Overflow-Bbkp_PL3.js} +1 -1
  50. package/src/assets/web-panel/assets/{P2P-DzjSI0aI.js → P2P-BD8QUgoZ.js} +2 -2
  51. package/src/assets/web-panel/assets/{PdhVaultBrowser-DKTf5xIP.js → PdhVaultBrowser-BO-2PeaB.js} +3 -3
  52. package/src/assets/web-panel/assets/{Permissions-DhQ5klhR.js → Permissions-D_OPVa02.js} +4 -4
  53. package/src/assets/web-panel/assets/{PersonalDataHub-CUL1iHrW.js → PersonalDataHub-mh-pEmaB.js} +4 -4
  54. package/src/assets/web-panel/assets/{Pipeline-DU88iu4N.js → Pipeline-6JC2EDdb.js} +1 -1
  55. package/src/assets/web-panel/assets/{Privacy-Bw-sYfyK.js → Privacy-CUshzUAG.js} +1 -1
  56. package/src/assets/web-panel/assets/{ProjectInit-Bq3Q5y0z.js → ProjectInit-Cs2Aalri.js} +2 -2
  57. package/src/assets/web-panel/assets/{ProjectSettings-C8spzpho.js → ProjectSettings-1ak49tcj.js} +2 -2
  58. package/src/assets/web-panel/assets/Projects-BV4oApsm.js +1 -0
  59. package/src/assets/web-panel/assets/{Providers-Bf4eTP9W.js → Providers-CXXxvafH.js} +1 -1
  60. package/src/assets/web-panel/assets/{QrScannerModal-Dme9BDU1.js → QrScannerModal-PTcDsF58.js} +1 -1
  61. package/src/assets/web-panel/assets/{QuickAsk-CIXfv1rE.js → QuickAsk-CDwucrJw.js} +1 -1
  62. package/src/assets/web-panel/assets/{Recommend-CIxvdeZ-.js → Recommend-CJlLVRXR.js} +1 -1
  63. package/src/assets/web-panel/assets/{RemoteSession-C6k7MExI.js → RemoteSession-DlN-ETsO.js} +2 -2
  64. package/src/assets/web-panel/assets/{Reputation-CISDz2bl.js → Reputation-DI23LR6m.js} +1 -1
  65. package/src/assets/web-panel/assets/{Row-CdnDxXyf.js → Row-Cl7UQMrM.js} +1 -1
  66. package/src/assets/web-panel/assets/{RssFeed-C-QpRHsl.js → RssFeed-CexHNfh8.js} +3 -3
  67. package/src/assets/web-panel/assets/{Search-DBjaGzSn.js → Search-BneBWvRN.js} +1 -1
  68. package/src/assets/web-panel/assets/{Security-CQKukOeE.js → Security-DX7Phgrp.js} +4 -4
  69. package/src/assets/web-panel/assets/{Services-DZz86jWb.js → Services-BWXO8Mte.js} +2 -2
  70. package/src/assets/web-panel/assets/{Skeleton-jb8RMK0d.js → Skeleton-DCyOjKPJ.js} +1 -1
  71. package/src/assets/web-panel/assets/{Skills-CdY76fZG.js → Skills-CXqERsTB.js} +1 -1
  72. package/src/assets/web-panel/assets/{Sla-C_CSiZX9.js → Sla-bhbrbz8E.js} +1 -1
  73. package/src/assets/web-panel/assets/SpeechSettings-3IPU0HBZ.js +1 -0
  74. package/src/assets/web-panel/assets/{SyncSettings-qvimGO0O.js → SyncSettings-DBNTQLbM.js} +2 -2
  75. package/src/assets/web-panel/assets/{Tasks-Dng5LPJC.js → Tasks-quP0bkN-.js} +1 -1
  76. package/src/assets/web-panel/assets/{Templates-Bwqq5Wll.js → Templates-BouWmgho.js} +1 -1
  77. package/src/assets/web-panel/assets/{Tenant-DNWuwP7Z.js → Tenant-qv0D9s7z.js} +1 -1
  78. package/src/assets/web-panel/assets/{Terminal-B-6Othpp.js → Terminal-BxvbE8BB.js} +2 -2
  79. package/src/assets/web-panel/assets/{TimelineRenderer-DKpKZfzx.js → TimelineRenderer-D-IeLnJ2.js} +1 -1
  80. package/src/assets/web-panel/assets/{Tokens-DzfGMd2J.js → Tokens-CFMKKyVG.js} +1 -1
  81. package/src/assets/web-panel/assets/{Trigger-BXwUi74J.js → Trigger-L8as3UuJ.js} +1 -1
  82. package/src/assets/web-panel/assets/{Trust-BNzUq4ui.js → Trust-MxmA4VZG.js} +1 -1
  83. package/src/assets/web-panel/assets/{UkeySign-BYugxww4.js → UkeySign-C0VvJYRi.js} +1 -1
  84. package/src/assets/web-panel/assets/{VideoEditing-DdauhtSJ.js → VideoEditing-Bf5QZj8N.js} +1 -1
  85. package/src/assets/web-panel/assets/{Wallet-CSC9bCe_.js → Wallet-BIJ9riRz.js} +4 -4
  86. package/src/assets/web-panel/assets/{WebAuthn-Q8AN3bTO.js → WebAuthn-B8p9Mhkw.js} +5 -5
  87. package/src/assets/web-panel/assets/{WorkflowEditor-d-LBGCuA.js → WorkflowEditor-BVsuaPen.js} +1 -1
  88. package/src/assets/web-panel/assets/{chat-BLVDCgsZ.js → chat-BfkjoKpV.js} +1 -1
  89. package/src/assets/web-panel/assets/{colors-BZlPSEHl.js → colors-DeAlsWK8.js} +1 -1
  90. package/src/assets/web-panel/assets/{compact-item-IWseP6R6.js → compact-item-Cs97bz5j.js} +1 -1
  91. package/src/assets/web-panel/assets/{createContext-DnVRV3Ao.js → createContext-BqbG60LH.js} +1 -1
  92. package/src/assets/web-panel/assets/devWarning-DPty0mGN.js +1 -0
  93. package/src/assets/web-panel/assets/{hasIn-DLQZA_Wn.js → hasIn-i_RY0b1X.js} +1 -1
  94. package/src/assets/web-panel/assets/{index-B_N4-Gq9.js → index-0-agYlWL.js} +1 -1
  95. package/src/assets/web-panel/assets/{index-DB0MJgF_.js → index-79V_zv1s.js} +1 -1
  96. package/src/assets/web-panel/assets/{index-BOZJBTWZ.js → index-B-3tEpTc.js} +1 -1
  97. package/src/assets/web-panel/assets/{index-DUq0c7gq.js → index-B-JVTrpr.js} +1 -1
  98. package/src/assets/web-panel/assets/{index-Dy0R5ZED.js → index-B1CbOH1i.js} +1 -1
  99. package/src/assets/web-panel/assets/{index-Bxp7uDD8.js → index-B5UkyA3c.js} +1 -1
  100. package/src/assets/web-panel/assets/{index-DpVrK6fr.js → index-BESLFt4q.js} +1 -1
  101. package/src/assets/web-panel/assets/{index-CT9vVN-X.js → index-BOVkf1K3.js} +1 -1
  102. package/src/assets/web-panel/assets/{index-DOoQpuQY.js → index-BYUVuwKt.js} +1 -1
  103. package/src/assets/web-panel/assets/{index-CINJQEj6.js → index-BcUXAB2s.js} +1 -1
  104. package/src/assets/web-panel/assets/{index-CarMTIyQ.js → index-BkBi5TmC.js} +1 -1
  105. package/src/assets/web-panel/assets/{index-C5dgnilC.js → index-Bm7lLR4c.js} +1 -1
  106. package/src/assets/web-panel/assets/index-Bt7g0Vq_.js +1 -0
  107. package/src/assets/web-panel/assets/{index-CbQNL0ue.js → index-BuLQobzW.js} +1 -1
  108. package/src/assets/web-panel/assets/{index-CpBaJ3TD.js → index-CE2VtTlp.js} +1 -1
  109. package/src/assets/web-panel/assets/{index-jWb70ZgZ.js → index-CED2PL_y.js} +1 -1
  110. package/src/assets/web-panel/assets/{index-BEu2lMBy.js → index-CG3PRyAL.js} +1 -1
  111. package/src/assets/web-panel/assets/{index-DocEhinm.js → index-CIG8fQ2_.js} +1 -1
  112. package/src/assets/web-panel/assets/{index-D_LQodeJ.js → index-COdGA-8n.js} +1 -1
  113. package/src/assets/web-panel/assets/{index-DZpYeqrh.js → index-CeWoNHau.js} +1 -1
  114. package/src/assets/web-panel/assets/{index-gt8bNefW.js → index-Cf3WCLmy.js} +1 -1
  115. package/src/assets/web-panel/assets/{index-N_GoWZX4.js → index-CgsqdnRV.js} +1 -1
  116. package/src/assets/web-panel/assets/index-ChVT0p9u.js +1 -0
  117. package/src/assets/web-panel/assets/{index-CZDekj7T.js → index-D2B4VPyP.js} +1 -1
  118. package/src/assets/web-panel/assets/{index-YSw_gxlZ.js → index-D9evtvg2.js} +1 -1
  119. package/src/assets/web-panel/assets/{index-DTbxp4UH.js → index-DUUrv141.js} +1 -1
  120. package/src/assets/web-panel/assets/{index-Bbj2NS_I.js → index-DiSkx4UB.js} +3 -3
  121. package/src/assets/web-panel/assets/{index-BZs5bBnk.js → index-Dk2qouAz.js} +1 -1
  122. package/src/assets/web-panel/assets/{index-DalA0TKC.js → index-Dk7-v4hP.js} +1 -1
  123. package/src/assets/web-panel/assets/{index-1jeAudHd.js → index-DmbfhJUP.js} +1 -1
  124. package/src/assets/web-panel/assets/{index-C1ifZtXo.js → index-DqBgBpgp.js} +1 -1
  125. package/src/assets/web-panel/assets/{index-BhA3QEni.js → index-PT_5sBMA.js} +1 -1
  126. package/src/assets/web-panel/assets/{index-BSjilZ9L.js → index-U4f5izHz.js} +1 -1
  127. package/src/assets/web-panel/assets/{index-C2FqfMvU.js → index-YXgLng0K.js} +1 -1
  128. package/src/assets/web-panel/assets/{index-COev0W2g.js → index-ZxO_-E8a.js} +1 -1
  129. package/src/assets/web-panel/assets/{index-M7wxfAbm.js → index-dIHr4B_u.js} +1 -1
  130. package/src/assets/web-panel/assets/{index-DjWnONdy.js → index-gyFpvL7l.js} +1 -1
  131. package/src/assets/web-panel/assets/{index-Dkwsi1Kn.js → index-s1opAL-M.js} +1 -1
  132. package/src/assets/web-panel/assets/{index-hwVDeoC0.js → index-yObj1O4n.js} +1 -1
  133. package/src/assets/web-panel/assets/{initDefaultProps-Co67Kqmb.js → initDefaultProps-C9h9yKL3.js} +1 -1
  134. package/src/assets/web-panel/assets/{motion-BMOxQSF_.js → motion-BvmLsPdu.js} +1 -1
  135. package/src/assets/web-panel/assets/{move-C5fWDw-N.js → move-CoL4ekU1.js} +1 -1
  136. package/src/assets/web-panel/assets/{mtc-parser-BJevpL0e.js → mtc-parser-C4pMy6xV.js} +1 -1
  137. package/src/assets/web-panel/assets/{omit-BCzNU8s6.js → omit-DPuJfNxx.js} +1 -1
  138. package/src/assets/web-panel/assets/{pickAttrs-CybWvDPk.js → pickAttrs-D0KEeaK2.js} +1 -1
  139. package/src/assets/web-panel/assets/{placementArrow-BMWS9HnW.js → placementArrow-BpkyINxt.js} +1 -1
  140. package/src/assets/web-panel/assets/{responsiveObserve-BoL2UnGY.js → responsiveObserve-DQI_wOq8.js} +1 -1
  141. package/src/assets/web-panel/assets/{slide-DWNfXraU.js → slide-DYfdDiRg.js} +1 -1
  142. package/src/assets/web-panel/assets/{statusUtils-BquTM3tX.js → statusUtils-B5dtvb0D.js} +1 -1
  143. package/src/assets/web-panel/assets/{styleChecker-BEkCleim.js → styleChecker-CF310mSj.js} +1 -1
  144. package/src/assets/web-panel/assets/{useFlexGapSupport-Oecnq7EA.js → useFlexGapSupport-D1Qp4BLp.js} +1 -1
  145. package/src/assets/web-panel/assets/{useFs-CkDffJ4F.js → useFs-C7_iyvJK.js} +1 -1
  146. package/src/assets/web-panel/assets/{usePersonalDataHub-qbvKXesB.js → usePersonalDataHub-DZUt_fIm.js} +1 -1
  147. package/src/assets/web-panel/assets/{vnode-C6v8n6mA.js → vnode-D3dsi0lZ.js} +1 -1
  148. package/src/assets/web-panel/assets/{zoom-4CTeFILV.js → zoom-WdbOlvdQ.js} +1 -1
  149. package/src/assets/web-panel/index.html +1 -1
  150. package/src/command-help-index.json +2 -2
  151. package/src/commands/agent.js +90 -1
  152. package/src/commands/chat.js +11 -2
  153. package/src/commands/compact.js +106 -23
  154. package/src/commands/loop.js +28 -10
  155. package/src/commands/routine.js +1 -1
  156. package/src/commands/scheduler-daemon.js +359 -2
  157. package/src/commands/session-observability.js +305 -0
  158. package/src/commands/session.js +38 -0
  159. package/src/data/changelog.json +14 -1
  160. package/src/gateways/ws/ws-agent-handler.js +439 -5
  161. package/src/harness/jsonl-session-store.js +463 -125
  162. package/src/harness/prompt-compressor.js +86 -13
  163. package/src/harness/provider-backed-compaction.js +100 -27
  164. package/src/lib/advisor-runtime.js +3 -1
  165. package/src/lib/agent-schedule-store.js +414 -4
  166. package/src/lib/agent-team/team-adjudication.js +40 -1
  167. package/src/lib/automation-engine.js +494 -0
  168. package/src/lib/background-agent-supervisor.js +793 -215
  169. package/src/lib/background-command-argv.js +24 -0
  170. package/src/lib/background-interaction-journal.js +361 -52
  171. package/src/lib/background-interaction-resolver.js +32 -0
  172. package/src/lib/background-launch-profile.js +2 -0
  173. package/src/lib/causal-observability.js +2661 -0
  174. package/src/lib/chat-session-admission.js +76 -0
  175. package/src/lib/cost-budget.js +5 -7
  176. package/src/lib/cowork/ab-comparator-cli.js +11 -0
  177. package/src/lib/cowork/debate-review-cli.js +12 -0
  178. package/src/lib/cowork-adapter.js +159 -38
  179. package/src/lib/cowork-cron.js +357 -1
  180. package/src/lib/cowork-task-runner.js +112 -17
  181. package/src/lib/delivery-coordinator.js +142 -0
  182. package/src/lib/direct-model-usage.js +121 -0
  183. package/src/lib/file-lines.js +183 -47
  184. package/src/lib/llm-pricing.js +12 -17
  185. package/src/lib/loop.js +26 -2
  186. package/src/lib/routine-store.js +380 -21
  187. package/src/lib/runtime-usage-ledger.js +182 -0
  188. package/src/lib/scheduler-kernel/agenda-adapter.js +308 -3
  189. package/src/lib/scheduler-kernel/automation-adapter.js +410 -8
  190. package/src/lib/scheduler-kernel/automation-event-adapter.js +36 -0
  191. package/src/lib/scheduler-kernel/cowork-cron-adapter.js +285 -3
  192. package/src/lib/scheduler-kernel/loop-adapter.js +541 -6
  193. package/src/lib/scheduler-kernel/migration-admin.js +346 -0
  194. package/src/lib/scheduler-kernel/migration-rollback-admin.js +932 -0
  195. package/src/lib/scheduler-kernel/routine-adapter.js +394 -20
  196. package/src/lib/scheduler-kernel/runtime.js +63 -1
  197. package/src/lib/scheduler-kernel/source-locator-path.js +142 -0
  198. package/src/lib/scheduler-kernel/store.js +2586 -85
  199. package/src/lib/session-host-lease.js +171 -2
  200. package/src/lib/session-host-snapshot.js +14 -3
  201. package/src/lib/session-persistence-failure.js +1 -0
  202. package/src/lib/session-tail.js +179 -34
  203. package/src/lib/session-transcript-structure.js +96 -0
  204. package/src/lib/sub-agent-context.js +328 -22
  205. package/src/lib/ws-agent-handler.js +4 -1
  206. package/src/repl/agent-repl.js +732 -91
  207. package/src/repl/chat-repl.js +5 -1
  208. package/src/runtime/agent-core.js +1272 -207
  209. package/src/runtime/fallback-model.js +5 -0
  210. package/src/runtime/headless-runner.js +324 -82
  211. package/src/runtime/headless-stream.js +232 -26
  212. package/src/runtime/policies/agent-policy.js +4 -0
  213. package/src/workers/background-agent-worker.js +403 -96
  214. package/src/assets/web-panel/assets/Projects-Dd-xuPIc.js +0 -1
  215. package/src/assets/web-panel/assets/SpeechSettings-C7tT9iad.js +0 -1
  216. package/src/assets/web-panel/assets/devWarning-Jzu62CLC.js +0 -1
  217. package/src/assets/web-panel/assets/index-Zb2MeDSy.js +0 -1
  218. package/src/assets/web-panel/assets/index-fikqml4k.js +0 -1
@@ -17,6 +17,10 @@ import { fileURLToPath } from "node:url";
17
17
  import { getHomeDir } from "./paths.js";
18
18
  import { withFileLock } from "./with-file-lock.js";
19
19
  import { deriveSessionState } from "./session-lifecycle.js";
20
+ import {
21
+ getSessionPresence,
22
+ SESSION_PRESENCE,
23
+ } from "../harness/jsonl-session-store.js";
20
24
  import {
21
25
  finishAgentWorktree,
22
26
  validateAgentWorktree,
@@ -25,10 +29,12 @@ import { rejectPendingBackgroundInteractions } from "./background-interaction-jo
25
29
  import executionBroker from "./process-execution-broker/index.js";
26
30
  import {
27
31
  agentPrintArgument,
32
+ assertBackgroundArgvDurable,
28
33
  stripFirstTurnPromptArgv,
29
34
  } from "./background-command-argv.js";
30
35
  import { terminateOwnedProcessTree } from "./process-tree-termination.js";
31
36
  import { sendAgentNotification } from "./agent-notify.js";
37
+ import { withSessionHostRecoveryLease } from "./session-host-lease.js";
32
38
  import {
33
39
  buildNeedsInputNotification,
34
40
  claimNeedsInputNotification,
@@ -172,6 +178,83 @@ function defaultReadProcessStartTimeMs(pid) {
172
178
  return null;
173
179
  }
174
180
 
181
+ /**
182
+ * Read the POSIX process state letter (`R`, `S`, `Z`, ...), or null when it
183
+ * cannot be determined. A successfully signalled child can remain as a zombie
184
+ * until its parent returns to the event loop; `kill(pid, 0)` still reports that
185
+ * PID as present even though it can no longer execute any code.
186
+ */
187
+ function defaultReadProcessState(pid) {
188
+ const target = Number(pid);
189
+ if (
190
+ process.platform === "win32" ||
191
+ !Number.isInteger(target) ||
192
+ target <= 0
193
+ ) {
194
+ return null;
195
+ }
196
+ try {
197
+ const result = runSupervisorCommand(
198
+ "ps",
199
+ ["-o", "stat=", "-p", String(target)],
200
+ { encoding: "utf8", timeout: 5_000 },
201
+ "background-agent:process-state",
202
+ );
203
+ if (result.error || result.status !== 0) return null;
204
+ const state = String(result.stdout || "")
205
+ .trim()
206
+ .charAt(0)
207
+ .toUpperCase();
208
+ return /^[A-Z]$/.test(state) ? state : null;
209
+ } catch {
210
+ return null;
211
+ }
212
+ }
213
+
214
+ /**
215
+ * Read every state in the POSIX process group whose id is `pgid`.
216
+ *
217
+ * Both procps (Linux) and BSD ps (macOS) support repeated `-o` fields and an
218
+ * empty header (`field=`). Enumerating the table is more portable than the
219
+ * incompatible Linux/BSD process-group selector flags. `null` means the
220
+ * snapshot could not be trusted; callers must keep treating the group as live.
221
+ */
222
+ function defaultReadProcessGroupStates(pgid) {
223
+ const target = Number(pgid);
224
+ if (
225
+ process.platform === "win32" ||
226
+ !Number.isInteger(target) ||
227
+ target <= 0
228
+ ) {
229
+ return null;
230
+ }
231
+ try {
232
+ const result = runSupervisorCommand(
233
+ "ps",
234
+ ["-A", "-o", "pgid=", "-o", "stat="],
235
+ {
236
+ encoding: "utf8",
237
+ timeout: 5_000,
238
+ maxBuffer: 16 * 1024 * 1024,
239
+ },
240
+ "background-agent:process-group-state",
241
+ );
242
+ if (result.error || result.status !== 0) return null;
243
+ const states = [];
244
+ for (const line of String(result.stdout || "").split(/\r?\n/)) {
245
+ if (!line.trim()) continue;
246
+ const match = /^\s*(\d+)\s+([A-Za-z])/.exec(line);
247
+ // Unexpected output (including an unsuppressed header) makes the whole
248
+ // snapshot untrusted instead of accidentally declaring a group dead.
249
+ if (!match) return null;
250
+ if (Number(match[1]) === target) states.push(match[2].toUpperCase());
251
+ }
252
+ return states;
253
+ } catch {
254
+ return null;
255
+ }
256
+ }
257
+
175
258
  /**
176
259
  * Kill a whole process tree (Gap 2 orphan reclaim). Windows: `taskkill /T`.
177
260
  * POSIX: negative-pid group signal (the worker spawns the agent child
@@ -219,7 +302,11 @@ export const _deps = {
219
302
  // "worker-death" CI flake: a stop-flow test recorded pid=process.pid and
220
303
  // SIGTERMed its own vitest worker).
221
304
  kill: (pid, signal) => process.kill(pid, signal),
305
+ getSessionPresence,
222
306
  readProcessStartTimeMs: defaultReadProcessStartTimeMs,
307
+ readProcessState: defaultReadProcessState,
308
+ readProcessGroupStates: defaultReadProcessGroupStates,
309
+ processExitWaitDeadlineMs: 2_000,
223
310
  killProcessTree: defaultKillProcessTree,
224
311
  terminateOwnedProcessTree,
225
312
  };
@@ -271,7 +358,7 @@ export function stopBackgroundAgentChildTree(
271
358
  }
272
359
 
273
360
  /** Cached probe (default impl only — injected probes run uncached for tests). */
274
- function processStartTimeMs(pid) {
361
+ function processStartTimeMs(pid, options = {}) {
275
362
  if (_deps.readProcessStartTimeMs !== defaultReadProcessStartTimeMs) {
276
363
  const raw = _deps.readProcessStartTimeMs(pid);
277
364
  const n = Number(raw);
@@ -279,6 +366,11 @@ function processStartTimeMs(pid) {
279
366
  }
280
367
  const key = Number(pid);
281
368
  const at = Date.now();
369
+ if (options.fresh === true) {
370
+ const value = defaultReadProcessStartTimeMs(key);
371
+ _startTimeCache.set(key, { at, value });
372
+ return value;
373
+ }
282
374
  const hit = _startTimeCache.get(key);
283
375
  if (hit && at - hit.at < START_TIME_CACHE_TTL_MS) return hit.value;
284
376
  const value = defaultReadProcessStartTimeMs(key);
@@ -365,7 +457,13 @@ function mergeBackgroundAgentState(current, requested) {
365
457
  // clobber window from "any caller's RMW span" to microseconds; rename/pin
366
458
  // additionally verify-and-retry on top of this.
367
459
  if (current) {
368
- if (TERMINAL_STATUSES.has(current.status)) {
460
+ const explicitHeartbeatStop =
461
+ current.status === "lost" &&
462
+ current.lostReason === "heartbeat-stale" &&
463
+ requested.status === "stopped" &&
464
+ current.stopRequestedAt &&
465
+ requested.stopRequestedAt === current.stopRequestedAt;
466
+ if (TERMINAL_STATUSES.has(current.status) && !explicitHeartbeatStop) {
369
467
  next = {
370
468
  ...next,
371
469
  status: current.status,
@@ -420,6 +518,9 @@ function mergeBackgroundAgentState(current, requested) {
420
518
  next = {
421
519
  ...next,
422
520
  ...(current.sessionId ? { sessionId: current.sessionId } : {}),
521
+ ...(current.sessionBootstrapExpected !== undefined
522
+ ? { sessionBootstrapExpected: current.sessionBootstrapExpected }
523
+ : {}),
423
524
  ...(current.cwd ? { cwd: current.cwd } : {}),
424
525
  ...(current.startedAt !== undefined
425
526
  ? { startedAt: current.startedAt }
@@ -806,6 +907,36 @@ export function isProcessAlive(pid) {
806
907
  }
807
908
  }
808
909
 
910
+ function isProcessExecutionAlive(pid) {
911
+ if (!isProcessAlive(pid)) return false;
912
+ const state = _deps.readProcessState(pid);
913
+ // Z = zombie, X = dead. Both retain a PID table entry temporarily but have
914
+ // no executable process body. Unknown probe results fail closed as alive.
915
+ return state !== "Z" && state !== "X";
916
+ }
917
+
918
+ function inspectProcessGroupExecution(pgid) {
919
+ const states = _deps.readProcessGroupStates(pgid);
920
+ if (!Array.isArray(states)) return { status: "unknown" };
921
+ return {
922
+ status: states.some((state) => state !== "Z" && state !== "X")
923
+ ? "alive"
924
+ : "dead",
925
+ states,
926
+ };
927
+ }
928
+
929
+ function isSignalledTargetExecutionAlive(target) {
930
+ if (isProcessExecutionAlive(target.pid)) return true;
931
+ if (process.platform === "win32" || target.signalledAsGroup !== true) {
932
+ return false;
933
+ }
934
+ // The detached group leader can be reaped (or remain a zombie) while a tool
935
+ // grandchild in the same group still executes. Unknown snapshots fail
936
+ // closed as alive so interaction recovery never races an orphaned tool.
937
+ return inspectProcessGroupExecution(target.pid).status !== "dead";
938
+ }
939
+
809
940
  /**
810
941
  * Is the live process at `pid` the SAME process the state file recorded at
811
942
  * `expectedStartedAtMs` — or a pid-reusing stranger? (Gap 1.)
@@ -819,7 +950,7 @@ export function isProcessAlive(pid) {
819
950
  * born after the original died)
820
951
  */
821
952
  export function isSameProcess(pid, expectedStartedAtMs, options = {}) {
822
- if (!isProcessAlive(pid)) return false;
953
+ if (!isProcessExecutionAlive(pid)) return false;
823
954
  const expected = Number(expectedStartedAtMs);
824
955
  if (!Number.isFinite(expected) || expected <= 0) return true;
825
956
  const actual = processStartTimeMs(pid);
@@ -830,6 +961,117 @@ export function isSameProcess(pid, expectedStartedAtMs, options = {}) {
830
961
  return actual <= expected + tolerance;
831
962
  }
832
963
 
964
+ /**
965
+ * Destructive process identity is deliberately stricter than read-only
966
+ * reconciliation. A signal is authorized only when the persisted positive
967
+ * creation-time anchor can be compared with a successful, fresh OS probe and
968
+ * both timestamps describe the same launch window. Missing evidence never
969
+ * falls back to pid-only liveness.
970
+ */
971
+ function inspectDestructiveProcessIdentity(
972
+ pid,
973
+ expectedStartedAtMs,
974
+ options = {},
975
+ ) {
976
+ const target = Number(pid);
977
+ if (!Number.isInteger(target) || target <= 0) {
978
+ return { status: "absent", pid: target };
979
+ }
980
+ // A corrupt record must never authorize the daemon-stop process to signal
981
+ // itself. This guard covers every recorded role (including agentPid), not
982
+ // only the primary worker pid checked by the public stop flow.
983
+ if (target === process.pid) {
984
+ return { status: "self", reason: "current-process", pid: target };
985
+ }
986
+ const leaderAlive = isProcessAlive(target);
987
+ const leaderState = leaderAlive ? _deps.readProcessState(target) : null;
988
+ const leaderCanExecute =
989
+ leaderAlive && leaderState !== "Z" && leaderState !== "X";
990
+ if (!leaderCanExecute) {
991
+ if (process.platform === "win32") {
992
+ return { status: "dead", reason: "not-executing", pid: target };
993
+ }
994
+ const group = inspectProcessGroupExecution(target);
995
+ if (group.status === "dead") {
996
+ return {
997
+ status: "dead",
998
+ reason: "process-group-not-executing",
999
+ pid: target,
1000
+ };
1001
+ }
1002
+ if (group.status === "unknown") {
1003
+ return {
1004
+ status: "unverifiable",
1005
+ reason: "process-group-state-probe-failed",
1006
+ pid: target,
1007
+ };
1008
+ }
1009
+ if (!leaderAlive) {
1010
+ // A live group whose leader has already disappeared has no remaining
1011
+ // creation-time identity that can authorize signalling. Keep the stop
1012
+ // fenced rather than risking an unrelated, reused process group.
1013
+ return {
1014
+ status: "unverifiable",
1015
+ reason: "process-group-owner-not-verifiable",
1016
+ pid: target,
1017
+ };
1018
+ }
1019
+ }
1020
+ const expected = Number(expectedStartedAtMs);
1021
+ if (!Number.isFinite(expected) || expected <= 0) {
1022
+ return {
1023
+ status: "unverifiable",
1024
+ reason: "started-at-missing",
1025
+ pid: target,
1026
+ };
1027
+ }
1028
+ const actual = processStartTimeMs(target, { fresh: true });
1029
+ if (actual === null) {
1030
+ return {
1031
+ status: "unverifiable",
1032
+ reason: "creation-time-probe-failed",
1033
+ pid: target,
1034
+ expectedStartedAt: expected,
1035
+ };
1036
+ }
1037
+ const tolerance = Number.isFinite(Number(options.toleranceMs))
1038
+ ? Number(options.toleranceMs)
1039
+ : PID_IDENTITY_TOLERANCE_MS;
1040
+ if (Math.abs(actual - expected) > tolerance) {
1041
+ if (process.platform !== "win32") {
1042
+ const group = inspectProcessGroupExecution(target);
1043
+ // The leader PID can be reused while descendants from its detached
1044
+ // process group still execute under the old PGID. We can prove the new
1045
+ // PID is not ours, but cannot prove that the group is empty unless this
1046
+ // snapshot succeeds. Never recover/delete through that ambiguity.
1047
+ if (group.status !== "dead") {
1048
+ return {
1049
+ status: "unverifiable",
1050
+ reason:
1051
+ group.status === "alive"
1052
+ ? "process-group-survived-pid-reuse"
1053
+ : "process-group-state-probe-failed",
1054
+ pid: target,
1055
+ expectedStartedAt: expected,
1056
+ actualStartedAt: actual,
1057
+ };
1058
+ }
1059
+ }
1060
+ return {
1061
+ status: "reused",
1062
+ pid: target,
1063
+ expectedStartedAt: expected,
1064
+ actualStartedAt: actual,
1065
+ };
1066
+ }
1067
+ return {
1068
+ status: "match",
1069
+ pid: target,
1070
+ expectedStartedAt: expected,
1071
+ actualStartedAt: actual,
1072
+ };
1073
+ }
1074
+
833
1075
  /**
834
1076
  * Reap a lost worker's recorded agent child (Gap 2). The worker persists
835
1077
  * `agentPid` + `agentStartedAt` per turn; when the WORKER dies (crash /
@@ -839,9 +1081,8 @@ export function isSameProcess(pid, expectedStartedAtMs, options = {}) {
839
1081
  * - `agentStartedAt` anchor is REQUIRED — unlike the read-only liveness
840
1082
  * probe, this path kills, so a legacy state without the anchor fails
841
1083
  * CLOSED (no kill) rather than risking a pid-reused stranger;
842
- * - with the anchor present, a failed creation-time probe fails open to the
843
- * kill (the orphan leak is the common case, reuse the rare one — and the
844
- * anchor already bounds the window to this session's lifetime).
1084
+ * - with the anchor present, the fresh creation-time probe must also succeed
1085
+ * and match; missing or unverifiable identity evidence fails closed.
845
1086
  *
846
1087
  * @returns {boolean} true when a kill was actually issued
847
1088
  */
@@ -851,7 +1092,9 @@ export function reclaimOrphanAgentProcess(state) {
851
1092
  if (agentPid === Number(state?.pid)) return false; // never the worker itself
852
1093
  const anchor = Number(state?.agentStartedAt);
853
1094
  if (!Number.isFinite(anchor) || anchor <= 0) return false; // fail closed
854
- if (!isSameProcess(agentPid, anchor)) return false; // reused / already gone
1095
+ if (inspectDestructiveProcessIdentity(agentPid, anchor).status !== "match") {
1096
+ return false;
1097
+ }
855
1098
  return _deps.killProcessTree(agentPid, "SIGKILL") === true;
856
1099
  }
857
1100
 
@@ -925,9 +1168,141 @@ function deriveEffectiveBackgroundAgentState(state, options = {}) {
925
1168
  return next;
926
1169
  }
927
1170
 
1171
+ function backgroundInteractionFallback(state) {
1172
+ const pending = state?.pendingQuestion;
1173
+ if (!pending) return null;
1174
+ const requestId = pending?.requestId || pending?.intId;
1175
+ return {
1176
+ requestId: requestId || null,
1177
+ binding: pending?.binding,
1178
+ payload: {
1179
+ kind: "question",
1180
+ question: pending?.question || pending?.prompt || "",
1181
+ options: pending?.options || null,
1182
+ multiSelect: pending?.multiSelect === true,
1183
+ timeoutMs: pending?.timeoutMs,
1184
+ },
1185
+ createdAt: pending?.askedAt,
1186
+ };
1187
+ }
1188
+
1189
+ function interactionRecoverySettledForState(state) {
1190
+ const recovery = state?.interactionRecovery;
1191
+ return Boolean(
1192
+ recovery &&
1193
+ ["clean", "rejected"].includes(recovery.status) &&
1194
+ Number(recovery.turn) === Number(state.turnCount || 0) &&
1195
+ String(recovery.workerGeneration || "") ===
1196
+ String(state.workerGeneration || ""),
1197
+ );
1198
+ }
1199
+
1200
+ function persistTerminalInteractionRecovery(state, patch) {
1201
+ const expectedRecovery = state.interactionRecovery || null;
1202
+ const expectedPendingRequestId =
1203
+ state.pendingQuestion?.requestId || state.pendingQuestion?.intId || null;
1204
+ const mutation = mutateBackgroundAgentState(state.id, (current) => {
1205
+ if (
1206
+ !current ||
1207
+ current.status !== state.status ||
1208
+ current.lostReason !== state.lostReason ||
1209
+ current.endedAt !== state.endedAt ||
1210
+ current.workerGeneration !== state.workerGeneration ||
1211
+ current.turnCount !== state.turnCount ||
1212
+ current.agentPid !== state.agentPid ||
1213
+ current.agentStartedAt !== state.agentStartedAt ||
1214
+ (current.pendingQuestion?.requestId ||
1215
+ current.pendingQuestion?.intId ||
1216
+ null) !== expectedPendingRequestId ||
1217
+ (current.interactionRecovery?.status || null) !==
1218
+ (expectedRecovery?.status || null) ||
1219
+ (current.interactionRecovery?.turn ?? null) !==
1220
+ (expectedRecovery?.turn ?? null) ||
1221
+ (current.interactionRecovery?.workerGeneration || null) !==
1222
+ (expectedRecovery?.workerGeneration || null) ||
1223
+ (current.interactionRecovery?.recoveredAt ?? null) !==
1224
+ (expectedRecovery?.recoveredAt ?? null)
1225
+ ) {
1226
+ return null;
1227
+ }
1228
+ return { ...current, ...patch };
1229
+ });
1230
+ // Never report an uncommitted recovery patch after the CAS loses a race.
1231
+ // Callers (notably remove/worktree cleanup) must observe the fresh durable
1232
+ // state and retry, rather than acting on a fabricated terminal projection.
1233
+ return mutation.state || state;
1234
+ }
1235
+
1236
+ function recoverTerminalBackgroundInteractions(
1237
+ state,
1238
+ options = {},
1239
+ {
1240
+ code = "INTERACTION_WORKER_LOST",
1241
+ message = "The background worker was lost before the interaction settled",
1242
+ } = {},
1243
+ ) {
1244
+ if (!state?.sessionId) return state;
1245
+ const t = nowMs(options);
1246
+ if (recordedBackgroundProcessesAlive(state)) {
1247
+ return persistTerminalInteractionRecovery(state, {
1248
+ interactionRecovery: {
1249
+ status: "failed",
1250
+ code: "INTERACTION_RECOVERY_CHILD_STILL_RUNNING",
1251
+ message:
1252
+ "The agent child is still alive; interaction recovery will retry after it exits",
1253
+ recoveredAt: t,
1254
+ turn: Number(state.turnCount || 0),
1255
+ workerGeneration: state.workerGeneration || null,
1256
+ },
1257
+ });
1258
+ }
1259
+
1260
+ const fallbackRequest = backgroundInteractionFallback(state);
1261
+ try {
1262
+ const recovery = withSessionHostRecoveryLease(state.sessionId, () =>
1263
+ rejectPendingBackgroundInteractions(state.sessionId, state.id, {
1264
+ fallbackRequest,
1265
+ code,
1266
+ message,
1267
+ }),
1268
+ );
1269
+ return persistTerminalInteractionRecovery(state, {
1270
+ phase: null,
1271
+ pendingQuestion: null,
1272
+ interactionRecovery: {
1273
+ status: recovery.changed ? "rejected" : "clean",
1274
+ requestIds: recovery.rejected.map((record) => record.requestId),
1275
+ recoveredAt: t,
1276
+ turn: Number(state.turnCount || 0),
1277
+ workerGeneration: state.workerGeneration || null,
1278
+ },
1279
+ });
1280
+ } catch (error) {
1281
+ return persistTerminalInteractionRecovery(state, {
1282
+ interactionRecovery: {
1283
+ status: "failed",
1284
+ code: error?.code || "INTERACTION_RECOVERY_FAILED",
1285
+ message: error?.message || String(error),
1286
+ recoveredAt: t,
1287
+ turn: Number(state.turnCount || 0),
1288
+ workerGeneration: state.workerGeneration || null,
1289
+ },
1290
+ });
1291
+ }
1292
+ }
1293
+
928
1294
  export function effectiveBackgroundAgentState(state, options = {}) {
929
1295
  if (!state) return null;
930
- if (state.status !== "running") return state;
1296
+ if (state.status !== "running") {
1297
+ if (
1298
+ state.sessionId &&
1299
+ !interactionRecoverySettledForState(state) &&
1300
+ ["lost", "failed", "stopped", "completed"].includes(state.status)
1301
+ ) {
1302
+ return recoverTerminalBackgroundInteractions(state, options);
1303
+ }
1304
+ return state;
1305
+ }
931
1306
  // OS identity probes can take seconds on Windows. Perform them outside the
932
1307
  // state lock, then use the exact observed identity/heartbeat as a CAS fence
933
1308
  // inside the short persistence transaction.
@@ -975,91 +1350,9 @@ export function effectiveBackgroundAgentState(state, options = {}) {
975
1350
  } catch {
976
1351
  /* best-effort */
977
1352
  }
978
- if (previous?.sessionId) {
979
- const t = nowMs(options);
980
- const stateBeforeRecovery = next;
981
- const pending = previous.pendingQuestion;
982
- const requestId = pending?.requestId || pending?.intId;
983
- try {
984
- const recovery = rejectPendingBackgroundInteractions(
985
- previous.sessionId,
986
- previous.id,
987
- {
988
- fallbackRequest: requestId
989
- ? {
990
- requestId,
991
- binding: pending?.binding,
992
- payload: {
993
- kind: "question",
994
- question: pending?.question || pending?.prompt || "",
995
- options: pending?.options || null,
996
- multiSelect: pending?.multiSelect === true,
997
- timeoutMs: pending?.timeoutMs,
998
- },
999
- createdAt: pending?.askedAt,
1000
- }
1001
- : null,
1002
- code: "INTERACTION_WORKER_LOST",
1003
- message:
1004
- "The background worker was lost before the interaction settled",
1005
- },
1006
- );
1007
- const recoveredRecord = requestId
1008
- ? recovery.journal.get(requestId)
1009
- : null;
1010
- if (
1011
- recovery.changed ||
1012
- (recoveredRecord && recoveredRecord.status !== "pending")
1013
- ) {
1014
- next = {
1015
- ...next,
1016
- phase: null,
1017
- pendingQuestion: null,
1018
- interactionRecovery: {
1019
- status: "rejected",
1020
- requestIds: recovery.rejected.map((record) => record.requestId),
1021
- recoveredAt: t,
1022
- },
1023
- };
1024
- }
1025
- } catch (error) {
1026
- next = {
1027
- ...next,
1028
- interactionRecovery: {
1029
- status: "failed",
1030
- code: error?.code || "INTERACTION_RECOVERY_FAILED",
1031
- message: error?.message || String(error),
1032
- recoveredAt: t,
1033
- },
1034
- };
1035
- }
1036
- if (next !== stateBeforeRecovery) {
1037
- try {
1038
- const recoveryMutation = mutateBackgroundAgentState(
1039
- previous.id,
1040
- (current) => {
1041
- if (
1042
- !current ||
1043
- current.status !== stateBeforeRecovery.status ||
1044
- current.lostReason !== stateBeforeRecovery.lostReason
1045
- ) {
1046
- return null;
1047
- }
1048
- return {
1049
- ...current,
1050
- phase: next.phase,
1051
- pendingQuestion: next.pendingQuestion,
1052
- interactionRecovery: next.interactionRecovery,
1053
- };
1054
- },
1055
- );
1056
- next = recoveryMutation.state || next;
1057
- } catch {
1058
- /* best-effort; the terminal transition itself is already durable */
1059
- }
1060
- }
1061
- }
1062
1353
  }
1354
+ if (previous?.sessionId)
1355
+ next = recoverTerminalBackgroundInteractions(next, options);
1063
1356
  return next;
1064
1357
  }
1065
1358
 
@@ -1113,28 +1406,40 @@ export function renameBackgroundAgent(id, title, options = {}) {
1113
1406
  * conversation session is NOT touched — that's `cc session delete`.
1114
1407
  */
1115
1408
  export function removeBackgroundAgent(id, options = {}) {
1116
- let state = effectiveBackgroundAgentState(readBackgroundAgentState(id), {
1117
- now: options.now,
1118
- heartbeatStaleMs: options.heartbeatStaleMs,
1119
- });
1409
+ let state = readBackgroundAgentState(id);
1120
1410
  if (!state) throw new Error(`Background agent not found: ${id}`);
1121
- if (state.status === "running") {
1411
+ const explicitlyStoppable =
1412
+ state.status === "running" ||
1413
+ (state.status === "lost" && state.lostReason === "heartbeat-stale");
1414
+ if (!explicitlyStoppable) {
1415
+ state = effectiveBackgroundAgentState(state, {
1416
+ now: options.now,
1417
+ heartbeatStaleMs: options.heartbeatStaleMs,
1418
+ });
1419
+ }
1420
+ if (explicitlyStoppable) {
1122
1421
  if (options.force !== true) {
1123
1422
  throw new Error(
1124
1423
  `${id} is still running — stop it first (cc daemon stop ${id}) or pass --force`,
1125
1424
  );
1126
1425
  }
1127
- try {
1128
- stopBackgroundAgent(id);
1129
- state =
1130
- effectiveBackgroundAgentState(readBackgroundAgentState(id), {
1131
- now: options.now,
1132
- heartbeatStaleMs: options.heartbeatStaleMs,
1133
- }) || state;
1134
- } catch {
1135
- /* best-effort — the record removal below is the point of rm --force */
1426
+ const stopped = stopBackgroundAgent(id);
1427
+ state = readBackgroundAgentState(id) || stopped;
1428
+ if (state.status === "running" || stopped.stopPending === true) {
1429
+ const error = new Error(
1430
+ `${id} cannot be removed until process termination and interaction recovery complete`,
1431
+ );
1432
+ error.code = "BACKGROUND_AGENT_REMOVE_RECOVERY_PENDING";
1433
+ throw error;
1136
1434
  }
1137
1435
  }
1436
+ if (state.sessionId && !interactionRecoverySettledForState(state)) {
1437
+ const error = new Error(
1438
+ `${id} cannot be removed while interaction recovery is incomplete`,
1439
+ );
1440
+ error.code = "BACKGROUND_AGENT_REMOVE_RECOVERY_PENDING";
1441
+ throw error;
1442
+ }
1138
1443
  const worktree = cleanupBackgroundAgentWorktree(state, {
1139
1444
  keepWorktree: options.keepWorktree === true,
1140
1445
  });
@@ -1145,6 +1450,7 @@ export function removeBackgroundAgent(id, options = {}) {
1145
1450
  current.launchFinalizationUncertain !== true &&
1146
1451
  !current.turnLaunchIntent &&
1147
1452
  current.turnLaunchFinalizationUncertain !== true &&
1453
+ (!current.sessionId || interactionRecoverySettledForState(current)) &&
1148
1454
  activeWorktreeProcesses(current).length === 0,
1149
1455
  );
1150
1456
  if (!deleted) throw new Error(`Background agent not found: ${id}`);
@@ -1622,7 +1928,24 @@ export function launchBackgroundAgent({
1622
1928
  launchProfile,
1623
1929
  apiKey,
1624
1930
  }) {
1931
+ assertBackgroundArgvDurable(argv);
1932
+ assertBackgroundArgvDurable(followUpArgv, "background follow-up");
1625
1933
  assertUsableCwd(cwd);
1934
+ const sessionPresence = _deps.getSessionPresence(sessionId);
1935
+ if (
1936
+ ![SESSION_PRESENCE.ABSENT, SESSION_PRESENCE.PRESENT].includes(
1937
+ sessionPresence,
1938
+ )
1939
+ ) {
1940
+ const error = new Error(
1941
+ `Background session ${sessionId} cannot launch from canonical presence ${sessionPresence}`,
1942
+ );
1943
+ error.code = "BACKGROUND_SESSION_BOOTSTRAP_EVIDENCE_INVALID";
1944
+ error.sessionId = sessionId;
1945
+ error.presence = sessionPresence;
1946
+ throw error;
1947
+ }
1948
+ const sessionBootstrapExpected = sessionPresence === SESSION_PRESENCE.ABSENT;
1626
1949
  const worktreeState = normalizeBackgroundWorktree(worktree, cwd);
1627
1950
  const id = createBackgroundAgentId();
1628
1951
  const governanceState = normalizeBackgroundGovernance(governance, {
@@ -1663,6 +1986,7 @@ export function launchBackgroundAgent({
1663
1986
  argv: initialSecrets.argv,
1664
1987
  cwd,
1665
1988
  sessionId,
1989
+ sessionBootstrapExpected,
1666
1990
  title: title || "Background agent",
1667
1991
  cliEntry: cliEntry || process.argv[1],
1668
1992
  logFile: logPath(id),
@@ -1682,6 +2006,7 @@ export function launchBackgroundAgent({
1682
2006
  id,
1683
2007
  workerGeneration,
1684
2008
  sessionId,
2009
+ sessionBootstrapExpected,
1685
2010
  title: job.title,
1686
2011
  cwd,
1687
2012
  pid: null,
@@ -1798,6 +2123,14 @@ export function launchBackgroundAgent({
1798
2123
  status: "failed",
1799
2124
  endedAt: Date.now(),
1800
2125
  lostReason: `spawn-error: ${error.code || error.message}`,
2126
+ interactionRecovery: current.sessionId
2127
+ ? {
2128
+ status: "failed",
2129
+ code: "INTERACTION_WORKER_SPAWN_ERROR",
2130
+ message: error?.message || String(error),
2131
+ recoveredAt: Date.now(),
2132
+ }
2133
+ : current.interactionRecovery,
1801
2134
  launchFinalizationUncertain: false,
1802
2135
  });
1803
2136
  }
@@ -1823,6 +2156,15 @@ export function launchBackgroundAgent({
1823
2156
  lostReason: `worker-exited-before-finalize: exit=${
1824
2157
  Number.isInteger(code) ? code : "null"
1825
2158
  } signal=${signal || "none"}`,
2159
+ interactionRecovery: current.sessionId
2160
+ ? {
2161
+ status: "failed",
2162
+ code: "INTERACTION_WORKER_EXITED_BEFORE_FINALIZE",
2163
+ message:
2164
+ "The background worker exited before interaction recovery completed",
2165
+ recoveredAt: Date.now(),
2166
+ }
2167
+ : current.interactionRecovery,
1826
2168
  launchFinalizationUncertain: false,
1827
2169
  });
1828
2170
  }
@@ -1831,19 +2173,37 @@ export function launchBackgroundAgent({
1831
2173
  }
1832
2174
  });
1833
2175
  }
1834
- const current = readBackgroundAgentState(id) || state;
1835
- withPid = {
1836
- ...current,
1837
- // A platform wrapper (notably the Windows Job Object adapter) can return
1838
- // from spawn after the real worker has already claimed the state file.
1839
- // Preserve that authoritative process.pid instead of racing it back to
1840
- // the wrapper pid. With an ordinary direct spawn both fields are still
1841
- // null here and are initialized from child.pid as before.
1842
- pid: current.pid ?? child.pid,
1843
- workerPid: current.workerPid ?? child.pid,
1844
- launchFinalizationUncertain: false,
1845
- };
1846
- writeBackgroundAgentState(withPid);
2176
+ // Read child.pid before entering the state transaction. A platform wrapper
2177
+ // (notably the Windows Job Object adapter) can return from spawn just as the
2178
+ // real worker claims the state record. Finalize the wrapper pid against the
2179
+ // freshest locked record so a launcher snapshot can never erase a turn the
2180
+ // worker already published (agentPid, pendingQuestion, recovery marker,
2181
+ // turnCount, ...).
2182
+ const spawnedWorkerPid = child.pid;
2183
+ const pidFinalization = mutateBackgroundAgentState(id, (current) => {
2184
+ if (
2185
+ !current ||
2186
+ (current.workerGeneration &&
2187
+ current.workerGeneration !== workerGeneration)
2188
+ ) {
2189
+ return null;
2190
+ }
2191
+ return {
2192
+ ...current,
2193
+ // Once the real worker has claimed the record its process.pid is the
2194
+ // authoritative identity; otherwise initialize both fields from the
2195
+ // platform child returned by spawn.
2196
+ pid: current.pid ?? spawnedWorkerPid,
2197
+ workerPid: current.workerPid ?? spawnedWorkerPid,
2198
+ launchFinalizationUncertain: false,
2199
+ };
2200
+ });
2201
+ if (!pidFinalization.applied || !pidFinalization.state) {
2202
+ throw new Error(
2203
+ `Background worker ${id} lost its launch-generation state before pid finalization`,
2204
+ );
2205
+ }
2206
+ withPid = pidFinalization.state;
1847
2207
  child.unref();
1848
2208
  } catch (error) {
1849
2209
  postSpawnError ||= error;
@@ -1866,10 +2226,171 @@ export function readBackgroundAgentLog(id, options = {}) {
1866
2226
  return lines.slice(-limit).join("\n");
1867
2227
  }
1868
2228
 
2229
+ function recordedBackgroundProcessIdentities(state) {
2230
+ const records = [
2231
+ { role: "agent", pid: state?.agentPid, startedAt: state?.agentStartedAt },
2232
+ { role: "worker", pid: state?.pid, startedAt: state?.startedAt },
2233
+ {
2234
+ role: "worker-wrapper",
2235
+ pid: state?.workerPid,
2236
+ startedAt: state?.startedAt,
2237
+ },
2238
+ ];
2239
+ const seen = new Set();
2240
+ return records.filter((record) => {
2241
+ const pid = Number(record.pid);
2242
+ if (!Number.isInteger(pid) || pid <= 0 || seen.has(pid)) return false;
2243
+ seen.add(pid);
2244
+ record.pid = pid;
2245
+ return true;
2246
+ });
2247
+ }
2248
+
2249
+ function inspectRecordedBackgroundProcesses(state) {
2250
+ return recordedBackgroundProcessIdentities(state).map((record) => ({
2251
+ ...record,
2252
+ identity: inspectDestructiveProcessIdentity(record.pid, record.startedAt),
2253
+ }));
2254
+ }
2255
+
2256
+ function recordedBackgroundProcessesAlive(state) {
2257
+ return inspectRecordedBackgroundProcesses(state).some(({ identity }) =>
2258
+ ["match", "unverifiable", "self"].includes(identity.status),
2259
+ );
2260
+ }
2261
+
2262
+ function isRecordedProcessTreeExecutionAlive(pid, startedAt) {
2263
+ const identity = inspectDestructiveProcessIdentity(pid, startedAt);
2264
+ // `unverifiable` includes a vanished/zombie POSIX leader whose process group
2265
+ // is still executable or whose group snapshot failed. Both must block
2266
+ // worktree deletion just as a verified live leader does.
2267
+ return ["match", "unverifiable", "self"].includes(identity.status);
2268
+ }
2269
+
2270
+ function signalBackgroundProcessTree(pid, signal = "SIGTERM") {
2271
+ const target = Number(pid);
2272
+ if (process.platform === "win32") {
2273
+ const killed = runSupervisorCommand(
2274
+ "taskkill",
2275
+ ["/PID", String(target), "/T", "/F"],
2276
+ { windowsHide: true, encoding: "utf8" },
2277
+ "background-agent:stop-tree",
2278
+ );
2279
+ if (killed.error || (killed.status !== 0 && isProcessAlive(target))) {
2280
+ throw new Error(
2281
+ killed.error?.message ||
2282
+ killed.stderr ||
2283
+ `taskkill exited ${killed.status}`,
2284
+ );
2285
+ }
2286
+ return "process-tree";
2287
+ }
2288
+ try {
2289
+ _deps.kill(-target, signal);
2290
+ return "process-group";
2291
+ } catch {
2292
+ _deps.kill(target, signal);
2293
+ return "process";
2294
+ }
2295
+ }
2296
+
2297
+ function persistStopPending(state, reason, details = {}) {
2298
+ const at = Date.now();
2299
+ const mutation = mutateBackgroundAgentState(state.id, (current) =>
2300
+ current
2301
+ ? {
2302
+ ...current,
2303
+ phase:
2304
+ reason === "identity-unverifiable"
2305
+ ? "stop_identity_unverifiable"
2306
+ : "stop_waiting_for_exit",
2307
+ stopPending: true,
2308
+ stopPendingReason: reason,
2309
+ ...(details.identity
2310
+ ? { processIdentityError: details.identity }
2311
+ : {}),
2312
+ ...(current.sessionId
2313
+ ? {
2314
+ interactionRecovery: {
2315
+ status: "failed",
2316
+ code:
2317
+ reason === "identity-unverifiable"
2318
+ ? "BACKGROUND_PROCESS_IDENTITY_UNVERIFIABLE"
2319
+ : "INTERACTION_RECOVERY_CHILD_STILL_RUNNING",
2320
+ message:
2321
+ details.message ||
2322
+ "Process termination is still pending; interaction recovery has not started",
2323
+ recoveredAt: at,
2324
+ turn: Number(current.turnCount || 0),
2325
+ workerGeneration: current.workerGeneration || null,
2326
+ },
2327
+ }
2328
+ : {}),
2329
+ }
2330
+ : null,
2331
+ );
2332
+ return {
2333
+ ...(mutation.state || state),
2334
+ stopped: false,
2335
+ stopPending: true,
2336
+ stopPendingReason: reason,
2337
+ };
2338
+ }
2339
+
2340
+ function waitForSignalledProcessesToExit(
2341
+ targets,
2342
+ deadlineMs = _deps.processExitWaitDeadlineMs,
2343
+ ) {
2344
+ const configuredDeadline = Number(deadlineMs);
2345
+ const waitMs = Number.isFinite(configuredDeadline)
2346
+ ? Math.max(0, configuredDeadline)
2347
+ : 2_000;
2348
+ const deadline = Date.now() + waitMs;
2349
+ while (targets.some(isSignalledTargetExecutionAlive)) {
2350
+ if (Date.now() >= deadline) break;
2351
+ sleepSyncMs(10);
2352
+ }
2353
+ return targets.filter((target) => {
2354
+ if (!isSignalledTargetExecutionAlive(target)) return false;
2355
+ const identity = inspectDestructiveProcessIdentity(
2356
+ target.pid,
2357
+ target.startedAt,
2358
+ );
2359
+ return ["match", "unverifiable", "self"].includes(identity.status);
2360
+ });
2361
+ }
2362
+
2363
+ function recoverStoppedInteractions(state) {
2364
+ if (!state?.sessionId) return { state, recovery: null };
2365
+ const recovery = withSessionHostRecoveryLease(state.sessionId, () =>
2366
+ rejectPendingBackgroundInteractions(state.sessionId, state.id, {
2367
+ fallbackRequest: backgroundInteractionFallback(state),
2368
+ code: "INTERACTION_STOPPED",
2369
+ message:
2370
+ "The background agent was stopped before the interaction settled",
2371
+ }),
2372
+ );
2373
+ return {
2374
+ state,
2375
+ recovery: {
2376
+ status: recovery.changed ? "rejected" : "clean",
2377
+ requestIds: recovery.rejected.map((record) => record.requestId),
2378
+ recoveredAt: Date.now(),
2379
+ turn: Number(state.turnCount || 0),
2380
+ workerGeneration: state.workerGeneration || null,
2381
+ },
2382
+ };
2383
+ }
2384
+
1869
2385
  export function stopBackgroundAgent(id) {
1870
- let state = effectiveBackgroundAgentState(readBackgroundAgentState(id));
2386
+ // Explicit stop starts from the raw durable record. Read-only reconciliation
2387
+ // may already have classified a stale heartbeat as `lost`, but a verified
2388
+ // live worker still owns a process tree and must remain stoppable.
2389
+ let state = readBackgroundAgentState(id);
1871
2390
  if (!state) throw new Error(`Background agent not found: ${id}`);
1872
- if (state.status !== "running") {
2391
+ const staleHeartbeatOwner =
2392
+ state.status === "lost" && state.lostReason === "heartbeat-stale";
2393
+ if (state.status !== "running" && !staleHeartbeatOwner) {
1873
2394
  // Gap 2: stopping an already-lost session still reaps a leaked agent
1874
2395
  // child recorded before the worker died (identity-guarded inside).
1875
2396
  if (state.status === "lost") {
@@ -1878,18 +2399,34 @@ export function stopBackgroundAgent(id) {
1878
2399
  } catch {
1879
2400
  /* best-effort */
1880
2401
  }
2402
+ if (
2403
+ state.sessionId &&
2404
+ !["clean", "rejected"].includes(state.interactionRecovery?.status)
2405
+ ) {
2406
+ state = recoverTerminalBackgroundInteractions(state);
2407
+ }
1881
2408
  }
1882
2409
  return { ...state, stopped: false };
1883
2410
  }
1884
2411
  const stopRequestedAt = Date.now();
1885
2412
  const fence = mutateBackgroundAgentState(id, (current) => {
1886
- if (!current || current.status !== "running") return null;
2413
+ if (
2414
+ !current ||
2415
+ (current.status !== "running" &&
2416
+ !(
2417
+ current.status === "lost" && current.lostReason === "heartbeat-stale"
2418
+ ))
2419
+ ) {
2420
+ return null;
2421
+ }
1887
2422
  return {
1888
2423
  ...current,
1889
2424
  stopRequestedAt: current.stopRequestedAt || stopRequestedAt,
1890
2425
  stopRequestedBy: "user",
1891
2426
  phase: "stopping",
1892
2427
  transport: null,
2428
+ stopPending: false,
2429
+ stopPendingReason: null,
1893
2430
  };
1894
2431
  });
1895
2432
  if (!fence.applied) {
@@ -1897,10 +2434,7 @@ export function stopBackgroundAgent(id) {
1897
2434
  return { ...fence.state, stopped: false };
1898
2435
  }
1899
2436
  state = fence.state;
1900
- if (
1901
- state.turnLaunchIntent ||
1902
- state.turnLaunchFinalizationUncertain === true
1903
- ) {
2437
+ if (state.launchFinalizationUncertain === true || state.turnLaunchIntent) {
1904
2438
  // The worker has durably announced that native spawn is prepared or that
1905
2439
  // post-spawn pid persistence is unresolved. Killing the worker from this
1906
2440
  // snapshot could strand its detached child before ownership is published.
@@ -1910,9 +2444,11 @@ export function stopBackgroundAgent(id) {
1910
2444
  ...state,
1911
2445
  stopped: false,
1912
2446
  stopPending: true,
1913
- stopPendingReason: state.turnLaunchIntent
1914
- ? "turn-launch-intent"
1915
- : "turn-launch-finalization",
2447
+ stopPendingReason: state.launchFinalizationUncertain
2448
+ ? "launch-finalization"
2449
+ : state.turnLaunchIntent
2450
+ ? "turn-launch-intent"
2451
+ : "turn-launch-finalization",
1916
2452
  };
1917
2453
  }
1918
2454
  // Self-pid guard: a worker record whose pid is the CURRENT process is
@@ -1927,90 +2463,136 @@ export function stopBackgroundAgent(id) {
1927
2463
  endedAt: Date.now(),
1928
2464
  lostReason: "self-pid-corrupt-record",
1929
2465
  };
1930
- const written = writeBackgroundAgentState(lost) || lost;
1931
- return { ...written, stopped: false };
1932
- }
1933
- // Gap 1: last-instant identity re-check before the kill — never
1934
- // taskkill/SIGTERM a pid the OS has re-assigned to an unrelated process.
1935
- if (!isSameProcess(state.pid, state.startedAt)) {
1936
- try {
1937
- reclaimOrphanAgentProcess(state);
1938
- } catch {
1939
- /* best-effort */
2466
+ let written = writeBackgroundAgentState(lost) || lost;
2467
+ if (written.sessionId) {
2468
+ written = recoverTerminalBackgroundInteractions(written);
1940
2469
  }
1941
- const lost = {
1942
- ...state,
1943
- status: "lost",
1944
- endedAt: Date.now(),
1945
- lostReason: "pid-reused",
1946
- };
1947
- const written = writeBackgroundAgentState(lost) || lost;
1948
2470
  return { ...written, stopped: false };
1949
2471
  }
1950
- try {
1951
- if (process.platform === "win32") {
1952
- const killed = runSupervisorCommand(
1953
- "taskkill",
1954
- ["/PID", String(state.pid), "/T", "/F"],
1955
- {
1956
- windowsHide: true,
1957
- encoding: "utf8",
1958
- },
1959
- "background-agent:stop-tree",
1960
- );
1961
- if (killed.error || (killed.status !== 0 && isProcessAlive(state.pid))) {
1962
- throw new Error(
1963
- `Failed to stop background agent ${id}: ${killed.error?.message || killed.stderr || `taskkill exited ${killed.status}`}`,
1964
- );
2472
+ let postKill = state;
2473
+ let workerPidReused = false;
2474
+ const signalledPids = new Set();
2475
+ // A worker may publish a just-spawned child identity while the stop fence is
2476
+ // being observed. Re-read once after the first termination round so that a
2477
+ // newly durable, exact-owned agent tree is also terminated before recovery.
2478
+ for (let round = 0; round < 2; round++) {
2479
+ const inspected = inspectRecordedBackgroundProcesses(postKill);
2480
+ const unverifiable = inspected.filter(({ identity }) =>
2481
+ ["unverifiable", "self"].includes(identity.status),
2482
+ );
2483
+ if (unverifiable.length > 0) {
2484
+ return persistStopPending(postKill, "identity-unverifiable", {
2485
+ identity: unverifiable.map(({ role, pid, identity }) => ({
2486
+ role,
2487
+ pid,
2488
+ reason: identity.reason || identity.status,
2489
+ })),
2490
+ message:
2491
+ "Process identity could not be verified; no destructive signal was issued",
2492
+ });
2493
+ }
2494
+ const primaryWorker =
2495
+ inspected.find(({ role }) => role === "worker") ||
2496
+ inspected.find(({ role }) => role === "worker-wrapper");
2497
+ if (primaryWorker?.identity.status === "reused") workerPidReused = true;
2498
+ const targets = inspected.filter(
2499
+ ({ pid, identity }) =>
2500
+ identity.status === "match" && !signalledPids.has(pid),
2501
+ );
2502
+ if (targets.length === 0) break;
2503
+ try {
2504
+ // The child is detached into its own group and therefore gets its own
2505
+ // exact-identity signal before the worker tree is terminated.
2506
+ for (const target of targets) {
2507
+ target.signalledAsGroup =
2508
+ signalBackgroundProcessTree(target.pid, "SIGTERM") ===
2509
+ "process-group";
2510
+ signalledPids.add(target.pid);
1965
2511
  }
1966
- } else {
2512
+ } catch (error) {
1967
2513
  try {
1968
- _deps.kill(-Number(state.pid), "SIGTERM");
2514
+ mutateBackgroundAgentState(id, (current) =>
2515
+ current
2516
+ ? {
2517
+ ...current,
2518
+ phase: "stop_failed",
2519
+ stopPending: true,
2520
+ stopPendingReason: "process-termination",
2521
+ stopError: error?.message || String(error),
2522
+ }
2523
+ : null,
2524
+ );
1969
2525
  } catch {
1970
- _deps.kill(Number(state.pid), "SIGTERM");
1971
- }
1972
- // The worker detaches the agent child into its OWN process group (so a
1973
- // crashed worker's child can be group-killed) — which also means the
1974
- // worker-group SIGTERM above no longer reaches it. Kill the agent group
1975
- // explicitly, identity-guarded via its agentStartedAt anchor.
1976
- const agentPid = Number(state.agentPid);
1977
- const anchor = Number(state.agentStartedAt);
1978
- if (
1979
- Number.isInteger(agentPid) &&
1980
- agentPid > 0 &&
1981
- agentPid !== Number(state.pid) &&
1982
- agentPid !== process.pid &&
1983
- Number.isFinite(anchor) &&
1984
- anchor > 0 &&
1985
- isSameProcess(agentPid, anchor)
1986
- ) {
1987
- try {
1988
- _deps.kill(-agentPid, "SIGTERM");
1989
- } catch {
1990
- try {
1991
- _deps.kill(agentPid, "SIGTERM");
1992
- } catch {
1993
- /* already gone */
1994
- }
1995
- }
2526
+ /* the durable stopRequestedAt fence already blocks new turns */
1996
2527
  }
1997
- }
1998
- } catch (error) {
1999
- try {
2000
- mutateBackgroundAgentState(id, (current) =>
2001
- current
2002
- ? {
2003
- ...current,
2004
- phase: "stop_failed",
2005
- stopError: error?.message || String(error),
2006
- }
2007
- : null,
2528
+ throw new Error(
2529
+ `Failed to stop background agent ${id}: ${error.message}`,
2008
2530
  );
2009
- } catch {
2010
- /* the durable stopRequestedAt fence already blocks new turns */
2011
2531
  }
2012
- throw error;
2532
+ const stillAlive = waitForSignalledProcessesToExit(targets);
2533
+ postKill = readBackgroundAgentState(id) || postKill;
2534
+ if (stillAlive.length > 0) {
2535
+ return persistStopPending(postKill, "process-exit", {
2536
+ message:
2537
+ "Process termination is still pending; interaction recovery has not started",
2538
+ });
2539
+ }
2540
+ postKill = readBackgroundAgentState(id) || postKill;
2541
+ }
2542
+
2543
+ if (recordedBackgroundProcessesAlive(postKill)) {
2544
+ return persistStopPending(postKill, "process-exit");
2545
+ }
2546
+
2547
+ if (workerPidReused) {
2548
+ const lostMutation = mutateBackgroundAgentState(id, (current) =>
2549
+ current
2550
+ ? {
2551
+ ...current,
2552
+ status: "lost",
2553
+ endedAt: current.endedAt || Date.now(),
2554
+ lostReason: "pid-reused",
2555
+ phase: null,
2556
+ stopPending: false,
2557
+ stopPendingReason: null,
2558
+ }
2559
+ : null,
2560
+ );
2561
+ let lost = lostMutation.state || postKill;
2562
+ if (lost.sessionId) lost = recoverTerminalBackgroundInteractions(lost);
2563
+ return { ...lost, stopped: false };
2564
+ }
2565
+
2566
+ let stopRecovery = null;
2567
+ try {
2568
+ stopRecovery = recoverStoppedInteractions(postKill).recovery;
2569
+ } catch (error) {
2570
+ const failed = mutateBackgroundAgentState(id, (current) =>
2571
+ current
2572
+ ? {
2573
+ ...current,
2574
+ phase: "stop_recovery_failed",
2575
+ interactionRecovery: {
2576
+ status: "failed",
2577
+ code: error?.code || "INTERACTION_RECOVERY_FAILED",
2578
+ message: error?.message || String(error),
2579
+ recoveredAt: Date.now(),
2580
+ turn: Number(current.turnCount || 0),
2581
+ workerGeneration: current.workerGeneration || null,
2582
+ },
2583
+ stopPending: true,
2584
+ stopPendingReason: "interaction-recovery",
2585
+ }
2586
+ : null,
2587
+ );
2588
+ return {
2589
+ ...(failed.state || postKill),
2590
+ stopped: false,
2591
+ stopPending: true,
2592
+ stopPendingReason: "interaction-recovery",
2593
+ };
2013
2594
  }
2595
+
2014
2596
  // Build the terminal write from the freshest record under the lock. In
2015
2597
  // particular, never spread the pre-kill fence snapshot over a worker's
2016
2598
  // concurrently persisted process-tree termination confirmation.
@@ -2021,23 +2603,19 @@ export function stopBackgroundAgent(id) {
2021
2603
  status: "stopped",
2022
2604
  endedAt: current.endedAt || Date.now(),
2023
2605
  stoppedByUser: true,
2606
+ lostReason: null,
2024
2607
  phase: null,
2608
+ pendingQuestion: null,
2025
2609
  transport: null,
2026
2610
  stopError: null,
2611
+ stopPending: false,
2612
+ stopPendingReason: null,
2613
+ processIdentityError: null,
2614
+ ...(stopRecovery ? { interactionRecovery: stopRecovery } : {}),
2027
2615
  }
2028
2616
  : null,
2029
2617
  );
2030
2618
  const written = terminal.state || state;
2031
- // A turn-spawn persistence failure may publish its agent pid after the
2032
- // stopper took the first snapshot. Re-read/merge above preserves that
2033
- // uncertainty identity; make one final identity-guarded tree reap attempt.
2034
- if (written.agentPid) {
2035
- try {
2036
- reclaimOrphanAgentProcess(written);
2037
- } catch {
2038
- /* retained agentPid + uncertainty fence block destructive cleanup */
2039
- }
2040
- }
2041
2619
  return { ...written, stopped: written.status === "stopped" };
2042
2620
  }
2043
2621
 
@@ -2046,14 +2624,14 @@ function activeWorktreeProcesses(state) {
2046
2624
  if (
2047
2625
  Number.isInteger(Number(state?.pid)) &&
2048
2626
  Number(state.pid) > 0 &&
2049
- isSameProcess(state.pid, state.startedAt)
2627
+ isRecordedProcessTreeExecutionAlive(state.pid, state.startedAt)
2050
2628
  ) {
2051
2629
  active.push("worker");
2052
2630
  }
2053
2631
  if (
2054
2632
  Number.isInteger(Number(state?.agentPid)) &&
2055
2633
  Number(state.agentPid) > 0 &&
2056
- isSameProcess(state.agentPid, state.agentStartedAt)
2634
+ isRecordedProcessTreeExecutionAlive(state.agentPid, state.agentStartedAt)
2057
2635
  ) {
2058
2636
  active.push("agent");
2059
2637
  }