chainlesschain 0.162.35 → 0.162.36
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-vAVAFNJ4.js} +1 -1
- package/src/assets/web-panel/assets/{ActionButton-ewURAAoy.js → ActionButton-BnRHFCKM.js} +1 -1
- package/src/assets/web-panel/assets/{Analytics-BiSadESb.js → Analytics-BOjwqWqG.js} +3 -3
- package/src/assets/web-panel/assets/{AppLayout-BR0WOEug.js → AppLayout-Dc0D1Txn.js} +5 -5
- package/src/assets/web-panel/assets/{Audit-CrqcYx0e.js → Audit-dd_2efaZ.js} +1 -1
- package/src/assets/web-panel/assets/{Backup-DtbSBn4e.js → Backup-HF1jgm8G.js} +1 -1
- package/src/assets/web-panel/assets/{BaseInput-BjSc9j0o.js → BaseInput-CCtzmoKe.js} +1 -1
- package/src/assets/web-panel/assets/{Chat-ixzrlCJE.js → Chat-BNfH1c3p.js} +6 -6
- package/src/assets/web-panel/assets/{ChatBubbleRenderer-B78nEq05.js → ChatBubbleRenderer-DCWFqmI4.js} +1 -1
- package/src/assets/web-panel/assets/{Checkbox-UGYeSsgr.js → Checkbox-BOr-NscK.js} +1 -1
- package/src/assets/web-panel/assets/{Codegen-B97OOAg4.js → Codegen-DE058N7-.js} +1 -1
- package/src/assets/web-panel/assets/{Col-D9aGkaZ6.js → Col-SOREo1XE.js} +1 -1
- package/src/assets/web-panel/assets/{Community-Dc2v2RGS.js → Community-sOvNZo9f.js} +1 -1
- package/src/assets/web-panel/assets/{Compact-B_FYlUQR.js → Compact-DnBe558D.js} +1 -1
- package/src/assets/web-panel/assets/{Compliance-C4FiTHyC.js → Compliance-o-r6CUbg.js} +1 -1
- package/src/assets/web-panel/assets/{Cowork-CQ8j3LIg.js → Cowork-D6_k9mHP.js} +3 -3
- package/src/assets/web-panel/assets/{Cron-Dzjs9Z9Z.js → Cron-CEV3Xkrm.js} +2 -2
- package/src/assets/web-panel/assets/{Crosschain-BXI24uzI.js → Crosschain-eJ1lQWKU.js} +1 -1
- package/src/assets/web-panel/assets/{DID-C-I4_d07.js → DID-B-WqM9Hp.js} +2 -2
- package/src/assets/web-panel/assets/{Dashboard-BzzGh5mo.js → Dashboard-ZnKPcsHN.js} +2 -2
- package/src/assets/web-panel/assets/{Dropdown-Bh8H70De.js → Dropdown-B8uLWDIP.js} +1 -1
- package/src/assets/web-panel/assets/{EmailListRenderer-DI_qybJP.js → EmailListRenderer-Jmj2Y7aH.js} +1 -1
- package/src/assets/web-panel/assets/{FamilyGuardDashboard-DkKTsfc4.js → FamilyGuardDashboard-Cb2xetG-.js} +1 -1
- package/src/assets/web-panel/assets/{Federation-DS7CmvVG.js → Federation-C_07GXoq.js} +1 -1
- package/src/assets/web-panel/assets/{FormItemContext-CI97WsB5.js → FormItemContext-D3kbYrMU.js} +1 -1
- package/src/assets/web-panel/assets/{GenericCardRenderer-Da27EdR4.js → GenericCardRenderer-9xgqvGPg.js} +1 -1
- package/src/assets/web-panel/assets/{Git-CEh0gR2W.js → Git-BlwWlMMB.js} +2 -2
- package/src/assets/web-panel/assets/{Governance-kIr3tls2.js → Governance-DxN3wQZ_.js} +1 -1
- package/src/assets/web-panel/assets/{Inference-CC1GzyC1.js → Inference-ls7pSw_D.js} +1 -1
- package/src/assets/web-panel/assets/{KnowledgeGraph-BNgTiWOB.js → KnowledgeGraph-_n9hYuPI.js} +1 -1
- package/src/assets/web-panel/assets/{Logs-B2P10gB1.js → Logs-CvEVY5TK.js} +2 -2
- package/src/assets/web-panel/assets/{Marketplace-HPfBvbFZ.js → Marketplace-C3qvQJT7.js} +1 -1
- package/src/assets/web-panel/assets/{McpTools-ByYotSKb.js → McpTools-DiwKpnKx.js} +3 -3
- package/src/assets/web-panel/assets/{Memory-BGIAzFVS.js → Memory-CIBPi_da.js} +2 -2
- package/src/assets/web-panel/assets/{MobileBridge-CroNYTAH.js → MobileBridge-D-v0Se8y.js} +2 -2
- package/src/assets/web-panel/assets/MobileProjects-cP1apTQD.js +1 -0
- package/src/assets/web-panel/assets/{Mtc-BqhyIwo9.js → Mtc-BMFWrI65.js} +4 -4
- package/src/assets/web-panel/assets/{MtcAudit-BpEKOvx9.js → MtcAudit-2s8LaHtR.js} +2 -2
- package/src/assets/web-panel/assets/{Multisig-DST1d_Qo.js → Multisig-dL_nvj7d.js} +3 -3
- package/src/assets/web-panel/assets/{NLProgramming-DlMsZcK_.js → NLProgramming-BbrJp06R.js} +1 -1
- package/src/assets/web-panel/assets/{Notes-C734UJvD.js → Notes-jR9irwy3.js} +3 -3
- package/src/assets/web-panel/assets/{NotificationSettings-C0-pPxvk.js → NotificationSettings-Dk-STCIX.js} +1 -1
- package/src/assets/web-panel/assets/{OrderTableRenderer-C7zT9eFc.js → OrderTableRenderer-CqqfY6zq.js} +1 -1
- package/src/assets/web-panel/assets/{Organization-C5iHC_yW.js → Organization-BCK5jylo.js} +4 -4
- package/src/assets/web-panel/assets/{Overflow-CovuHHVR.js → Overflow-BRAY7Smt.js} +1 -1
- package/src/assets/web-panel/assets/{P2P-Dx9QL-Gy.js → P2P-BltVRGjb.js} +2 -2
- package/src/assets/web-panel/assets/{PdhVaultBrowser-IP1dEt6-.js → PdhVaultBrowser-CV8UbXHe.js} +4 -4
- package/src/assets/web-panel/assets/{Permissions-BrR1XZG5.js → Permissions-_tNl47Qh.js} +4 -4
- package/src/assets/web-panel/assets/{PersonalDataHub-BgqxVE5m.js → PersonalDataHub-Cgc4HjpX.js} +2 -2
- package/src/assets/web-panel/assets/{Pipeline-DzMk5HAz.js → Pipeline-Bn_QU4mu.js} +1 -1
- package/src/assets/web-panel/assets/{Privacy-CDoLa6tk.js → Privacy-jzJowp5P.js} +1 -1
- package/src/assets/web-panel/assets/{ProjectInit-Dy5gc6ve.js → ProjectInit-B_1pJ8qd.js} +2 -2
- package/src/assets/web-panel/assets/{ProjectSettings-DXy-k4hG.js → ProjectSettings-CPVZpXzs.js} +2 -2
- package/src/assets/web-panel/assets/Projects-CQsHOWnT.js +1 -0
- package/src/assets/web-panel/assets/Providers-CzzMiLC0.js +1 -0
- package/src/assets/web-panel/assets/{QuickAsk-B8KEHCnd.js → QuickAsk-MxBKIn9o.js} +1 -1
- package/src/assets/web-panel/assets/{Recommend-DNVHGYYZ.js → Recommend-D8lN6Lis.js} +1 -1
- package/src/assets/web-panel/assets/{Reputation-CaDhWP03.js → Reputation-CfYK-IrV.js} +1 -1
- package/src/assets/web-panel/assets/{Row-CrGLI02x.js → Row-Bg7NZDP9.js} +1 -1
- package/src/assets/web-panel/assets/{RssFeed-BX7P8I6i.js → RssFeed-BOVNJhj0.js} +3 -3
- package/src/assets/web-panel/assets/Search-B38qzmhY.js +1 -0
- package/src/assets/web-panel/assets/{Security-B6J7IFc1.js → Security-CjqleZpe.js} +4 -4
- package/src/assets/web-panel/assets/{Services-vvdcO3mM.js → Services-Bu9JSJap.js} +2 -2
- package/src/assets/web-panel/assets/{Skeleton-BoAoPTzZ.js → Skeleton-B2RvRkaX.js} +1 -1
- package/src/assets/web-panel/assets/{Skills-CyIQV5b3.js → Skills-_h42mxMN.js} +1 -1
- package/src/assets/web-panel/assets/{Sla-BAQVgdZV.js → Sla-BssLs56D.js} +1 -1
- package/src/assets/web-panel/assets/{SpeechSettings-Bxcn1Jkj.js → SpeechSettings-DCxFYHsd.js} +1 -1
- package/src/assets/web-panel/assets/{SyncSettings-Dpaj3hDM.js → SyncSettings-D2xQuNLE.js} +2 -2
- package/src/assets/web-panel/assets/{Tasks-Bwqo89En.js → Tasks-DhpOGOlo.js} +1 -1
- package/src/assets/web-panel/assets/{Templates-Bowcqifn.js → Templates-CYG-R-aS.js} +1 -1
- package/src/assets/web-panel/assets/{Tenant-DOkf85uG.js → Tenant-BQRYLsvP.js} +1 -1
- package/src/assets/web-panel/assets/{Terminal-v4MM9dCj.js → Terminal-imKU7N5j.js} +2 -2
- package/src/assets/web-panel/assets/{TimelineRenderer-B9A3zDXA.js → TimelineRenderer-BIZzBftk.js} +1 -1
- package/src/assets/web-panel/assets/{Tokens-jtVVqKFr.js → Tokens-uMLH5p_a.js} +1 -1
- package/src/assets/web-panel/assets/{Trigger-26Iw-iIl.js → Trigger-BzS6XPqx.js} +1 -1
- package/src/assets/web-panel/assets/{Trust-DqY5ORrH.js → Trust-R4zhHufZ.js} +1 -1
- package/src/assets/web-panel/assets/{UkeySign-BFsbr3y7.js → UkeySign-DATQCoGe.js} +1 -1
- package/src/assets/web-panel/assets/{VideoEditing-BtDbj3oa.js → VideoEditing-ClUmKOtS.js} +1 -1
- package/src/assets/web-panel/assets/{Wallet-BAwmwHbk.js → Wallet-DzJTbQzD.js} +4 -4
- package/src/assets/web-panel/assets/{WebAuthn-DINJTsfq.js → WebAuthn-CrXrLmzQ.js} +5 -5
- package/src/assets/web-panel/assets/{WorkflowEditor-BEorm8SK.js → WorkflowEditor-CpvZ0Tma.js} +1 -1
- package/src/assets/web-panel/assets/{chat-CE39-Dxg.js → chat-a6wpYmVL.js} +1 -1
- package/src/assets/web-panel/assets/{colors-C_cLZ93a.js → colors-CXJADb1t.js} +1 -1
- package/src/assets/web-panel/assets/{compact-item-BSioWA2c.js → compact-item-CL2pohS_.js} +1 -1
- package/src/assets/web-panel/assets/{createContext-CGTk4mhN.js → createContext-xFi_1G5_.js} +1 -1
- package/src/assets/web-panel/assets/devWarning-BtmELbtB.js +1 -0
- package/src/assets/web-panel/assets/{hasIn-Dl1fRwS_.js → hasIn-Bchh1rAi.js} +1 -1
- package/src/assets/web-panel/assets/{index-BAhinBPR.js → index-B3Tpv7-d.js} +1 -1
- package/src/assets/web-panel/assets/index-B4l4vLTB.js +1 -0
- package/src/assets/web-panel/assets/{index-BWpfxzVm.js → index-B4zNisy9.js} +1 -1
- package/src/assets/web-panel/assets/{index-CA6K7lZB.js → index-B6NehWty.js} +1 -1
- package/src/assets/web-panel/assets/index-B7Ek5iiY.js +1 -0
- package/src/assets/web-panel/assets/{index-DJyeeygd.js → index-B7knYOpm.js} +1 -1
- package/src/assets/web-panel/assets/{index-hv4jUdG3.js → index-B7wT5VRi.js} +1 -1
- package/src/assets/web-panel/assets/{index-9_mmaR42.js → index-BF4xx1_b.js} +1 -1
- package/src/assets/web-panel/assets/{index-Cxw3p73X.js → index-BH9t10pe.js} +1 -1
- package/src/assets/web-panel/assets/{index-B016Fsqr.js → index-BPH5ESqs.js} +3 -3
- package/src/assets/web-panel/assets/{index-BmbVyhk1.js → index-BmsIKzyu.js} +1 -1
- package/src/assets/web-panel/assets/{index-CST381Qf.js → index-BoaRB-4a.js} +1 -1
- package/src/assets/web-panel/assets/{index-D9D4q-qI.js → index-BrbJBnT-.js} +1 -1
- package/src/assets/web-panel/assets/{index-pngH1and.js → index-C2eMYASq.js} +1 -1
- package/src/assets/web-panel/assets/{index-DKEipmR8.js → index-C4yBRKT4.js} +1 -1
- package/src/assets/web-panel/assets/{index-B2QiUEgK.js → index-CGq4HQno.js} +1 -1
- package/src/assets/web-panel/assets/{index-C4JXchTG.js → index-CMybtJY6.js} +1 -1
- package/src/assets/web-panel/assets/{index-iiZfONfx.js → index-CR3kFPuC.js} +1 -1
- package/src/assets/web-panel/assets/{index-DTKEXyaW.js → index-CTRd7vkq.js} +1 -1
- package/src/assets/web-panel/assets/{index-C2ly7sCw.js → index-CdU8BwRW.js} +1 -1
- package/src/assets/web-panel/assets/{index-Di6nvW1N.js → index-Cua_P8St.js} +1 -1
- package/src/assets/web-panel/assets/{index-BnEPB1Mz.js → index-CuehgDOp.js} +1 -1
- package/src/assets/web-panel/assets/{index-DrWERr8C.js → index-D-TT9Swq.js} +1 -1
- package/src/assets/web-panel/assets/{index-II3JhQu2.js → index-DEYcLAl7.js} +1 -1
- package/src/assets/web-panel/assets/{index-CbXnyoSO.js → index-DQ_hw_5P.js} +1 -1
- package/src/assets/web-panel/assets/{index-Ceo9P9tQ.js → index-DTEu7TSF.js} +1 -1
- package/src/assets/web-panel/assets/{index-DDQx2YFc.js → index-DVo1GJoj.js} +1 -1
- package/src/assets/web-panel/assets/{index-C-VVk1Jg.js → index-DjdOL159.js} +1 -1
- package/src/assets/web-panel/assets/{index-CAwVwBOL.js → index-DsbMVBj1.js} +1 -1
- package/src/assets/web-panel/assets/{index-Bvi14vJ7.js → index-DxahxRP7.js} +1 -1
- package/src/assets/web-panel/assets/{index-BhqOTuMW.js → index-EPERz4Pu.js} +1 -1
- package/src/assets/web-panel/assets/{index-Ds2RzRG0.js → index-IkvkNxbc.js} +1 -1
- package/src/assets/web-panel/assets/{index-ChwpS1f0.js → index-KCib1PTw.js} +1 -1
- package/src/assets/web-panel/assets/{index-OCxo0X6J.js → index-M8SZI11a.js} +1 -1
- package/src/assets/web-panel/assets/{index-Qj2x55mz.js → index-TxbHusq2.js} +1 -1
- package/src/assets/web-panel/assets/{index-CisXVbSt.js → index-dsLc7t6W.js} +1 -1
- package/src/assets/web-panel/assets/{index-C9tq8Da8.js → index-jMcv1u5o.js} +1 -1
- package/src/assets/web-panel/assets/{index--SWvw6yW.js → index-majCS3s2.js} +1 -1
- package/src/assets/web-panel/assets/{index-DTpCUi0m.js → index-u8K1y_lh.js} +1 -1
- package/src/assets/web-panel/assets/{initDefaultProps-GOhLA2-f.js → initDefaultProps-DYn3Gc09.js} +1 -1
- package/src/assets/web-panel/assets/{motion-jqxFzHTx.js → motion-ZS3eolb9.js} +1 -1
- package/src/assets/web-panel/assets/{move-CSLsp6TA.js → move-CEw4uqr3.js} +1 -1
- package/src/assets/web-panel/assets/{omit-Cnlrb25c.js → omit-DlHFZnPp.js} +1 -1
- package/src/assets/web-panel/assets/{pickAttrs-CLqlxWWD.js → pickAttrs-eZQvV5fA.js} +1 -1
- package/src/assets/web-panel/assets/{placementArrow-BAWIWtul.js → placementArrow-B31jQwa-.js} +1 -1
- package/src/assets/web-panel/assets/{responsiveObserve-CSR1DayS.js → responsiveObserve-DAsNmVto.js} +1 -1
- package/src/assets/web-panel/assets/{slide-CNhoPJOp.js → slide-gPQPrYZC.js} +1 -1
- package/src/assets/web-panel/assets/{statusUtils-BZiYHRHW.js → statusUtils-DwWKX5co.js} +1 -1
- package/src/assets/web-panel/assets/{styleChecker-BMoY-Fm5.js → styleChecker-B3VOtXuH.js} +1 -1
- package/src/assets/web-panel/assets/{useFlexGapSupport-DhtNdlaS.js → useFlexGapSupport-6ADctM2r.js} +1 -1
- package/src/assets/web-panel/assets/{useFs-DNPtDOZ4.js → useFs-6Zx1SSKs.js} +1 -1
- package/src/assets/web-panel/assets/{usePersonalDataHub-DTdjNvAI.js → usePersonalDataHub-BzReowln.js} +1 -1
- package/src/assets/web-panel/assets/{vnode-C9zW9IJ2.js → vnode-C8IpEQbD.js} +1 -1
- package/src/assets/web-panel/assets/{zoom-D-6RYJJr.js → zoom-ruc9vHr0.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/command.js +7 -2
- package/src/commands/hook.js +136 -28
- package/src/commands/ide.js +168 -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 +8 -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/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 +445 -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 +1 -0
- package/src/runtime/system-prompt.js +6 -1
- package/src/assets/web-panel/assets/MobileProjects-CH-qnGEV.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/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,183 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* hook-runner — execute a Claude-Code `command` hook with the JSON protocol.
|
|
5
|
+
*
|
|
6
|
+
* Protocol (Claude-Code parity):
|
|
7
|
+
* - the hook event payload is written to the command's STDIN as JSON;
|
|
8
|
+
* - exit code 2 → BLOCK (reason = stderr) — the canonical "deny" path;
|
|
9
|
+
* - exit code 0 + JSON stdout → honored decision:
|
|
10
|
+
* { "decision": "block"|"approve"|"ask", "reason": "..." }
|
|
11
|
+
* { "hookSpecificOutput": { "permissionDecision": "deny"|"allow"|"ask",
|
|
12
|
+
* "permissionDecisionReason": "..." } }
|
|
13
|
+
* { "continue": false, "stopReason": "..." } → block
|
|
14
|
+
* { "additionalContext": "..." } → continue
|
|
15
|
+
* - any other non-zero → non-blocking error (surfaced, never blocks);
|
|
16
|
+
* - spawn failure / timeout → non-blocking (a broken hook must not wedge the
|
|
17
|
+
* agent — only an explicit block decision blocks).
|
|
18
|
+
*
|
|
19
|
+
* Returns a normalized `{ decision, reason, exitCode, stdout, stderr, ... }`.
|
|
20
|
+
* `_deps.spawnSync` is injected for unit tests (no real process needed).
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
const cpDefault = require("node:child_process");
|
|
24
|
+
|
|
25
|
+
const _deps = { spawnSync: cpDefault.spawnSync };
|
|
26
|
+
|
|
27
|
+
const HOOK_DECISIONS = Object.freeze({
|
|
28
|
+
BLOCK: "block",
|
|
29
|
+
ALLOW: "allow",
|
|
30
|
+
ASK: "ask",
|
|
31
|
+
CONTINUE: "continue",
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
/** Parse a hook's stdout JSON into a normalized decision, or null if not JSON. */
|
|
35
|
+
function tryParseDecision(stdout) {
|
|
36
|
+
const text = String(stdout || "").trim();
|
|
37
|
+
if (!text || text[0] !== "{") return null;
|
|
38
|
+
let obj;
|
|
39
|
+
try {
|
|
40
|
+
obj = JSON.parse(text);
|
|
41
|
+
} catch {
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
// PreToolUse-specific permission decision
|
|
45
|
+
const hso = obj.hookSpecificOutput;
|
|
46
|
+
if (hso && hso.permissionDecision) {
|
|
47
|
+
const pd = String(hso.permissionDecision).toLowerCase();
|
|
48
|
+
const decision =
|
|
49
|
+
pd === "deny"
|
|
50
|
+
? HOOK_DECISIONS.BLOCK
|
|
51
|
+
: pd === "ask"
|
|
52
|
+
? HOOK_DECISIONS.ASK
|
|
53
|
+
: pd === "allow"
|
|
54
|
+
? HOOK_DECISIONS.ALLOW
|
|
55
|
+
: HOOK_DECISIONS.CONTINUE;
|
|
56
|
+
return { decision, reason: hso.permissionDecisionReason || null };
|
|
57
|
+
}
|
|
58
|
+
// Generic decision field
|
|
59
|
+
if (obj.decision) {
|
|
60
|
+
const d = String(obj.decision).toLowerCase();
|
|
61
|
+
const decision =
|
|
62
|
+
d === "block" || d === "deny"
|
|
63
|
+
? HOOK_DECISIONS.BLOCK
|
|
64
|
+
: d === "approve" || d === "allow"
|
|
65
|
+
? HOOK_DECISIONS.ALLOW
|
|
66
|
+
: d === "ask"
|
|
67
|
+
? HOOK_DECISIONS.ASK
|
|
68
|
+
: HOOK_DECISIONS.CONTINUE;
|
|
69
|
+
return { decision, reason: obj.reason || null };
|
|
70
|
+
}
|
|
71
|
+
// continue:false → stop/block
|
|
72
|
+
if (obj.continue === false) {
|
|
73
|
+
return {
|
|
74
|
+
decision: HOOK_DECISIONS.BLOCK,
|
|
75
|
+
reason: obj.stopReason || obj.reason || "hook requested stop",
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
return {
|
|
79
|
+
decision: HOOK_DECISIONS.CONTINUE,
|
|
80
|
+
reason: null,
|
|
81
|
+
additionalContext: obj.additionalContext || null,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Run one command hook. `input` is JSON-serialized to the hook's stdin.
|
|
87
|
+
*
|
|
88
|
+
* @param {string} command
|
|
89
|
+
* @param {object} [input] the hook event payload (tool_name, tool_input, …)
|
|
90
|
+
* @param {object} [opts] { timeout=60000, cwd, event }
|
|
91
|
+
* @returns {{ decision:string, reason:string|null, exitCode:number|null,
|
|
92
|
+
* stdout?:string, stderr?:string, additionalContext?:string,
|
|
93
|
+
* nonBlockingError?:boolean, error?:string }}
|
|
94
|
+
*/
|
|
95
|
+
function runCommandHook(command, input = {}, opts = {}) {
|
|
96
|
+
const { timeout = 60000, cwd, event } = opts;
|
|
97
|
+
if (!command) {
|
|
98
|
+
return { decision: HOOK_DECISIONS.CONTINUE, reason: null, exitCode: 0 };
|
|
99
|
+
}
|
|
100
|
+
let res;
|
|
101
|
+
try {
|
|
102
|
+
res = _deps.spawnSync(command, {
|
|
103
|
+
input: JSON.stringify(input),
|
|
104
|
+
cwd: cwd || process.cwd(),
|
|
105
|
+
encoding: "utf-8",
|
|
106
|
+
timeout,
|
|
107
|
+
shell: true,
|
|
108
|
+
maxBuffer: 8 * 1024 * 1024,
|
|
109
|
+
env: {
|
|
110
|
+
...process.env,
|
|
111
|
+
CLAUDE_HOOK_EVENT: event || input.hook_event_name || "",
|
|
112
|
+
},
|
|
113
|
+
});
|
|
114
|
+
} catch (err) {
|
|
115
|
+
return {
|
|
116
|
+
decision: HOOK_DECISIONS.CONTINUE,
|
|
117
|
+
reason: `hook spawn failed: ${err.message}`,
|
|
118
|
+
exitCode: null,
|
|
119
|
+
error: err.message,
|
|
120
|
+
nonBlockingError: true,
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
// spawnSync surfaces timeout/ENOENT on res.error (status null) — non-blocking.
|
|
124
|
+
if (res.error) {
|
|
125
|
+
return {
|
|
126
|
+
decision: HOOK_DECISIONS.CONTINUE,
|
|
127
|
+
reason: `hook error: ${res.error.message}`,
|
|
128
|
+
exitCode: null,
|
|
129
|
+
stdout: String(res.stdout || ""),
|
|
130
|
+
stderr: String(res.stderr || ""),
|
|
131
|
+
error: res.error.message,
|
|
132
|
+
nonBlockingError: true,
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
const exitCode = res.status;
|
|
136
|
+
const stdout = String(res.stdout || "");
|
|
137
|
+
const stderr = String(res.stderr || "");
|
|
138
|
+
|
|
139
|
+
if (exitCode === 2) {
|
|
140
|
+
return {
|
|
141
|
+
decision: HOOK_DECISIONS.BLOCK,
|
|
142
|
+
reason: stderr.trim() || "hook exited 2 (blocked)",
|
|
143
|
+
exitCode,
|
|
144
|
+
stdout,
|
|
145
|
+
stderr,
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
if (exitCode === 0) {
|
|
149
|
+
const parsed = tryParseDecision(stdout);
|
|
150
|
+
if (parsed) return { ...parsed, exitCode, stdout, stderr };
|
|
151
|
+
return { decision: HOOK_DECISIONS.CONTINUE, reason: null, exitCode, stdout, stderr };
|
|
152
|
+
}
|
|
153
|
+
// Other non-zero → non-blocking error.
|
|
154
|
+
return {
|
|
155
|
+
decision: HOOK_DECISIONS.CONTINUE,
|
|
156
|
+
reason: stderr.trim() || `hook exited ${exitCode}`,
|
|
157
|
+
exitCode,
|
|
158
|
+
stdout,
|
|
159
|
+
stderr,
|
|
160
|
+
nonBlockingError: true,
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Run a list of command hooks in order; first BLOCK (or ASK) short-circuits.
|
|
166
|
+
* @returns {{ decision, reason, hook?, results }}
|
|
167
|
+
*/
|
|
168
|
+
function runHooks(commandHooks, input = {}, opts = {}) {
|
|
169
|
+
const results = [];
|
|
170
|
+
for (const h of commandHooks || []) {
|
|
171
|
+
const r = runCommandHook(h.command, input, {
|
|
172
|
+
...opts,
|
|
173
|
+
timeout: h.timeout != null ? h.timeout * 1000 : opts.timeout,
|
|
174
|
+
});
|
|
175
|
+
results.push({ command: h.command, ...r });
|
|
176
|
+
if (r.decision === HOOK_DECISIONS.BLOCK || r.decision === HOOK_DECISIONS.ASK) {
|
|
177
|
+
return { decision: r.decision, reason: r.reason, hook: h.command, results };
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
return { decision: HOOK_DECISIONS.CONTINUE, reason: null, results };
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
module.exports = { runCommandHook, runHooks, tryParseDecision, HOOK_DECISIONS, _deps };
|
|
@@ -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
|
+
}
|