chainlesschain 0.162.159 → 0.162.161

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 (199) hide show
  1. package/README.md +1 -1
  2. package/package.json +1 -1
  3. package/src/assets/web-panel/assets/{AIOps-ByIiF5bT.js → AIOps-DMn528q-.js} +1 -1
  4. package/src/assets/web-panel/assets/{ActionButton-NlgSFZq5.js → ActionButton-D9ZcDCJL.js} +1 -1
  5. package/src/assets/web-panel/assets/{Analytics-D17cOUcb.js → Analytics-A_K-8PX0.js} +3 -3
  6. package/src/assets/web-panel/assets/{AppLayout-B4mEZcbl.js → AppLayout-D4pFW3X6.js} +5 -5
  7. package/src/assets/web-panel/assets/{Artifacts-CASsmyQw.js → Artifacts-Dz46COAd.js} +1 -1
  8. package/src/assets/web-panel/assets/{Audit-DYAia2e2.js → Audit-cbMjbyg0.js} +1 -1
  9. package/src/assets/web-panel/assets/{BackgroundAgents-C1lvYUSu.js → BackgroundAgents-Dtf88GAj.js} +1 -1
  10. package/src/assets/web-panel/assets/{Backup-B6agK6bA.js → Backup-C5KRxn52.js} +1 -1
  11. package/src/assets/web-panel/assets/{BaseInput-DM43R2vy.js → BaseInput-Dd2UK3BN.js} +1 -1
  12. package/src/assets/web-panel/assets/{Chat-Dtdp3eXp.js → Chat-lptKTFdG.js} +6 -6
  13. package/src/assets/web-panel/assets/{ChatBubbleRenderer-BL5c_OQ1.js → ChatBubbleRenderer-C2KLtwHz.js} +1 -1
  14. package/src/assets/web-panel/assets/{Checkbox-D2xf7uZt.js → Checkbox-_yspG6rG.js} +1 -1
  15. package/src/assets/web-panel/assets/{Codegen-D3j45jJL.js → Codegen-BGq_GAha.js} +1 -1
  16. package/src/assets/web-panel/assets/{Col-voPJ9fUZ.js → Col-CePor71g.js} +1 -1
  17. package/src/assets/web-panel/assets/{Community-DEsyTESF.js → Community-CLOWhIT7.js} +1 -1
  18. package/src/assets/web-panel/assets/{Compact-OHV_QXLD.js → Compact-CivJt2vT.js} +1 -1
  19. package/src/assets/web-panel/assets/{Compliance-eDTJWC-J.js → Compliance-Btmo1GGE.js} +1 -1
  20. package/src/assets/web-panel/assets/{Cowork-BBvy-6d1.js → Cowork-Ct35OlG4.js} +2 -2
  21. package/src/assets/web-panel/assets/{Cron-Dw2eoJxu.js → Cron-CTc2GmhB.js} +2 -2
  22. package/src/assets/web-panel/assets/{Crosschain-DQHms_Bh.js → Crosschain-ZtiBETk1.js} +1 -1
  23. package/src/assets/web-panel/assets/{DID-Cyu7Id1c.js → DID-BNqMQQXw.js} +2 -2
  24. package/src/assets/web-panel/assets/{Dashboard-sIDzkG92.js → Dashboard-BmzZd-9V.js} +2 -2
  25. package/src/assets/web-panel/assets/{Dropdown-DUW3T2ey.js → Dropdown-CMVgvP2M.js} +1 -1
  26. package/src/assets/web-panel/assets/{EmailListRenderer-CECELXW8.js → EmailListRenderer-CcqQFAfT.js} +1 -1
  27. package/src/assets/web-panel/assets/{FamilyGuardDashboard-BIZR37ov.js → FamilyGuardDashboard-CTqB1_4V.js} +1 -1
  28. package/src/assets/web-panel/assets/{Federation-BEZIQX84.js → Federation-CiKOOzlb.js} +1 -1
  29. package/src/assets/web-panel/assets/{FormItemContext-BcdUMznL.js → FormItemContext-Wk-OT0gr.js} +1 -1
  30. package/src/assets/web-panel/assets/{GenericCardRenderer-DsN_fr5H.js → GenericCardRenderer-DbEQT6ue.js} +1 -1
  31. package/src/assets/web-panel/assets/{Git-63LhjBx2.js → Git-0ZvCPSD9.js} +2 -2
  32. package/src/assets/web-panel/assets/{Governance-CYtQtIcH.js → Governance-CmVuKkPb.js} +1 -1
  33. package/src/assets/web-panel/assets/{Inference-BwR6UUox.js → Inference-CTWE3Jwg.js} +1 -1
  34. package/src/assets/web-panel/assets/{KnowledgeGraph-D9lVNTUA.js → KnowledgeGraph-CBEIF00L.js} +1 -1
  35. package/src/assets/web-panel/assets/{Logs-C4q5h7Ut.js → Logs-DLhxDwVG.js} +2 -2
  36. package/src/assets/web-panel/assets/MarkdownRenderer-DxywvhTU.js +1 -0
  37. package/src/assets/web-panel/assets/{Marketplace-Plksliuw.js → Marketplace-BgsCBrnc.js} +1 -1
  38. package/src/assets/web-panel/assets/{McpTools-DZUFfeuG.js → McpTools-ClBUGDS2.js} +3 -3
  39. package/src/assets/web-panel/assets/{Memory-NoDYt29V.js → Memory-CnG_ZnYw.js} +2 -2
  40. package/src/assets/web-panel/assets/{MobileBridge-CEXGTkdh.js → MobileBridge-BUOAbR1Z.js} +1 -1
  41. package/src/assets/web-panel/assets/{MobileProjects-DnbJkbZo.js → MobileProjects-Dnt_9-WB.js} +1 -1
  42. package/src/assets/web-panel/assets/{Mtc-DTAa0cwy.js → Mtc-CLjt4kCD.js} +5 -5
  43. package/src/assets/web-panel/assets/{MtcAudit-CVV7LnNS.js → MtcAudit-f24U9Aes.js} +2 -2
  44. package/src/assets/web-panel/assets/{Multisig-BqqGrjt_.js → Multisig-GrmGQbSq.js} +3 -3
  45. package/src/assets/web-panel/assets/{NLProgramming-C4WP8DRp.js → NLProgramming-DADaWn-n.js} +1 -1
  46. package/src/assets/web-panel/assets/{Notes-Zo4DJUVI.js → Notes-xtf5RX6C.js} +3 -3
  47. package/src/assets/web-panel/assets/{NotificationSettings-DBcjRaz2.js → NotificationSettings-Cd27Rgtx.js} +1 -1
  48. package/src/assets/web-panel/assets/OrderTableRenderer-fXRxlDjl.js +1 -0
  49. package/src/assets/web-panel/assets/{Organization-BT9WPBOX.js → Organization-joCFlNB0.js} +4 -4
  50. package/src/assets/web-panel/assets/{Overflow-D6b4bZ3q.js → Overflow-B1HoURq0.js} +1 -1
  51. package/src/assets/web-panel/assets/{P2P-Cw3OsYx-.js → P2P-DiwsKbT7.js} +2 -2
  52. package/src/assets/web-panel/assets/{PdhVaultBrowser-C0nyiK2b.js → PdhVaultBrowser-Xe-BsW6S.js} +3 -3
  53. package/src/assets/web-panel/assets/{Permissions-zVBZEB_C.js → Permissions-C23qi8kl.js} +3 -3
  54. package/src/assets/web-panel/assets/{PersonalDataHub-BOspsFew.js → PersonalDataHub-BS1syX2f.js} +2 -2
  55. package/src/assets/web-panel/assets/{Pipeline-CQWfZ4XH.js → Pipeline-DRYwwDyh.js} +1 -1
  56. package/src/assets/web-panel/assets/{Privacy-C2oGJ_WL.js → Privacy-BQKaDHPH.js} +1 -1
  57. package/src/assets/web-panel/assets/{ProjectInit-BZMR4qY1.js → ProjectInit-BC6F9xiG.js} +2 -2
  58. package/src/assets/web-panel/assets/{ProjectSettings-DAujoP63.js → ProjectSettings-ClFPc1Nd.js} +2 -2
  59. package/src/assets/web-panel/assets/Projects-Dn-f5aYe.js +1 -0
  60. package/src/assets/web-panel/assets/{Providers-D_RGHM-v.js → Providers-CLWuOUNt.js} +1 -1
  61. package/src/assets/web-panel/assets/{QrScannerModal-Mf6KfL2J.js → QrScannerModal-DiqULTxb.js} +1 -1
  62. package/src/assets/web-panel/assets/{QuickAsk-5D8URoI_.js → QuickAsk-9A03P-E_.js} +1 -1
  63. package/src/assets/web-panel/assets/{Recommend-Df4kC8Gy.js → Recommend-CJZAnbq_.js} +1 -1
  64. package/src/assets/web-panel/assets/{RemoteSession-DGi3f3vO.js → RemoteSession-C9A1R77h.js} +2 -2
  65. package/src/assets/web-panel/assets/{Reputation-jmkGFNWb.js → Reputation-BIZc6Rky.js} +1 -1
  66. package/src/assets/web-panel/assets/{Row-DP6Pcu3m.js → Row-DfIPNtuG.js} +1 -1
  67. package/src/assets/web-panel/assets/{RssFeed-D60hSuPO.js → RssFeed-C5znFcQ1.js} +3 -3
  68. package/src/assets/web-panel/assets/{Search-0mf1BQUK.js → Search-DSs2z3OX.js} +1 -1
  69. package/src/assets/web-panel/assets/{Security-CfZtLFYV.js → Security-CV6LcrLY.js} +4 -4
  70. package/src/assets/web-panel/assets/{Services-D9zpZLWz.js → Services-cmhoX0Xj.js} +2 -2
  71. package/src/assets/web-panel/assets/{Skeleton-D1_44eMD.js → Skeleton-tCsg2po7.js} +1 -1
  72. package/src/assets/web-panel/assets/{Skills-cVFa8zkz.js → Skills-BK1KUDiy.js} +1 -1
  73. package/src/assets/web-panel/assets/{Sla-Cinpzc3L.js → Sla-yRgyeA82.js} +1 -1
  74. package/src/assets/web-panel/assets/{SpeechSettings-bqdiJE_P.js → SpeechSettings-c1mo1mlp.js} +1 -1
  75. package/src/assets/web-panel/assets/{SyncSettings-Dgl_SLgj.js → SyncSettings-koIAfuT3.js} +2 -2
  76. package/src/assets/web-panel/assets/{Tasks-DawWAEhy.js → Tasks-DkkreAaB.js} +1 -1
  77. package/src/assets/web-panel/assets/{Templates-Ca0nY651.js → Templates-fy5Ctf9M.js} +1 -1
  78. package/src/assets/web-panel/assets/{Tenant-CgaGEhLm.js → Tenant-Bw70Jf6S.js} +1 -1
  79. package/src/assets/web-panel/assets/{Terminal-D-9EnpRx.js → Terminal-BKh6Rx-4.js} +2 -2
  80. package/src/assets/web-panel/assets/{TimelineRenderer-CSOxDHZ6.js → TimelineRenderer-Dg54WP0c.js} +1 -1
  81. package/src/assets/web-panel/assets/{Tokens-BpwLZpL3.js → Tokens-BvvD5SxJ.js} +1 -1
  82. package/src/assets/web-panel/assets/{Trigger-DgowwBK5.js → Trigger-B7Ug9x4e.js} +1 -1
  83. package/src/assets/web-panel/assets/{Trust-BZutapjE.js → Trust-BjvuMORY.js} +1 -1
  84. package/src/assets/web-panel/assets/{UkeySign-QZPt8_Sp.js → UkeySign-CZqhkx8p.js} +1 -1
  85. package/src/assets/web-panel/assets/{VideoEditing-DlQx5UDJ.js → VideoEditing-ClR50lhX.js} +1 -1
  86. package/src/assets/web-panel/assets/{Wallet-ZfdTgtWA.js → Wallet-DlfppYjx.js} +4 -4
  87. package/src/assets/web-panel/assets/{WebAuthn-CAp0y_Cr.js → WebAuthn-BirgVr8B.js} +5 -5
  88. package/src/assets/web-panel/assets/{WorkflowEditor-BVITZEmr.js → WorkflowEditor-B8DP_bax.js} +1 -1
  89. package/src/assets/web-panel/assets/{chat-CC2tPaNg.js → chat-DA8AUZzL.js} +1 -1
  90. package/src/assets/web-panel/assets/{colors-31brc9CA.js → colors-C38pLGp1.js} +1 -1
  91. package/src/assets/web-panel/assets/{compact-item-DLMkC9C-.js → compact-item-81rtr98o.js} +1 -1
  92. package/src/assets/web-panel/assets/{createContext-BMtveI6F.js → createContext-D34ixq8t.js} +1 -1
  93. package/src/assets/web-panel/assets/devWarning-CHDIEOeN.js +1 -0
  94. package/src/assets/web-panel/assets/{hasIn-DbD6WHW2.js → hasIn-D4bzqTrZ.js} +1 -1
  95. package/src/assets/web-panel/assets/{index-BoO4WEJB.js → index-9qoVpPt6.js} +1 -1
  96. package/src/assets/web-panel/assets/{index-CJhREOlG.js → index-B0QArPQt.js} +1 -1
  97. package/src/assets/web-panel/assets/{index-BnFLzejT.js → index-B8mkLM4x.js} +1 -1
  98. package/src/assets/web-panel/assets/{index-Bbc2JeQG.js → index-BIP62a6r.js} +1 -1
  99. package/src/assets/web-panel/assets/{index-CFXKbOek.js → index-BP-zkXK8.js} +1 -1
  100. package/src/assets/web-panel/assets/{index-C-cGlCDW.js → index-BS3M5H9V.js} +1 -1
  101. package/src/assets/web-panel/assets/{index-AaIKG60c.js → index-BXd2_LmH.js} +1 -1
  102. package/src/assets/web-panel/assets/{index-CEXa-4zd.js → index-BlxEphl7.js} +1 -1
  103. package/src/assets/web-panel/assets/{index-DZl-UXB6.js → index-BqIhLAbA.js} +1 -1
  104. package/src/assets/web-panel/assets/{index-BOM-VFlv.js → index-BtGxuQrO.js} +1 -1
  105. package/src/assets/web-panel/assets/{index-CcVszn2b.js → index-BvD7b2IK.js} +1 -1
  106. package/src/assets/web-panel/assets/{index-C21pdBqs.js → index-BvL_3J_n.js} +1 -1
  107. package/src/assets/web-panel/assets/{index-DJvxFyIz.js → index-COe_cOLJ.js} +1 -1
  108. package/src/assets/web-panel/assets/{index-ALvoNTRF.js → index-CP5alL4t.js} +1 -1
  109. package/src/assets/web-panel/assets/index-CY1NURWZ.js +1 -0
  110. package/src/assets/web-panel/assets/{index-Dm0byjm1.js → index-CbhE3j5j.js} +1 -1
  111. package/src/assets/web-panel/assets/{index-WQUO2vvK.js → index-Cd3KuNK5.js} +1 -1
  112. package/src/assets/web-panel/assets/{index-bdaJwTXJ.js → index-CoVgZw_b.js} +1 -1
  113. package/src/assets/web-panel/assets/{index-COHHQ9cU.js → index-CuS-UPoj.js} +1 -1
  114. package/src/assets/web-panel/assets/{index-C7LHNw_N.js → index-D0Ih7Hwn.js} +1 -1
  115. package/src/assets/web-panel/assets/{index-Cod97Urs.js → index-D3zCCZRS.js} +3 -3
  116. package/src/assets/web-panel/assets/{index-Cwbqh0KZ.js → index-D6GfvEQG.js} +1 -1
  117. package/src/assets/web-panel/assets/{index-BKAB6-6V.js → index-DBFSo1NF.js} +1 -1
  118. package/src/assets/web-panel/assets/{index-CEwjBA9q.js → index-DFHa1HIg.js} +1 -1
  119. package/src/assets/web-panel/assets/{index-DndNnYc0.js → index-DFyWKOBD.js} +1 -1
  120. package/src/assets/web-panel/assets/{index-3oBsXZmF.js → index-DMgnRwrK.js} +1 -1
  121. package/src/assets/web-panel/assets/{index-C_eUKGYP.js → index-DUQLXLQM.js} +1 -1
  122. package/src/assets/web-panel/assets/{index-CUmLMFsy.js → index-D_2VYPr1.js} +1 -1
  123. package/src/assets/web-panel/assets/{index-BPpS-lGb.js → index-DakfFZ-k.js} +1 -1
  124. package/src/assets/web-panel/assets/{index-ep6JchNG.js → index-Ddk0e_Pi.js} +1 -1
  125. package/src/assets/web-panel/assets/{index-D7cV0tu0.js → index-DkHqMnn3.js} +1 -1
  126. package/src/assets/web-panel/assets/{index-jG6h22Qg.js → index-Dmo3dFHb.js} +1 -1
  127. package/src/assets/web-panel/assets/index-DokUYqpq.js +1 -0
  128. package/src/assets/web-panel/assets/{index-C1PfeI88.js → index-DvfCEJG-.js} +1 -1
  129. package/src/assets/web-panel/assets/{index-ntVQqSML.js → index-E1OJRXs4.js} +1 -1
  130. package/src/assets/web-panel/assets/{index-CWgfipcC.js → index-F76w1NiL.js} +1 -1
  131. package/src/assets/web-panel/assets/{index-BiRQUoyE.js → index-ZRJRItm0.js} +1 -1
  132. package/src/assets/web-panel/assets/{index-BFRLmCR3.js → index-fI5sXMLg.js} +1 -1
  133. package/src/assets/web-panel/assets/{index-D67kIagl.js → index-tKiC8T-4.js} +1 -1
  134. package/src/assets/web-panel/assets/{initDefaultProps-D5UoBRhN.js → initDefaultProps-ByyvflIR.js} +1 -1
  135. package/src/assets/web-panel/assets/{motion-DltOw0CR.js → motion-B3FQm2By.js} +1 -1
  136. package/src/assets/web-panel/assets/{move-_oPkk5zj.js → move-BSfns6Ef.js} +1 -1
  137. package/src/assets/web-panel/assets/{mtc-parser-EES8ROCS.js → mtc-parser-CTG1Ieri.js} +1 -1
  138. package/src/assets/web-panel/assets/{omit-B-_39x_M.js → omit-DObM_2j6.js} +1 -1
  139. package/src/assets/web-panel/assets/{pickAttrs-l6D0yJHz.js → pickAttrs-DOfelQ1t.js} +1 -1
  140. package/src/assets/web-panel/assets/{placementArrow-DIeUK31L.js → placementArrow-CHZILds_.js} +1 -1
  141. package/src/assets/web-panel/assets/{responsiveObserve-dG4EzUQo.js → responsiveObserve-REbucKSx.js} +1 -1
  142. package/src/assets/web-panel/assets/{slide-DWFzYGdH.js → slide-C6EZzD7o.js} +1 -1
  143. package/src/assets/web-panel/assets/{statusUtils-CECdTA5H.js → statusUtils-BxkWS-g3.js} +1 -1
  144. package/src/assets/web-panel/assets/{styleChecker-D6kP-mMK.js → styleChecker-Chklyoq6.js} +1 -1
  145. package/src/assets/web-panel/assets/{useFlexGapSupport-BWfna_rd.js → useFlexGapSupport-CRVwKrrI.js} +1 -1
  146. package/src/assets/web-panel/assets/{useFs-Bb-j6rzo.js → useFs-DV24ik_J.js} +1 -1
  147. package/src/assets/web-panel/assets/{usePersonalDataHub-D5OIsYM4.js → usePersonalDataHub-C7vIo4Pg.js} +1 -1
  148. package/src/assets/web-panel/assets/{vnode-DNpwzFTM.js → vnode-By-v5GlF.js} +1 -1
  149. package/src/assets/web-panel/assets/{zoom-vNLpFQBW.js → zoom-B1w9Am3r.js} +1 -1
  150. package/src/assets/web-panel/index.html +1 -1
  151. package/src/commands/agenda.js +15 -1
  152. package/src/commands/agent.js +72 -6
  153. package/src/commands/background-session.js +46 -2
  154. package/src/commands/mcp.js +46 -0
  155. package/src/commands/session.js +70 -0
  156. package/src/data/changelog.json +12 -2
  157. package/src/gateways/ws/background-agent-protocol.js +88 -14
  158. package/src/harness/jsonl-session-store.js +70 -1
  159. package/src/harness/mcp-client.js +50 -0
  160. package/src/lib/agent-authority.js +251 -0
  161. package/src/lib/agent-sandbox.js +68 -0
  162. package/src/lib/agents.js +12 -4
  163. package/src/lib/api-key-helper.js +62 -0
  164. package/src/lib/background-agent-phase.js +100 -0
  165. package/src/lib/background-agent-supervisor.js +320 -8
  166. package/src/lib/backpressure-policy.js +105 -0
  167. package/src/lib/capability-negotiation.js +212 -0
  168. package/src/lib/credential-guard.js +11 -0
  169. package/src/lib/credential-proxy.js +182 -0
  170. package/src/lib/dependency-install-policy.js +130 -0
  171. package/src/lib/event-seq-replay.js +167 -0
  172. package/src/lib/exit-codes.cjs +77 -0
  173. package/src/lib/goal-condition-engine.js +369 -0
  174. package/src/lib/headless-manifest.js +188 -0
  175. package/src/lib/hook-runner.cjs +70 -4
  176. package/src/lib/ide-context-redaction.js +130 -0
  177. package/src/lib/ide-context.js +128 -13
  178. package/src/lib/jsonl-session-store.js +2 -0
  179. package/src/lib/llm-config-defaults.js +24 -8
  180. package/src/lib/project-instructions.js +98 -1
  181. package/src/lib/project-mcp-trust.js +87 -0
  182. package/src/lib/remote-path-mapping.js +229 -0
  183. package/src/lib/schedule-planner.js +136 -0
  184. package/src/lib/sensitive-file-guard.js +51 -0
  185. package/src/lib/sub-agent-context.js +7 -3
  186. package/src/repl/bg-dashboard.js +13 -12
  187. package/src/runtime/agent-core.js +113 -10
  188. package/src/runtime/coding-agent-contract-shared.cjs +19 -2
  189. package/src/runtime/headless-runner.js +66 -7
  190. package/src/runtime/headless-stream.js +224 -12
  191. package/src/runtime/mcp-config.js +32 -1
  192. package/src/runtime/system-prompt.js +3 -0
  193. package/src/workers/background-agent-worker.js +37 -2
  194. package/src/assets/web-panel/assets/MarkdownRenderer-DexLlXfN.js +0 -1
  195. package/src/assets/web-panel/assets/OrderTableRenderer-BCQel9bD.js +0 -1
  196. package/src/assets/web-panel/assets/Projects-DvH_P2Uq.js +0 -1
  197. package/src/assets/web-panel/assets/devWarning-CB8pPOtd.js +0 -1
  198. package/src/assets/web-panel/assets/index-BhXiaiev.js +0 -1
  199. package/src/assets/web-panel/assets/index-WpaYaE3D.js +0 -1
package/src/lib/agents.js CHANGED
@@ -8,10 +8,13 @@
8
8
  * `review/security.md` is the agent `review:security`.
9
9
  *
10
10
  * Frontmatter (all optional):
11
- * name override the filename-derived name
12
- * description one-line summary (when to use this agent)
13
- * tools allow-list — comma string or YAML array; omit = inherit all
14
- * model model override for runs of this agent
11
+ * name override the filename-derived name
12
+ * description one-line summary (when to use this agent)
13
+ * tools allow-list — comma string or YAML array; omit = inherit all
14
+ * disallowedTools deny-list — removed from whatever the allow-list resolves to
15
+ * model model override for runs of this agent
16
+ * maxTurns per-run iteration cap for this agent (positive integer)
17
+ * isolation "worktree" → run in a fresh git worktree (gap 2026-07-11)
15
18
  *
16
19
  * Project scope shadows personal on a name clash. Discovery + parse are pure
17
20
  * (inject fs/path/home) so the whole thing is unit-testable.
@@ -132,13 +135,18 @@ export function parseAgentFile(file, scope, opts = {}) {
132
135
  return null;
133
136
  }
134
137
  const { data, body } = parseFrontmatter(content);
138
+ const maxTurns = Number(data.maxTurns);
135
139
  return {
136
140
  file,
137
141
  scope,
138
142
  name: data.name || null, // resolved against the path in discoverAgents
139
143
  description: data.description || "",
140
144
  tools: normalizeTools(data.tools),
145
+ disallowedTools: normalizeTools(data.disallowedTools),
141
146
  model: data.model || null,
147
+ maxTurns:
148
+ Number.isFinite(maxTurns) && maxTurns > 0 ? Math.floor(maxTurns) : null,
149
+ isolation: data.isolation === "worktree" ? "worktree" : null,
142
150
  systemPrompt: body || "",
143
151
  };
144
152
  }
@@ -0,0 +1,62 @@
1
+ /**
2
+ * `llm.apiKeyHelper` (gap-analysis 2026-07-11 P0 "依赖安装与凭据"): fetch the
3
+ * LLM API key from an external command instead of storing it in plaintext
4
+ * config.json. Point it at your OS credential store, e.g.:
5
+ *
6
+ * Windows : "powershell -NoProfile -Command \"...CredentialManager...\""
7
+ * macOS : "security find-generic-password -s cc-llm -w"
8
+ * Linux : "secret-tool lookup service cc-llm"
9
+ *
10
+ * Resolution precedence stays: --api-key > CC_API_KEY > config llm.apiKey >
11
+ * llm.apiKeyHelper. The helper's stdout (trimmed) is the key; a failing or
12
+ * empty helper resolves null so the run fails with the provider's own
13
+ * missing-key error (never a silent fallback — see the no-silent-substitution
14
+ * rule). Cached per process (5 min) so multi-call runs don't re-spawn it.
15
+ */
16
+
17
+ import { execSync } from "node:child_process";
18
+
19
+ const TTL_MS = 5 * 60 * 1000;
20
+ const _cache = new Map(); // helper command → { key, at }
21
+ let _warnedFailure = false;
22
+
23
+ /**
24
+ * @param {string} helperCmd shell command printing the key on stdout
25
+ * @param {{exec?:Function, now?:()=>number, writeErr?:(s:string)=>void}} [opts]
26
+ * @returns {string|null}
27
+ */
28
+ export function resolveApiKeyFromHelper(helperCmd, opts = {}) {
29
+ const cmd = typeof helperCmd === "string" ? helperCmd.trim() : "";
30
+ if (!cmd) return null;
31
+ const now = opts.now || Date.now;
32
+ const hit = _cache.get(cmd);
33
+ if (hit && now() - hit.at < TTL_MS) return hit.key;
34
+ const exec = opts.exec || execSync;
35
+ try {
36
+ const out = exec(cmd, {
37
+ encoding: "utf-8",
38
+ timeout: 10000,
39
+ windowsHide: true,
40
+ stdio: ["ignore", "pipe", "pipe"],
41
+ });
42
+ const key = String(out || "").trim();
43
+ if (!key) return null;
44
+ _cache.set(cmd, { key, at: now() });
45
+ return key;
46
+ } catch (err) {
47
+ // Warn once per process, then stay quiet — the provider's missing-key
48
+ // error is the actionable surface.
49
+ if (!_warnedFailure) {
50
+ _warnedFailure = true;
51
+ const writeErr = opts.writeErr || ((s) => process.stderr.write(s));
52
+ writeErr(`[api-key-helper] llm.apiKeyHelper failed: ${err.message}\n`);
53
+ }
54
+ return null;
55
+ }
56
+ }
57
+
58
+ /** Test seam: drop the per-process cache + warn-once latch. */
59
+ export function clearApiKeyHelperCache() {
60
+ _cache.clear();
61
+ _warnedFailure = false;
62
+ }
@@ -0,0 +1,100 @@
1
+ /**
2
+ * Background-agent phase vocabulary + dashboard grouping (P0 state-machine
3
+ * slice — CLAUDE_CODE_CLI_INCREMENTAL_GAP_ANALYSIS_2026-07-12 §"后台 Agent
4
+ * 状态机").
5
+ *
6
+ * A session has two orthogonal axes:
7
+ * - `status` — running | completed | failed | stopped | lost — owned by the
8
+ * supervisor's liveness reconciliation.
9
+ * - `phase` — the live sub-state WHILE running, persisted by the worker.
10
+ *
11
+ * Historically the worker only emitted "turn" (a turn is executing) and
12
+ * "idle" (between turns, transport open), and the dashboard mislabeled *idle*
13
+ * as "Needs input" — so an agent that had simply finished a turn and was
14
+ * parked shouted for attention it didn't need. Claude-Code's Agent View keeps
15
+ * these distinct (Working / Needs input / Idle / …).
16
+ *
17
+ * This module is the single source of truth that (a) names the fuller phase
18
+ * vocabulary the runtime is growing into and (b) classifies a session into a
19
+ * dashboard group, crucially separating `idle` (finished a turn, nothing
20
+ * blocking) from a genuine *needs-input* state (a human decision — a question
21
+ * or a pending permission — is blocking progress). Pure + zero-dependency so
22
+ * the worker, the supervisor and the REPL dashboard can all share it without a
23
+ * worker ever importing UI code.
24
+ */
25
+
26
+ /**
27
+ * Canonical running sub-phases. `working`/`idle` are the steady states;
28
+ * `needs_input`/`waiting_permission` mark a turn that cannot advance without a
29
+ * human. `starting` is the brief pre-first-turn window.
30
+ */
31
+ export const BACKGROUND_AGENT_PHASES = Object.freeze({
32
+ STARTING: "starting",
33
+ WORKING: "working",
34
+ IDLE: "idle",
35
+ NEEDS_INPUT: "needs_input",
36
+ WAITING_PERMISSION: "waiting_permission",
37
+ });
38
+
39
+ /**
40
+ * Aliases accepted from producers / older state schemas → canonical phase.
41
+ * The legacy worker emits "turn" for a live turn; map it to `working` so old
42
+ * on-disk states and new ones classify identically. Kebab and *_approval
43
+ * spellings are tolerated because different producers naturally reach for
44
+ * different casing.
45
+ */
46
+ const PHASE_ALIASES = new Map([
47
+ ["turn", BACKGROUND_AGENT_PHASES.WORKING],
48
+ ["working", BACKGROUND_AGENT_PHASES.WORKING],
49
+ ["starting", BACKGROUND_AGENT_PHASES.STARTING],
50
+ ["idle", BACKGROUND_AGENT_PHASES.IDLE],
51
+ ["needs_input", BACKGROUND_AGENT_PHASES.NEEDS_INPUT],
52
+ ["needs-input", BACKGROUND_AGENT_PHASES.NEEDS_INPUT],
53
+ ["awaiting_input", BACKGROUND_AGENT_PHASES.NEEDS_INPUT],
54
+ ["question", BACKGROUND_AGENT_PHASES.NEEDS_INPUT],
55
+ ["waiting_permission", BACKGROUND_AGENT_PHASES.WAITING_PERMISSION],
56
+ ["waiting-permission", BACKGROUND_AGENT_PHASES.WAITING_PERMISSION],
57
+ ["waiting_approval", BACKGROUND_AGENT_PHASES.WAITING_PERMISSION],
58
+ ["awaiting_approval", BACKGROUND_AGENT_PHASES.WAITING_PERMISSION],
59
+ ]);
60
+
61
+ /**
62
+ * Normalize a raw phase value to a canonical phase, or null when absent /
63
+ * unrecognized (callers treat null as "no declared phase"). Never throws.
64
+ */
65
+ export function normalizeBackgroundAgentPhase(value) {
66
+ if (typeof value !== "string") return null;
67
+ return PHASE_ALIASES.get(value.trim().toLowerCase()) || null;
68
+ }
69
+
70
+ /** Phases where a human decision is blocking progress → "Needs input". */
71
+ const BLOCKING_PHASES = new Set([
72
+ BACKGROUND_AGENT_PHASES.NEEDS_INPUT,
73
+ BACKGROUND_AGENT_PHASES.WAITING_PERMISSION,
74
+ ]);
75
+
76
+ /**
77
+ * Dashboard group for a session. Running sessions split by phase:
78
+ * - a pending approval OR a blocking phase (needs_input / waiting_permission)
79
+ * → "needs-input" (a human decision unblocks it)
80
+ * - "idle" (finished a turn, transport open, available but nothing blocking)
81
+ * → "idle"
82
+ * - everything else (working / turn / starting / unknown) → "working"
83
+ * Terminal statuses map to their own groups; lost/unknown → "failed".
84
+ *
85
+ * `pendingApprovals` wins over `phase` because an approval that is genuinely
86
+ * pending blocks progress regardless of the phase label the worker last wrote.
87
+ */
88
+ export function phaseGroupKey(session) {
89
+ const status = session?.status;
90
+ if (status === "running") {
91
+ if (Number(session?.pendingApprovals) > 0) return "needs-input";
92
+ const phase = normalizeBackgroundAgentPhase(session?.phase);
93
+ if (BLOCKING_PHASES.has(phase)) return "needs-input";
94
+ if (phase === BACKGROUND_AGENT_PHASES.IDLE) return "idle";
95
+ return "working";
96
+ }
97
+ if (status === "completed") return "completed";
98
+ if (status === "stopped") return "stopped";
99
+ return "failed"; // failed | lost | unknown
100
+ }
@@ -16,11 +16,167 @@ import { join } from "node:path";
16
16
  import { fileURLToPath } from "node:url";
17
17
  import { getHomeDir } from "./paths.js";
18
18
 
19
- export const _deps = { spawn, spawnSync };
20
-
21
19
  export const DEFAULT_HEARTBEAT_INTERVAL_MS = 5000;
22
20
  export const DEFAULT_HEARTBEAT_STALE_MS = 120000;
23
21
 
22
+ /**
23
+ * Pid identity tolerance (Gap 1: OS pid reuse). A pid alone does not identify
24
+ * a process — after the worker dies the OS can hand the same pid to an
25
+ * unrelated process, making `kill(pid, 0)` lie: the session shows "running"
26
+ * until the heartbeat goes stale and, far worse, `stopBackgroundAgent` would
27
+ * `taskkill /T /F` an innocent process tree. The state file records
28
+ * `startedAt` (launcher clock, written just before the spawn), so we compare
29
+ * it against the pid's REAL creation time. Reuse is one-sided: a process
30
+ * that took over the pid can only have been created AFTER the original
31
+ * worker died — i.e. noticeably later than startedAt. Creation at/before
32
+ * startedAt (± tolerance for clock skew and spawn latency) is our process.
33
+ */
34
+ export const PID_IDENTITY_TOLERANCE_MS = 60000;
35
+
36
+ const START_TIME_CACHE_TTL_MS = 10000;
37
+ const _startTimeCache = new Map();
38
+
39
+ /** CIM_DATETIME (`20260711120000.500000+480`) → epoch ms, null when unparseable. */
40
+ export function parseCimDateToMs(raw) {
41
+ const m =
42
+ /^(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})\.(\d{6})([+-])(\d{3})$/.exec(
43
+ String(raw || "").trim(),
44
+ );
45
+ if (!m) return null;
46
+ const localAsUtc = Date.UTC(
47
+ Number(m[1]),
48
+ Number(m[2]) - 1,
49
+ Number(m[3]),
50
+ Number(m[4]),
51
+ Number(m[5]),
52
+ Number(m[6]),
53
+ Math.floor(Number(m[7]) / 1000),
54
+ );
55
+ const offsetMinutes = (m[8] === "-" ? -1 : 1) * Number(m[9]);
56
+ return localAsUtc - offsetMinutes * 60000;
57
+ }
58
+
59
+ /**
60
+ * Read a process's creation time (epoch ms) with ONE synchronous probe, or
61
+ * null when it cannot be determined (missing tools, permissions, platform
62
+ * quirks). Callers must treat null as "unknown" and FAIL OPEN to the legacy
63
+ * kill(pid, 0) answer — a broken probe must never declare a live worker dead
64
+ * (nor green-light killing a process we could not identify as ours; see the
65
+ * per-call-site policy).
66
+ */
67
+ function defaultReadProcessStartTimeMs(pid) {
68
+ const target = Number(pid);
69
+ if (!Number.isInteger(target) || target <= 0) return null;
70
+ if (process.platform === "win32") {
71
+ // wmic first (fast, ~100-300ms); PowerShell CIM as fallback (wmic is
72
+ // removed from recent Windows 11 builds).
73
+ try {
74
+ const r = spawnSync(
75
+ "wmic",
76
+ [
77
+ "process",
78
+ "where",
79
+ `ProcessId=${target}`,
80
+ "get",
81
+ "CreationDate",
82
+ "/value",
83
+ ],
84
+ { windowsHide: true, encoding: "utf8", timeout: 5000 },
85
+ );
86
+ if (!r.error && r.status === 0) {
87
+ const m = /CreationDate=([^\r\n]+)/.exec(r.stdout || "");
88
+ const ms = m ? parseCimDateToMs(m[1]) : null;
89
+ if (ms !== null) return ms;
90
+ }
91
+ } catch {
92
+ /* fall through to PowerShell */
93
+ }
94
+ try {
95
+ const script =
96
+ `$p = Get-CimInstance Win32_Process -Filter 'ProcessId=${target}' -ErrorAction SilentlyContinue; ` +
97
+ "if ($p -and $p.CreationDate) { [DateTimeOffset]::new($p.CreationDate).ToUnixTimeMilliseconds() }";
98
+ const r = spawnSync(
99
+ "powershell",
100
+ ["-NoProfile", "-NonInteractive", "-Command", script],
101
+ { windowsHide: true, encoding: "utf8", timeout: 10000 },
102
+ );
103
+ if (!r.error && r.status === 0) {
104
+ const n = Number(String(r.stdout || "").trim());
105
+ if (Number.isFinite(n) && n > 0) return n;
106
+ }
107
+ } catch {
108
+ /* fail open below */
109
+ }
110
+ return null;
111
+ }
112
+ // POSIX: `ps -o lstart=` gives an absolute start timestamp on Linux + macOS
113
+ // (procps and BSD ps both support it; busybox ps does not → null → open).
114
+ try {
115
+ const r = spawnSync("ps", ["-o", "lstart=", "-p", String(target)], {
116
+ encoding: "utf8",
117
+ timeout: 5000,
118
+ });
119
+ if (!r.error && r.status === 0) {
120
+ const t = Date.parse(String(r.stdout || "").trim());
121
+ if (Number.isFinite(t) && t > 0) return t;
122
+ }
123
+ } catch {
124
+ /* fail open below */
125
+ }
126
+ return null;
127
+ }
128
+
129
+ /**
130
+ * Kill a whole process tree (Gap 2 orphan reclaim). Windows: `taskkill /T`.
131
+ * POSIX: negative-pid group signal (the worker spawns the agent child
132
+ * detached → its own group), falling back to a direct kill.
133
+ */
134
+ function defaultKillProcessTree(pid, signal = "SIGKILL") {
135
+ const target = Number(pid);
136
+ if (!Number.isInteger(target) || target <= 0) return false;
137
+ try {
138
+ if (process.platform === "win32") {
139
+ const r = spawnSync("taskkill", ["/PID", String(target), "/T", "/F"], {
140
+ windowsHide: true,
141
+ encoding: "utf8",
142
+ });
143
+ return !r.error && r.status === 0;
144
+ }
145
+ try {
146
+ process.kill(-target, signal);
147
+ return true;
148
+ } catch {
149
+ process.kill(target, signal);
150
+ return true;
151
+ }
152
+ } catch {
153
+ return false;
154
+ }
155
+ }
156
+
157
+ export const _deps = {
158
+ spawn,
159
+ spawnSync,
160
+ readProcessStartTimeMs: defaultReadProcessStartTimeMs,
161
+ killProcessTree: defaultKillProcessTree,
162
+ };
163
+
164
+ /** Cached probe (default impl only — injected probes run uncached for tests). */
165
+ function processStartTimeMs(pid) {
166
+ if (_deps.readProcessStartTimeMs !== defaultReadProcessStartTimeMs) {
167
+ const raw = _deps.readProcessStartTimeMs(pid);
168
+ const n = Number(raw);
169
+ return Number.isFinite(n) && n > 0 ? n : null;
170
+ }
171
+ const key = Number(pid);
172
+ const at = Date.now();
173
+ const hit = _startTimeCache.get(key);
174
+ if (hit && at - hit.at < START_TIME_CACHE_TTL_MS) return hit.value;
175
+ const value = defaultReadProcessStartTimeMs(key);
176
+ _startTimeCache.set(key, { at, value });
177
+ return value;
178
+ }
179
+
24
180
  function nowMs(options = {}) {
25
181
  return typeof options.now === "number" ? options.now : Date.now();
26
182
  }
@@ -136,6 +292,55 @@ export function isProcessAlive(pid) {
136
292
  }
137
293
  }
138
294
 
295
+ /**
296
+ * Is the live process at `pid` the SAME process the state file recorded at
297
+ * `expectedStartedAtMs` — or a pid-reusing stranger? (Gap 1.)
298
+ *
299
+ * - dead pid → false (not the same process; it is no process at all)
300
+ * - no anchor (legacy state without startedAt) → true (legacy semantics)
301
+ * - probe failed → true (FAIL OPEN: never declare a live worker dead — and
302
+ * never kill — on the strength of a broken probe; callers fall back to the
303
+ * plain kill(pid,0) behavior this system always had)
304
+ * - creation time > startedAt + tolerance → false (a reuser can only be
305
+ * born after the original died)
306
+ */
307
+ export function isSameProcess(pid, expectedStartedAtMs, options = {}) {
308
+ if (!isProcessAlive(pid)) return false;
309
+ const expected = Number(expectedStartedAtMs);
310
+ if (!Number.isFinite(expected) || expected <= 0) return true;
311
+ const actual = processStartTimeMs(pid);
312
+ if (actual === null) return true;
313
+ const tolerance = Number.isFinite(Number(options.toleranceMs))
314
+ ? Number(options.toleranceMs)
315
+ : PID_IDENTITY_TOLERANCE_MS;
316
+ return actual <= expected + tolerance;
317
+ }
318
+
319
+ /**
320
+ * Reap a lost worker's recorded agent child (Gap 2). The worker persists
321
+ * `agentPid` + `agentStartedAt` per turn; when the WORKER dies (crash /
322
+ * pid-reuse detection) nothing used to kill that grandchild — it kept
323
+ * running unsupervised. Kill it, tree-wide, IF we can prove identity:
324
+ *
325
+ * - `agentStartedAt` anchor is REQUIRED — unlike the read-only liveness
326
+ * probe, this path kills, so a legacy state without the anchor fails
327
+ * CLOSED (no kill) rather than risking a pid-reused stranger;
328
+ * - with the anchor present, a failed creation-time probe fails open to the
329
+ * kill (the orphan leak is the common case, reuse the rare one — and the
330
+ * anchor already bounds the window to this session's lifetime).
331
+ *
332
+ * @returns {boolean} true when a kill was actually issued
333
+ */
334
+ export function reclaimOrphanAgentProcess(state) {
335
+ const agentPid = Number(state?.agentPid);
336
+ if (!Number.isInteger(agentPid) || agentPid <= 0) return false;
337
+ if (agentPid === Number(state?.pid)) return false; // never the worker itself
338
+ const anchor = Number(state?.agentStartedAt);
339
+ if (!Number.isFinite(anchor) || anchor <= 0) return false; // fail closed
340
+ if (!isSameProcess(agentPid, anchor)) return false; // reused / already gone
341
+ return _deps.killProcessTree(agentPid, "SIGKILL") === true;
342
+ }
343
+
139
344
  export function normalizeBackgroundAgentTitle(title) {
140
345
  const value = String(title || "").trim();
141
346
  if (!value) throw new Error("Background agent title cannot be empty");
@@ -164,12 +369,34 @@ export function effectiveBackgroundAgentState(state, options = {}) {
164
369
  endedAt: state.endedAt || t,
165
370
  lostReason: "process-exited",
166
371
  };
372
+ } else if (!isSameProcess(state.pid, state.startedAt)) {
373
+ // Gap 1: the pid is alive but belongs to a process created well after
374
+ // this session started — the OS reused the worker's pid.
375
+ next = {
376
+ ...state,
377
+ status: "lost",
378
+ endedAt: state.endedAt || t,
379
+ lostReason: "pid-reused",
380
+ };
167
381
  }
168
- if (next !== state && options.persist !== false) {
169
- try {
170
- writeBackgroundAgentState(next);
171
- } catch {
172
- /* best-effort; callers still get the corrected state */
382
+ if (next !== state) {
383
+ // Gap 2: the worker is gone (dead pid or pid-reused stranger) — reap the
384
+ // recorded agent grandchild so a crashed worker never leaves it running
385
+ // unsupervised. A merely-stale heartbeat with a live, verified worker
386
+ // still owns its child, so isSameProcess gates the reclaim.
387
+ if (!isSameProcess(state.pid, state.startedAt)) {
388
+ try {
389
+ reclaimOrphanAgentProcess(state);
390
+ } catch {
391
+ /* best-effort */
392
+ }
393
+ }
394
+ if (options.persist !== false) {
395
+ try {
396
+ writeBackgroundAgentState(next);
397
+ } catch {
398
+ /* best-effort; callers still get the corrected state */
399
+ }
173
400
  }
174
401
  }
175
402
  return next;
@@ -223,6 +450,38 @@ export function renameBackgroundAgent(id, title, options = {}) {
223
450
  return next;
224
451
  }
225
452
 
453
+ /**
454
+ * Remove a background agent's RECORD (state file + log) — `cc daemon rm`
455
+ * (gap-analysis 2026-07-11 P0 "后台 Agent Supervisor" 补 rm 动词). Terminal
456
+ * sessions (completed/failed/stopped/lost) remove directly; a still-running
457
+ * one is refused unless `force`, which stops it first. The underlying JSONL
458
+ * conversation session is NOT touched — that's `cc session delete`.
459
+ */
460
+ export function removeBackgroundAgent(id, options = {}) {
461
+ const state = effectiveBackgroundAgentState(readBackgroundAgentState(id), {
462
+ now: options.now,
463
+ heartbeatStaleMs: options.heartbeatStaleMs,
464
+ });
465
+ if (!state) throw new Error(`Background agent not found: ${id}`);
466
+ if (state.status === "running") {
467
+ if (options.force !== true) {
468
+ throw new Error(
469
+ `${id} is still running — stop it first (cc daemon stop ${id}) or pass --force`,
470
+ );
471
+ }
472
+ try {
473
+ stopBackgroundAgent(id);
474
+ } catch {
475
+ /* best-effort — the record removal below is the point of rm --force */
476
+ }
477
+ }
478
+ rmSync(statePath(id), { force: true });
479
+ if (options.keepLog !== true) {
480
+ rmSync(logPath(id), { force: true });
481
+ }
482
+ return { id, removed: true, status: state.status };
483
+ }
484
+
226
485
  /**
227
486
  * Pin/unpin a session for the dashboard (`cc daemon view`) — pinned sessions
228
487
  * sort first inside their group. Same read-modify-write + verify-retry dance
@@ -398,6 +657,7 @@ export function launchBackgroundAgent({
398
657
  pid: null,
399
658
  workerPid: null,
400
659
  agentPid: null,
660
+ agentStartedAt: null,
401
661
  status: "running",
402
662
  startedAt: Date.now(),
403
663
  heartbeatAt: Date.now(),
@@ -464,7 +724,35 @@ export function readBackgroundAgentLog(id, options = {}) {
464
724
  export function stopBackgroundAgent(id) {
465
725
  const state = effectiveBackgroundAgentState(readBackgroundAgentState(id));
466
726
  if (!state) throw new Error(`Background agent not found: ${id}`);
467
- if (state.status !== "running") return { ...state, stopped: false };
727
+ if (state.status !== "running") {
728
+ // Gap 2: stopping an already-lost session still reaps a leaked agent
729
+ // child recorded before the worker died (identity-guarded inside).
730
+ if (state.status === "lost") {
731
+ try {
732
+ reclaimOrphanAgentProcess(state);
733
+ } catch {
734
+ /* best-effort */
735
+ }
736
+ }
737
+ return { ...state, stopped: false };
738
+ }
739
+ // Gap 1: last-instant identity re-check before the kill — never
740
+ // taskkill/SIGTERM a pid the OS has re-assigned to an unrelated process.
741
+ if (!isSameProcess(state.pid, state.startedAt)) {
742
+ try {
743
+ reclaimOrphanAgentProcess(state);
744
+ } catch {
745
+ /* best-effort */
746
+ }
747
+ const lost = {
748
+ ...state,
749
+ status: "lost",
750
+ endedAt: Date.now(),
751
+ lostReason: "pid-reused",
752
+ };
753
+ writeBackgroundAgentState(lost);
754
+ return { ...lost, stopped: false };
755
+ }
468
756
  if (process.platform === "win32") {
469
757
  const killed = _deps.spawnSync(
470
758
  "taskkill",
@@ -485,6 +773,30 @@ export function stopBackgroundAgent(id) {
485
773
  } catch {
486
774
  process.kill(Number(state.pid), "SIGTERM");
487
775
  }
776
+ // The worker detaches the agent child into its OWN process group (so a
777
+ // crashed worker's child can be group-killed) — which also means the
778
+ // worker-group SIGTERM above no longer reaches it. Kill the agent group
779
+ // explicitly, identity-guarded via its agentStartedAt anchor.
780
+ const agentPid = Number(state.agentPid);
781
+ const anchor = Number(state.agentStartedAt);
782
+ if (
783
+ Number.isInteger(agentPid) &&
784
+ agentPid > 0 &&
785
+ agentPid !== Number(state.pid) &&
786
+ Number.isFinite(anchor) &&
787
+ anchor > 0 &&
788
+ isSameProcess(agentPid, anchor)
789
+ ) {
790
+ try {
791
+ process.kill(-agentPid, "SIGTERM");
792
+ } catch {
793
+ try {
794
+ process.kill(agentPid, "SIGTERM");
795
+ } catch {
796
+ /* already gone */
797
+ }
798
+ }
799
+ }
488
800
  }
489
801
  const next = {
490
802
  ...state,
@@ -0,0 +1,105 @@
1
+ /**
2
+ * Outbound backpressure policy — bounds server→client buffering when a
3
+ * consumer drains too slowly, so a stuck WS client can't grow the sender's
4
+ * memory without limit (the classic slow-consumer blowup) and the UI degrades
5
+ * explicitly instead of freezing.
6
+ *
7
+ * Pure + deterministic (no transport, no timers): the caller feeds the
8
+ * transport's current queued-byte count (e.g. `ws.bufferedAmount`) and a frame
9
+ * class; the policy decides send-vs-drop with hysteresis and reports the
10
+ * lag-episode transitions the caller turns into a notice.
11
+ *
12
+ * Designed to compose with EventReplayBuffer (event-seq-replay.js): a dropped
13
+ * *droppable* frame should still be RECORDED (it gets a seq and stays in the
14
+ * bounded replay buffer), just not sent. The seq gap on the next *sent* frame
15
+ * then trips the consumer's SeqGapTracker, which requests replaySince — so the
16
+ * dropped frames are recovered once the consumer catches up (or a truncated
17
+ * replay tells it to full-resync). Critical frames are never dropped.
18
+ */
19
+
20
+ const DEFAULT_HIGH_WATER_BYTES = 4 << 20; // 4 MiB queued → consumer is behind
21
+ const DEFAULT_LOW_WATER_BYTES = 1 << 20; // 1 MiB → considered caught up again
22
+
23
+ /** A frame that must always be delivered (lifecycle, errors, acks). */
24
+ export const CRITICAL = "critical";
25
+ /** A frame safe to drop under pressure and recover via replay/resync. */
26
+ export const DROPPABLE = "droppable";
27
+
28
+ export class BackpressurePolicy {
29
+ constructor({
30
+ highWaterBytes = DEFAULT_HIGH_WATER_BYTES,
31
+ lowWaterBytes = DEFAULT_LOW_WATER_BYTES,
32
+ } = {}) {
33
+ const high = Math.max(
34
+ 1,
35
+ Math.floor(highWaterBytes) || DEFAULT_HIGH_WATER_BYTES,
36
+ );
37
+ // Low water must sit below high water or hysteresis collapses to a flapping
38
+ // single threshold; clamp it to at most high-1.
39
+ const low = Math.min(Math.max(0, Math.floor(lowWaterBytes) || 0), high - 1);
40
+ this._high = high;
41
+ this._low = low;
42
+ this._lagging = false;
43
+ this._dropped = 0; // dropped since the CURRENT lag episode started
44
+ this._droppedTotal = 0; // dropped across the whole connection
45
+ }
46
+
47
+ /** True while the consumer is behind (buffered ≥ high, until it drains ≤ low). */
48
+ get lagging() {
49
+ return this._lagging;
50
+ }
51
+
52
+ /** Frames dropped across the whole connection. */
53
+ get droppedTotal() {
54
+ return this._droppedTotal;
55
+ }
56
+
57
+ /**
58
+ * Decide what to do with one outbound frame.
59
+ * @param {number} bufferedBytes transport's currently-queued bytes
60
+ * (`ws.bufferedAmount`); non-finite → treated as 0 (never lags).
61
+ * @param {"critical"|"droppable"} frameClass
62
+ * @returns {{send:boolean, drop:boolean, lagStarted:boolean, lagEnded:boolean,
63
+ * dropped:number}} `dropped` = count dropped in the episode that just
64
+ * started/ended (for the caller's notice); `send:false` only ever for a
65
+ * droppable frame while lagging.
66
+ */
67
+ evaluate(bufferedBytes, frameClass) {
68
+ const b = Number.isFinite(bufferedBytes) ? bufferedBytes : 0;
69
+ let lagStarted = false;
70
+ let lagEnded = false;
71
+
72
+ if (!this._lagging && b >= this._high) {
73
+ this._lagging = true;
74
+ this._dropped = 0;
75
+ lagStarted = true;
76
+ } else if (this._lagging && b <= this._low) {
77
+ this._lagging = false;
78
+ lagEnded = true;
79
+ }
80
+
81
+ // While lagging, shed droppable frames to let the buffer drain; critical
82
+ // frames (and everything while not lagging) always go out.
83
+ if (this._lagging && frameClass === DROPPABLE) {
84
+ this._dropped += 1;
85
+ this._droppedTotal += 1;
86
+ return {
87
+ send: false,
88
+ drop: true,
89
+ lagStarted,
90
+ lagEnded,
91
+ dropped: this._dropped,
92
+ };
93
+ }
94
+
95
+ // `dropped` carries the episode's running count so a lag-end notice can
96
+ // report how many droppable frames were shed.
97
+ return {
98
+ send: true,
99
+ drop: false,
100
+ lagStarted,
101
+ lagEnded,
102
+ dropped: this._dropped,
103
+ };
104
+ }
105
+ }