chainlesschain 0.162.35 → 0.162.37

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 (193) hide show
  1. package/package.json +1 -1
  2. package/src/assets/web-panel/assets/{AIOps-CJn02U42.js → AIOps-_oxz4VHy.js} +1 -1
  3. package/src/assets/web-panel/assets/{ActionButton-ewURAAoy.js → ActionButton-uaeqFuDj.js} +1 -1
  4. package/src/assets/web-panel/assets/{Analytics-BiSadESb.js → Analytics-BPVV0OUf.js} +3 -3
  5. package/src/assets/web-panel/assets/{AppLayout-BR0WOEug.js → AppLayout-ppCYKm3I.js} +4 -4
  6. package/src/assets/web-panel/assets/{Audit-CrqcYx0e.js → Audit-DFAY6umk.js} +1 -1
  7. package/src/assets/web-panel/assets/{Backup-DtbSBn4e.js → Backup-pAPBFDyP.js} +1 -1
  8. package/src/assets/web-panel/assets/{BaseInput-BjSc9j0o.js → BaseInput-BbBl0uT2.js} +1 -1
  9. package/src/assets/web-panel/assets/{Chat-ixzrlCJE.js → Chat-Ct22JUnT.js} +6 -6
  10. package/src/assets/web-panel/assets/{ChatBubbleRenderer-B78nEq05.js → ChatBubbleRenderer-DPlsLl22.js} +1 -1
  11. package/src/assets/web-panel/assets/{Checkbox-UGYeSsgr.js → Checkbox-DEkCollc.js} +1 -1
  12. package/src/assets/web-panel/assets/{Codegen-B97OOAg4.js → Codegen-Tor-de39.js} +1 -1
  13. package/src/assets/web-panel/assets/{Col-D9aGkaZ6.js → Col-ojNrLQU7.js} +1 -1
  14. package/src/assets/web-panel/assets/{Community-Dc2v2RGS.js → Community-CLOGhqMF.js} +1 -1
  15. package/src/assets/web-panel/assets/{Compact-B_FYlUQR.js → Compact-CYKNlSZ4.js} +1 -1
  16. package/src/assets/web-panel/assets/{Compliance-C4FiTHyC.js → Compliance-C5E6ABuA.js} +1 -1
  17. package/src/assets/web-panel/assets/{Cowork-CQ8j3LIg.js → Cowork-CHeEsZ3W.js} +2 -2
  18. package/src/assets/web-panel/assets/{Cron-Dzjs9Z9Z.js → Cron-B4e1n2e7.js} +2 -2
  19. package/src/assets/web-panel/assets/{Crosschain-BXI24uzI.js → Crosschain-DbNV8P9R.js} +1 -1
  20. package/src/assets/web-panel/assets/{DID-C-I4_d07.js → DID-C5_Tk3nC.js} +2 -2
  21. package/src/assets/web-panel/assets/{Dashboard-BzzGh5mo.js → Dashboard-BhdV_c4N.js} +2 -2
  22. package/src/assets/web-panel/assets/{Dropdown-Bh8H70De.js → Dropdown-CEi5AMtM.js} +1 -1
  23. package/src/assets/web-panel/assets/{EmailListRenderer-DI_qybJP.js → EmailListRenderer-DOhPiYng.js} +1 -1
  24. package/src/assets/web-panel/assets/{FamilyGuardDashboard-DkKTsfc4.js → FamilyGuardDashboard-fu4NRP3X.js} +1 -1
  25. package/src/assets/web-panel/assets/{Federation-DS7CmvVG.js → Federation-B7BtIWKL.js} +1 -1
  26. package/src/assets/web-panel/assets/{FormItemContext-CI97WsB5.js → FormItemContext-BmPWZVLP.js} +1 -1
  27. package/src/assets/web-panel/assets/GenericCardRenderer-hsOPNJq8.js +1 -0
  28. package/src/assets/web-panel/assets/{Git-CEh0gR2W.js → Git-Bi_EFBUH.js} +2 -2
  29. package/src/assets/web-panel/assets/{Governance-kIr3tls2.js → Governance-emf2ubDK.js} +1 -1
  30. package/src/assets/web-panel/assets/{Inference-CC1GzyC1.js → Inference-B7KjKzkI.js} +1 -1
  31. package/src/assets/web-panel/assets/{KnowledgeGraph-BNgTiWOB.js → KnowledgeGraph-uAaBK0F3.js} +1 -1
  32. package/src/assets/web-panel/assets/{Logs-B2P10gB1.js → Logs-utK7hNpj.js} +2 -2
  33. package/src/assets/web-panel/assets/{Marketplace-HPfBvbFZ.js → Marketplace-CzQe6n3z.js} +1 -1
  34. package/src/assets/web-panel/assets/{McpTools-ByYotSKb.js → McpTools-CuAaJr51.js} +5 -5
  35. package/src/assets/web-panel/assets/{Memory-BGIAzFVS.js → Memory-CRuZZJ75.js} +2 -2
  36. package/src/assets/web-panel/assets/{MobileBridge-CroNYTAH.js → MobileBridge-Cp06wunh.js} +2 -2
  37. package/src/assets/web-panel/assets/MobileProjects-DJEdUwhr.js +1 -0
  38. package/src/assets/web-panel/assets/{Mtc-BqhyIwo9.js → Mtc-8YY4dR7g.js} +2 -2
  39. package/src/assets/web-panel/assets/{MtcAudit-BpEKOvx9.js → MtcAudit-BmPJYHar.js} +2 -2
  40. package/src/assets/web-panel/assets/{Multisig-DST1d_Qo.js → Multisig-d-ydyVdq.js} +3 -3
  41. package/src/assets/web-panel/assets/{NLProgramming-DlMsZcK_.js → NLProgramming-DA_ikw_n.js} +1 -1
  42. package/src/assets/web-panel/assets/{Notes-C734UJvD.js → Notes-DIyF-fRe.js} +3 -3
  43. package/src/assets/web-panel/assets/{NotificationSettings-C0-pPxvk.js → NotificationSettings-CzPZXEtK.js} +1 -1
  44. package/src/assets/web-panel/assets/OrderTableRenderer-BiLtg-LY.js +1 -0
  45. package/src/assets/web-panel/assets/{Organization-C5iHC_yW.js → Organization-DdDZ_Ap6.js} +4 -4
  46. package/src/assets/web-panel/assets/{Overflow-CovuHHVR.js → Overflow-BnMBkttv.js} +1 -1
  47. package/src/assets/web-panel/assets/{P2P-Dx9QL-Gy.js → P2P-Es1050f-.js} +2 -2
  48. package/src/assets/web-panel/assets/{PdhVaultBrowser-IP1dEt6-.js → PdhVaultBrowser-CKkRmyn9.js} +4 -4
  49. package/src/assets/web-panel/assets/{Permissions-BrR1XZG5.js → Permissions-zU9n9cAD.js} +4 -4
  50. package/src/assets/web-panel/assets/{PersonalDataHub-BgqxVE5m.js → PersonalDataHub-BZi5Xwas.js} +2 -2
  51. package/src/assets/web-panel/assets/{Pipeline-DzMk5HAz.js → Pipeline-CRfeGiFc.js} +1 -1
  52. package/src/assets/web-panel/assets/{Privacy-CDoLa6tk.js → Privacy-CQA_IgLA.js} +1 -1
  53. package/src/assets/web-panel/assets/{ProjectInit-Dy5gc6ve.js → ProjectInit-C9hmEvoT.js} +2 -2
  54. package/src/assets/web-panel/assets/{ProjectSettings-DXy-k4hG.js → ProjectSettings-yXA72ws4.js} +2 -2
  55. package/src/assets/web-panel/assets/Projects-BpWS-qam.js +1 -0
  56. package/src/assets/web-panel/assets/Providers-Cxe55dRD.js +1 -0
  57. package/src/assets/web-panel/assets/{QuickAsk-B8KEHCnd.js → QuickAsk-Do0aUTQr.js} +1 -1
  58. package/src/assets/web-panel/assets/{Recommend-DNVHGYYZ.js → Recommend--ysZHjyA.js} +1 -1
  59. package/src/assets/web-panel/assets/{Reputation-CaDhWP03.js → Reputation-BOBU8JrH.js} +1 -1
  60. package/src/assets/web-panel/assets/{Row-CrGLI02x.js → Row-C6X7bRKE.js} +1 -1
  61. package/src/assets/web-panel/assets/{RssFeed-BX7P8I6i.js → RssFeed-D8AwqlkQ.js} +3 -3
  62. package/src/assets/web-panel/assets/Search-Bi3rCZD4.js +1 -0
  63. package/src/assets/web-panel/assets/{Security-B6J7IFc1.js → Security-DxUDVrtY.js} +4 -4
  64. package/src/assets/web-panel/assets/{Services-vvdcO3mM.js → Services-BXXN7yC1.js} +2 -2
  65. package/src/assets/web-panel/assets/{Skeleton-BoAoPTzZ.js → Skeleton-B3BR34tZ.js} +1 -1
  66. package/src/assets/web-panel/assets/{Skills-CyIQV5b3.js → Skills-BjYu8OQ1.js} +1 -1
  67. package/src/assets/web-panel/assets/{Sla-BAQVgdZV.js → Sla-DDkCtD8w.js} +1 -1
  68. package/src/assets/web-panel/assets/{SpeechSettings-Bxcn1Jkj.js → SpeechSettings-CGhYzP7V.js} +1 -1
  69. package/src/assets/web-panel/assets/{SyncSettings-Dpaj3hDM.js → SyncSettings-CYNKVAHA.js} +2 -2
  70. package/src/assets/web-panel/assets/{Tasks-Bwqo89En.js → Tasks-DavmlJpd.js} +1 -1
  71. package/src/assets/web-panel/assets/{Templates-Bowcqifn.js → Templates-CQuYFf2C.js} +1 -1
  72. package/src/assets/web-panel/assets/{Tenant-DOkf85uG.js → Tenant-DdzZh8vE.js} +1 -1
  73. package/src/assets/web-panel/assets/Terminal-D75WeG9d.js +3 -0
  74. package/src/assets/web-panel/assets/{TimelineRenderer-B9A3zDXA.js → TimelineRenderer-DKOARnc_.js} +1 -1
  75. package/src/assets/web-panel/assets/{Tokens-jtVVqKFr.js → Tokens-D7QRNG8y.js} +1 -1
  76. package/src/assets/web-panel/assets/{Trigger-26Iw-iIl.js → Trigger-BCsqLZl4.js} +1 -1
  77. package/src/assets/web-panel/assets/{Trust-DqY5ORrH.js → Trust-BarGUa6p.js} +1 -1
  78. package/src/assets/web-panel/assets/{UkeySign-BFsbr3y7.js → UkeySign-pHrg5a8E.js} +1 -1
  79. package/src/assets/web-panel/assets/{VideoEditing-BtDbj3oa.js → VideoEditing-Dug3m1py.js} +1 -1
  80. package/src/assets/web-panel/assets/{Wallet-BAwmwHbk.js → Wallet-BfK3Z_Ez.js} +4 -4
  81. package/src/assets/web-panel/assets/{WebAuthn-DINJTsfq.js → WebAuthn-CYRdl9td.js} +5 -5
  82. package/src/assets/web-panel/assets/{WorkflowEditor-BEorm8SK.js → WorkflowEditor-DTW5AcqM.js} +1 -1
  83. package/src/assets/web-panel/assets/{chat-CE39-Dxg.js → chat-CCXz4j38.js} +1 -1
  84. package/src/assets/web-panel/assets/{colors-C_cLZ93a.js → colors-BJBOhAqa.js} +1 -1
  85. package/src/assets/web-panel/assets/{compact-item-BSioWA2c.js → compact-item-E9M6BQcM.js} +1 -1
  86. package/src/assets/web-panel/assets/{createContext-CGTk4mhN.js → createContext-Cg9CAws4.js} +1 -1
  87. package/src/assets/web-panel/assets/devWarning-BrsbTJUv.js +1 -0
  88. package/src/assets/web-panel/assets/{hasIn-Dl1fRwS_.js → hasIn-DhVtqv5L.js} +1 -1
  89. package/src/assets/web-panel/assets/{index-pngH1and.js → index--7o5YdL6.js} +1 -1
  90. package/src/assets/web-panel/assets/{index-BmbVyhk1.js → index-4N5lNXGP.js} +1 -1
  91. package/src/assets/web-panel/assets/{index-BnEPB1Mz.js → index-6-04M2Nx.js} +1 -1
  92. package/src/assets/web-panel/assets/{index-Cxw3p73X.js → index-B111fZ21.js} +1 -1
  93. package/src/assets/web-panel/assets/{index-CST381Qf.js → index-B4NBF4Sa.js} +1 -1
  94. package/src/assets/web-panel/assets/{index-ChwpS1f0.js → index-B8bjEHrQ.js} +1 -1
  95. package/src/assets/web-panel/assets/{index--SWvw6yW.js → index-BAB0nGP7.js} +1 -1
  96. package/src/assets/web-panel/assets/{index-CAwVwBOL.js → index-BFZPRd0T.js} +1 -1
  97. package/src/assets/web-panel/assets/{index-hv4jUdG3.js → index-B_SMPD4L.js} +1 -1
  98. package/src/assets/web-panel/assets/{index-Qj2x55mz.js → index-BxSzyly9.js} +1 -1
  99. package/src/assets/web-panel/assets/{index-BWpfxzVm.js → index-ByazO4Q9.js} +1 -1
  100. package/src/assets/web-panel/assets/{index-Di6nvW1N.js → index-C-2dUIli.js} +1 -1
  101. package/src/assets/web-panel/assets/{index-BhqOTuMW.js → index-CFarAlXj.js} +1 -1
  102. package/src/assets/web-panel/assets/{index-CA6K7lZB.js → index-CFp-wdrQ.js} +1 -1
  103. package/src/assets/web-panel/assets/{index-DTKEXyaW.js → index-CJ8nNT8h.js} +1 -1
  104. package/src/assets/web-panel/assets/{index-iiZfONfx.js → index-CSiyjCYi.js} +1 -1
  105. package/src/assets/web-panel/assets/{index-DTpCUi0m.js → index-CUp_c8Le.js} +1 -1
  106. package/src/assets/web-panel/assets/{index-Bvi14vJ7.js → index-CVR_s-pT.js} +1 -1
  107. package/src/assets/web-panel/assets/{index-DKEipmR8.js → index-Ca8BYV1g.js} +1 -1
  108. package/src/assets/web-panel/assets/{index-DJyeeygd.js → index-CeRlLp3F.js} +1 -1
  109. package/src/assets/web-panel/assets/{index-C9tq8Da8.js → index-ChsSljaN.js} +1 -1
  110. package/src/assets/web-panel/assets/{index-B2QiUEgK.js → index-CkTeBHI9.js} +1 -1
  111. package/src/assets/web-panel/assets/{index-OCxo0X6J.js → index-Cm1m7BJh.js} +1 -1
  112. package/src/assets/web-panel/assets/{index-DrWERr8C.js → index-ComyTKz-.js} +1 -1
  113. package/src/assets/web-panel/assets/{index-B016Fsqr.js → index-CznfPnOx.js} +3 -3
  114. package/src/assets/web-panel/assets/{index-CisXVbSt.js → index-D5yC2Ps8.js} +1 -1
  115. package/src/assets/web-panel/assets/{index-C-VVk1Jg.js → index-D7DXdf7x.js} +1 -1
  116. package/src/assets/web-panel/assets/{index-DDQx2YFc.js → index-DDcJO27F.js} +1 -1
  117. package/src/assets/web-panel/assets/{index-Ds2RzRG0.js → index-DSQazU6J.js} +1 -1
  118. package/src/assets/web-panel/assets/index-DSTQDO-Y.js +1 -0
  119. package/src/assets/web-panel/assets/{index-C4JXchTG.js → index-DaFe1aqY.js} +1 -1
  120. package/src/assets/web-panel/assets/{index-BAhinBPR.js → index-DdhnGez0.js} +1 -1
  121. package/src/assets/web-panel/assets/{index-9_mmaR42.js → index-Di5LBXcE.js} +1 -1
  122. package/src/assets/web-panel/assets/{index-D9D4q-qI.js → index-Dwvewrul.js} +1 -1
  123. package/src/assets/web-panel/assets/{index-CbXnyoSO.js → index-MdXEhfdJ.js} +1 -1
  124. package/src/assets/web-panel/assets/{index-II3JhQu2.js → index-_PNqQ5mE.js} +1 -1
  125. package/src/assets/web-panel/assets/index-c2U6LV3Q.js +1 -0
  126. package/src/assets/web-panel/assets/{index-C2ly7sCw.js → index-kz1oXl1a.js} +1 -1
  127. package/src/assets/web-panel/assets/{index-Ceo9P9tQ.js → index-wkt-o5q5.js} +1 -1
  128. package/src/assets/web-panel/assets/{initDefaultProps-GOhLA2-f.js → initDefaultProps-iyBaePF-.js} +1 -1
  129. package/src/assets/web-panel/assets/{motion-jqxFzHTx.js → motion-RWtj4rgu.js} +1 -1
  130. package/src/assets/web-panel/assets/{move-CSLsp6TA.js → move-CqPRVzpH.js} +1 -1
  131. package/src/assets/web-panel/assets/{omit-Cnlrb25c.js → omit-DsvJze25.js} +1 -1
  132. package/src/assets/web-panel/assets/{pickAttrs-CLqlxWWD.js → pickAttrs-B4tfZBhc.js} +1 -1
  133. package/src/assets/web-panel/assets/{placementArrow-BAWIWtul.js → placementArrow-KvHUwXMA.js} +1 -1
  134. package/src/assets/web-panel/assets/{responsiveObserve-CSR1DayS.js → responsiveObserve-DGdJ-b7W.js} +1 -1
  135. package/src/assets/web-panel/assets/{slide-CNhoPJOp.js → slide-Cd6ebRmw.js} +1 -1
  136. package/src/assets/web-panel/assets/{statusUtils-BZiYHRHW.js → statusUtils-Bg9GcIAn.js} +1 -1
  137. package/src/assets/web-panel/assets/{styleChecker-BMoY-Fm5.js → styleChecker-MQjKsG84.js} +1 -1
  138. package/src/assets/web-panel/assets/{useFlexGapSupport-DhtNdlaS.js → useFlexGapSupport-C241WujP.js} +1 -1
  139. package/src/assets/web-panel/assets/{useFs-DNPtDOZ4.js → useFs-CMpy7RS4.js} +1 -1
  140. package/src/assets/web-panel/assets/{usePersonalDataHub-DTdjNvAI.js → usePersonalDataHub-BLHtapKb.js} +1 -1
  141. package/src/assets/web-panel/assets/{vnode-C9zW9IJ2.js → vnode-DmcTV67c.js} +1 -1
  142. package/src/assets/web-panel/assets/{zoom-D-6RYJJr.js → zoom-DHL8_0Y8.js} +1 -1
  143. package/src/assets/web-panel/index.html +1 -1
  144. package/src/commands/agent.js +161 -6
  145. package/src/commands/agents.js +8 -2
  146. package/src/commands/cli-anything.js +14 -6
  147. package/src/commands/command.js +7 -2
  148. package/src/commands/hook.js +136 -28
  149. package/src/commands/ide.js +168 -0
  150. package/src/commands/loop.js +450 -0
  151. package/src/commands/mcp.js +92 -0
  152. package/src/commands/output-style.js +127 -0
  153. package/src/commands/permissions.js +211 -0
  154. package/src/commands/statusline.js +93 -0
  155. package/src/index.js +10 -2
  156. package/src/lib/agent-core.js +7 -0
  157. package/src/lib/agents.js +5 -0
  158. package/src/lib/hook-manager.js +1 -0
  159. package/src/lib/hook-runner.cjs +183 -0
  160. package/src/lib/ide-bridge.js +310 -0
  161. package/src/lib/image-input.js +156 -0
  162. package/src/lib/loop.js +198 -0
  163. package/src/lib/mcp-oauth.js +415 -0
  164. package/src/lib/output-styles.js +179 -0
  165. package/src/lib/permission-rules.cjs +325 -0
  166. package/src/lib/provider-options.js +11 -7
  167. package/src/lib/settings-hook-events.cjs +102 -0
  168. package/src/lib/settings-hooks.cjs +163 -0
  169. package/src/lib/settings-loader.cjs +244 -0
  170. package/src/lib/slash-commands.js +4 -0
  171. package/src/lib/status-line.cjs +204 -0
  172. package/src/lib/sub-agent-profiles.js +3 -0
  173. package/src/lib/web-search.js +487 -0
  174. package/src/repl/agent-repl.js +450 -35
  175. package/src/repl/slash-macro.js +45 -0
  176. package/src/runtime/agent-core.js +799 -21
  177. package/src/runtime/coding-agent-contract-shared.cjs +94 -4
  178. package/src/runtime/coding-agent-policy.cjs +24 -0
  179. package/src/runtime/headless-runner.js +162 -6
  180. package/src/runtime/headless-stream.js +133 -7
  181. package/src/runtime/mcp-config.js +161 -15
  182. package/src/runtime/policies/agent-policy.js +4 -0
  183. package/src/runtime/system-prompt.js +6 -1
  184. package/src/assets/web-panel/assets/GenericCardRenderer-Da27EdR4.js +0 -1
  185. package/src/assets/web-panel/assets/MobileProjects-CH-qnGEV.js +0 -1
  186. package/src/assets/web-panel/assets/OrderTableRenderer-C7zT9eFc.js +0 -1
  187. package/src/assets/web-panel/assets/Projects-DvsaEbZR.js +0 -1
  188. package/src/assets/web-panel/assets/Providers-Demck9PO.js +0 -1
  189. package/src/assets/web-panel/assets/Search-laS6rz8M.js +0 -1
  190. package/src/assets/web-panel/assets/Terminal-v4MM9dCj.js +0 -3
  191. package/src/assets/web-panel/assets/devWarning-PObcVnJR.js +0 -1
  192. package/src/assets/web-panel/assets/index-BNwIzLyX.js +0 -1
  193. package/src/assets/web-panel/assets/index-Dh6FxR9B.js +0 -1
@@ -0,0 +1,310 @@
1
+ /**
2
+ * IDE bridge discovery (Phase 0) — find a first-party IDE MCP server that an
3
+ * editor extension advertises via a lockfile, and turn it into an MCP server
4
+ * config the agent loop can connect to.
5
+ *
6
+ * Protocol (design: docs/design/modules/98_IDE桥接对标方案.md): each running
7
+ * IDE instance writes
8
+ * ~/.chainlesschain/ide/<port>.json (file 0600, dir 0700)
9
+ * describing a localhost SSE/HTTP MCP server + a per-instance bearer token.
10
+ * The extension that owns the integrated terminal ALSO injects
11
+ * CHAINLESSCHAIN_IDE_PORT (+ optional CHAINLESSCHAIN_IDE_TOKEN)
12
+ * so the CLI can lock onto the exact instance without scanning/guessing
13
+ * (the deterministic "path A" below). Plain terminals fall back to a lockfile
14
+ * scan + workspace match ("path B").
15
+ *
16
+ * Phase 0 is pure CLI: no extension code, no VS Code dependency, fully
17
+ * unit-testable through `_deps`. The connect path itself reuses the existing
18
+ * MCP client (SSE/HTTP) via mcp-config.js `loadIdeMcp`.
19
+ */
20
+ import fs from "fs";
21
+ import path from "path";
22
+ import os from "os";
23
+
24
+ /** A lock whose pid is dead and whose file is older than this = stale. */
25
+ const STALE_TTL_MS = 30_000;
26
+
27
+ /** Transports the CLI's MCP client can actually talk (see mcp-client.js). */
28
+ const SUPPORTED_TRANSPORTS = new Set(["sse", "http", "https"]);
29
+
30
+ /** Liveness probe via signal 0 — cross-platform (works on Windows too). */
31
+ function defaultProcessAlive(pid) {
32
+ if (!pid || typeof pid !== "number") return false;
33
+ try {
34
+ process.kill(pid, 0);
35
+ return true;
36
+ } catch (err) {
37
+ // EPERM: the process exists but we may not signal it → still alive.
38
+ return !!(err && err.code === "EPERM");
39
+ }
40
+ }
41
+
42
+ export const _deps = {
43
+ readDir: (d) => fs.readdirSync(d),
44
+ readFile: (p) => fs.readFileSync(p, "utf-8"),
45
+ statMtimeMs: (p) => fs.statSync(p).mtimeMs,
46
+ homedir: () => os.homedir(),
47
+ now: () => Date.now(),
48
+ processAlive: defaultProcessAlive,
49
+ };
50
+
51
+ /** `~/.chainlesschain/ide/`. */
52
+ export function ideLockDir(deps = _deps) {
53
+ return path.join(deps.homedir(), ".chainlesschain", "ide");
54
+ }
55
+
56
+ function isLocalhostUrl(url) {
57
+ try {
58
+ const u = new URL(url);
59
+ return u.hostname === "127.0.0.1" || u.hostname === "::1";
60
+ } catch {
61
+ return false;
62
+ }
63
+ }
64
+
65
+ function inferTransportFromUrl(url) {
66
+ if (/\/sse(\b|\/|$)/i.test(url)) return "sse";
67
+ if (/^https:/i.test(url)) return "https";
68
+ if (/^http:/i.test(url)) return "http";
69
+ return null;
70
+ }
71
+
72
+ /** Parse + validate one lock JSON. Returns a normalized lock or null. */
73
+ function parseLock(raw, filePath) {
74
+ let lock;
75
+ try {
76
+ lock = JSON.parse(raw);
77
+ } catch {
78
+ return null;
79
+ }
80
+ if (!lock || typeof lock !== "object") return null;
81
+ if (!lock.url || !isLocalhostUrl(lock.url)) return null;
82
+
83
+ const transport = String(
84
+ lock.transport || inferTransportFromUrl(lock.url) || "",
85
+ ).toLowerCase();
86
+ if (!SUPPORTED_TRANSPORTS.has(transport)) return null; // e.g. ws — not yet
87
+
88
+ let folders = lock.workspaceFolders;
89
+ if (typeof folders === "string") folders = [folders];
90
+ if (!Array.isArray(folders)) folders = [];
91
+ folders = folders.filter((f) => typeof f === "string" && f.length > 0);
92
+
93
+ return {
94
+ ide: typeof lock.ide === "string" ? lock.ide : "unknown",
95
+ transport,
96
+ url: lock.url,
97
+ port: lock.port,
98
+ token: typeof lock.token === "string" ? lock.token : null,
99
+ workspaceFolders: folders,
100
+ pid: typeof lock.pid === "number" ? lock.pid : null,
101
+ startedAt: typeof lock.started_at === "number" ? lock.started_at : 0,
102
+ _file: filePath,
103
+ };
104
+ }
105
+
106
+ /** True when a lock is dead: pid gone AND file older than the TTL. */
107
+ function isStale(lock, deps) {
108
+ if (lock.pid && deps.processAlive(lock.pid)) return false;
109
+ let mtime;
110
+ try {
111
+ mtime = deps.statMtimeMs(lock._file);
112
+ } catch {
113
+ return true;
114
+ }
115
+ return deps.now() - mtime > STALE_TTL_MS;
116
+ }
117
+
118
+ /**
119
+ * Scan the lock dir → normalized, live, localhost, supported-transport locks.
120
+ * Missing dir / unreadable files are silently skipped (best-effort).
121
+ */
122
+ export function readIdeLocks(deps = _deps) {
123
+ const dir = ideLockDir(deps);
124
+ let names;
125
+ try {
126
+ names = deps.readDir(dir);
127
+ } catch {
128
+ return [];
129
+ }
130
+ const out = [];
131
+ for (const name of names || []) {
132
+ if (!String(name).endsWith(".json")) continue;
133
+ const fp = path.join(dir, name);
134
+ let raw;
135
+ try {
136
+ raw = deps.readFile(fp);
137
+ } catch {
138
+ continue;
139
+ }
140
+ const lock = parseLock(raw, fp);
141
+ if (!lock) continue;
142
+ if (isStale(lock, deps)) continue;
143
+ out.push(lock);
144
+ }
145
+ return out;
146
+ }
147
+
148
+ /** Are we running inside an editor's integrated terminal? */
149
+ export function isInIdeTerminal(env = process.env) {
150
+ if (!env) return false;
151
+ if (env.CHAINLESSCHAIN_IDE_PORT) return true;
152
+ if (env.TERM_PROGRAM === "vscode") return true;
153
+ if (env.CHAINLESSCHAIN_IDE) return true;
154
+ if (env.TERMINAL_EMULATOR && /jetbrains/i.test(env.TERMINAL_EMULATOR)) {
155
+ return true;
156
+ }
157
+ return false;
158
+ }
159
+
160
+ /** Normalize a path for prefix comparison (sep + trailing slash + win case). */
161
+ function normPath(p) {
162
+ const s = String(p)
163
+ .replace(/\\/g, "/")
164
+ .replace(/\/+$/, "");
165
+ return process.platform === "win32" ? s.toLowerCase() : s;
166
+ }
167
+
168
+ /**
169
+ * Longest matching workspace-folder length for `cwd`, or -1 if none of the
170
+ * lock's folders contains (or equals) cwd. Longer = more specific.
171
+ */
172
+ function workspaceMatchScore(lock, cwd) {
173
+ const c = normPath(cwd);
174
+ let best = -1;
175
+ for (const f of lock.workspaceFolders) {
176
+ const nf = normPath(f);
177
+ if (c === nf || c.startsWith(nf + "/")) {
178
+ if (nf.length > best) best = nf.length;
179
+ }
180
+ }
181
+ return best;
182
+ }
183
+
184
+ /**
185
+ * Pick the IDE server to connect to, or null.
186
+ *
187
+ * Path A (deterministic): if `CHAINLESSCHAIN_IDE_PORT` is set and a live lock
188
+ * has that port, use it (token may come from the lock or the env).
189
+ * Path B (scan): otherwise rank live locks by (longest workspace-prefix match,
190
+ * then newest started_at). With `force`, fall back to the newest lock even
191
+ * when no folder matches (used by `--ide`).
192
+ *
193
+ * @param {object} opts { cwd?, env?, force? }
194
+ * @param {object} [deps]
195
+ */
196
+ export function discoverIdeServer(
197
+ { cwd = process.cwd(), env = process.env, force = false } = {},
198
+ deps = _deps,
199
+ ) {
200
+ const locks = readIdeLocks(deps);
201
+ if (!locks.length) return null;
202
+
203
+ // Path A — env fast-path.
204
+ const envPort = env && env.CHAINLESSCHAIN_IDE_PORT;
205
+ if (envPort) {
206
+ const hit = locks.find((l) => String(l.port) === String(envPort));
207
+ if (hit) {
208
+ if (!hit.token && env.CHAINLESSCHAIN_IDE_TOKEN) {
209
+ hit.token = env.CHAINLESSCHAIN_IDE_TOKEN;
210
+ }
211
+ return hit;
212
+ }
213
+ // env named a port with no live lock — fall through to scan.
214
+ }
215
+
216
+ // Path B — workspace match.
217
+ let best = null;
218
+ let bestScore = -1;
219
+ let bestStarted = -1;
220
+ for (const l of locks) {
221
+ const score = workspaceMatchScore(l, cwd);
222
+ if (score < 0) continue;
223
+ if (
224
+ score > bestScore ||
225
+ (score === bestScore && l.startedAt > bestStarted)
226
+ ) {
227
+ best = l;
228
+ bestScore = score;
229
+ bestStarted = l.startedAt;
230
+ }
231
+ }
232
+ if (best) return best;
233
+
234
+ // --ide forced but no workspace match → newest live lock.
235
+ if (force) {
236
+ return locks
237
+ .slice()
238
+ .sort((a, b) => b.startedAt - a.startedAt)[0];
239
+ }
240
+ return null;
241
+ }
242
+
243
+ /**
244
+ * A discovered lock → an MCP server config row for `setupMcpFromConfig`.
245
+ * `longRunning` is forward-compat metadata: IDE tools like `ide_openDiff` may
246
+ * block on the user for minutes, so the agent loop should exempt them from a
247
+ * per-call timeout (consumed in Phase 1).
248
+ */
249
+ export function ideServerToMcpConfig(lock) {
250
+ if (!lock || !lock.url) return null;
251
+ const headers = {};
252
+ if (lock.token) headers.Authorization = `Bearer ${lock.token}`;
253
+ return {
254
+ url: lock.url,
255
+ transport: lock.transport,
256
+ headers,
257
+ longRunning: true,
258
+ };
259
+ }
260
+
261
+ /**
262
+ * Human-readable explanation of why discovery did/didn't find a server — backs
263
+ * `cc ide doctor`. Returns { inIdeTerminal, locks:[{...,reasons?}], chosen,
264
+ * reason }.
265
+ */
266
+ export function diagnoseIde(
267
+ { cwd = process.cwd(), env = process.env, force = false } = {},
268
+ deps = _deps,
269
+ ) {
270
+ const inIdeTerminal = isInIdeTerminal(env);
271
+ const locks = readIdeLocks(deps);
272
+ const chosen = discoverIdeServer({ cwd, env, force }, deps);
273
+
274
+ let reason;
275
+ if (chosen) {
276
+ reason = env && env.CHAINLESSCHAIN_IDE_PORT &&
277
+ String(chosen.port) === String(env.CHAINLESSCHAIN_IDE_PORT)
278
+ ? "matched CHAINLESSCHAIN_IDE_PORT (env fast-path)"
279
+ : force
280
+ ? "forced (--ide); newest live lock"
281
+ : "workspace match";
282
+ } else if (!locks.length) {
283
+ reason =
284
+ "no live IDE lockfiles in " +
285
+ ideLockDir(deps) +
286
+ " (is an IDE extension running? are locks stale?)";
287
+ } else {
288
+ reason =
289
+ "lockfiles present but none match cwd's workspace " +
290
+ "(run with --ide to force the newest, or cd into the IDE workspace)";
291
+ }
292
+ return {
293
+ inIdeTerminal,
294
+ lockDir: ideLockDir(deps),
295
+ locks: locks.map((l) => ({
296
+ ide: l.ide,
297
+ port: l.port,
298
+ transport: l.transport,
299
+ url: l.url,
300
+ hasToken: !!l.token,
301
+ workspaceFolders: l.workspaceFolders,
302
+ pid: l.pid,
303
+ matchScore: workspaceMatchScore(l, cwd),
304
+ })),
305
+ chosen: chosen
306
+ ? { ide: chosen.ide, port: chosen.port, url: chosen.url }
307
+ : null,
308
+ reason,
309
+ };
310
+ }
@@ -0,0 +1,156 @@
1
+ /**
2
+ * image-input — multimodal (vision) input for `cc agent`.
3
+ *
4
+ * cc's internal message format is OpenAI-shaped, so the internal multimodal
5
+ * representation is OpenAI's: a user message whose `content` is an array of
6
+ * `{type:"text"}` / `{type:"image_url", image_url:{url:"data:<media>;base64,..."}}`
7
+ * parts. The OpenAI-compatible providers (openai/volcengine/deepseek/…) accept
8
+ * that verbatim, so their branch needs no conversion. ollama and anthropic use
9
+ * different shapes — `toOllamaMessages` and `imageUrlBlockToAnthropic` convert.
10
+ *
11
+ * All functions are pure (except `resolveImages`, which reads files via the
12
+ * injectable `_deps.fs`).
13
+ */
14
+
15
+ import fs from "fs";
16
+ import path from "path";
17
+
18
+ const EXT_MEDIA = {
19
+ ".png": "image/png",
20
+ ".jpg": "image/jpeg",
21
+ ".jpeg": "image/jpeg",
22
+ ".gif": "image/gif",
23
+ ".webp": "image/webp",
24
+ };
25
+
26
+ /**
27
+ * Read `--image <path>` values into `[{ mediaType, data(base64) }]`.
28
+ * Throws on an unsupported extension so a typo fails loudly instead of sending
29
+ * a broken request.
30
+ */
31
+ export function resolveImages(paths, deps = {}) {
32
+ const _fs = deps.fs || fs;
33
+ if (!Array.isArray(paths) || paths.length === 0) return [];
34
+ return paths.map((p) => {
35
+ const ext = path.extname(String(p)).toLowerCase();
36
+ const mediaType = EXT_MEDIA[ext];
37
+ if (!mediaType) {
38
+ throw new Error(
39
+ `Unsupported image type "${ext || p}" — use png/jpg/jpeg/gif/webp.`,
40
+ );
41
+ }
42
+ const data = _fs.readFileSync(p).toString("base64");
43
+ return { mediaType, data };
44
+ });
45
+ }
46
+
47
+ /**
48
+ * Build a user-message `content`: the plain string when there are no images,
49
+ * else an OpenAI-style multimodal array (the internal representation).
50
+ */
51
+ export function buildUserContent(text, images) {
52
+ if (!Array.isArray(images) || images.length === 0) return text;
53
+ const parts = [];
54
+ if (text) parts.push({ type: "text", text });
55
+ for (const img of images) {
56
+ parts.push({
57
+ type: "image_url",
58
+ image_url: { url: `data:${img.mediaType};base64,${img.data}` },
59
+ });
60
+ }
61
+ return parts;
62
+ }
63
+
64
+ /** Parse a `data:<media>;base64,<data>` URL into `{ mediaType, data }` or null. */
65
+ export function parseDataUrl(url) {
66
+ const m = /^data:([^;,]+);base64,(.*)$/s.exec(String(url || ""));
67
+ return m ? { mediaType: m[1], data: m[2] } : null;
68
+ }
69
+
70
+ /** True when a message carries any image_url part (i.e. needs provider conversion). */
71
+ export function hasImageContent(messages) {
72
+ return (messages || []).some(
73
+ (m) =>
74
+ Array.isArray(m?.content) &&
75
+ m.content.some((p) => p?.type === "image_url"),
76
+ );
77
+ }
78
+
79
+ /**
80
+ * Convert OpenAI-shaped multimodal messages for the ollama `/api/chat` body:
81
+ * ollama wants `{ content: "<text>", images: ["<base64>", …] }` (bare base64,
82
+ * no `data:` prefix). Non-multimodal messages pass through untouched.
83
+ */
84
+ export function toOllamaMessages(messages) {
85
+ return (messages || []).map((m) => {
86
+ if (!m || !Array.isArray(m.content)) return m;
87
+ let text = "";
88
+ const images = [];
89
+ for (const part of m.content) {
90
+ if (part?.type === "text") {
91
+ text += (text ? "\n" : "") + (part.text || "");
92
+ } else if (part?.type === "image_url") {
93
+ const parsed = parseDataUrl(part.image_url?.url);
94
+ if (parsed) images.push(parsed.data);
95
+ else if (part.image_url?.url) images.push(part.image_url.url);
96
+ }
97
+ }
98
+ const out = { ...m, content: text };
99
+ if (images.length) out.images = images;
100
+ return out;
101
+ });
102
+ }
103
+
104
+ /**
105
+ * Convert one OpenAI `image_url` content block into an Anthropic `image` block,
106
+ * or null when it isn't an image_url block / has no usable data URL.
107
+ */
108
+ export function imageUrlBlockToAnthropic(block) {
109
+ if (!block || block.type !== "image_url") return null;
110
+ const parsed = parseDataUrl(block.image_url?.url);
111
+ if (!parsed) return null;
112
+ return {
113
+ type: "image",
114
+ source: { type: "base64", media_type: parsed.mediaType, data: parsed.data },
115
+ };
116
+ }
117
+
118
+ /** Default vision model (Volcengine Ark Doubao-Seed-1.6 Vision) when none configured. */
119
+ export const DEFAULT_VISION_MODEL = "doubao-seed-1-6-vision-250815";
120
+
121
+ /**
122
+ * Resolve the effective LLM config for a run. When an image is attached, default
123
+ * the provider/model/baseUrl/apiKey to the configured vision LLM so
124
+ * `cc agent --image foo.png` works without extra flags — using `llm.visionModel`
125
+ * (a separate, configurable vision model) and falling back to DEFAULT_VISION_MODEL.
126
+ * Explicit flags always win; with no image, behaviour is unchanged (vision config
127
+ * is ignored). `--vision-model` overrides the configured/default vision model.
128
+ *
129
+ * @param {object} p
130
+ * @param {boolean} p.hasImage true when the run carries an attached image
131
+ * @param {object} p.flags { provider, model, baseUrl, apiKey, visionModel }
132
+ * @param {object} p.llm config.llm ({ provider, model, baseUrl, apiKey, visionModel })
133
+ * @returns {{provider, model, baseUrl, apiKey}}
134
+ */
135
+ export function resolveVisionLlm({ hasImage, flags = {}, llm = {} } = {}) {
136
+ // No image → no vision override; the caller falls back to its normal LLM
137
+ // config (`visionLlm.x || options.x`).
138
+ if (!hasImage) {
139
+ return {
140
+ provider: undefined,
141
+ model: undefined,
142
+ baseUrl: undefined,
143
+ apiKey: undefined,
144
+ };
145
+ }
146
+ const visionModel =
147
+ flags.visionModel || llm.visionModel || DEFAULT_VISION_MODEL;
148
+ // `flags.model` must be the EXPLICIT `--model` (not a settings/default), so an
149
+ // attached image uses the vision model unless the user deliberately picked one.
150
+ return {
151
+ provider: flags.provider || llm.provider,
152
+ model: flags.model || visionModel,
153
+ baseUrl: flags.baseUrl || llm.baseUrl,
154
+ apiKey: flags.apiKey || llm.apiKey,
155
+ };
156
+ }
@@ -0,0 +1,198 @@
1
+ /**
2
+ * cc loop — core driver (pure, dependency-injected) for the fixed-interval
3
+ * loop runner. The command layer (src/commands/loop.js) supplies a concrete
4
+ * `runIteration` (spawns a child process) plus a real clock; everything here
5
+ * is side-effect-free and clock-injected so the loop semantics — iteration
6
+ * counting, stop conditions, between-run delay — are deterministically
7
+ * testable without timers or subprocesses.
8
+ *
9
+ * Claude-Code `/loop` parity (fixed-interval MVP): run a command or agent
10
+ * prompt repeatedly until a stop condition fires (max iterations / exit 0 /
11
+ * output match) or the caller aborts (Ctrl-C).
12
+ */
13
+
14
+ /** Multipliers for the duration suffixes we accept. */
15
+ const DURATION_UNITS = { ms: 1, s: 1000, m: 60000, h: 3600000 };
16
+
17
+ /**
18
+ * Parse a human interval ("30s", "5m", "1.5h", "500ms") into milliseconds.
19
+ * A bare number is interpreted as SECONDS (the natural unit for an interval),
20
+ * so `--every 30` === `--every 30s`. Throws on anything unparseable.
21
+ */
22
+ export function parseDuration(input) {
23
+ if (typeof input === "number" && Number.isFinite(input)) {
24
+ return Math.max(0, Math.round(input));
25
+ }
26
+ const s = String(input ?? "")
27
+ .trim()
28
+ .toLowerCase();
29
+ const m = s.match(/^(\d+(?:\.\d+)?)\s*(ms|s|m|h)?$/);
30
+ if (!m) {
31
+ throw new Error(
32
+ `invalid duration: "${input}" (use 30s, 5m, 1.5h, or 500ms)`,
33
+ );
34
+ }
35
+ const value = parseFloat(m[1]);
36
+ const unit = m[2] || "s"; // bare number → seconds
37
+ return Math.round(value * DURATION_UNITS[unit]);
38
+ }
39
+
40
+ /** Render a millisecond duration back to a compact human string. */
41
+ export function formatDuration(ms) {
42
+ if (ms < 1000) return `${ms}ms`;
43
+ if (ms < 60000) return `${trim(ms / 1000)}s`;
44
+ if (ms < 3600000) return `${trim(ms / 60000)}m`;
45
+ return `${trim(ms / 3600000)}h`;
46
+ }
47
+
48
+ function trim(n) {
49
+ // Strip trailing ".0" so 5.0 → "5" but 1.5 stays "1.5".
50
+ return Number.isInteger(n) ? String(n) : n.toFixed(2).replace(/\.?0+$/, "");
51
+ }
52
+
53
+ /**
54
+ * Parse the `--dynamic` control directives an iteration may print so it can
55
+ * self-pace. An iteration ends its output with at most one of:
56
+ * [[loop:next <interval>]] schedule the next run after <interval>
57
+ * [[loop:stop]] the task is done; stop looping
58
+ * Returns { done, nextDelayMs }. `stop` wins over `next` (done short-circuits
59
+ * before the next sleep). A malformed interval is ignored (falls back to the
60
+ * fixed `--every`). Lives here so the protocol is unit-testable in isolation.
61
+ */
62
+ export function parseLoopDirectives(output) {
63
+ const text = String(output || "");
64
+ const result = { done: false, nextDelayMs: null };
65
+ if (/\[\[\s*loop:stop\s*\]\]/i.test(text)) result.done = true;
66
+ const m = text.match(/\[\[\s*loop:next\s+([0-9.]+\s*(?:ms|s|m|h)?)\s*\]\]/i);
67
+ if (m) {
68
+ try {
69
+ result.nextDelayMs = parseDuration(m[1]);
70
+ } catch {
71
+ /* malformed interval → leave null, caller falls back to --every */
72
+ }
73
+ }
74
+ return result;
75
+ }
76
+
77
+ /**
78
+ * Reduce a persisted loop session's events into the state needed to resume it:
79
+ * the original `loop_config`, how many iterations already completed, and the
80
+ * last recorded exit code. Pure (operates on the event array, no fs) so the
81
+ * resume reconstruction is unit-testable without the session store.
82
+ */
83
+ export function summarizeLoopEvents(events) {
84
+ let config = null;
85
+ let completedIterations = 0;
86
+ let lastExitCode = null;
87
+ for (const e of events || []) {
88
+ if (e?.type === "loop_config") {
89
+ config = e.data || null;
90
+ } else if (e?.type === "loop_iteration") {
91
+ completedIterations += 1;
92
+ if (e.data && typeof e.data.exitCode !== "undefined") {
93
+ lastExitCode = e.data.exitCode;
94
+ }
95
+ }
96
+ }
97
+ return { config, completedIterations, lastExitCode };
98
+ }
99
+
100
+ /** Default abortable sleep — resolves early if the signal aborts. */
101
+ export function makeSleep(signal) {
102
+ return (ms) =>
103
+ new Promise((resolve) => {
104
+ if (signal?.aborted || ms <= 0) return resolve();
105
+ // NB: do NOT unref() — the pending interval timer is what keeps the
106
+ // process alive between rounds. Under a TTY the active stdin would mask
107
+ // an unref'd timer, but headless (piped stdin / CI / cron) the loop would
108
+ // exit after the first round. SIGINT aborts the wait via the signal.
109
+ const t = setTimeout(resolve, ms);
110
+ signal?.addEventListener(
111
+ "abort",
112
+ () => {
113
+ clearTimeout(t);
114
+ resolve();
115
+ },
116
+ { once: true },
117
+ );
118
+ });
119
+ }
120
+
121
+ /**
122
+ * Drive the loop. Calls `runIteration(n)` once per round (1-based), evaluates
123
+ * the stop conditions AFTER each round, and sleeps `intervalMs` between rounds
124
+ * (never after the final round). Returns a summary describing why it stopped.
125
+ *
126
+ * @param {object} opts
127
+ * @param {(n:number)=>Promise<{exitCode?:number, output?:string}>} opts.runIteration
128
+ * @param {number} opts.intervalMs default delay between iterations (>= 0)
129
+ * @param {number} [opts.maxIterations] stop after N rounds (>= 1)
130
+ * @param {boolean} [opts.untilExitZero] stop once a round exits with code 0
131
+ * @param {RegExp} [opts.untilRegex] stop once a round's output matches
132
+ * @param {(ms:number)=>Promise<void>} [opts.sleep] injectable delay
133
+ * @param {()=>boolean} [opts.shouldStop] external stop probe (e.g. SIGINT)
134
+ * @param {(n:number, res:object)=>void} [opts.onIteration] per-round hook
135
+ * @param {number} [opts.startIndex] iterations already done (resume)
136
+ * @returns {Promise<{iterations:number, stoppedBy:string, results:object[]}>}
137
+ * `iterations` is cumulative (startIndex + rounds run this call);
138
+ * `results` holds only this call's rounds.
139
+ */
140
+ export async function runLoop({
141
+ runIteration,
142
+ intervalMs,
143
+ maxIterations,
144
+ untilExitZero = false,
145
+ untilRegex = null,
146
+ sleep,
147
+ shouldStop,
148
+ onIteration,
149
+ startIndex = 0,
150
+ }) {
151
+ if (typeof runIteration !== "function") {
152
+ throw new Error("runLoop requires a runIteration function");
153
+ }
154
+ const delay = sleep || makeSleep();
155
+ const results = [];
156
+ // Iterations already completed in a prior (resumed) run. `i` continues from
157
+ // here so the displayed/persisted round numbers are cumulative and
158
+ // `maxIterations` counts across resume.
159
+ let i = startIndex;
160
+
161
+ while (true) {
162
+ if (shouldStop && shouldStop()) {
163
+ return { iterations: i, stoppedBy: "signal", results };
164
+ }
165
+
166
+ i += 1;
167
+ const res = (await runIteration(i)) || {};
168
+ results.push(res);
169
+ if (onIteration) onIteration(i, res);
170
+
171
+ // Stop conditions, most-specific first. Evaluated after the round so the
172
+ // work always runs at least once before any condition can end the loop.
173
+ // `res.done` is the iteration's own explicit stop (e.g. a --dynamic
174
+ // [[loop:stop]] directive) and wins over everything else.
175
+ if (res.done) {
176
+ return { iterations: i, stoppedBy: "done", results };
177
+ }
178
+ if (untilExitZero && res.exitCode === 0) {
179
+ return { iterations: i, stoppedBy: "exit-zero", results };
180
+ }
181
+ if (untilRegex && untilRegex.test(res.output || "")) {
182
+ return { iterations: i, stoppedBy: "match", results };
183
+ }
184
+ if (maxIterations && i >= maxIterations) {
185
+ return { iterations: i, stoppedBy: "max-iterations", results };
186
+ }
187
+ if (shouldStop && shouldStop()) {
188
+ return { iterations: i, stoppedBy: "signal", results };
189
+ }
190
+
191
+ // An iteration may set its own next interval (--dynamic [[loop:next]]);
192
+ // otherwise fall back to the fixed --every delay.
193
+ const nextMs = Number.isFinite(res.nextDelayMs)
194
+ ? res.nextDelayMs
195
+ : intervalMs;
196
+ await delay(nextMs);
197
+ }
198
+ }