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.
- package/package.json +1 -1
- package/src/assets/web-panel/assets/{AIOps-CJn02U42.js → AIOps-_oxz4VHy.js} +1 -1
- package/src/assets/web-panel/assets/{ActionButton-ewURAAoy.js → ActionButton-uaeqFuDj.js} +1 -1
- package/src/assets/web-panel/assets/{Analytics-BiSadESb.js → Analytics-BPVV0OUf.js} +3 -3
- package/src/assets/web-panel/assets/{AppLayout-BR0WOEug.js → AppLayout-ppCYKm3I.js} +4 -4
- package/src/assets/web-panel/assets/{Audit-CrqcYx0e.js → Audit-DFAY6umk.js} +1 -1
- package/src/assets/web-panel/assets/{Backup-DtbSBn4e.js → Backup-pAPBFDyP.js} +1 -1
- package/src/assets/web-panel/assets/{BaseInput-BjSc9j0o.js → BaseInput-BbBl0uT2.js} +1 -1
- package/src/assets/web-panel/assets/{Chat-ixzrlCJE.js → Chat-Ct22JUnT.js} +6 -6
- package/src/assets/web-panel/assets/{ChatBubbleRenderer-B78nEq05.js → ChatBubbleRenderer-DPlsLl22.js} +1 -1
- package/src/assets/web-panel/assets/{Checkbox-UGYeSsgr.js → Checkbox-DEkCollc.js} +1 -1
- package/src/assets/web-panel/assets/{Codegen-B97OOAg4.js → Codegen-Tor-de39.js} +1 -1
- package/src/assets/web-panel/assets/{Col-D9aGkaZ6.js → Col-ojNrLQU7.js} +1 -1
- package/src/assets/web-panel/assets/{Community-Dc2v2RGS.js → Community-CLOGhqMF.js} +1 -1
- package/src/assets/web-panel/assets/{Compact-B_FYlUQR.js → Compact-CYKNlSZ4.js} +1 -1
- package/src/assets/web-panel/assets/{Compliance-C4FiTHyC.js → Compliance-C5E6ABuA.js} +1 -1
- package/src/assets/web-panel/assets/{Cowork-CQ8j3LIg.js → Cowork-CHeEsZ3W.js} +2 -2
- package/src/assets/web-panel/assets/{Cron-Dzjs9Z9Z.js → Cron-B4e1n2e7.js} +2 -2
- package/src/assets/web-panel/assets/{Crosschain-BXI24uzI.js → Crosschain-DbNV8P9R.js} +1 -1
- package/src/assets/web-panel/assets/{DID-C-I4_d07.js → DID-C5_Tk3nC.js} +2 -2
- package/src/assets/web-panel/assets/{Dashboard-BzzGh5mo.js → Dashboard-BhdV_c4N.js} +2 -2
- package/src/assets/web-panel/assets/{Dropdown-Bh8H70De.js → Dropdown-CEi5AMtM.js} +1 -1
- package/src/assets/web-panel/assets/{EmailListRenderer-DI_qybJP.js → EmailListRenderer-DOhPiYng.js} +1 -1
- package/src/assets/web-panel/assets/{FamilyGuardDashboard-DkKTsfc4.js → FamilyGuardDashboard-fu4NRP3X.js} +1 -1
- package/src/assets/web-panel/assets/{Federation-DS7CmvVG.js → Federation-B7BtIWKL.js} +1 -1
- package/src/assets/web-panel/assets/{FormItemContext-CI97WsB5.js → FormItemContext-BmPWZVLP.js} +1 -1
- package/src/assets/web-panel/assets/GenericCardRenderer-hsOPNJq8.js +1 -0
- package/src/assets/web-panel/assets/{Git-CEh0gR2W.js → Git-Bi_EFBUH.js} +2 -2
- package/src/assets/web-panel/assets/{Governance-kIr3tls2.js → Governance-emf2ubDK.js} +1 -1
- package/src/assets/web-panel/assets/{Inference-CC1GzyC1.js → Inference-B7KjKzkI.js} +1 -1
- package/src/assets/web-panel/assets/{KnowledgeGraph-BNgTiWOB.js → KnowledgeGraph-uAaBK0F3.js} +1 -1
- package/src/assets/web-panel/assets/{Logs-B2P10gB1.js → Logs-utK7hNpj.js} +2 -2
- package/src/assets/web-panel/assets/{Marketplace-HPfBvbFZ.js → Marketplace-CzQe6n3z.js} +1 -1
- package/src/assets/web-panel/assets/{McpTools-ByYotSKb.js → McpTools-CuAaJr51.js} +5 -5
- package/src/assets/web-panel/assets/{Memory-BGIAzFVS.js → Memory-CRuZZJ75.js} +2 -2
- package/src/assets/web-panel/assets/{MobileBridge-CroNYTAH.js → MobileBridge-Cp06wunh.js} +2 -2
- package/src/assets/web-panel/assets/MobileProjects-DJEdUwhr.js +1 -0
- package/src/assets/web-panel/assets/{Mtc-BqhyIwo9.js → Mtc-8YY4dR7g.js} +2 -2
- package/src/assets/web-panel/assets/{MtcAudit-BpEKOvx9.js → MtcAudit-BmPJYHar.js} +2 -2
- package/src/assets/web-panel/assets/{Multisig-DST1d_Qo.js → Multisig-d-ydyVdq.js} +3 -3
- package/src/assets/web-panel/assets/{NLProgramming-DlMsZcK_.js → NLProgramming-DA_ikw_n.js} +1 -1
- package/src/assets/web-panel/assets/{Notes-C734UJvD.js → Notes-DIyF-fRe.js} +3 -3
- package/src/assets/web-panel/assets/{NotificationSettings-C0-pPxvk.js → NotificationSettings-CzPZXEtK.js} +1 -1
- package/src/assets/web-panel/assets/OrderTableRenderer-BiLtg-LY.js +1 -0
- package/src/assets/web-panel/assets/{Organization-C5iHC_yW.js → Organization-DdDZ_Ap6.js} +4 -4
- package/src/assets/web-panel/assets/{Overflow-CovuHHVR.js → Overflow-BnMBkttv.js} +1 -1
- package/src/assets/web-panel/assets/{P2P-Dx9QL-Gy.js → P2P-Es1050f-.js} +2 -2
- package/src/assets/web-panel/assets/{PdhVaultBrowser-IP1dEt6-.js → PdhVaultBrowser-CKkRmyn9.js} +4 -4
- package/src/assets/web-panel/assets/{Permissions-BrR1XZG5.js → Permissions-zU9n9cAD.js} +4 -4
- package/src/assets/web-panel/assets/{PersonalDataHub-BgqxVE5m.js → PersonalDataHub-BZi5Xwas.js} +2 -2
- package/src/assets/web-panel/assets/{Pipeline-DzMk5HAz.js → Pipeline-CRfeGiFc.js} +1 -1
- package/src/assets/web-panel/assets/{Privacy-CDoLa6tk.js → Privacy-CQA_IgLA.js} +1 -1
- package/src/assets/web-panel/assets/{ProjectInit-Dy5gc6ve.js → ProjectInit-C9hmEvoT.js} +2 -2
- package/src/assets/web-panel/assets/{ProjectSettings-DXy-k4hG.js → ProjectSettings-yXA72ws4.js} +2 -2
- package/src/assets/web-panel/assets/Projects-BpWS-qam.js +1 -0
- package/src/assets/web-panel/assets/Providers-Cxe55dRD.js +1 -0
- package/src/assets/web-panel/assets/{QuickAsk-B8KEHCnd.js → QuickAsk-Do0aUTQr.js} +1 -1
- package/src/assets/web-panel/assets/{Recommend-DNVHGYYZ.js → Recommend--ysZHjyA.js} +1 -1
- package/src/assets/web-panel/assets/{Reputation-CaDhWP03.js → Reputation-BOBU8JrH.js} +1 -1
- package/src/assets/web-panel/assets/{Row-CrGLI02x.js → Row-C6X7bRKE.js} +1 -1
- package/src/assets/web-panel/assets/{RssFeed-BX7P8I6i.js → RssFeed-D8AwqlkQ.js} +3 -3
- package/src/assets/web-panel/assets/Search-Bi3rCZD4.js +1 -0
- package/src/assets/web-panel/assets/{Security-B6J7IFc1.js → Security-DxUDVrtY.js} +4 -4
- package/src/assets/web-panel/assets/{Services-vvdcO3mM.js → Services-BXXN7yC1.js} +2 -2
- package/src/assets/web-panel/assets/{Skeleton-BoAoPTzZ.js → Skeleton-B3BR34tZ.js} +1 -1
- package/src/assets/web-panel/assets/{Skills-CyIQV5b3.js → Skills-BjYu8OQ1.js} +1 -1
- package/src/assets/web-panel/assets/{Sla-BAQVgdZV.js → Sla-DDkCtD8w.js} +1 -1
- package/src/assets/web-panel/assets/{SpeechSettings-Bxcn1Jkj.js → SpeechSettings-CGhYzP7V.js} +1 -1
- package/src/assets/web-panel/assets/{SyncSettings-Dpaj3hDM.js → SyncSettings-CYNKVAHA.js} +2 -2
- package/src/assets/web-panel/assets/{Tasks-Bwqo89En.js → Tasks-DavmlJpd.js} +1 -1
- package/src/assets/web-panel/assets/{Templates-Bowcqifn.js → Templates-CQuYFf2C.js} +1 -1
- package/src/assets/web-panel/assets/{Tenant-DOkf85uG.js → Tenant-DdzZh8vE.js} +1 -1
- package/src/assets/web-panel/assets/Terminal-D75WeG9d.js +3 -0
- package/src/assets/web-panel/assets/{TimelineRenderer-B9A3zDXA.js → TimelineRenderer-DKOARnc_.js} +1 -1
- package/src/assets/web-panel/assets/{Tokens-jtVVqKFr.js → Tokens-D7QRNG8y.js} +1 -1
- package/src/assets/web-panel/assets/{Trigger-26Iw-iIl.js → Trigger-BCsqLZl4.js} +1 -1
- package/src/assets/web-panel/assets/{Trust-DqY5ORrH.js → Trust-BarGUa6p.js} +1 -1
- package/src/assets/web-panel/assets/{UkeySign-BFsbr3y7.js → UkeySign-pHrg5a8E.js} +1 -1
- package/src/assets/web-panel/assets/{VideoEditing-BtDbj3oa.js → VideoEditing-Dug3m1py.js} +1 -1
- package/src/assets/web-panel/assets/{Wallet-BAwmwHbk.js → Wallet-BfK3Z_Ez.js} +4 -4
- package/src/assets/web-panel/assets/{WebAuthn-DINJTsfq.js → WebAuthn-CYRdl9td.js} +5 -5
- package/src/assets/web-panel/assets/{WorkflowEditor-BEorm8SK.js → WorkflowEditor-DTW5AcqM.js} +1 -1
- package/src/assets/web-panel/assets/{chat-CE39-Dxg.js → chat-CCXz4j38.js} +1 -1
- package/src/assets/web-panel/assets/{colors-C_cLZ93a.js → colors-BJBOhAqa.js} +1 -1
- package/src/assets/web-panel/assets/{compact-item-BSioWA2c.js → compact-item-E9M6BQcM.js} +1 -1
- package/src/assets/web-panel/assets/{createContext-CGTk4mhN.js → createContext-Cg9CAws4.js} +1 -1
- package/src/assets/web-panel/assets/devWarning-BrsbTJUv.js +1 -0
- package/src/assets/web-panel/assets/{hasIn-Dl1fRwS_.js → hasIn-DhVtqv5L.js} +1 -1
- package/src/assets/web-panel/assets/{index-pngH1and.js → index--7o5YdL6.js} +1 -1
- package/src/assets/web-panel/assets/{index-BmbVyhk1.js → index-4N5lNXGP.js} +1 -1
- package/src/assets/web-panel/assets/{index-BnEPB1Mz.js → index-6-04M2Nx.js} +1 -1
- package/src/assets/web-panel/assets/{index-Cxw3p73X.js → index-B111fZ21.js} +1 -1
- package/src/assets/web-panel/assets/{index-CST381Qf.js → index-B4NBF4Sa.js} +1 -1
- package/src/assets/web-panel/assets/{index-ChwpS1f0.js → index-B8bjEHrQ.js} +1 -1
- package/src/assets/web-panel/assets/{index--SWvw6yW.js → index-BAB0nGP7.js} +1 -1
- package/src/assets/web-panel/assets/{index-CAwVwBOL.js → index-BFZPRd0T.js} +1 -1
- package/src/assets/web-panel/assets/{index-hv4jUdG3.js → index-B_SMPD4L.js} +1 -1
- package/src/assets/web-panel/assets/{index-Qj2x55mz.js → index-BxSzyly9.js} +1 -1
- package/src/assets/web-panel/assets/{index-BWpfxzVm.js → index-ByazO4Q9.js} +1 -1
- package/src/assets/web-panel/assets/{index-Di6nvW1N.js → index-C-2dUIli.js} +1 -1
- package/src/assets/web-panel/assets/{index-BhqOTuMW.js → index-CFarAlXj.js} +1 -1
- package/src/assets/web-panel/assets/{index-CA6K7lZB.js → index-CFp-wdrQ.js} +1 -1
- package/src/assets/web-panel/assets/{index-DTKEXyaW.js → index-CJ8nNT8h.js} +1 -1
- package/src/assets/web-panel/assets/{index-iiZfONfx.js → index-CSiyjCYi.js} +1 -1
- package/src/assets/web-panel/assets/{index-DTpCUi0m.js → index-CUp_c8Le.js} +1 -1
- package/src/assets/web-panel/assets/{index-Bvi14vJ7.js → index-CVR_s-pT.js} +1 -1
- package/src/assets/web-panel/assets/{index-DKEipmR8.js → index-Ca8BYV1g.js} +1 -1
- package/src/assets/web-panel/assets/{index-DJyeeygd.js → index-CeRlLp3F.js} +1 -1
- package/src/assets/web-panel/assets/{index-C9tq8Da8.js → index-ChsSljaN.js} +1 -1
- package/src/assets/web-panel/assets/{index-B2QiUEgK.js → index-CkTeBHI9.js} +1 -1
- package/src/assets/web-panel/assets/{index-OCxo0X6J.js → index-Cm1m7BJh.js} +1 -1
- package/src/assets/web-panel/assets/{index-DrWERr8C.js → index-ComyTKz-.js} +1 -1
- package/src/assets/web-panel/assets/{index-B016Fsqr.js → index-CznfPnOx.js} +3 -3
- package/src/assets/web-panel/assets/{index-CisXVbSt.js → index-D5yC2Ps8.js} +1 -1
- package/src/assets/web-panel/assets/{index-C-VVk1Jg.js → index-D7DXdf7x.js} +1 -1
- package/src/assets/web-panel/assets/{index-DDQx2YFc.js → index-DDcJO27F.js} +1 -1
- package/src/assets/web-panel/assets/{index-Ds2RzRG0.js → index-DSQazU6J.js} +1 -1
- package/src/assets/web-panel/assets/index-DSTQDO-Y.js +1 -0
- package/src/assets/web-panel/assets/{index-C4JXchTG.js → index-DaFe1aqY.js} +1 -1
- package/src/assets/web-panel/assets/{index-BAhinBPR.js → index-DdhnGez0.js} +1 -1
- package/src/assets/web-panel/assets/{index-9_mmaR42.js → index-Di5LBXcE.js} +1 -1
- package/src/assets/web-panel/assets/{index-D9D4q-qI.js → index-Dwvewrul.js} +1 -1
- package/src/assets/web-panel/assets/{index-CbXnyoSO.js → index-MdXEhfdJ.js} +1 -1
- package/src/assets/web-panel/assets/{index-II3JhQu2.js → index-_PNqQ5mE.js} +1 -1
- package/src/assets/web-panel/assets/index-c2U6LV3Q.js +1 -0
- package/src/assets/web-panel/assets/{index-C2ly7sCw.js → index-kz1oXl1a.js} +1 -1
- package/src/assets/web-panel/assets/{index-Ceo9P9tQ.js → index-wkt-o5q5.js} +1 -1
- package/src/assets/web-panel/assets/{initDefaultProps-GOhLA2-f.js → initDefaultProps-iyBaePF-.js} +1 -1
- package/src/assets/web-panel/assets/{motion-jqxFzHTx.js → motion-RWtj4rgu.js} +1 -1
- package/src/assets/web-panel/assets/{move-CSLsp6TA.js → move-CqPRVzpH.js} +1 -1
- package/src/assets/web-panel/assets/{omit-Cnlrb25c.js → omit-DsvJze25.js} +1 -1
- package/src/assets/web-panel/assets/{pickAttrs-CLqlxWWD.js → pickAttrs-B4tfZBhc.js} +1 -1
- package/src/assets/web-panel/assets/{placementArrow-BAWIWtul.js → placementArrow-KvHUwXMA.js} +1 -1
- package/src/assets/web-panel/assets/{responsiveObserve-CSR1DayS.js → responsiveObserve-DGdJ-b7W.js} +1 -1
- package/src/assets/web-panel/assets/{slide-CNhoPJOp.js → slide-Cd6ebRmw.js} +1 -1
- package/src/assets/web-panel/assets/{statusUtils-BZiYHRHW.js → statusUtils-Bg9GcIAn.js} +1 -1
- package/src/assets/web-panel/assets/{styleChecker-BMoY-Fm5.js → styleChecker-MQjKsG84.js} +1 -1
- package/src/assets/web-panel/assets/{useFlexGapSupport-DhtNdlaS.js → useFlexGapSupport-C241WujP.js} +1 -1
- package/src/assets/web-panel/assets/{useFs-DNPtDOZ4.js → useFs-CMpy7RS4.js} +1 -1
- package/src/assets/web-panel/assets/{usePersonalDataHub-DTdjNvAI.js → usePersonalDataHub-BLHtapKb.js} +1 -1
- package/src/assets/web-panel/assets/{vnode-C9zW9IJ2.js → vnode-DmcTV67c.js} +1 -1
- package/src/assets/web-panel/assets/{zoom-D-6RYJJr.js → zoom-DHL8_0Y8.js} +1 -1
- package/src/assets/web-panel/index.html +1 -1
- package/src/commands/agent.js +161 -6
- package/src/commands/agents.js +8 -2
- package/src/commands/cli-anything.js +14 -6
- package/src/commands/command.js +7 -2
- package/src/commands/hook.js +136 -28
- package/src/commands/ide.js +168 -0
- package/src/commands/loop.js +450 -0
- package/src/commands/mcp.js +92 -0
- package/src/commands/output-style.js +127 -0
- package/src/commands/permissions.js +211 -0
- package/src/commands/statusline.js +93 -0
- package/src/index.js +10 -2
- package/src/lib/agent-core.js +7 -0
- package/src/lib/agents.js +5 -0
- package/src/lib/hook-manager.js +1 -0
- package/src/lib/hook-runner.cjs +183 -0
- package/src/lib/ide-bridge.js +310 -0
- package/src/lib/image-input.js +156 -0
- package/src/lib/loop.js +198 -0
- package/src/lib/mcp-oauth.js +415 -0
- package/src/lib/output-styles.js +179 -0
- package/src/lib/permission-rules.cjs +325 -0
- package/src/lib/provider-options.js +11 -7
- package/src/lib/settings-hook-events.cjs +102 -0
- package/src/lib/settings-hooks.cjs +163 -0
- package/src/lib/settings-loader.cjs +244 -0
- package/src/lib/slash-commands.js +4 -0
- package/src/lib/status-line.cjs +204 -0
- package/src/lib/sub-agent-profiles.js +3 -0
- package/src/lib/web-search.js +487 -0
- package/src/repl/agent-repl.js +450 -35
- package/src/repl/slash-macro.js +45 -0
- package/src/runtime/agent-core.js +799 -21
- package/src/runtime/coding-agent-contract-shared.cjs +94 -4
- package/src/runtime/coding-agent-policy.cjs +24 -0
- package/src/runtime/headless-runner.js +162 -6
- package/src/runtime/headless-stream.js +133 -7
- package/src/runtime/mcp-config.js +161 -15
- package/src/runtime/policies/agent-policy.js +4 -0
- package/src/runtime/system-prompt.js +6 -1
- package/src/assets/web-panel/assets/GenericCardRenderer-Da27EdR4.js +0 -1
- package/src/assets/web-panel/assets/MobileProjects-CH-qnGEV.js +0 -1
- package/src/assets/web-panel/assets/OrderTableRenderer-C7zT9eFc.js +0 -1
- package/src/assets/web-panel/assets/Projects-DvsaEbZR.js +0 -1
- package/src/assets/web-panel/assets/Providers-Demck9PO.js +0 -1
- package/src/assets/web-panel/assets/Search-laS6rz8M.js +0 -1
- package/src/assets/web-panel/assets/Terminal-v4MM9dCj.js +0 -3
- package/src/assets/web-panel/assets/devWarning-PObcVnJR.js +0 -1
- package/src/assets/web-panel/assets/index-BNwIzLyX.js +0 -1
- 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
|
+
}
|
package/src/lib/loop.js
ADDED
|
@@ -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
|
+
}
|