@luchenglong/cli-bridge 0.2.9 → 0.2.10

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.
@@ -2,6 +2,7 @@
2
2
  //
3
3
  // 构建(node scripts/build.mjs --only=opencode)把 core/bridge-kit.mjs 与
4
4
  // providers/opencode.mjs 分别内联进下方两个占位标记行(各占一行、行内仅此标记),
5
+ // 第三个占位标记行负责内联 providers/opencode-*.mjs 片文件(可执行探测/模型解析),
5
6
  // 生成 dynamic/opencode.host.js。注意:标记字面量绝不能出现在本注释里 —— 构建做全局
6
7
  // 替换,注释中出现同样会被替换成正文代码而炸掉语法。
7
8
  // 沙箱纪律(旧仓 dynamic/host.js 头注 verbatim 精神):
@@ -561,590 +562,745 @@ function createRunner(o) {
561
562
  return { coreExecute, runSync, askFallback }
562
563
  }
563
564
 
564
- // providers/opencode.mjs — opencode provider descriptor(第四家 CLI,无旧包可迁)。
565
- //
566
- // 基线 = 本机实测 OpenCode V2 CLI v2.0.29(桌面版自带 opencode-cli.exe)+ 源码 emit 全集。
567
- // 完整 CLI 事实表见 docs/HANDOVER.md §13;这里只留写代码时最容易踩的几条:
568
- // • 非交互 = `opencode run "<prompt>"`,prompt 是【位置参数】(run 子命令无 --prompt;
569
- // `-p` 是 --password)。`--format json` → NDJSON。`--auto` = 自动批准权限。
570
- // • ⚠️ V2 无 --dir(cwd 靠 spawn)、无 --variant(并入 --model#variant)。
571
- // • 退出码才是成败判据:0 成功 / 1 错误 / 130 SIGINT。
572
- // • ⚠️ 无 result/complete/init 事件;type 全集 = step_start/text/reasoning/tool_use/
573
- // step_finish/error。文本在 part.text,统计在 step_finish.part —— 两者是不同事件。
574
- //
575
- // 红线(PROVIDER-CONTRACT.md §8 + §1):
576
- // - 本文件会被 build 内联进 dynamic 沙箱(new Function):绝不出现 import.meta 字面量;
577
- // process 只经 deps 注入或 typeof 守卫读取;宿主能力全部走 deps/attach(hostOpts)。
578
- // - 只允许 import '../core/bridge-kit.mjs'。
579
-
580
- function nowIso() { return new Date().toISOString() }
581
-
582
- // ── 可执行文件解析(env > 桌面版已知路径 > PATH)───────────────────────────────
583
- // 二进制名是 opencode-cli.exe(不是 opencode.exe):V2 桌面版自带,且 V1/V2 不能并存
584
- // 安装(官方原话),故不做「opencode.exe 兜底」以免误命中另一个版本。
585
- // 桌面版各版本目录形如 ...\ai.opencode.desktop\cli\<ver>-<arch>\opencode-cli.exe。
586
-
587
- function schedBuiltin(name) {
588
- try {
589
- const proc = globalThis.process
590
- return (proc && typeof proc.getBuiltinModule === 'function') ? proc.getBuiltinModule(name) : null
591
- } catch (e) { return null }
592
- }
593
-
594
- /**
595
- * 探测本机 opencode 可执行文件候选路径(优先桌面版 V2)。
596
- * 纯函数、零副作用;无 fs 能力时返回空数组 —— 由调用方退回 PATH 查找。
597
- * @param {object} env 环境变量(读 APPDATA / LOCALAPPDATA / PROGRAMFILES / HOME)
598
- */
599
- function resolveExePrefix(env, binPath) {
600
- const e = env || {}
601
- const bin = binPath || e.OPENCODE_BIN || e.OPENCODE_CLI || ''
602
- if (bin) {
603
- if (/\.(mjs|cjs|js)$/i.test(bin)) {
604
- const proc = globalThis.process
605
- const node = (proc && proc.execPath) ? proc.execPath : 'node'
606
- return [node, bin]
607
- }
608
- return [String(bin)]
609
- }
610
- const found = candidateExePaths(e)
611
- if (found.length) return [found[0]]
612
- return [EXE_BASENAMES[0]]
613
- }
614
-
615
- // ── 模型列表(`opencode models`;纯文本每行 provider/model)─────────────────────
616
- // 同步版给 settings.meta(面板下拉);异步版给 MCP 工具。解析宽容:只认
617
- // provider/model 形态的行,其余(表头/说明/空行)一律丢弃。
618
- const MODEL_LINE_RE = /^([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.#@+-]+)$/
619
-
620
- function parseModelList(text) {
621
- const out = []
622
- const seen = Object.create(null)
623
- for (const raw of String(text || '').split(/\r?\n/)) {
624
- const ln = raw.trim()
625
- if (!ln) continue
626
- const m = ln.match(MODEL_LINE_RE)
627
- if (!m) continue
628
- const id = m[1] + '/' + m[2]
629
- if (seen[id]) continue
630
- seen[id] = true
631
- out.push({ id: id, provider: m[1], name: m[2], free: /(-free$|free)/i.test(m[2]) })
632
- }
633
- return out
634
- }
635
-
636
- /**
637
- * 同步抓取模型列表(供设置面板下拉)。失败返回空数组 —— 下拉退化为自由输入,
638
- * 绝不因探测失败影响面板渲染。仅 spawnSync 可用时生效(宿主形态)。
639
- */
640
- function fetchModelsSync(env, binPath, timeoutMs) {
641
- try {
642
- const child = schedBuiltin('node:child_process')
643
- if (!child || typeof child.spawnSync !== 'function') return []
644
- const prefix = resolveExePrefix(env, binPath)
645
- const r = child.spawnSync(prefix[0], prefix.slice(1).concat(['models']), {
646
- encoding: 'utf8', timeout: clampInt(timeoutMs, 8000, 1000, 30000),
647
- windowsHide: true, stdio: ['ignore', 'pipe', 'pipe']
648
- })
649
- if (!r || r.error || r.status !== 0) return []
650
- return parseModelList(String(r.stdout || ''))
651
- } catch (e) { return [] }
652
- }
653
-
654
- // ── 三工具 schema ────────────────────────────────────────────────────────────
655
- const RUN_PARAMS = {
656
- type: 'object',
657
- additionalProperties: false,
658
- required: ['prompt'],
659
- properties: {
660
- prompt: { type: 'string', description: 'The task instruction for opencode. On first dispatch for a topic, include a compact CONTEXT preamble (goal, repo root, key paths, decisions/constraints; point at paths, do not paste file contents — opencode reads the repo itself). For follow-ups on the same topic, use opencode_continue with latest=true or sessionId (opencode keeps session context; state only what is new).' },
661
- model: { type: 'string', description: 'Optional model in provider/model form (e.g. "opencode/big-pickle"). Prefer a -free model when the task does not need a frontier model. Omit to use the model chosen in the CLI Bridge settings panel, or (if unset there) opencode\'s own default.' },
662
- agent: { type: 'string', description: 'Optional opencode agent name (primary agents only; subagent names are rejected by opencode itself).' },
663
- cwd: { type: 'string', description: 'Working directory for opencode. Defaults to the DSH workspace root.' },
664
- files: { type: 'array', items: { type: 'string' }, description: 'Optional file paths to attach to the message.' },
665
- title: { type: 'string', description: 'Optional session title (opencode truncates the prompt when omitted).' },
666
- timeoutSec: { type: 'integer', description: 'Execution timeout seconds (10-3600, default 600).' },
667
- autoApprove: { type: 'boolean', description: 'Default true: pass --auto so opencode auto-approves permissions and never blocks on a prompt. Set false to require explicit approval (opencode may then block and hit the timeout).' },
668
- background: { type: 'boolean', description: 'Defaults to true: dispatch returns a jobId immediately; collect with job_output, watch progress with opencode_status. Set false to block for the final answer.' }
669
- }
670
- }
671
- const CONTINUE_PARAMS = {
672
- type: 'object',
673
- additionalProperties: false,
674
- required: ['prompt'],
675
- properties: {
676
- prompt: { type: 'string', description: 'Follow-up instruction for the ongoing opencode session. State only what is new; opencode preserves its session context.' },
677
- sessionId: { type: 'string', description: 'opencode session id to continue (from a prior opencode_run result).' },
678
- latest: { type: 'boolean', description: 'Continue the most recent opencode session instead of a specific id.' },
679
- model: { type: 'string', description: 'Optional model in provider/model form.' },
680
- agent: { type: 'string', description: 'Optional opencode agent name.' },
681
- cwd: { type: 'string', description: 'Working directory for opencode.' },
682
- files: { type: 'array', items: { type: 'string' }, description: 'Optional file paths to attach.' },
683
- timeoutSec: { type: 'integer', description: 'Execution timeout seconds (10-3600, default 600).' },
684
- autoApprove: { type: 'boolean', description: 'Default true: pass --auto. Set false to require explicit approval.' },
685
- background: { type: 'boolean', description: 'Defaults to true: dispatch returns a jobId immediately; collect with job_output, watch progress with opencode_status. Set false to block for the final answer.' }
686
- }
687
- }
688
- const STATUS_PARAMS = { type: 'object', additionalProperties: false, required: [], properties: { cwd: { type: 'string', description: 'Optional: filter the snapshot to a single project (working directory).' } } }
689
-
690
- // opencode_models(附加工具):列出本机 opencode 可用模型(免费模型优先展示)。
691
- const MODELS_PARAMS = {
692
- type: 'object',
693
- additionalProperties: false,
694
- required: [],
695
- properties: {
696
- freeOnly: { type: 'boolean', description: 'Return only free models (id ends with -free). Default false = all.' },
697
- provider: { type: 'string', description: 'Optional provider filter, e.g. "opencode" or "opencode-go".' }
698
- }
699
- }
700
- const MODELS_DESCRIPTION = 'List the models available to the local opencode CLI (from `opencode models`). Returns { ok, models: [{id, provider, name, free}], count }. Use this to pick a model before opencode_run, or to check which models are free. Prefer a -free model for routine work and reserve paid/frontier models for tasks that genuinely need them. If the CLI is missing or not logged in, this returns ok:false with an actionable error — it never silently invents a model list.'
701
-
702
- function renderModels(value) {
703
- const v = value || {}
704
- if (!v.ok) return [{ type: 'text', text: 'opencode_models failed: ' + String(v.error || 'unknown') }]
705
- const list = Array.isArray(v.models) ? v.models : []
706
- if (!list.length) return [{ type: 'text', text: 'opencode_models: no models returned' }]
707
- const lines = ['opencode models (' + list.length + '):']
708
- for (const m of list.slice(0, 40)) lines.push(' ' + m.id + (m.free ? ' [free]' : ''))
709
- if (list.length > 40) lines.push(' … and ' + (list.length - 40) + ' more')
710
- const free = list.filter((m) => m.free).length
711
- lines.push('free: ' + free + ' / ' + list.length)
712
- return [{ type: 'text', text: lines.join('\n') }]
713
- }
714
-
715
- function defineProvider (deps = {}) {
716
- const env = deps.env || {}
717
- let host = null
718
- let settingsRef = { binPath: '', defaultModel: '', defaultAgent: '' }
719
-
720
- /**
721
- * 取当前生效的偏好设置。三条来源按优先级合并,每次 buildArgv 都重新读 ——
722
- * 面板改默认模型后无需重启即生效:
723
- * 1) `host.getSettings('opencode')` —— preset/dynamic 形态(attach 时由宿主注入);
724
- * 2) `deps.readSettings()` —— MCP 形态(真 Node 进程,每次现读设置文件);
725
- * 3) `settingsRef` —— attach 时固化的一次性快照 / 缺省值。
726
- * 三条都拿不到 → 缺省空值(= 不传 --model,交给 opencode 自身配置决定)。
727
- *
728
- * 历史坑:MCP 形态不 attach(自建 engine),只认 host.getSettings 会让面板设置的
729
- * defaultModel 在 MCP 面对 opencode_run 完全失效 —— 只有 args.model 才落地。
730
- * 三个形态行为不一致是缺陷,故此处统一把 readSettings 纳入(与 mimo 同构)。
731
- */
732
- function currentSettings() {
733
- const out = Object.assign({}, settingsRef)
734
- try {
735
- if (typeof deps.readSettings === 'function') {
736
- const r = deps.readSettings()
737
- if (r && typeof r === 'object') {
738
- const part = ('partition' in r || 'path' in r || 'home' in r) ? r.partition : r
739
- if (part && typeof part === 'object') Object.assign(out, part)
740
- }
741
- }
742
- } catch (e) { }
743
- try {
744
- if (host && typeof host.getSettings === 'function') {
745
- const s = host.getSettings('opencode')
746
- if (s && typeof s === 'object') Object.assign(out, s)
747
- }
748
- } catch (e) { }
749
- return out
750
- }
751
-
752
- const descriptor = {
753
- id: 'opencode', label: 'OPENCODE', presetId: 'opencode-first', defaultBackend: null, defaultMode: 'auto',
754
- exeName: 'opencode-cli', binEnvVars: ['OPENCODE_BIN', 'OPENCODE_CLI'], unavailableStatus: 'OPENCODE_UNAVAILABLE',
755
-
756
- // 单后端:opencode 自身选择 provider/model,桥不引入第二后端概念。
757
- backends() { return null },
758
- normalizeBackend() { return null },
759
- resolveBackend(args, target, preferred) { return null },
760
-
761
- // 实测 flag 集(V2.0.29)。
762
- buildArgv(exePrefix, args, opts) {
763
- const o = opts || {}
764
- const argv = Array.isArray(exePrefix) ? exePrefix.slice() : [String(exePrefix)]
765
- argv.push('run', String(args.prompt), '--format', 'json')
766
- if (args.autoApprove !== false) argv.push('--auto')
767
- const timeoutSec = clampInt(args.timeoutSec, 600, 10, 3600)
768
- // 模型优先级:调用参数 > 设置面板 defaultModel > 不传(交给 opencode 自身默认)。
769
- // 用户要求「不设默认 → 由 opencode 自决」,故 settingsRef.defaultModel 为空时不落地 --model。
770
- const model = args.model || currentSettings().defaultModel || ''
771
- if (model) argv.push('--model', String(model))
772
- const agent = args.agent || currentSettings().defaultAgent || ''
773
- if (agent) argv.push('--agent', String(agent))
774
- if (args.title) argv.push('--title', String(args.title))
775
- if (Array.isArray(args.files)) for (const f of args.files) { if (f) argv.push('--file', String(f)) }
776
- if (args.sessionId) argv.push('--session', String(args.sessionId))
777
- else if (args.continueLatest) argv.push('--continue')
778
- return { argv, mode: 'auto', timeoutSec }
779
- },
780
-
781
- // JSONL(每行一个 JSON 对象)。事件 type 取自 CLI 源码 emit 全集(实测一致):
782
- // step_start / text / reasoning / tool_use / step_finish / error。
783
- // ⚠️ 不存在 result/complete/init 事件 —— 终止判据是「进程退出码」,不是某个终态事件。
784
- // ⚠️ 文本与统计在【不同】事件里:text.part.text 是回复正文,step_finish.part 只有
785
- // cost/tokens。若直接取 step_finish 会得到「成功但正文为空」的结果。
786
- // ⚠️ 因此必须【正向】扫完整个流再决策:倒序会在读到 text 之前就命中末尾的
787
- // step_finish 并返回,回填永远为空(第一版实现的真实 bug)。
788
- // 实测一次真实调用只产生 step_start + text(无 step_finish)—— 末段 text 兜底
789
- // 是活的必经分支,不是死代码。
790
- // 决策优先级:error > step_finish > 末段 text > 末条事件;命中前两者时把末段文本
791
- // 回填进 part.text,让 buildResult 统一从 part.text 读正文。
792
- // 无 JSON 时返回 null,由 buildResult 走纯文本兜底(绝不假造 status)。
793
- parseJson(stdoutText) {
794
- const trimmed = String(stdoutText || '').trim()
795
- if (!trimmed) return null
796
- const lines = trimmed.split(/\r?\n/).filter(Boolean)
797
- let lastText = null
798
- let lastTextStr = ''
799
- let terminal = null
800
- let fallback = null
801
- for (const raw of lines) {
802
- const ln = raw.trim()
803
- if (!ln.startsWith('{')) continue
804
- let obj
805
- try { obj = JSON.parse(ln) } catch (e) { continue }
806
- if (!obj || typeof obj !== 'object') continue
807
- const part = obj.part && typeof obj.part === 'object' ? obj.part : null
808
- if (obj.type === 'text' && part && typeof part.text === 'string') {
809
- lastText = obj
810
- lastTextStr = part.text
811
- } else if (obj.type === 'error') {
812
- terminal = obj
813
- } else if (obj.type === 'step_finish') {
814
- if (!terminal || terminal.type !== 'error') terminal = obj
815
- }
816
- fallback = obj
817
- }
818
- if (terminal) {
819
- const part = terminal.part && typeof terminal.part === 'object' ? terminal.part : null
820
- if (lastTextStr && !(part && typeof part.text === 'string')) {
821
- terminal.part = Object.assign({}, part || {}, { text: lastTextStr })
822
- }
823
- return terminal
824
- }
825
- return lastText || fallback
826
- },
827
-
828
- // 统一结果对象(契约 §1 buildResult 行)。
829
- // 事件负载一律在 part 里(CLI emit: {type,timestamp,sessionID,...data},data = {part})。
830
- // 终止判据 = exit code(0 成功 / 1 错误 / 130 SIGINT),不存在 result 事件可依赖。
831
- buildResult(parsed, outcome, mode, stderrText, stdoutText, backend) {
832
- const exitCode = outcome ? outcome.exitCode : null
833
- const stderr = stderrText ? String(stderrText).slice(-2000) : ''
834
- if (parsed && typeof parsed === 'object') {
835
- const sessionId = typeof parsed.sessionID === 'string' ? parsed.sessionID
836
- : (typeof parsed.sessionId === 'string' ? parsed.sessionId : null)
837
- const part = parsed.part && typeof parsed.part === 'object' ? parsed.part : {}
838
- const errObj = parsed.error && typeof parsed.error === 'object' ? parsed.error : null
839
- const dataObj = errObj && errObj.data && typeof errObj.data === 'object' ? errObj.data : null
840
- const errMsg = dataObj && typeof dataObj.message === 'string' ? dataObj.message
841
- : (errObj && typeof errObj.message === 'string' ? errObj.message
842
- : (typeof parsed.error === 'string' ? parsed.error : ''))
843
- let response = ''
844
- if (typeof part.text === 'string') response = part.text
845
- else if (typeof parsed.response === 'string') response = parsed.response
846
- const isErrorEvent = parsed.type === 'error'
847
- let status
848
- if (exitCode === 130) status = 'ABORTED'
849
- else if (isErrorEvent) status = 'ERROR'
850
- else if (typeof parsed.status === 'string') status = parsed.status
851
- else status = exitCode === 0 ? 'SUCCESS' : 'ERROR'
852
- const tokens = part.tokens && typeof part.tokens === 'object' ? part.tokens : null
853
- let totalTokens = null
854
- if (tokens) {
855
- if (typeof tokens.total === 'number') totalTokens = tokens.total
856
- else if (typeof tokens.totalTokens === 'number') totalTokens = tokens.totalTokens
857
- else {
858
- let sum = 0
859
- let any = false
860
- for (const k of Object.keys(tokens)) {
861
- if (typeof tokens[k] === 'number') { sum += tokens[k]; any = true }
862
- }
863
- if (any) totalTokens = sum
864
- }
865
- }
866
- return {
867
- ok: exitCode === 0 && !isErrorEvent,
868
- status,
869
- response,
870
- sessionId,
871
- durationSeconds: null,
872
- numTurns: null,
873
- totalTokens,
874
- exitCode, mode, backend: backend || null,
875
- stderr: (stderr ? stderr + ' ' : '') + errMsg,
876
- cost: typeof part.cost === 'number' ? part.cost : null
877
- }
878
- }
879
- const text = String(stdoutText || '').trim()
880
- return {
881
- ok: exitCode === 0,
882
- status: exitCode === 0 ? 'SUCCESS' : 'ERROR',
883
- response: text,
884
- sessionId: null,
885
- durationSeconds: null, numTurns: null, totalTokens: null,
886
- exitCode, mode, backend: backend || null, stderr,
887
- rawStdout: text.slice(-2000)
888
- }
889
- },
890
-
891
- // 实时步骤折叠(写 project.current/trail)。
892
- // tool_use.part = { tool:"bash", state:{status,input,output,title,time}, … }
893
- // text.part = { text:"…", time:{start,end} }
894
- foldStreamEvent(ev, p, summarizeArgs, maxArgLen, maxTrail) {
895
- if (!ev || typeof ev !== 'object') return
896
- const type = ev.type
897
- if (!type) return
898
- const part = ev.part && typeof ev.part === 'object' ? ev.part : {}
899
- p.updatedAt = Date.now()
900
- if (type === 'tool_use') {
901
- const tool = part.tool || part.name || 'tool'
902
- const st = part.state && typeof part.state === 'object' ? part.state : {}
903
- const args = summarizeArgs(st.input || part.input)
904
- const state = st.status === 'completed' ? 'DONE' : (st.status === 'failed' ? 'ERROR' : 'ACTIVE')
905
- const entry = { stepIndex: p.trail.length, state: state, tool: tool, args: args || null, at: nowIso() }
906
- p.trail.push(entry)
907
- const cap = maxTrail || 12
908
- if (p.trail.length > cap) p.trail.shift()
909
- if (p.current) p.current = null
910
- } else if (type === 'text' || type === 'reasoning') {
911
- const delta = typeof part.text === 'string' ? part.text : ''
912
- if (!delta) return
913
- const capLen = maxArgLen || 120
914
- p.current = { tool: type === 'reasoning' ? 'reasoning' : 'agent_response', args: { text_delta: String(delta).slice(0, capLen) }, stepIndex: p.trail.length, since: nowIso() }
915
- } else if (type === 'step_finish') {
916
- if (p.current) p.current = null
917
- }
918
- },
919
-
920
- isTransientCliError(res) {
921
- if (!res || res.ok) return false
922
- if (isLimited(res)) return false
923
- return res.status === 'ERROR_DURING_EXECUTION'
924
- },
925
-
926
- failureHint(res) {
927
- if (!res || res.ok) return ''
928
- const st = String(res.status || '')
929
- if (st === 'OPENCODE_UNAVAILABLE') return '[诊断] 未找到 opencode 可执行文件:请安装 OpenCode(桌面版自带 CLI),或在 CLI Bridge 设置面板填写 opencode 可执行文件路径。'
930
- const hay = String(res.stderr || '') + ' ' + String(res.response || '')
931
- if (/Incorrect API key|invalid_api_key|401|unauthorized|not logged|login/i.test(hay) && !/rate.?limit|quota|限流|额度/i.test(hay)) {
932
- return '[诊断] opencode 凭据问题(未登录 / API key 无效):请让用户执行 `opencode auth login` 登录 provider。这不是限流。'
933
- }
934
- if (/No models available|no models|model not found|Unknown provider|not found.*model/i.test(hay)) {
935
- return '[诊断] opencode 没有可用模型或模型名无效:先用 opencode_models(或 `opencode models`)列出可用模型,再传 model。'
936
- }
937
- if (/quota|额度|exhausted|insufficient|credit|balance/i.test(hay)) {
938
- return '[诊断] opencode 额度可能耗尽:换一个(优先 -free)模型,或改用原生工具完成本任务。'
939
- }
940
- return ''
941
- },
942
-
943
- isSuccessStatus(st) { return st === 'SUCCESS' },
944
-
945
- tools: {
946
- run: { name: 'opencode_run', description: 'Dispatch a coding/build/debug/investigation task to the local opencode agent CLI and return its final answer. Prefer this for implementation, edits, refactors, multi-file investigation and debugging in every mode. DSH fully controls opencode: it always runs non-interactively with permissions auto-approved (opencode never blocks on a prompt). Use read-only native tools only for quick lookups and for final build/test verification. Runs in the background by default (returns a jobId immediately; collect with job_output); set background=false to block for the final answer.', inputSchema: RUN_PARAMS },
947
- continue: { name: 'opencode_continue', description: 'Continue an existing opencode session with a follow-up prompt, reusing opencode context. Pass sessionId from a prior opencode_run result, or set latest=true to continue the most recent opencode session. Same DSH-controlled, no-prompt execution as opencode_run.', inputSchema: CONTINUE_PARAMS },
948
- status: { name: 'opencode_status', description: 'Read a live snapshot of what the local opencode agent is currently doing. Returns one section per project (working directory): running count, the current step (tool name + arguments being executed, or agent_response thinking/typing), the recent step trail (tools executed, done/error), and the last completed run status + session id. Optional cwd filters to a single project. Call this to check on an in-flight opencode_run/opencode_continue without waiting for it to finish.', inputSchema: STATUS_PARAMS }
949
- },
950
-
951
- // opencode_models:宿主有 spawn 能力时注册(preset 注入 deps.process / MCP 注入
952
- // deps.nodeChild;dynamic 沙箱形态省略)。
953
- extraTools: (deps.process || deps.nodeChild) ? [{
954
- name: 'opencode_models',
955
- description: MODELS_DESCRIPTION,
956
- inputSchema: MODELS_PARAMS,
957
- render: renderModels,
958
- async logic(args) {
959
- return await descriptor.listModels(args || {})
960
- }
961
- }] : [],
962
-
963
- /**
964
- * 列出可用模型(三形态共用)。真实执行 `opencode models`;
965
- * CLI 缺失/未登录 → { ok:false, error }(绝不凭空造列表)。
966
- */
967
- async listModels(args) {
968
- const a = args || {}
969
- const child = deps.nodeChild
970
- if (!child || typeof child.spawn !== 'function') {
971
- // preset 形态:宿主 subprocess(attach 后可用)。
972
- if (!(host && host.subprocess)) return { ok: false, error: 'no spawn capability in this host form' }
973
- }
974
- const prefix = resolveExePrefix(env, currentSettings().binPath)
975
- const argv = prefix.slice(1).concat(['models'])
976
- const run = child && typeof child.spawn === 'function'
977
- ? spawnModelsNode(child, prefix[0], argv)
978
- : spawnModelsHost(argv)
979
- let res
980
- try { res = await run } catch (e) { return { ok: false, error: String(e && e.message || e) } }
981
- if (!res.ok) return res
982
- let list = res.models
983
- if (a.provider) {
984
- const pv = String(a.provider).toLowerCase()
985
- list = list.filter((m) => String(m.provider).toLowerCase() === pv)
986
- }
987
- if (a.freeOnly) list = list.filter((m) => m.free)
988
- // 免费模型优先展示(用户要求:优先用性能较好的免费模型),其次按字母序。
989
- list.sort((x, y) => (x.free === y.free ? String(x.id).localeCompare(String(y.id)) : (x.free ? -1 : 1)))
990
- return { ok: true, models: list, count: list.length }
991
- },
992
-
993
- // 设置分区:用户可选「用哪个 opencode(exe 路径)」+「默认模型」+「默认 agent」。
994
- settings: {
995
- keys: ['binPath', 'defaultModel', 'defaultAgent'],
996
- defaults: () => ({ binPath: '', defaultModel: '', defaultAgent: '' }),
997
- normalize: (raw) => {
998
- const r = raw && typeof raw === 'object' ? raw : {}
999
- const out = { binPath: '', defaultModel: '', defaultAgent: '' }
1000
- for (const k of ['binPath', 'defaultModel', 'defaultAgent']) {
1001
- if (typeof r[k] === 'string') out[k] = String(r[k]).trim()
1002
- }
1003
- return out
1004
- },
1005
- meta: (env2) => {
1006
- const e = env2 && typeof env2 === 'object' ? env2 : {}
1007
- const s = currentSettings()
1008
- // 下拉选项:实时 `opencode models`(失败则退化为空 → 面板仍可自由输入)。
1009
- const models = fetchModelsSync(e, s.binPath, 8000)
1010
- const options = models.map((m) => ({ value: m.id, label: m.id + (m.free ? ' [free]' : '') }))
1011
- // 默认给「可用 exe 路径」的下拉候选(env 覆盖 > 桌面版探测)。
1012
- const exeCandidates = []
1013
- if (e.OPENCODE_BIN || e.OPENCODE_CLI) exeCandidates.push(String(e.OPENCODE_BIN || e.OPENCODE_CLI))
1014
- for (const p of candidateExePaths(e)) if (exeCandidates.indexOf(p) < 0) exeCandidates.push(p)
1015
- return {
1016
- titleSuffix: 'OpenCode',
1017
- fields: [
1018
- {
1019
- key: 'binPath', label: 'opencode 可执行文件路径', type: 'select', allowCustom: true,
1020
- options: exeCandidates.map((p) => ({ value: p, label: p })),
1021
- placeholder: '留空 = 自动探测桌面版 / PATH',
1022
- note: '检测到 ' + exeCandidates.length + ' 个候选;也可手动粘贴任意路径(含 .mjs 会经 node 启动)。'
1023
- },
1024
- {
1025
- key: 'defaultModel', label: '默认模型', type: 'select', allowCustom: true,
1026
- options: options,
1027
- placeholder: '留空 = 由 opencode 自身配置决定',
1028
- note: options.length
1029
- ? '共 ' + options.length + ' 个可用模型,免费模型已排在前面([free])。调用时传 model 可覆盖本项。'
1030
- : '未能读取模型列表(CLI 未安装/未登录/超时)—— 仍可手动输入 provider/model。'
1031
- },
1032
- {
1033
- key: 'defaultAgent', label: '默认 agent', type: 'select', allowCustom: true,
1034
- options: [{ value: 'build', label: 'build(默认,全权限)' }, { value: 'plan', label: 'plan(只读)' }],
1035
- placeholder: '留空 = opencode 默认 agent',
1036
- note: 'opencode 只接受 primary agent;subagent 名会被 opencode 自身拒绝并回退。'
1037
- }
1038
- ]
1039
- }
1040
- },
1041
- diagnose: (settings, env2) => {
1042
- const s = settings && typeof settings === 'object' ? settings : {}
1043
- const e = env2 && typeof env2 === 'object' ? env2 : {}
1044
- const bits = []
1045
- const found = candidateExePaths(e)
1046
- if (s.binPath) bits.push('bin=设置:' + s.binPath)
1047
- else if (e.OPENCODE_BIN || e.OPENCODE_CLI) bits.push('bin=env:' + (e.OPENCODE_BIN || e.OPENCODE_CLI))
1048
- else if (found.length) bits.push('bin=桌面版:' + found[0].split(/[\\/]/).slice(-3).join('/'))
1049
- else bits.push('bin=PATH:opencode-cli')
1050
- bits.push(s.defaultModel ? 'model=' + s.defaultModel : 'model=(opencode 自决)')
1051
- if (s.defaultAgent) bits.push('agent=' + s.defaultAgent)
1052
- return bits.join(' · ')
1053
- }
1054
- },
1055
-
1056
- mcp: { version: '0.2.2' }
1057
- }
1058
-
1059
- // MCP/测试形态:真 child_process。
1060
- function spawnModelsNode(child, exe, argv) {
1061
- return new Promise((resolve) => {
1062
- let c
1063
- try { c = child.spawn(exe, argv, { stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true }) }
1064
- catch (e) { resolve({ ok: false, error: 'spawn: ' + String(e && e.message || e) }); return }
1065
- let out = ''
1066
- let err = ''
1067
- const timer = setTimeout(() => { try { c.kill() } catch (e) {} }, 15000)
1068
- c.stdout.on('data', (d) => { out += d })
1069
- c.stderr.on('data', (d) => { err += d })
1070
- c.on('error', (e) => { clearTimeout(timer); resolve({ ok: false, error: 'spawn: ' + String(e && e.message || e) }) })
1071
- c.on('close', (code) => {
1072
- clearTimeout(timer)
1073
- if (code !== 0) { resolve({ ok: false, error: '`opencode models` exited ' + code + ': ' + String(err || out).slice(0, 300) }); return }
1074
- const list = parseModelList(out)
1075
- if (!list.length) { resolve({ ok: false, error: 'no models parsed from output: ' + String(out).slice(0, 200) }); return }
1076
- resolve({ ok: true, models: list })
1077
- })
1078
- })
1079
- }
1080
-
1081
- // preset 形态:DSH subprocess。
1082
- async function spawnModelsHost(argv) {
1083
- try {
1084
- const spec = { argv: [resolveExePrefix(env, currentSettings().binPath)[0]].concat(argv), stdio: { stdin: 'ignore', stdout: { maxBytes: 1000000 }, stderr: { maxBytes: 200000 } }, graceMs: 5000 }
1085
- if (deps.process && typeof deps.process.cwd === 'function') spec.cwd = deps.process.cwd()
1086
- const handle = host.subprocess.spawn(spec)
1087
- const outcome = await handle.done
1088
- const stdoutText = handle.collected && handle.collected.stdout ? handle.collected.stdout.readFrom(0).text : ''
1089
- const stderrText = handle.collected && handle.collected.stderr ? handle.collected.stderr.readFrom(0).text : ''
1090
- if (!outcome || outcome.exitCode !== 0) return { ok: false, error: '`opencode models` exited ' + (outcome && outcome.exitCode) + ': ' + String(stderrText || stdoutText).slice(0, 300) }
1091
- const list = parseModelList(stdoutText)
1092
- if (!list.length) return { ok: false, error: 'no models parsed from output: ' + String(stdoutText).slice(0, 200) }
1093
- return { ok: true, models: list }
1094
- } catch (e) { return { ok: false, error: String(e && e.message || e) } }
1095
- }
1096
-
1097
- descriptor.runToolLogic = async function (args, exec) {
1098
- const a = args || {}
1099
- const normalized = a.background === undefined ? Object.assign({}, a, { background: true }) : a
1100
- return this.runner.coreExecute(normalized, exec)
1101
- }
1102
- descriptor.continueToolLogic = async function (args, exec) {
1103
- const a = args || {}
1104
- const mapped = {
1105
- prompt: a.prompt, model: a.model, agent: a.agent,
1106
- cwd: a.cwd, files: a.files, timeoutSec: a.timeoutSec, autoApprove: a.autoApprove,
1107
- background: a.background === undefined ? true : a.background
1108
- }
1109
- const sid = a.sessionId || null
1110
- if (sid) mapped.sessionId = sid
1111
- else if (a.latest) mapped.continueLatest = true
1112
- return this.runner.coreExecute(mapped, exec)
1113
- }
1114
- descriptor.statusToolLogic = function (args) {
1115
- const a = args || {}
1116
- const snap = this.engine.statusSnapshot()
1117
- if (a.cwd) {
1118
- const key = String(a.cwd)
1119
- snap.projects = snap.projects.filter((p) => p.cwd === key)
1120
- const g = snap.projects[0]
1121
- if (g) {
1122
- Object.assign(snap, {
1123
- state: g.state, running: g.running, current: g.current, trail: g.trail,
1124
- lastStatus: g.lastStatus, lastAt: g.lastAt, lastSessionId: g.lastSessionId,
1125
- lastBackend: g.lastBackend, fallbackActive: g.fallbackActive, updatedAt: g.updatedAt
1126
- })
1127
- }
1128
- }
1129
- return snap
1130
- }
1131
-
1132
- descriptor.attach = function (hostOpts) {
1133
- host = hostOpts || {}
1134
- // 宿主注入 getSettings 时才能把面板偏好带进 buildArgv(preset/dynamic 形态)。
1135
- try {
1136
- if (typeof host.getSettings === 'function') {
1137
- const s = host.getSettings('opencode')
1138
- if (s && typeof s === 'object') settingsRef = descriptor.settings.normalize(s)
1139
- }
1140
- } catch (e) { }
1141
- this.engine = createStatusEngine({ provider: this, publish: host.publish })
1142
- const runner = createRunner(Object.assign({}, host, { provider: this, engine: this.engine }))
1143
- this.runner = runner
1144
- return runner
1145
- }
1146
- return descriptor
1147
- }
565
+ // providers/opencode.mjs — opencode provider descriptor(第四家 CLI,无旧包可迁)。
566
+ //
567
+ // 基线 = 本机实测 OpenCode V2 CLI v2.0.29(桌面版自带 opencode-cli.exe)+ 源码 emit 全集。
568
+ // 完整 CLI 事实表见 docs/HANDOVER.md §13;这里只留写代码时最容易踩的几条:
569
+ // • 非交互 = `opencode run "<prompt>"`,prompt 是【位置参数】(run 子命令无 --prompt;
570
+ // `-p` 是 --password)。`--format json` → NDJSON。`--auto` = 自动批准权限。
571
+ // • ⚠️ V2 无 --dir(cwd 靠 spawn)、无 --variant(并入 --model#variant)。
572
+ // • 退出码才是成败判据:0 成功 / 1 错误 / 130 SIGINT。
573
+ // • ⚠️ 无 result/complete/init 事件;type 全集 = step_start/text/reasoning/tool_use/
574
+ // step_finish/error。文本在 part.text,统计在 step_finish.part —— 两者是不同事件。
575
+ //
576
+ // 红线(PROVIDER-CONTRACT.md §8 + §1):
577
+ // - 本文件会被 build 内联进 dynamic 沙箱(new Function):绝不出现 import.meta 字面量;
578
+ // process 只经 deps 注入或 typeof 守卫读取;宿主能力全部走 deps/attach(hostOpts)。
579
+ // - 只允许 import '../core/bridge-kit.mjs'(以及同目录的 opencode-exe.mjs 片文件,
580
+ // build 会把 providers/opencode-*.mjs 一并内联进同一沙箱作用域)。
581
+ // 契约 §3.2:设置面板**不自动探测**模型列表 —— settings.meta() 只读上次手动拉取的
582
+ // 缓存(缓存在 providers/opencode-exe.mjs),用户点「拉取可用模型」才走 refreshModels。
583
+ // import { createStatusEngine, createRunner, clampInt, isLimited } from '../core/bridge-kit.mjs'
584
+ // import { candidateExePaths, resolveExePrefix, parseModelList, sortModelsFreeFirst, fetchModelsDetailed, cachedModels, rememberModels } from './opencode-exe.mjs'
585
+
586
+ function nowIso() { return new Date().toISOString() }
587
+
588
+ // 模型清单 → 下拉选项 / id→label 映射(免费模型带 [free] 后缀,与 opencode_models 展示一致)。
589
+ function modelLabel(m) { return String(m.id) + ((m && m.free) ? ' [free]' : '') }
590
+ function modelsToOptions(models) {
591
+ return (Array.isArray(models) ? models : []).map((m) => ({ value: String(m.id), label: modelLabel(m) }))
592
+ }
593
+
594
+ // 可执行文件探测 / 模型清单解析 / 手动拉取缓存全部在 providers/opencode-exe.mjs,
595
+ // 本文件只消费(契约 §3.2:settings.meta 只读缓存,绝不在面板渲染时 spawn)。
596
+ // 二进制名是 opencode-cli(V2 桌面版自带):V1/V2 不能并存安装(官方原话),
597
+ // 故不做 opencode.exe 兜底,以免误命中另一个版本。
598
+
599
+ // ── 三工具 schema ────────────────────────────────────────────────────────────
600
+ const RUN_PARAMS = {
601
+ type: 'object',
602
+ additionalProperties: false,
603
+ required: ['prompt'],
604
+ properties: {
605
+ prompt: { type: 'string', description: 'The task instruction for opencode. On first dispatch for a topic, include a compact CONTEXT preamble (goal, repo root, key paths, decisions/constraints; point at paths, do not paste file contents — opencode reads the repo itself). For follow-ups on the same topic, use opencode_continue with latest=true or sessionId (opencode keeps session context; state only what is new).' },
606
+ model: { type: 'string', description: 'Optional model in provider/model form (e.g. "opencode/big-pickle"). Prefer a -free model when the task does not need a frontier model. Omit to use the model chosen in the CLI Bridge settings panel, or (if unset there) opencode\'s own default.' },
607
+ agent: { type: 'string', description: 'Optional opencode agent name (primary agents only; subagent names are rejected by opencode itself).' },
608
+ cwd: { type: 'string', description: 'Working directory for opencode. Defaults to the DSH workspace root.' },
609
+ files: { type: 'array', items: { type: 'string' }, description: 'Optional file paths to attach to the message.' },
610
+ title: { type: 'string', description: 'Optional session title (opencode truncates the prompt when omitted).' },
611
+ timeoutSec: { type: 'integer', description: 'Execution timeout seconds (10-3600, default 600).' },
612
+ autoApprove: { type: 'boolean', description: 'Default true: pass --auto so opencode auto-approves permissions and never blocks on a prompt. Set false to require explicit approval (opencode may then block and hit the timeout).' },
613
+ background: { type: 'boolean', description: 'Defaults to true: dispatch returns a jobId immediately; collect with job_output, watch progress with opencode_status. Set false to block for the final answer.' }
614
+ }
615
+ }
616
+ const CONTINUE_PARAMS = {
617
+ type: 'object',
618
+ additionalProperties: false,
619
+ required: ['prompt'],
620
+ properties: {
621
+ prompt: { type: 'string', description: 'Follow-up instruction for the ongoing opencode session. State only what is new; opencode preserves its session context.' },
622
+ sessionId: { type: 'string', description: 'opencode session id to continue (from a prior opencode_run result).' },
623
+ latest: { type: 'boolean', description: 'Continue the most recent opencode session instead of a specific id.' },
624
+ model: { type: 'string', description: 'Optional model in provider/model form.' },
625
+ agent: { type: 'string', description: 'Optional opencode agent name.' },
626
+ cwd: { type: 'string', description: 'Working directory for opencode.' },
627
+ files: { type: 'array', items: { type: 'string' }, description: 'Optional file paths to attach.' },
628
+ timeoutSec: { type: 'integer', description: 'Execution timeout seconds (10-3600, default 600).' },
629
+ autoApprove: { type: 'boolean', description: 'Default true: pass --auto. Set false to require explicit approval.' },
630
+ background: { type: 'boolean', description: 'Defaults to true: dispatch returns a jobId immediately; collect with job_output, watch progress with opencode_status. Set false to block for the final answer.' }
631
+ }
632
+ }
633
+ const STATUS_PARAMS = { type: 'object', additionalProperties: false, required: [], properties: { cwd: { type: 'string', description: 'Optional: filter the snapshot to a single project (working directory).' } } }
634
+
635
+ // opencode_models(附加工具):列出本机 opencode 可用模型(免费模型优先展示)。
636
+ const MODELS_PARAMS = {
637
+ type: 'object',
638
+ additionalProperties: false,
639
+ required: [],
640
+ properties: {
641
+ freeOnly: { type: 'boolean', description: 'Return only free models (id ends with -free). Default false = all.' },
642
+ provider: { type: 'string', description: 'Optional provider filter, e.g. "opencode" or "opencode-go".' }
643
+ }
644
+ }
645
+ const MODELS_DESCRIPTION = 'List the models available to the local opencode CLI (from `opencode models`). Returns { ok, models: [{id, provider, name, free}], count }. Use this to pick a model before opencode_run, or to check which models are free. Prefer a -free model for routine work and reserve paid/frontier models for tasks that genuinely need them. If the CLI is missing or not logged in, this returns ok:false with an actionable error — it never silently invents a model list.'
646
+
647
+ function renderModels(value) {
648
+ const v = value || {}
649
+ if (!v.ok) return [{ type: 'text', text: 'opencode_models failed: ' + String(v.error || 'unknown') }]
650
+ const list = Array.isArray(v.models) ? v.models : []
651
+ if (!list.length) return [{ type: 'text', text: 'opencode_models: no models returned' }]
652
+ const lines = ['opencode models (' + list.length + '):']
653
+ for (const m of list.slice(0, 40)) lines.push(' ' + m.id + (m.free ? ' [free]' : ''))
654
+ if (list.length > 40) lines.push(' … and ' + (list.length - 40) + ' more')
655
+ const free = list.filter((m) => m.free).length
656
+ lines.push('free: ' + free + ' / ' + list.length)
657
+ return [{ type: 'text', text: lines.join('\n') }]
658
+ }
659
+
660
+ function defineProvider (deps = {}) {
661
+ const env = deps.env || {}
662
+ let host = null
663
+ let settingsRef = { binPath: '', defaultModel: '', defaultAgent: '' }
664
+
665
+ /**
666
+ * 取当前生效的偏好设置。三条来源按优先级合并,每次 buildArgv 都重新读 ——
667
+ * 面板改默认模型后无需重启即生效:
668
+ * 1) `host.getSettings('opencode')` —— preset/dynamic 形态(attach 时由宿主注入);
669
+ * 2) `deps.readSettings()` —— MCP 形态(真 Node 进程,每次现读设置文件);
670
+ * 3) `settingsRef` —— attach 时固化的一次性快照 / 缺省值。
671
+ * 三条都拿不到 → 缺省空值(= 不传 --model,交给 opencode 自身配置决定)。
672
+ *
673
+ * 历史坑:MCP 形态不 attach(自建 engine),只认 host.getSettings 会让面板设置的
674
+ * defaultModel 在 MCP 面对 opencode_run 完全失效 —— 只有 args.model 才落地。
675
+ * 三个形态行为不一致是缺陷,故此处统一把 readSettings 纳入(与 mimo 同构)。
676
+ */
677
+ function currentSettings() {
678
+ const out = Object.assign({}, settingsRef)
679
+ try {
680
+ if (typeof deps.readSettings === 'function') {
681
+ const r = deps.readSettings()
682
+ if (r && typeof r === 'object') {
683
+ const part = ('partition' in r || 'path' in r || 'home' in r) ? r.partition : r
684
+ if (part && typeof part === 'object') Object.assign(out, part)
685
+ }
686
+ }
687
+ } catch (e) { }
688
+ try {
689
+ if (host && typeof host.getSettings === 'function') {
690
+ const s = host.getSettings('opencode')
691
+ if (s && typeof s === 'object') Object.assign(out, s)
692
+ }
693
+ } catch (e) { }
694
+ return out
695
+ }
696
+
697
+ const descriptor = {
698
+ id: 'opencode', label: 'OPENCODE', presetId: 'opencode-first', defaultBackend: null, defaultMode: 'auto',
699
+ exeName: 'opencode-cli', binEnvVars: ['OPENCODE_BIN', 'OPENCODE_CLI'], unavailableStatus: 'OPENCODE_UNAVAILABLE',
700
+
701
+ // 单后端:opencode 自身选择 provider/model,桥不引入第二后端概念。
702
+ backends() { return null },
703
+ normalizeBackend() { return null },
704
+ resolveBackend(args, target, preferred) { return null },
705
+
706
+ // 实测 flag 集(V2.0.29)。
707
+ buildArgv(exePrefix, args, opts) {
708
+ const o = opts || {}
709
+ const argv = Array.isArray(exePrefix) ? exePrefix.slice() : [String(exePrefix)]
710
+ argv.push('run', String(args.prompt), '--format', 'json')
711
+ if (args.autoApprove !== false) argv.push('--auto')
712
+ const timeoutSec = clampInt(args.timeoutSec, 600, 10, 3600)
713
+ // 模型优先级:调用参数 > 设置面板 defaultModel > 不传(交给 opencode 自身默认)。
714
+ // 用户要求「不设默认 → 由 opencode 自决」,故 settingsRef.defaultModel 为空时不落地 --model。
715
+ const model = args.model || currentSettings().defaultModel || ''
716
+ if (model) argv.push('--model', String(model))
717
+ const agent = args.agent || currentSettings().defaultAgent || ''
718
+ if (agent) argv.push('--agent', String(agent))
719
+ if (args.title) argv.push('--title', String(args.title))
720
+ if (Array.isArray(args.files)) for (const f of args.files) { if (f) argv.push('--file', String(f)) }
721
+ if (args.sessionId) argv.push('--session', String(args.sessionId))
722
+ else if (args.continueLatest) argv.push('--continue')
723
+ return { argv, mode: 'auto', timeoutSec }
724
+ },
725
+
726
+ // JSONL(每行一个 JSON 对象)。事件 type 取自 CLI 源码 emit 全集(实测一致):
727
+ // step_start / text / reasoning / tool_use / step_finish / error。
728
+ // ⚠️ 不存在 result/complete/init 事件 —— 终止判据是「进程退出码」,不是某个终态事件。
729
+ // ⚠️ 文本与统计在【不同】事件里:text.part.text 是回复正文,step_finish.part 只有
730
+ // cost/tokens。若直接取 step_finish 会得到「成功但正文为空」的结果。
731
+ // ⚠️ 因此必须【正向】扫完整个流再决策:倒序会在读到 text 之前就命中末尾的
732
+ // step_finish 并返回,回填永远为空(第一版实现的真实 bug)。
733
+ // 实测一次真实调用只产生 step_start + text(无 step_finish)—— 末段 text 兜底
734
+ // 是活的必经分支,不是死代码。
735
+ // 决策优先级:error > step_finish > 末段 text > 末条事件;命中前两者时把末段文本
736
+ // 回填进 part.text,让 buildResult 统一从 part.text 读正文。
737
+ // 无 JSON 时返回 null,由 buildResult 走纯文本兜底(绝不假造 status)。
738
+ parseJson(stdoutText) {
739
+ const trimmed = String(stdoutText || '').trim()
740
+ if (!trimmed) return null
741
+ const lines = trimmed.split(/\r?\n/).filter(Boolean)
742
+ let lastText = null
743
+ let lastTextStr = ''
744
+ let terminal = null
745
+ let fallback = null
746
+ for (const raw of lines) {
747
+ const ln = raw.trim()
748
+ if (!ln.startsWith('{')) continue
749
+ let obj
750
+ try { obj = JSON.parse(ln) } catch (e) { continue }
751
+ if (!obj || typeof obj !== 'object') continue
752
+ const part = obj.part && typeof obj.part === 'object' ? obj.part : null
753
+ if (obj.type === 'text' && part && typeof part.text === 'string') {
754
+ lastText = obj
755
+ lastTextStr = part.text
756
+ } else if (obj.type === 'error') {
757
+ terminal = obj
758
+ } else if (obj.type === 'step_finish') {
759
+ if (!terminal || terminal.type !== 'error') terminal = obj
760
+ }
761
+ fallback = obj
762
+ }
763
+ if (terminal) {
764
+ const part = terminal.part && typeof terminal.part === 'object' ? terminal.part : null
765
+ if (lastTextStr && !(part && typeof part.text === 'string')) {
766
+ terminal.part = Object.assign({}, part || {}, { text: lastTextStr })
767
+ }
768
+ return terminal
769
+ }
770
+ return lastText || fallback
771
+ },
772
+
773
+ // 统一结果对象(契约 §1 buildResult 行)。
774
+ // 事件负载一律在 part 里(CLI emit: {type,timestamp,sessionID,...data},data = {part})。
775
+ // 终止判据 = exit code(0 成功 / 1 错误 / 130 SIGINT),不存在 result 事件可依赖。
776
+ buildResult(parsed, outcome, mode, stderrText, stdoutText, backend) {
777
+ const exitCode = outcome ? outcome.exitCode : null
778
+ const stderr = stderrText ? String(stderrText).slice(-2000) : ''
779
+ if (parsed && typeof parsed === 'object') {
780
+ const sessionId = typeof parsed.sessionID === 'string' ? parsed.sessionID
781
+ : (typeof parsed.sessionId === 'string' ? parsed.sessionId : null)
782
+ const part = parsed.part && typeof parsed.part === 'object' ? parsed.part : {}
783
+ const errObj = parsed.error && typeof parsed.error === 'object' ? parsed.error : null
784
+ const dataObj = errObj && errObj.data && typeof errObj.data === 'object' ? errObj.data : null
785
+ const errMsg = dataObj && typeof dataObj.message === 'string' ? dataObj.message
786
+ : (errObj && typeof errObj.message === 'string' ? errObj.message
787
+ : (typeof parsed.error === 'string' ? parsed.error : ''))
788
+ let response = ''
789
+ if (typeof part.text === 'string') response = part.text
790
+ else if (typeof parsed.response === 'string') response = parsed.response
791
+ const isErrorEvent = parsed.type === 'error'
792
+ let status
793
+ if (exitCode === 130) status = 'ABORTED'
794
+ else if (isErrorEvent) status = 'ERROR'
795
+ else if (typeof parsed.status === 'string') status = parsed.status
796
+ else status = exitCode === 0 ? 'SUCCESS' : 'ERROR'
797
+ const tokens = part.tokens && typeof part.tokens === 'object' ? part.tokens : null
798
+ let totalTokens = null
799
+ if (tokens) {
800
+ if (typeof tokens.total === 'number') totalTokens = tokens.total
801
+ else if (typeof tokens.totalTokens === 'number') totalTokens = tokens.totalTokens
802
+ else {
803
+ let sum = 0
804
+ let any = false
805
+ for (const k of Object.keys(tokens)) {
806
+ if (typeof tokens[k] === 'number') { sum += tokens[k]; any = true }
807
+ }
808
+ if (any) totalTokens = sum
809
+ }
810
+ }
811
+ return {
812
+ ok: exitCode === 0 && !isErrorEvent,
813
+ status,
814
+ response,
815
+ sessionId,
816
+ durationSeconds: null,
817
+ numTurns: null,
818
+ totalTokens,
819
+ exitCode, mode, backend: backend || null,
820
+ stderr: (stderr ? stderr + ' ' : '') + errMsg,
821
+ cost: typeof part.cost === 'number' ? part.cost : null
822
+ }
823
+ }
824
+ const text = String(stdoutText || '').trim()
825
+ return {
826
+ ok: exitCode === 0,
827
+ status: exitCode === 0 ? 'SUCCESS' : 'ERROR',
828
+ response: text,
829
+ sessionId: null,
830
+ durationSeconds: null, numTurns: null, totalTokens: null,
831
+ exitCode, mode, backend: backend || null, stderr,
832
+ rawStdout: text.slice(-2000)
833
+ }
834
+ },
835
+
836
+ // 实时步骤折叠(写 project.current/trail)。
837
+ // tool_use.part = { tool:"bash", state:{status,input,output,title,time}, … }
838
+ // text.part = { text:"…", time:{start,end} }
839
+ foldStreamEvent(ev, p, summarizeArgs, maxArgLen, maxTrail) {
840
+ if (!ev || typeof ev !== 'object') return
841
+ const type = ev.type
842
+ if (!type) return
843
+ const part = ev.part && typeof ev.part === 'object' ? ev.part : {}
844
+ p.updatedAt = Date.now()
845
+ if (type === 'tool_use') {
846
+ const tool = part.tool || part.name || 'tool'
847
+ const st = part.state && typeof part.state === 'object' ? part.state : {}
848
+ const args = summarizeArgs(st.input || part.input)
849
+ const state = st.status === 'completed' ? 'DONE' : (st.status === 'failed' ? 'ERROR' : 'ACTIVE')
850
+ const entry = { stepIndex: p.trail.length, state: state, tool: tool, args: args || null, at: nowIso() }
851
+ p.trail.push(entry)
852
+ const cap = maxTrail || 12
853
+ if (p.trail.length > cap) p.trail.shift()
854
+ if (p.current) p.current = null
855
+ } else if (type === 'text' || type === 'reasoning') {
856
+ const delta = typeof part.text === 'string' ? part.text : ''
857
+ if (!delta) return
858
+ const capLen = maxArgLen || 120
859
+ p.current = { tool: type === 'reasoning' ? 'reasoning' : 'agent_response', args: { text_delta: String(delta).slice(0, capLen) }, stepIndex: p.trail.length, since: nowIso() }
860
+ } else if (type === 'step_finish') {
861
+ if (p.current) p.current = null
862
+ }
863
+ },
864
+
865
+ isTransientCliError(res) {
866
+ if (!res || res.ok) return false
867
+ if (isLimited(res)) return false
868
+ return res.status === 'ERROR_DURING_EXECUTION'
869
+ },
870
+
871
+ failureHint(res) {
872
+ if (!res || res.ok) return ''
873
+ const st = String(res.status || '')
874
+ if (st === 'OPENCODE_UNAVAILABLE') return '[诊断] 未找到 opencode 可执行文件:请安装 OpenCode(桌面版自带 CLI),或在 CLI Bridge 设置面板填写 opencode 可执行文件路径。'
875
+ const hay = String(res.stderr || '') + ' ' + String(res.response || '')
876
+ if (/Incorrect API key|invalid_api_key|401|unauthorized|not logged|login/i.test(hay) && !/rate.?limit|quota|限流|额度/i.test(hay)) {
877
+ return '[诊断] opencode 凭据问题(未登录 / API key 无效):请让用户执行 `opencode auth login` 登录 provider。这不是限流。'
878
+ }
879
+ if (/No models available|no models|model not found|Unknown provider|not found.*model/i.test(hay)) {
880
+ return '[诊断] opencode 没有可用模型或模型名无效:先用 opencode_models(或 `opencode models`)列出可用模型,再传 model。'
881
+ }
882
+ if (/quota|额度|exhausted|insufficient|credit|balance/i.test(hay)) {
883
+ return '[诊断] opencode 额度可能耗尽:换一个(优先 -free)模型,或改用原生工具完成本任务。'
884
+ }
885
+ return ''
886
+ },
887
+
888
+ isSuccessStatus(st) { return st === 'SUCCESS' },
889
+
890
+ tools: {
891
+ run: { name: 'opencode_run', description: 'Dispatch a coding/build/debug/investigation task to the local opencode agent CLI and return its final answer. Prefer this for implementation, edits, refactors, multi-file investigation and debugging in every mode. DSH fully controls opencode: it always runs non-interactively with permissions auto-approved (opencode never blocks on a prompt). Use read-only native tools only for quick lookups and for final build/test verification. Runs in the background by default (returns a jobId immediately; collect with job_output); set background=false to block for the final answer.', inputSchema: RUN_PARAMS },
892
+ continue: { name: 'opencode_continue', description: 'Continue an existing opencode session with a follow-up prompt, reusing opencode context. Pass sessionId from a prior opencode_run result, or set latest=true to continue the most recent opencode session. Same DSH-controlled, no-prompt execution as opencode_run.', inputSchema: CONTINUE_PARAMS },
893
+ status: { name: 'opencode_status', description: 'Read a live snapshot of what the local opencode agent is currently doing. Returns one section per project (working directory): running count, the current step (tool name + arguments being executed, or agent_response thinking/typing), the recent step trail (tools executed, done/error), and the last completed run status + session id. Optional cwd filters to a single project. Call this to check on an in-flight opencode_run/opencode_continue without waiting for it to finish.', inputSchema: STATUS_PARAMS }
894
+ },
895
+
896
+ // opencode_models:宿主有 spawn 能力时注册(preset 注入 deps.process / MCP 注入
897
+ // deps.nodeChild;dynamic 沙箱形态省略)。
898
+ extraTools: (deps.process || deps.nodeChild) ? [{
899
+ name: 'opencode_models',
900
+ description: MODELS_DESCRIPTION,
901
+ inputSchema: MODELS_PARAMS,
902
+ render: renderModels,
903
+ async logic(args) {
904
+ return await descriptor.listModels(args || {})
905
+ }
906
+ }] : [],
907
+
908
+ /**
909
+ * 列出可用模型(三形态共用)。真实执行 `opencode models`;
910
+ * CLI 缺失/未登录 → { ok:false, error }(绝不凭空造列表)。
911
+ */
912
+ async listModels(args) {
913
+ const a = args || {}
914
+ const child = deps.nodeChild
915
+ if (!child || typeof child.spawn !== 'function') {
916
+ // preset 形态:宿主 subprocess(attach 后可用)。
917
+ if (!(host && host.subprocess)) return { ok: false, error: 'no spawn capability in this host form' }
918
+ }
919
+ const prefix = resolveExePrefix(env, currentSettings().binPath)
920
+ const argv = prefix.slice(1).concat(['models'])
921
+ const run = child && typeof child.spawn === 'function'
922
+ ? spawnModelsNode(child, prefix[0], argv)
923
+ : spawnModelsHost(argv)
924
+ let res
925
+ try { res = await run } catch (e) { return { ok: false, error: String(e && e.message || e) } }
926
+ if (!res.ok) return res
927
+ let list = res.models
928
+ if (a.provider) {
929
+ const pv = String(a.provider).toLowerCase()
930
+ list = list.filter((m) => String(m.provider).toLowerCase() === pv)
931
+ }
932
+ if (a.freeOnly) list = list.filter((m) => m.free)
933
+ // 免费模型优先展示(用户要求:优先用免费模型),与设置面板拉取列表同一排序。
934
+ list = sortModelsFreeFirst(list)
935
+ return { ok: true, models: list, count: list.length }
936
+ },
937
+
938
+ // 设置分区:用户可选「用哪个 opencode(exe 路径)」+「默认模型」+「默认 agent」。
939
+ settings: {
940
+ keys: ['binPath', 'defaultModel', 'defaultAgent'],
941
+ defaults: () => ({ binPath: '', defaultModel: '', defaultAgent: '' }),
942
+ normalize: (raw) => {
943
+ const r = raw && typeof raw === 'object' ? raw : {}
944
+ const out = { binPath: '', defaultModel: '', defaultAgent: '' }
945
+ for (const k of ['binPath', 'defaultModel', 'defaultAgent']) {
946
+ if (typeof r[k] === 'string') out[k] = String(r[k]).trim()
947
+ }
948
+ return out
949
+ },
950
+ meta: (env2) => {
951
+ const e = env2 && typeof env2 === 'object' ? env2 : {}
952
+ const s = currentSettings()
953
+ // 契约 §3.2:面板渲染**只读缓存**(命中就填 options,没命中留空),绝不 spawn。
954
+ // 探测改由用户点「拉取可用模型」显式触发(settings.refreshModels)。
955
+ const options = modelsToOptions(cachedModels())
956
+ // 默认给「可用 exe 路径」的下拉候选(env 覆盖 > 桌面版探测)。
957
+ const exeCandidates = []
958
+ if (e.OPENCODE_BIN || e.OPENCODE_CLI) exeCandidates.push(String(e.OPENCODE_BIN || e.OPENCODE_CLI))
959
+ for (const p of candidateExePaths(e)) if (exeCandidates.indexOf(p) < 0) exeCandidates.push(p)
960
+ return {
961
+ titleSuffix: 'OpenCode',
962
+ fields: [
963
+ {
964
+ key: 'binPath', label: 'opencode 可执行文件路径', type: 'select', allowCustom: true,
965
+ options: exeCandidates.map((p) => ({ value: p, label: p })),
966
+ placeholder: '留空 = 自动探测桌面版 / PATH',
967
+ note: '检测到 ' + exeCandidates.length + ' 个候选;也可手动粘贴任意路径(含 .mjs 会经 node 启动)。'
968
+ },
969
+ {
970
+ key: 'defaultModel', label: '默认模型', type: 'select', allowCustom: true,
971
+ options: options,
972
+ refresh: { label: '拉取可用模型' },
973
+ placeholder: '留空 = 由 opencode 自身配置决定',
974
+ note: options.length
975
+ ? '共 ' + options.length + ' 个可用模型(上次手动拉取的结果,免费模型已排在前面)。点「拉取可用模型」可重新拉取;调用时传 model 可覆盖本项。'
976
+ : '还没拉取过可用模型清单 —— 点右侧「拉取可用模型」按钮拉取(面板不再自动探测,避免拖慢首屏)。拉取失败会显示具体原因(CLI 未安装 / 未登录 / 超时)。仍可手动输入 provider/model。'
977
+ },
978
+ {
979
+ key: 'defaultAgent', label: '默认 agent', type: 'select', allowCustom: true,
980
+ options: [{ value: 'build', label: 'build(默认,全权限)' }, { value: 'plan', label: 'plan(只读)' }],
981
+ placeholder: '留空 = opencode 默认 agent',
982
+ note: 'opencode 只接受 primary agent;subagent 名会被 opencode 自身拒绝并回退。'
983
+ }
984
+ ]
985
+ }
986
+ },
987
+ /**
988
+ * 契约 §3.2:手动拉取可用模型(面板按钮 → POST /cli-bridge/settings/models)。
989
+ * 复用 opencode-exe.mjs 的同步探测(包一层 async 即可 —— 该端点本身就是串行
990
+ * 请求,为它再写一套异步 spawn 只会多一份要维护的探测代码)。
991
+ * 成功写回缓存让随后的 meta() 命中;失败**必须** ok:false + 可操作原因,
992
+ * 绝不返回 { ok:true, ids:[] },也绝不硬编码模型名冒充清单。
993
+ */
994
+ async refreshModels(settings, envArg) {
995
+ const s = settings && typeof settings === 'object' ? settings : {}
996
+ const e = envArg && typeof envArg === 'object' ? envArg : env
997
+ const binPath = (typeof s.binPath === 'string' && s.binPath.trim()) ? s.binPath.trim() : (currentSettings().binPath || '')
998
+ const r = fetchModelsDetailed(e, binPath, 15000)
999
+ const source = 'opencode-models'
1000
+ if (!r.ok) return { ok: false, ids: [], source: source, error: r.error }
1001
+ const models = sortModelsFreeFirst(r.models)
1002
+ rememberModels(models)
1003
+ const labels = Object.create(null)
1004
+ for (const m of models) labels[m.id] = modelLabel(m)
1005
+ return { ok: true, ids: models.map((m) => String(m.id)), labels: labels, source: source }
1006
+ },
1007
+ diagnose: (settings, env2) => {
1008
+ const s = settings && typeof settings === 'object' ? settings : {}
1009
+ const e = env2 && typeof env2 === 'object' ? env2 : {}
1010
+ const bits = []
1011
+ const found = candidateExePaths(e)
1012
+ if (s.binPath) bits.push('bin=设置:' + s.binPath)
1013
+ else if (e.OPENCODE_BIN || e.OPENCODE_CLI) bits.push('bin=env:' + (e.OPENCODE_BIN || e.OPENCODE_CLI))
1014
+ else if (found.length) bits.push('bin=桌面版:' + found[0].split(/[\\/]/).slice(-3).join('/'))
1015
+ else bits.push('bin=PATH:opencode-cli')
1016
+ bits.push(s.defaultModel ? 'model=' + s.defaultModel : 'model=(opencode 自决)')
1017
+ if (s.defaultAgent) bits.push('agent=' + s.defaultAgent)
1018
+ return bits.join(' · ')
1019
+ }
1020
+ },
1021
+
1022
+ mcp: { version: '0.2.2' }
1023
+ }
1024
+
1025
+ // MCP/测试形态:真 child_process。
1026
+ function spawnModelsNode(child, exe, argv) {
1027
+ return new Promise((resolve) => {
1028
+ let c
1029
+ try { c = child.spawn(exe, argv, { stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true }) }
1030
+ catch (e) { resolve({ ok: false, error: 'spawn: ' + String(e && e.message || e) }); return }
1031
+ let out = ''
1032
+ let err = ''
1033
+ const timer = setTimeout(() => { try { c.kill() } catch (e) {} }, 15000)
1034
+ c.stdout.on('data', (d) => { out += d })
1035
+ c.stderr.on('data', (d) => { err += d })
1036
+ c.on('error', (e) => { clearTimeout(timer); resolve({ ok: false, error: 'spawn: ' + String(e && e.message || e) }) })
1037
+ c.on('close', (code) => {
1038
+ clearTimeout(timer)
1039
+ if (code !== 0) { resolve({ ok: false, error: '`opencode models` exited ' + code + ': ' + String(err || out).slice(0, 300) }); return }
1040
+ const list = parseModelList(out)
1041
+ if (!list.length) { resolve({ ok: false, error: 'no models parsed from output: ' + String(out).slice(0, 200) }); return }
1042
+ resolve({ ok: true, models: list })
1043
+ })
1044
+ })
1045
+ }
1046
+
1047
+ // preset 形态:DSH subprocess。
1048
+ async function spawnModelsHost(argv) {
1049
+ try {
1050
+ const spec = { argv: [resolveExePrefix(env, currentSettings().binPath)[0]].concat(argv), stdio: { stdin: 'ignore', stdout: { maxBytes: 1000000 }, stderr: { maxBytes: 200000 } }, graceMs: 5000 }
1051
+ if (deps.process && typeof deps.process.cwd === 'function') spec.cwd = deps.process.cwd()
1052
+ const handle = host.subprocess.spawn(spec)
1053
+ const outcome = await handle.done
1054
+ const stdoutText = handle.collected && handle.collected.stdout ? handle.collected.stdout.readFrom(0).text : ''
1055
+ const stderrText = handle.collected && handle.collected.stderr ? handle.collected.stderr.readFrom(0).text : ''
1056
+ if (!outcome || outcome.exitCode !== 0) return { ok: false, error: '`opencode models` exited ' + (outcome && outcome.exitCode) + ': ' + String(stderrText || stdoutText).slice(0, 300) }
1057
+ const list = parseModelList(stdoutText)
1058
+ if (!list.length) return { ok: false, error: 'no models parsed from output: ' + String(stdoutText).slice(0, 200) }
1059
+ return { ok: true, models: list }
1060
+ } catch (e) { return { ok: false, error: String(e && e.message || e) } }
1061
+ }
1062
+
1063
+ descriptor.runToolLogic = async function (args, exec) {
1064
+ const a = args || {}
1065
+ const normalized = a.background === undefined ? Object.assign({}, a, { background: true }) : a
1066
+ return this.runner.coreExecute(normalized, exec)
1067
+ }
1068
+ descriptor.continueToolLogic = async function (args, exec) {
1069
+ const a = args || {}
1070
+ const mapped = {
1071
+ prompt: a.prompt, model: a.model, agent: a.agent,
1072
+ cwd: a.cwd, files: a.files, timeoutSec: a.timeoutSec, autoApprove: a.autoApprove,
1073
+ background: a.background === undefined ? true : a.background
1074
+ }
1075
+ const sid = a.sessionId || null
1076
+ if (sid) mapped.sessionId = sid
1077
+ else if (a.latest) mapped.continueLatest = true
1078
+ return this.runner.coreExecute(mapped, exec)
1079
+ }
1080
+ descriptor.statusToolLogic = function (args) {
1081
+ const a = args || {}
1082
+ const snap = this.engine.statusSnapshot()
1083
+ if (a.cwd) {
1084
+ const key = String(a.cwd)
1085
+ snap.projects = snap.projects.filter((p) => p.cwd === key)
1086
+ const g = snap.projects[0]
1087
+ if (g) {
1088
+ Object.assign(snap, {
1089
+ state: g.state, running: g.running, current: g.current, trail: g.trail,
1090
+ lastStatus: g.lastStatus, lastAt: g.lastAt, lastSessionId: g.lastSessionId,
1091
+ lastBackend: g.lastBackend, fallbackActive: g.fallbackActive, updatedAt: g.updatedAt
1092
+ })
1093
+ }
1094
+ }
1095
+ return snap
1096
+ }
1097
+
1098
+ descriptor.attach = function (hostOpts) {
1099
+ host = hostOpts || {}
1100
+ // 宿主注入 getSettings 时才能把面板偏好带进 buildArgv(preset/dynamic 形态)。
1101
+ try {
1102
+ if (typeof host.getSettings === 'function') {
1103
+ const s = host.getSettings('opencode')
1104
+ if (s && typeof s === 'object') settingsRef = descriptor.settings.normalize(s)
1105
+ }
1106
+ } catch (e) { }
1107
+ this.engine = createStatusEngine({ provider: this, publish: host.publish })
1108
+ const runner = createRunner(Object.assign({}, host, { provider: this, engine: this.engine }))
1109
+ this.runner = runner
1110
+ return runner
1111
+ }
1112
+ return descriptor
1113
+ }
1114
+
1115
+ // ── inlined from providers/opencode-exe.mjs ──
1116
+ // providers/opencode-exe.mjs — opencode 可执行探测 / 模型清单解析 / 手动拉取缓存(无宿主依赖)。
1117
+ //
1118
+ // 从 providers/opencode.mjs 拆出,让两个文件都留在 600 行红线内(规则 §26)。
1119
+ // build 会把 providers/opencode-*.mjs 全部内联进 dynamic 沙箱同一作用域。
1120
+ // ⚠️ 注释里绝不写出口令标记字面量(build 全局替换会把注释劈开)。
1121
+ //
1122
+ // 本文件的三块职责(高内聚:全部围绕「怎么问本机 opencode」):
1123
+ // 1. 可执行文件候选探测(桌面版已知路径)与 exe 前缀解析;
1124
+ // 2. `opencode models` 文本 → 结构化模型清单;
1125
+ // 3. 契约 §3.2 的手动拉取:带原因的同步探测 + 面板展示缓存(meta 只读缓存,绝不 spawn)。
1126
+ // import { clampInt } from '../core/bridge-kit.mjs'
1127
+ // Node 内建守卫(沙箱可能没有 fs/path;读不到返回 null,调用方退到保守值)。
1128
+ // 名字带 exe 前缀:本文件与 providers/opencode.mjs 会被内联进【同一个】沙箱脚本作用域。
1129
+ function exeBuiltin(name) {
1130
+ try {
1131
+ const proc = globalThis.process
1132
+ return (proc && typeof proc.getBuiltinModule === 'function') ? proc.getBuiltinModule(name) : null
1133
+ } catch (e) { return null }
1134
+ }
1135
+
1136
+ // 二进制名是 opencode-cli.exe(V2 桌面版),不做 opencode.exe 兜底(V1/V2 不能并存)。
1137
+ const EXE_BASENAMES = ['opencode-cli', 'opencode']
1138
+ const DESKTOP_CLI_REL = ['ai.opencode.desktop', 'cli']
1139
+ // 桌面版目录形如 ...\ai.opencode.desktop\cli\<ver>-<arch>\opencode-cli.exe。
1140
+ // 沙箱内可能没有 fs —— 全部读取走 typeof 守卫,读不到就返回空数组(退回 PATH 查找)。
1141
+
1142
+ function candidateExePaths(env) {
1143
+ const e = env || {}
1144
+ const out = []
1145
+ const fs = exeBuiltin('node:fs')
1146
+ const path = exeBuiltin('node:path')
1147
+ if (!fs || typeof fs.readdirSync !== 'function' || !path || typeof path.join !== 'function') return out
1148
+ const roots = []
1149
+ for (const k of ['APPDATA', 'LOCALAPPDATA', 'PROGRAMFILES']) if (e[k]) roots.push(String(e[k]))
1150
+ const home = e.HOME || e.USERPROFILE
1151
+ if (home && !roots.length) roots.push(String(home))
1152
+ for (const root of roots) {
1153
+ const cliDir = path.join.apply(path, [root].concat(DESKTOP_CLI_REL))
1154
+ let versions = []
1155
+ try { versions = fs.readdirSync(cliDir, { withFileTypes: true }) } catch (err) { continue }
1156
+ // 版本目录按 semver 降序(新版本优先);解析失败的排末尾。
1157
+ const dirs = versions.filter((d) => d && d.isDirectory && d.isDirectory()).map((d) => d.name)
1158
+ const key = (n) => {
1159
+ const m = String(n).match(/(\d+)\.(\d+)\.(\d+)/)
1160
+ return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : [-1, -1, -1]
1161
+ }
1162
+ dirs.sort((a, b) => {
1163
+ const ka = key(a), kb = key(b)
1164
+ for (let i = 0; i < 3; i++) if (ka[i] !== kb[i]) return kb[i] - ka[i]
1165
+ return 0
1166
+ })
1167
+ for (const d of dirs) {
1168
+ for (const base of EXE_BASENAMES) {
1169
+ const exe = path.join(cliDir, d, base + (String(e.OS || '').toLowerCase().startsWith('win') || e.APPDATA ? '.exe' : ''))
1170
+ try { if (fs.existsSync(exe)) out.push(exe) } catch (err) { }
1171
+ }
1172
+ }
1173
+ }
1174
+ return out
1175
+ }
1176
+
1177
+ /**
1178
+ * 解析本次调用使用的 exe 前缀(数组或字符串)。
1179
+ * 优先级:binPath 设置 > env OPENCODE_BIN/OPENCODE_CLI > 桌面版探测 > PATH 名字。
1180
+ * @param {object} env
1181
+ * @param {string} [binPath] 设置面板里的显式路径(可空)
1182
+ */
1183
+ function resolveExePrefix(env, binPath) {
1184
+ const e = env || {}
1185
+ const bin = binPath || e.OPENCODE_BIN || e.OPENCODE_CLI || ''
1186
+ if (bin) {
1187
+ if (/\.(mjs|cjs|js)$/i.test(bin)) {
1188
+ const proc = globalThis.process
1189
+ const node = (proc && proc.execPath) ? proc.execPath : 'node'
1190
+ return [node, bin]
1191
+ }
1192
+ return [String(bin)]
1193
+ }
1194
+ const found = candidateExePaths(e)
1195
+ if (found.length) return [found[0]]
1196
+ return [EXE_BASENAMES[0]]
1197
+ }
1198
+
1199
+ // ── 模型列表(`opencode models`;纯文本每行 provider/model)─────────────────────
1200
+ // 解析宽容:只认 provider/model 形态的行,其余(表头/说明/空行)一律丢弃。
1201
+ const MODEL_LINE_RE = /^([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.#@+-]+)$/
1202
+
1203
+ function parseModelList(text) {
1204
+ const out = []
1205
+ const seen = Object.create(null)
1206
+ for (const raw of String(text || '').split(/\r?\n/)) {
1207
+ const ln = raw.trim()
1208
+ if (!ln) continue
1209
+ const m = ln.match(MODEL_LINE_RE)
1210
+ if (!m) continue
1211
+ const id = m[1] + '/' + m[2]
1212
+ if (seen[id]) continue
1213
+ seen[id] = true
1214
+ out.push({ id: id, provider: m[1], name: m[2], free: /(-free$|free)/i.test(m[2]) })
1215
+ }
1216
+ return out
1217
+ }
1218
+
1219
+ /** 免费模型优先展示(用户要求:优先用性能较好的免费模型),其次按字母序。 */
1220
+ function sortModelsFreeFirst(list) {
1221
+ return (Array.isArray(list) ? list.slice() : []).sort((x, y) => (
1222
+ x.free === y.free ? String(x.id).localeCompare(String(y.id)) : (x.free ? -1 : 1)
1223
+ ))
1224
+ }
1225
+
1226
+ // 诊断文本压成一行(CLI 的 stderr/stdout 常带换行与表格)。
1227
+ function exeOneLine(text, max) {
1228
+ const s = String(text == null ? '' : text).replace(/\s+/g, ' ').trim()
1229
+ const cap = max || 200
1230
+ return s.length > cap ? s.slice(0, cap) + '…' : s
1231
+ }
1232
+
1233
+ /**
1234
+ * 真正跑一次 `opencode models`,**带可操作失败原因**(契约 §3.2:失败必须 ok:false)。
1235
+ *
1236
+ * 为什么不复用「失败返回空数组」的旧写法:空数组无法区分「没装 CLI」「没登录」
1237
+ * 「超时」「输出格式变了」四种结局,而面板/工具都必须能告诉用户下一步做什么。
1238
+ * @returns {{ok:boolean, models:Array, exe:string, error?:string}}
1239
+ */
1240
+ function fetchModelsDetailed(env, binPath, timeoutMs) {
1241
+ const prefix = resolveExePrefix(env, binPath)
1242
+ const exe = prefix.join(' ')
1243
+ const child = exeBuiltin('node:child_process')
1244
+ if (!child || typeof child.spawnSync !== 'function') {
1245
+ return { ok: false, models: [], exe: exe, error: '当前宿主没有子进程能力(spawnSync 不可用)—— 请在有 Node 子进程能力的形态(DSH 主进程 / MCP 进程)里点「拉取可用模型」' }
1246
+ }
1247
+ let r = null
1248
+ try {
1249
+ r = child.spawnSync(prefix[0], prefix.slice(1).concat(['models']), {
1250
+ encoding: 'utf8', timeout: clampInt(timeoutMs, 20000, 2000, 60000),
1251
+ windowsHide: true, stdio: ['ignore', 'pipe', 'pipe']
1252
+ })
1253
+ } catch (e) {
1254
+ return { ok: false, models: [], exe: exe, error: '启动 `' + exe + ' models` 失败:' + exeOneLine(e && e.message || e) }
1255
+ }
1256
+ if (!r) return { ok: false, models: [], exe: exe, error: '`' + exe + ' models` 没有任何返回(spawnSync 返回 null),请确认可执行文件路径' }
1257
+ if (r.error) {
1258
+ const code = String(r.error.code || '')
1259
+ if (code === 'ENOENT') {
1260
+ return { ok: false, models: [], exe: exe, error: '未找到 opencode 可执行文件(' + exe + ')—— 请安装 OpenCode(桌面版自带 CLI),或在设置里填写「opencode 可执行文件路径」,或设置 OPENCODE_BIN 环境变量' }
1261
+ }
1262
+ if (code === 'ETIMEDOUT' || code === 'ABORT_ERR') {
1263
+ return { ok: false, models: [], exe: exe, error: '`' + exe + ' models` 超时 —— 网络或鉴权可能卡住:请先在终端手动跑一次 `opencode models` 确认,必要时执行 `opencode auth login`' }
1264
+ }
1265
+ return { ok: false, models: [], exe: exe, error: '`' + exe + ' models` 启动失败(' + (code || 'unknown') + '):' + exeOneLine(r.error.message || r.error) }
1266
+ }
1267
+ const stdout = String(r.stdout || '')
1268
+ const stderr = String(r.stderr || '')
1269
+ const models = parseModelList(stdout)
1270
+ if (models.length) return { ok: true, models: models, exe: exe }
1271
+ if (r.status !== 0) {
1272
+ return { ok: false, models: [], exe: exe, error: '`opencode models` 退出码 ' + r.status + ':' + exeOneLine(stderr || stdout) + ' —— 若提示未登录/无凭据,请先在终端执行 `opencode auth login`' }
1273
+ }
1274
+ return { ok: false, models: [], exe: exe, error: '`opencode models` 成功退出但没有解析到任何 provider/model 行' + (stdout ? '(输出开头:' + exeOneLine(stdout, 160) + ')' : '(输出为空)') + ' —— 请先在终端手动跑一次 `opencode models` 确认输出格式与登录状态' }
1275
+ }
1276
+
1277
+ /** 兼容薄封装:失败返回空数组(旧语义,供不关心原因的调用方)。 */
1278
+ function fetchModelsSync(env, binPath, timeoutMs) {
1279
+ const d = fetchModelsDetailed(env, binPath, timeoutMs)
1280
+ return d.ok ? d.models : []
1281
+ }
1282
+
1283
+ // ── 手动拉取缓存(契约 §3.2)─────────────────────────────────────────────────
1284
+ // `meta()` 每次面板渲染都会被调用:**只读本缓存,绝不 spawn**。缓存只存在进程内存里
1285
+ // (宿主进程重启即失效,不会把过时清单落盘成"配置");用户点按钮才刷新它。
1286
+ const MODEL_CACHE_TTL_MS = 6 * 60 * 60 * 1000
1287
+ const MODEL_CACHE = { at: 0, models: [] }
1288
+
1289
+ /** 读缓存(未拉过 / 已过期 → 空数组,面板据此提示「点按钮拉取」)。 */
1290
+ function cachedModels(nowMs) {
1291
+ const at = Number(nowMs) || Date.now()
1292
+ if (!MODEL_CACHE.models.length) return []
1293
+ if (at - MODEL_CACHE.at >= MODEL_CACHE_TTL_MS) return []
1294
+ return MODEL_CACHE.models.slice()
1295
+ }
1296
+
1297
+ /** 手动拉取成功后回填缓存;空清单绝不入缓存(否则下次渲染"有缓存但没内容")。 */
1298
+ function rememberModels(models, nowMs) {
1299
+ if (!Array.isArray(models) || !models.length) return false
1300
+ MODEL_CACHE.models = models.slice()
1301
+ MODEL_CACHE.at = Number(nowMs) || Date.now()
1302
+ return true
1303
+ }
1148
1304
 
1149
1305
  // core/scheduling.mjs — CLI 调度(启用集合 + 调用优先级)单源纯逻辑。
1150
1306
  //