oh-my-agent-web 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (142) hide show
  1. package/README.md +29 -2
  2. package/dist/server/engine/builtin.js +23 -0
  3. package/dist/server/engine/jsonrpc-stdio.js +1 -1
  4. package/dist/server/engine/narrate-guidance.js +22 -0
  5. package/dist/server/engine/opencode-map.js +499 -0
  6. package/dist/server/engine/opencode-projects.js +61 -0
  7. package/dist/server/engine/opencode-sessions.js +195 -0
  8. package/dist/server/engine/opencode-transport.js +48 -0
  9. package/dist/server/engine/opencode.js +394 -0
  10. package/dist/server/engine/pi-plugins.js +310 -0
  11. package/dist/server/engine/pi-sdk.js +163 -3
  12. package/dist/server/engine/pi-subagent-tool.js +166 -0
  13. package/dist/server/engine/pi-subagents.js +372 -0
  14. package/dist/server/engine/registry.js +1 -1
  15. package/dist/server/engine/subagent-guidance.js +22 -0
  16. package/dist/server/engine/wiring.js +59 -0
  17. package/dist/server/files/files-routes.js +29 -1
  18. package/dist/server/files/git-info.js +176 -1
  19. package/dist/server/http/aux-routes.js +16 -0
  20. package/dist/server/http/compress.js +2 -0
  21. package/dist/server/index.js +22 -1
  22. package/dist/server/mcp/mcp-auth.js +184 -0
  23. package/dist/server/mcp/mcp-command.js +94 -0
  24. package/dist/server/mcp/mcp-import.js +290 -0
  25. package/dist/server/mcp/mcp-market-sources.js +192 -0
  26. package/dist/server/mcp/mcp-market-types.js +5 -0
  27. package/dist/server/mcp/mcp-market.js +175 -0
  28. package/dist/server/mcp/mcp-probe.js +134 -0
  29. package/dist/server/mcp/mcp-routes.js +70 -0
  30. package/dist/server/mcp/mcp-store.js +286 -0
  31. package/dist/server/mcp/pi-mcp-config.js +280 -0
  32. package/dist/server/plugins/plugin-routes.js +21 -0
  33. package/dist/server/protocol-parse.js +64 -3
  34. package/dist/server/providers/codex-models.js +96 -0
  35. package/dist/server/providers/codex-test.js +124 -0
  36. package/dist/server/providers/import-routes.js +99 -32
  37. package/dist/server/providers/provider-handlers.js +26 -6
  38. package/dist/server/providers/provider-store.js +49 -17
  39. package/dist/server/providers/subscription-routes.js +181 -0
  40. package/dist/server/session/model-controller.js +9 -1
  41. package/dist/server/session/runtime.js +23 -1
  42. package/dist/server/session/session-stats.js +3 -0
  43. package/dist/server/session/subagents.js +116 -0
  44. package/dist/server/session/turn-queue.js +1 -1
  45. package/dist/server/session/turn-send.js +23 -3
  46. package/dist/server/skills/skill-install.js +186 -0
  47. package/dist/server/skills/skill-routes.js +107 -1
  48. package/dist/server/skills/skill-store.js +33 -4
  49. package/dist/server/skills/skillhub.js +214 -0
  50. package/dist/server/skills/zip.js +121 -0
  51. package/dist/server/snapshot/state.js +10 -1
  52. package/dist/server/subagents/profile-store.js +287 -0
  53. package/dist/server/util/env.js +6 -0
  54. package/dist/server/ws/handle-message.js +12 -0
  55. package/package.json +2 -1
  56. package/web/dist/assets/{abnfDiagram-WEYOIA3C-NLp09iEC.js → abnfDiagram-WEYOIA3C-VUKLHHSn.js} +1 -1
  57. package/web/dist/assets/{arc-DviL9o3c.js → arc-BeUkaHhk.js} +1 -1
  58. package/web/dist/assets/{architecture-WOLXFQ4H-fjsM_Sy3.js → architecture-WOLXFQ4H-B9I4s0NV.js} +1 -1
  59. package/web/dist/assets/{architectureDiagram-ZHVYKT53-Cn3B4TE8.js → architectureDiagram-ZHVYKT53-CaEkLhfq.js} +1 -1
  60. package/web/dist/assets/{blockDiagram-RJAE7QA3-CIuvxWvi.js → blockDiagram-RJAE7QA3-IR49UNE_.js} +1 -1
  61. package/web/dist/assets/{c4Diagram-XDPPPE54-DvdKmEpF.js → c4Diagram-XDPPPE54-B-81CloB.js} +1 -1
  62. package/web/dist/assets/channel-B_mTTjGt.js +1 -0
  63. package/web/dist/assets/{chunk-2E7WHVMD-HNNASGCS.js → chunk-2E7WHVMD-CaWVF7qv.js} +1 -1
  64. package/web/dist/assets/{chunk-4EMNKR2T-Cg-eRwBE.js → chunk-4EMNKR2T-BvXf2tq-.js} +1 -1
  65. package/web/dist/assets/{chunk-55XCU76K-BweiisZL.js → chunk-55XCU76K-Bz_d2e2D.js} +1 -1
  66. package/web/dist/assets/{chunk-7S6XSTFO-BCzIiP-6.js → chunk-7S6XSTFO-B_SPMiU8.js} +1 -1
  67. package/web/dist/assets/{chunk-AQDTSKZB-Dl0NRFuU.js → chunk-AQDTSKZB-Cen-_xej.js} +1 -1
  68. package/web/dist/assets/{chunk-DSNLSHJZ-zhHCjjN9.js → chunk-DSNLSHJZ-DEw1kFwj.js} +1 -1
  69. package/web/dist/assets/{chunk-HAGFOTED-BDQqU2EH.js → chunk-HAGFOTED-BwpOOcw8.js} +1 -1
  70. package/web/dist/assets/{chunk-HNE4Q36P-B-qRzMjX.js → chunk-HNE4Q36P-BjCwi2uW.js} +1 -1
  71. package/web/dist/assets/{chunk-NQOKRVIF-CNcvhMn_.js → chunk-NQOKRVIF-WcqhWFS5.js} +1 -1
  72. package/web/dist/assets/{chunk-UHW7PFBP-Dwr8LUzr.js → chunk-UHW7PFBP-quDvQby8.js} +1 -1
  73. package/web/dist/assets/{chunk-VQ5W62QI-O8sN6jJu.js → chunk-VQ5W62QI-DZBwmhzq.js} +1 -1
  74. package/web/dist/assets/{chunk-WQQL4CDK-B6-59Zg2.js → chunk-WQQL4CDK-wYyHYuui.js} +1 -1
  75. package/web/dist/assets/{chunk-XXDRQBXY-DvSbeUlZ.js → chunk-XXDRQBXY-DfJzC7nt.js} +1 -1
  76. package/web/dist/assets/{chunk-YZO67HFG-BXpKYKsw.js → chunk-YZO67HFG-CWLraGk_.js} +1 -1
  77. package/web/dist/assets/{chunk-ZAUX2LTX-B1Z5VSkz.js → chunk-ZAUX2LTX-ChBUt_FJ.js} +1 -1
  78. package/web/dist/assets/{chunk-ZCHMQSOR-h_5mBLIA.js → chunk-ZCHMQSOR-C57UFT60.js} +1 -1
  79. package/web/dist/assets/{classDiagram-v2-4MYWR3UA-Dr6JnaaV.js → classDiagram-v2-4MYWR3UA-Bo4i7SNY.js} +1 -1
  80. package/web/dist/assets/{cose-bilkent-JH36ORCC-36ShkR7R.js → cose-bilkent-JH36ORCC-CK360NdH.js} +1 -1
  81. package/web/dist/assets/{cynefin-EF2NZ3EQ-Dg6bI7pC.js → cynefin-EF2NZ3EQ-DlXFjIFm.js} +1 -1
  82. package/web/dist/assets/{cynefinDiagram-WF24UDRP-B0YHpBlR.js → cynefinDiagram-WF24UDRP-zvZIqA1c.js} +1 -1
  83. package/web/dist/assets/{dagre-NK4GIZVX-Cx3mn7YS.js → dagre-NK4GIZVX-CnKhCu-v.js} +1 -1
  84. package/web/dist/assets/{diagram-CBK3MWTC-l8aZG_Bm.js → diagram-CBK3MWTC-3QTR8Fjx.js} +1 -1
  85. package/web/dist/assets/{diagram-I5XIIV2D-Bd5f14_h.js → diagram-I5XIIV2D-D9lSd7z6.js} +1 -1
  86. package/web/dist/assets/{diagram-ODIL2UCW-hBsxCYUj.js → diagram-ODIL2UCW-DWJD6lPM.js} +1 -1
  87. package/web/dist/assets/{diagram-PFBEJUXE-BgpIBUYl.js → diagram-PFBEJUXE-CWJmoUg4.js} +1 -1
  88. package/web/dist/assets/{diagram-RJA3I4VV-CDU1gUVD.js → diagram-RJA3I4VV-D1SZHQcQ.js} +1 -1
  89. package/web/dist/assets/{diagram-TYUNFSNR-Iuxh7Tqf.js → diagram-TYUNFSNR-DCCjGi1x.js} +1 -1
  90. package/web/dist/assets/{dist-C86cKTuE.js → dist-VOk_Iv17.js} +1 -1
  91. package/web/dist/assets/{ebnfDiagram-V4P3TTPT-Cms0HFKh.js → ebnfDiagram-V4P3TTPT-CQLdqEiW.js} +1 -1
  92. package/web/dist/assets/{elk-QONTBCTQ-B7yEy0PQ.js → elk-QONTBCTQ-Dp2pEp6b.js} +1 -1
  93. package/web/dist/assets/{erDiagram-KYQIGQAA-CL48j4aU.js → erDiagram-KYQIGQAA-BFGNoFQ-.js} +1 -1
  94. package/web/dist/assets/{eventmodeling-K75KTNOO-CFvkaejh.js → eventmodeling-K75KTNOO-CFfAemGw.js} +1 -1
  95. package/web/dist/assets/flowDiagram-IUGLLWBU-DmSp3cbW.js +1 -0
  96. package/web/dist/assets/{ganttDiagram-R7GPNTJY-vdMr-0ZV.js → ganttDiagram-R7GPNTJY-vK3jPGDq.js} +1 -1
  97. package/web/dist/assets/{gitGraph-VSP46ZUC-2z85Wuwo.js → gitGraph-VSP46ZUC-rXBgHTdq.js} +1 -1
  98. package/web/dist/assets/{gitGraphDiagram-PDYL7XHY-BY4GPiqC.js → gitGraphDiagram-PDYL7XHY-CBbGhago.js} +1 -1
  99. package/web/dist/assets/index-DBOF_OJe.js +104 -0
  100. package/web/dist/assets/index-nlY_-OaR.css +1 -0
  101. package/web/dist/assets/{info-OHQRW6UA-ECzkYTx-.js → info-OHQRW6UA-Cd9vp-6C.js} +1 -1
  102. package/web/dist/assets/{infoDiagram-MVDRKAX5-DDk5EAeb.js → infoDiagram-MVDRKAX5-Ce-Sg_qu.js} +1 -1
  103. package/web/dist/assets/{ishikawaDiagram-HPEHV2GE-9yPH4J94.js → ishikawaDiagram-HPEHV2GE-DWlmR6BY.js} +1 -1
  104. package/web/dist/assets/{journeyDiagram-BOSHW7O7-BPTIap3l.js → journeyDiagram-BOSHW7O7-DmDG97ae.js} +1 -1
  105. package/web/dist/assets/{kanban-definition-FNSA4HC5-BbADHrA8.js → kanban-definition-FNSA4HC5-1Z4sDrgV.js} +1 -1
  106. package/web/dist/assets/{line-CHV9_4lL.js → line-DGM1-9KW.js} +1 -1
  107. package/web/dist/assets/{linear-C-eZsBTv.js → linear-B6hyeGHu.js} +1 -1
  108. package/web/dist/assets/{mermaid-parser.core-BiAhX1l1.js → mermaid-parser.core-D1HmdkA4.js} +3 -3
  109. package/web/dist/assets/{mermaid.core-BxP_A_zj.js → mermaid.core-DeUJrhO1.js} +4 -4
  110. package/web/dist/assets/{mindmap-definition-6OY57UG3-BMWlit95.js → mindmap-definition-6OY57UG3-C3-FByy8.js} +1 -1
  111. package/web/dist/assets/{packet-JDAUHWVQ-FVApuKOp.js → packet-JDAUHWVQ-BoOr7DaW.js} +1 -1
  112. package/web/dist/assets/{pegDiagram-ECIUJNBX-WjWJvDHM.js → pegDiagram-ECIUJNBX-Bn7m75TZ.js} +1 -1
  113. package/web/dist/assets/{pie-XZMESJXO-BGCZnvhp.js → pie-XZMESJXO-CGuMTDBp.js} +1 -1
  114. package/web/dist/assets/{pieDiagram-GNMPY74T-DJpAC_JC.js → pieDiagram-GNMPY74T-DHhaPwZe.js} +1 -1
  115. package/web/dist/assets/{quadrantDiagram-D57K4MC3-hxx5c7oq.js → quadrantDiagram-D57K4MC3-DxTgFHLK.js} +1 -1
  116. package/web/dist/assets/{radar-ABXABTNO-BU9Shc-O.js → radar-ABXABTNO-BiKxmALL.js} +1 -1
  117. package/web/dist/assets/{railroad-I3PHUGI6-pWj8d-G4.js → railroad-I3PHUGI6-BKYufgg4.js} +1 -1
  118. package/web/dist/assets/{railroad-abnf-LNEFI6M7-D7HZBTAj.js → railroad-abnf-LNEFI6M7-DmOrjlyL.js} +1 -1
  119. package/web/dist/assets/{railroad-ebnf-DWYD2UWJ-CKiXJSuM.js → railroad-ebnf-DWYD2UWJ-BgMyVDd1.js} +1 -1
  120. package/web/dist/assets/{railroad-peg-JR7BVNR7-DB3VJHgR.js → railroad-peg-JR7BVNR7-BN6NL8S-.js} +1 -1
  121. package/web/dist/assets/{railroadDiagram-Y72NRBL7-Cev7Y65k.js → railroadDiagram-Y72NRBL7-BsbA-9gP.js} +1 -1
  122. package/web/dist/assets/{requirementDiagram-EQYSW5RA-CqbwMLD8.js → requirementDiagram-EQYSW5RA-HUw4YqWd.js} +1 -1
  123. package/web/dist/assets/{sankeyDiagram-SY24JHO5-V2cyVi8q.js → sankeyDiagram-SY24JHO5-Dx1MOFRu.js} +1 -1
  124. package/web/dist/assets/{sequenceDiagram-BLEHNHQU-By6NJOPM.js → sequenceDiagram-BLEHNHQU-C0-Rzg3g.js} +1 -1
  125. package/web/dist/assets/{src-Da91fG3g.js → src-CuHygcpj.js} +1 -1
  126. package/web/dist/assets/{stateDiagram-v2-A6IESKWZ-BnPGX5UI.js → stateDiagram-v2-A6IESKWZ-BnEICGtg.js} +1 -1
  127. package/web/dist/assets/{swimlanes-B3ENRKFQ-C58BpIpm.js → swimlanes-B3ENRKFQ-CwDRCHZD.js} +1 -1
  128. package/web/dist/assets/swimlanesDiagram-AYQRV4AK-DZ_aAXmG.js +8 -0
  129. package/web/dist/assets/{timeline-definition-JXYKGLGB-Hgo1DhnS.js → timeline-definition-JXYKGLGB-C4BVpgzc.js} +1 -1
  130. package/web/dist/assets/{treeView-D4JQ5SDB-DF0dfjqB.js → treeView-D4JQ5SDB-BBsvEMP3.js} +1 -1
  131. package/web/dist/assets/{treemap-SAJKECNS-CSSM5FqB.js → treemap-SAJKECNS-BYw6cxC8.js} +1 -1
  132. package/web/dist/assets/{usecaseDiagram-WPOLBNNX-BkBBD1NY.js → usecaseDiagram-WPOLBNNX-BkOXLgwP.js} +1 -1
  133. package/web/dist/assets/{vennDiagram-Q3Y3IDCW-Bm28keX_.js → vennDiagram-Q3Y3IDCW-Kgjk_n6B.js} +1 -1
  134. package/web/dist/assets/{wardley-7MLQ67FV-By7KgEQX.js → wardley-7MLQ67FV-BGVObhvJ.js} +1 -1
  135. package/web/dist/assets/{wardleyDiagram-IVAIDKWA-oVtIrnGa.js → wardleyDiagram-IVAIDKWA-i97uD6tL.js} +1 -1
  136. package/web/dist/assets/{xychartDiagram-4URNHDU4-aI8FH4g2.js → xychartDiagram-4URNHDU4-awJi5TfJ.js} +1 -1
  137. package/web/dist/index.html +15 -2
  138. package/web/dist/assets/channel-C3AQ6nAs.js +0 -1
  139. package/web/dist/assets/flowDiagram-IUGLLWBU-6UyKXpqG.js +0 -1
  140. package/web/dist/assets/index-Bjn56a2i.js +0 -76
  141. package/web/dist/assets/index-Drp6e5iU.css +0 -1
  142. package/web/dist/assets/swimlanesDiagram-AYQRV4AK-RaivxSsc.js +0 -8
package/README.md CHANGED
@@ -79,7 +79,7 @@ oh-my-agent-web/
79
79
  │ ├─ index.ts 进程入口:路由、WS、静态产物、优雅退出
80
80
  │ ├─ cli.ts agent-web / omaw 命令(start / stop / restart / status / upgrade)
81
81
  │ ├─ protocol.ts WS 协议单源(前端通过 web/src/types.ts 复用类型)
82
- │ ├─ engine/ 引擎适配层:pi(原生 SDK)/ codex / dsh / fake(测试)
82
+ │ ├─ engine/ 引擎适配层:pi(原生 SDK)/ codex / dsh / opencode(ACP)/ fake(测试)
83
83
  │ ├─ session/ 会话运行时:切换、改名、删除、模型控制、审批桥
84
84
  │ ├─ sessions/ 会话文件扫描(标题 / 模型 / 缓存)
85
85
  │ ├─ files/ 文件树、预览、搜索、上传、git 分支
@@ -87,7 +87,8 @@ oh-my-agent-web/
87
87
  │ ├─ approval/ 审批规则与作用域判断
88
88
  │ ├─ providers/ 自定义 provider / 模型清单
89
89
  │ ├─ push/ Web Push
90
- │ └─ skills/ 技能发现与启停
90
+ │ ├─ skills/ 技能发现与启停
91
+ │ └─ subagents/ 子代理模板(落 ~/.pi/agent/agents/*.md,与 pi 共用同一套)
91
92
  ├─ web/ 前端(React 19 + Vite)
92
93
  │ └─ src/
93
94
  │ ├─ components/ 侧栏、消息流、输入框、文件树、终端、各类面板
@@ -103,10 +104,36 @@ oh-my-agent-web/
103
104
  | **pi**(默认) | 完整的插件 / 技能生态、工具延迟加载、审批门禁、目标模式与子代理;模型与 provider 由本工作台管理 |
104
105
  | **Codex** | codex 官方 agent,独立 loop 与沙箱,会话与 Codex Desktop / CLI 共用;审批是命令 / 文件级的 |
105
106
  | **DeepSeek Harness** | DeepSeek 官方 agent(ACP),模型与思考档可切,权限逐次确认 |
107
+ | **opencode** | opencode 官方 agent(ACP),自带工具 / MCP / 插件生态,模型与思考档可切,会话与 opencode CLI / TUI 共用;**审批取决于 opencode 自身的 `permission` 配置**(配成全局 allow 时不会询问) |
106
108
  | **fake** | 零 token 的脚本化引擎,只用来验证协议 / 工具 / 审批流程(`AGENT_WEB_ENGINE=fake`,默认不进切换面板) |
107
109
 
108
110
  装没装的引擎不影响启动:只有切到它时才会报「没找到」。
109
111
 
112
+ opencode 相关的环境变量(都有默认值,通常不用配):
113
+
114
+ | 变量 | 作用 |
115
+ | --- | --- |
116
+ | `AGENT_WEB_OPENCODE_BIN` | opencode 可执行文件路径(默认 PATH 里的 `opencode`) |
117
+ | `AGENT_WEB_OPENCODE_ARGS` | 传给 `opencode acp` 的额外参数,空格分隔(例如 `--pure` 跳过外部插件) |
118
+ | `AGENT_WEB_OPENCODE_MODEL` | 新会话优先用的模型(`provider/model`);不设就沿用 opencode 自己的默认 |
119
+
120
+ ## 子代理(多开 + 可管理)
121
+
122
+ pi 引擎带一套自己的子代理实现(**不依赖 pi-subagents 插件**):
123
+
124
+ - **怎么开**:主 Agent 拿到 `Agent` 工具(`subagent_type` / `prompt` / `description` / `run_in_background`),
125
+ 一次可以并行开好几个;每次委派都是**一个真实的 pi 会话**(自己的上下文、会话文件、工具白名单)。
126
+ - **在哪看**:运行卡片**就在对话下方**(输入区上方那条「子代理」卡片条):状态、耗时、工具次数、token 与花费,点「查看结果」原地展开结果原文与会话文件路径,随时**停掉某一个**;
127
+ 卡片条可折叠,切会话自动换成本会话的那批。顶栏「子代理」徽标保留一眼概览(几个在跑 / 合计花销),点开是小浮层。
128
+ - **怎么管**:同一个面板只剩**模板**(内置 explore / general-purpose / plan,可停用;可新建 / 编辑 / 删除),运行记录不在面板里。
129
+ 模板落 `~/.pi/agent/agents/*.md`,frontmatter 写 `name / description / tools / model / thinking / max_turns`,
130
+ 正文就是子代理的系统指令 —— 与 pi / pi-web 的 agent 文件同一套格式,直接可读可改可进 git。
131
+ - **并发**:面板右上角设上限(1–12,默认 4),超出的排队;总开关关掉后模型不再看到 `Agent` 工具。
132
+ - **隔离**:子代理默认不加载技能 / 插件 / 主题 / 上下文文件(快、可预期);模型不跟随父会话时会沿用父会话的模型。
133
+ 有的模型由扩展注册 provider,这类模型在子代理里会失败并提示「在模板里指定模型」。
134
+
135
+ 子代理的会话文件紧挨着父会话归档(`<父会话目录>/<父会话名>/subagents/`),不进侧栏的项目清单。
136
+
110
137
  ## 安全模型
111
138
 
112
139
  - 默认只监听 `127.0.0.1`,并校验 `Origin` / `Host`;要远程访问必须显式给 `-H` + `-P` + `--allow-host`。
@@ -1,6 +1,7 @@
1
1
  import { createCodexEngine } from "./codex.js";
2
2
  import { createDshEngine } from "./dsh.js";
3
3
  import { createFakeEngine } from "./fake.js";
4
+ import { createOpencodeEngine } from "./opencode.js";
4
5
  import { createPiSdkEngine } from "./pi-sdk.js";
5
6
  import { createEngineRegistry, FAKE_CAPABILITIES, PI_CAPABILITIES } from "./registry.js";
6
7
  export function createBuiltinEngineRegistry(options) {
@@ -47,6 +48,28 @@ export function createBuiltinEngineRegistry(options) {
47
48
  },
48
49
  ]
49
50
  : []),
51
+ ...(options.opencode
52
+ ? [
53
+ {
54
+ name: "opencode",
55
+ label: "opencode",
56
+ badge: "OC",
57
+ // 如实写:审批能不能真的拦住,取决于 opencode 自己的权限配置(见 engine/opencode.ts 头注释)
58
+ description: options.opencode.description ??
59
+ "opencode 官方 agent(ACP):自带工具与 MCP/插件生态,模型与思考档可切,会话与 opencode CLI / TUI 共用;审批取决于 opencode 自身 permission 配置",
60
+ providerConfig: false,
61
+ // 没有子代理面板(ACP 不暴露 task 子代理的运行视图)→ subagents 不写,界面就不出现那块
62
+ capabilities: { approvals: true, tools: true, modelSwitch: true, thinkingLevel: true, resume: true },
63
+ create: () => createOpencodeEngine({
64
+ client: options.opencode?.client,
65
+ bin: options.opencode?.bin,
66
+ args: options.opencode?.args,
67
+ model: options.opencode?.model,
68
+ log: options.onLog,
69
+ }),
70
+ },
71
+ ]
72
+ : []),
50
73
  {
51
74
  name: "fake",
52
75
  label: "假引擎",
@@ -166,7 +166,7 @@ export function createJsonRpcStdioClient(options) {
166
166
  else
167
167
  env[key] = value;
168
168
  }
169
- const next = spawn(options.bin, options.args, { env, stdio: ["pipe", "pipe", "pipe"] });
169
+ const next = spawn(options.bin, options.args, { env, cwd: options.cwd, stdio: ["pipe", "pipe", "pipe"] });
170
170
  child = next;
171
171
  wire(next);
172
172
  }
@@ -0,0 +1,22 @@
1
+ /**
2
+ * 教模型「动手之前先说人话」。
3
+ *
4
+ * 为什么必须有:对话区里的「过程」容器默认是**折叠**的 —— 用户一眼只看到
5
+ * 「1 段思考 1 次工具 过程 2 步」。模型要是连着调五个工具、一句正文都不写,
6
+ * 用户全程不知道它在查什么、改什么、碰到什么问题,只能干等。
7
+ *
8
+ * 所以:每一步动作前先给一两句「我发现了什么 / 我准备做什么」,
9
+ * 工具结果推翻计划时也说一句。界面侧同时有「正在运行 bash…」那行实时状态(见 LiveActivity),
10
+ * 两者合起来才是「看得懂在跑什么」。
11
+ *
12
+ * 同样写得短:这段会在**每一次**请求里重复计费。
13
+ */
14
+ export const NARRATE_GUIDANCE = [
15
+ "## Narrate before acting",
16
+ "The user watches this conversation live, and tool cards are collapsed by default — they only see",
17
+ '"1 thinking segment, 1 tool call" unless you write prose. So:',
18
+ "- Before the first tool call of a task, write 1-2 sentences: what you found and what you are about to do.",
19
+ "- Before modifying, writing or deleting files, say which files and why (1-2 sentences), then call the tools.",
20
+ "- When a tool result changes your plan (failure, unexpected output, a discovery), say what it means before the next call.",
21
+ "- Keep it short and in the user's language; never paste tool arguments back as prose.",
22
+ ].join("\n");
@@ -0,0 +1,499 @@
1
+ /**
2
+ * opencode ACP 的**纯映射**(不碰网络/子进程,单测直接覆盖)。
3
+ *
4
+ * 三件事:
5
+ * 1. `configOptions`(模型 / 思考档)→ 我们的模型下拉与档位;
6
+ * 2. `session/update` 通知 → 我们的 EngineEvent;
7
+ * 3. `session/request_permission` → 我们的审批请求 + 回包选项。
8
+ *
9
+ * 实测依据(opencode 1.19.0,`opencode acp`):
10
+ * - `session/new` 的 `configOptions` 有 2~3 项:
11
+ * `{id:"model", category:"model"}`、
12
+ * `{id:"effort", category:"thought_level"}`(**只有部分模型有**)、
13
+ * `{id:"mode", category:"mode"}`(build / plan)。
14
+ * 所以「思考档」必须按 `category` 找,不能写死 id(dsh 那边叫 `reasoning_effort`);
15
+ * 换成不支持推理的模型(如 deepseek-office/deepseek-flash)时该项会**整个消失**。
16
+ * - 模型 value 是 `provider/model`,而 provider 自己也可能带斜杠
17
+ * (`cloudflare-ai-gateway/anthropic/claude-opus-4.5`)→ 解析只切**第一个**斜杠。
18
+ * - 工具是两段式:先来一条 `tool_call`(`title` 就是工具名 `bash`、`rawInput` 只有 `{cwd}`),
19
+ * 真正的命令在随后 `tool_call_update` 的 `title`/`rawInput` 里;`status` 走
20
+ * pending → in_progress(可能多次)→ completed/failed。**所以 tool_start 要延迟到「有真参数」那一条**,
21
+ * 否则界面上卡片会先写着 `bash {"cwd":"…"}`,很误导。
22
+ * - `completed` 的 update 带 `content[]` 与 `rawOutput{output, metadata{exit, truncated}}`(有时只有 status)。
23
+ * - `session/prompt` 返回 `{stopReason, usage:{inputTokens, outputTokens, cachedReadTokens, thoughtTokens, totalTokens}}`
24
+ * —— 有 input/output 分桶(比 dsh 只给 used/size 强);`usage_update` 通知则**不稳定**
25
+ * (同一个 opencode 版本,deepseek-office 的模型一次都没发),不能拿它算账。
26
+ * - 权限请求带完整 `toolCall`(含 `rawInput`)+ 三个选项:
27
+ * `{optionId:"once", kind:"allow_once"}` / `{optionId:"always", kind:"allow_always"}` / `{optionId:"reject", kind:"reject_once"}`。
28
+ */
29
+ import { TOOL_EXCERPT_CHARS } from "../protocol.js";
30
+ /** 把 configOptions 里可能分组的选项摊平(保留 group 名,便于显示) */
31
+ export function flattenChoices(option) {
32
+ if (!option?.options)
33
+ return [];
34
+ const out = [];
35
+ for (const entry of option.options) {
36
+ if ("options" in entry && Array.isArray(entry.options)) {
37
+ for (const choice of entry.options)
38
+ out.push({ ...choice, group: choice.group ?? entry.group ?? entry.name });
39
+ }
40
+ else {
41
+ out.push(entry);
42
+ }
43
+ }
44
+ return out;
45
+ }
46
+ /** `provider/model` → { provider, model };provider 本身可能带斜杠,所以只切第一刀 */
47
+ export function parseModelValue(value) {
48
+ if (!value)
49
+ return null;
50
+ const index = value.indexOf("/");
51
+ if (index <= 0 || index === value.length - 1)
52
+ return null;
53
+ return { provider: value.slice(0, index), model: value.slice(index + 1) };
54
+ }
55
+ const modelOptionOf = (config) => (config ?? []).find((entry) => entry.category === "model" || entry.id === "model");
56
+ export function modelOptions(config) {
57
+ const option = modelOptionOf(config);
58
+ const current = option?.currentValue;
59
+ const seen = new Set();
60
+ const models = [];
61
+ for (const choice of flattenChoices(option)) {
62
+ const id = choice.value;
63
+ if (!id || seen.has(id))
64
+ continue;
65
+ seen.add(id);
66
+ models.push({
67
+ id,
68
+ label: choice.name ?? id,
69
+ ...(id === current ? { current: true } : {}),
70
+ });
71
+ }
72
+ return models;
73
+ }
74
+ export function currentModelId(config) {
75
+ return modelOptionOf(config)?.currentValue ?? null;
76
+ }
77
+ /** 模型 id(就是 value 原文)→ ACP 需要的 value;不在列表里返回 null */
78
+ export function modelValueFor(config, modelId) {
79
+ const option = modelOptionOf(config);
80
+ if (!option)
81
+ return null;
82
+ for (const choice of flattenChoices(option)) {
83
+ // label 也可能被当成 id 传进来(界面上显示的是 label),两种都认
84
+ if (choice.value === modelId || choice.name === modelId)
85
+ return choice.value;
86
+ }
87
+ return null;
88
+ }
89
+ /**
90
+ * 思考档那一项:按 `category: "thought_level"` 找(dsh 叫 reasoning_effort,opencode 叫 effort)。
91
+ * 不支持推理的模型没有这一项 → 返回 undefined,界面自动隐藏控件。
92
+ */
93
+ export function thinkingOptionOf(config) {
94
+ return (config ?? []).find((entry) => entry.category === "thought_level" || entry.id === "effort" || entry.id === "reasoning_effort");
95
+ }
96
+ export function thinkingLevels(config) {
97
+ return flattenChoices(thinkingOptionOf(config))
98
+ .map((choice) => choice.value)
99
+ .filter((value) => typeof value === "string" && value !== "");
100
+ }
101
+ export function currentThinkingLevel(config) {
102
+ return thinkingOptionOf(config)?.currentValue ?? null;
103
+ }
104
+ /** 思考档位的 configId(切档位时用,随模型而变) */
105
+ export function thinkingConfigId(config) {
106
+ return thinkingOptionOf(config)?.id ?? "effort";
107
+ }
108
+ /** 取 content 里的纯文本(两种形状都认:单块对象 / 内容数组) */
109
+ export function textOfContent(content) {
110
+ if (content === undefined || content === null)
111
+ return "";
112
+ if (Array.isArray(content)) {
113
+ return content
114
+ .map((item) => {
115
+ const entry = item;
116
+ if (entry?.content?.type === "text")
117
+ return entry.content.text ?? "";
118
+ if (entry?.type === "text")
119
+ return entry.text ?? "";
120
+ return "";
121
+ })
122
+ .join("");
123
+ }
124
+ const single = content;
125
+ return single?.type === "text" ? (single.text ?? "") : "";
126
+ }
127
+ /** 正文 / 思考增量 → 事件;其余类型返回 null(工具与配置由引擎自己处理) */
128
+ export function textEventOf(update) {
129
+ if (update.sessionUpdate === "agent_message_chunk") {
130
+ const text = textOfContent(update.content);
131
+ return text === "" ? null : { type: "text_delta", text };
132
+ }
133
+ if (update.sessionUpdate === "agent_thought_chunk") {
134
+ const text = textOfContent(update.content);
135
+ return text === "" ? null : { type: "thinking_delta", text };
136
+ }
137
+ return null;
138
+ }
139
+ /** opencode 的内置工具名:`title` 等于这些值时说明**还没**带上真参数,别急着开卡片 */
140
+ const GENERIC_TITLES = new Set([
141
+ "bash",
142
+ "edit",
143
+ "write",
144
+ "read",
145
+ "multi-edit",
146
+ "multiedit",
147
+ "patch",
148
+ "grep",
149
+ "glob",
150
+ "list",
151
+ "ls",
152
+ "webfetch",
153
+ "task",
154
+ "todowrite",
155
+ "todoread",
156
+ "skill",
157
+ ]);
158
+ /**
159
+ * 这条 update 是否已经「够格」开一张工具卡片。
160
+ *
161
+ * 判据:参数里有 `cwd` 之外的东西(bash 的真实命令 / edit 的路径),
162
+ * 或者 title 已经不是工具名本身(opencode 把 bash 的 title 设成命令原文)。
163
+ */
164
+ export function hasUsableToolFacts(facts) {
165
+ const input = facts.rawInput;
166
+ if (input !== null && typeof input === "object") {
167
+ if (Object.keys(input).some((key) => key !== "cwd"))
168
+ return true;
169
+ }
170
+ const title = (facts.title ?? "").trim();
171
+ if (title === "")
172
+ return false;
173
+ return !GENERIC_TITLES.has(title.toLowerCase());
174
+ }
175
+ export function toolStartEvent(facts) {
176
+ const raw = facts.rawInput === undefined ? "" : safeJson(facts.rawInput);
177
+ return {
178
+ type: "tool_start",
179
+ toolCallId: facts.toolCallId,
180
+ name: (facts.title ?? "").trim() || facts.kind || "tool",
181
+ argsSummary: raw.length > 300 ? `${raw.slice(0, 300)}…` : raw,
182
+ argsFull: raw,
183
+ };
184
+ }
185
+ /** 工具收口:只有 completed / failed 才发(in_progress 是过程态) */
186
+ export function isFinalToolStatus(status) {
187
+ return status === "completed" || status === "failed" || status === "error" || status === "cancelled";
188
+ }
189
+ export function toolEndEvent(update) {
190
+ const raw = update.rawOutput?.output;
191
+ const output = typeof raw === "string" ? raw : textOfContent(update.content);
192
+ const exit = update.rawOutput?.metadata?.exit;
193
+ return {
194
+ type: "tool_end",
195
+ toolCallId: update.toolCallId ?? "opencode-tool",
196
+ status: update.status === "completed" ? "ok" : "failed",
197
+ output,
198
+ ...(typeof exit === "number" ? { exitCode: exit } : {}),
199
+ };
200
+ }
201
+ function safeJson(value) {
202
+ try {
203
+ return JSON.stringify(value) ?? "";
204
+ }
205
+ catch {
206
+ return String(value);
207
+ }
208
+ }
209
+ /** ACP stopReason → 我们的回合结束原因(与 dsh 同口径) */
210
+ export function stopReasonToTurnEnd(reason) {
211
+ if (reason === "cancelled")
212
+ return "interrupted";
213
+ if (reason === "refusal")
214
+ return "error";
215
+ return "done"; // end_turn / max_tokens / max_turn_requests:这轮确实结束了
216
+ }
217
+ /**
218
+ * prompt 结果里的 usage → 我们的用量事件。
219
+ *
220
+ * `final: true`:这是**这一次模型调用**的最终值,宿主按轮累加(一轮里可能多次调用)。
221
+ * `cacheWrite` opencode 不单独给(写缓存并进 input 计费),留 0 而不是瞎猜。
222
+ */
223
+ export function usageEventOf(result) {
224
+ const usage = result?.usage;
225
+ if (!usage)
226
+ return null;
227
+ const input = Number(usage.inputTokens ?? 0);
228
+ const output = Number(usage.outputTokens ?? 0);
229
+ const cacheRead = Number(usage.cachedReadTokens ?? 0);
230
+ if (input === 0 && output === 0 && cacheRead === 0)
231
+ return null;
232
+ return { type: "usage", input, output, cacheRead, cacheWrite: 0, final: true };
233
+ }
234
+ // ---------------------------------------------------------------------------
235
+ // 权限请求
236
+ // ---------------------------------------------------------------------------
237
+ /**
238
+ * 权限请求 → 我们的审批请求。
239
+ *
240
+ * opencode 的请求体自带 `toolCall.rawInput`(命令原文),所以不像 dsh 那样要靠
241
+ * `rememberToolCall` 补全;`toolFacts` 只用于兜底。
242
+ */
243
+ export function permissionApprovalRequest(request, tool) {
244
+ const title = request.toolCall?.title ?? tool?.title ?? "opencode 工具调用";
245
+ const args = request.toolCall?.rawInput ?? tool?.rawInput;
246
+ const summary = args === undefined ? "" : safeJson(args);
247
+ const clipped = summary.length > 300 ? `${summary.slice(0, 300)}…` : summary;
248
+ const kind = request.toolCall?.kind ?? tool?.kind ?? "";
249
+ return {
250
+ toolName: title,
251
+ argsSummary: clipped,
252
+ scopeKey: `opencode|${title}`,
253
+ scopeHint: title,
254
+ riskNote: kind === "execute"
255
+ ? "opencode 请求执行这条命令;是否放行由你决定"
256
+ : "opencode 请求执行这个操作;是否放行由你决定",
257
+ };
258
+ }
259
+ /** 我们的三态 → 权限选项 id(优先按 kind 匹配,其次按 optionId 猜) */
260
+ export function permissionOptionId(options, decision) {
261
+ const list = options ?? [];
262
+ const byKind = (kinds) => {
263
+ for (const kind of kinds) {
264
+ const hit = list.find((option) => option.kind === kind);
265
+ if (hit)
266
+ return hit.optionId;
267
+ }
268
+ return null;
269
+ };
270
+ const picked = decision === "deny"
271
+ ? (byKind(["reject_once", "reject_always"]) ??
272
+ list.find((option) => /reject|deny|cancel/i.test(option.optionId))?.optionId)
273
+ : (byKind(decision === "always" ? ["allow_always", "allow_once"] : ["allow_once", "allow_always"]) ??
274
+ list.find((option) => /allow|accept|approve/i.test(option.optionId))?.optionId);
275
+ return picked ?? list[0]?.optionId ?? (decision === "deny" ? "reject" : "once");
276
+ }
277
+ const textPartsOf = (message, type) => (message.parts ?? [])
278
+ .filter((part) => part.type === type && typeof part.text === "string")
279
+ .map((part) => part.text ?? "")
280
+ .join("");
281
+ /** 工具输出可能放在 `state.output`(字符串)或数组里 */
282
+ function outputTextOf(value) {
283
+ if (typeof value === "string")
284
+ return value;
285
+ if (Array.isArray(value))
286
+ return textOfContent(value);
287
+ if (value && typeof value === "object") {
288
+ const record = value;
289
+ if (typeof record.output === "string")
290
+ return record.output;
291
+ const inner = textOfContent(record.content);
292
+ if (inner !== "")
293
+ return inner;
294
+ if (typeof record.text === "string")
295
+ return record.text;
296
+ }
297
+ return "";
298
+ }
299
+ /**
300
+ * export JSON → 对话区消息(历史回放)。
301
+ *
302
+ * 工具块状态直接取 `state.status`(completed → ok,error → failed),
303
+ * 还在跑的(导出时被中断)标 failed,不能留在 running 上骗人。
304
+ */
305
+ export function exportToUiMessages(exported) {
306
+ const messages = [];
307
+ for (const message of exported?.messages ?? []) {
308
+ const info = message.info ?? {};
309
+ const createdAt = info.time?.created;
310
+ const ts = typeof createdAt === "number" ? createdAt : 0;
311
+ const id = info.id ?? `m-${messages.length}`;
312
+ if (info.role === "user") {
313
+ const text = textPartsOf(message, "text");
314
+ if (text.trim() === "")
315
+ continue;
316
+ messages.push({ id: `u-${id}`, role: "user", ts, blocks: [{ kind: "text", text }] });
317
+ continue;
318
+ }
319
+ if (info.role !== "assistant")
320
+ continue;
321
+ const blocks = [];
322
+ for (const part of message.parts ?? []) {
323
+ if (part.type === "reasoning" && typeof part.text === "string" && part.text !== "") {
324
+ blocks.push({ kind: "thinking", text: part.text });
325
+ continue;
326
+ }
327
+ if (part.type === "text" && typeof part.text === "string" && part.text !== "") {
328
+ blocks.push({ kind: "text", text: part.text });
329
+ continue;
330
+ }
331
+ if (part.type === "tool") {
332
+ const state = part.state ?? {};
333
+ const rawInput = state.input === undefined ? "" : safeJson(state.input);
334
+ const output = outputTextOf(state.output);
335
+ const clipped = output.length > TOOL_EXCERPT_CHARS ? `${output.slice(0, TOOL_EXCERPT_CHARS)}…` : output;
336
+ const exit = state.metadata?.exit;
337
+ const status = state.status === "completed"
338
+ ? "ok"
339
+ : state.status === "error" || state.status === "failed"
340
+ ? "failed"
341
+ : "failed";
342
+ blocks.push({
343
+ kind: "tool",
344
+ toolCallId: part.callID ?? `tool-${id}-${blocks.length}`,
345
+ name: (state.title ?? part.tool ?? "tool").trim() || part.tool || "tool",
346
+ argsSummary: rawInput.length > 300 ? `${rawInput.slice(0, 300)}…` : rawInput,
347
+ argsFull: rawInput,
348
+ status,
349
+ outputExcerpt: clipped,
350
+ truncated: output.length > TOOL_EXCERPT_CHARS,
351
+ startedAt: typeof state.time?.start === "number" ? state.time.start : ts,
352
+ endedAt: typeof state.time?.end === "number" ? state.time.end : ts,
353
+ ...(typeof exit === "number" ? { exitCode: exit } : {}),
354
+ });
355
+ }
356
+ }
357
+ if (blocks.length === 0)
358
+ continue;
359
+ const tokens = info.tokens;
360
+ const endedAt = info.time?.completed;
361
+ messages.push({
362
+ id: `a-${id}`,
363
+ role: "assistant",
364
+ ts,
365
+ endedAt: typeof endedAt === "number" ? endedAt : ts,
366
+ blocks,
367
+ ...(info.providerID && info.modelID ? { model: `${info.providerID}/${info.modelID}` } : {}),
368
+ ...(tokens
369
+ ? {
370
+ usage: {
371
+ input: tokens.input ?? 0,
372
+ output: tokens.output ?? 0,
373
+ cacheRead: tokens.cache?.read ?? 0,
374
+ cacheWrite: tokens.cache?.write ?? 0,
375
+ ...(typeof info.cost === "number" ? { cost: info.cost } : {}),
376
+ },
377
+ }
378
+ : {}),
379
+ });
380
+ }
381
+ return messages;
382
+ }
383
+ /** export JSON → 引擎转录(只留正文) */
384
+ export function exportToTranscript(exported) {
385
+ const out = [];
386
+ for (const message of exported?.messages ?? []) {
387
+ const role = message.info?.role;
388
+ if (role !== "user" && role !== "assistant")
389
+ continue;
390
+ const text = textPartsOf(message, "text");
391
+ if (text.trim() === "")
392
+ continue;
393
+ const created = message.info?.time?.created;
394
+ out.push({ role, text, ts: typeof created === "number" ? created : 0 });
395
+ }
396
+ return out;
397
+ }
398
+ /**
399
+ * 这份导出里最后一条 assistant 是不是「还没记账」?
400
+ *
401
+ * 用在哪里:回合刚结束那一瞬间 `opencode export` 可能已经看得到消息、却**还没有 tokens**
402
+ * (实测会出现 `tokens:{}` 的行)—— 拿它算账会得到「消息数新、token 是上一轮的」这种半截数字。
403
+ * 所以统计遇到 pending 就再等一帧重读,而不是把这个中间态写进面板。
404
+ */
405
+ export function hasPendingUsage(exported) {
406
+ const assistants = (exported?.messages ?? []).filter((message) => message.info?.role === "assistant");
407
+ const last = assistants.at(-1);
408
+ if (!last)
409
+ return true; // 连 assistant 都没有:这一轮肯定还没落完
410
+ const tokens = last.info?.tokens;
411
+ if (!tokens)
412
+ return true;
413
+ const sum = (tokens.total ?? 0) +
414
+ (tokens.input ?? 0) +
415
+ (tokens.output ?? 0) +
416
+ (tokens.cache?.read ?? 0) +
417
+ (tokens.cache?.write ?? 0);
418
+ return sum === 0;
419
+ }
420
+ /**
421
+ * export JSON → 会话统计。
422
+ *
423
+ * 四桶用量来自每条 assistant 的 `info.tokens`(opencode 自己按次记账),
424
+ * 上下文占用取「最后一条 assistant 的 input + cache.read」(近似"当前上下文多大")。
425
+ */
426
+ export function exportStats(exported) {
427
+ const stats = {
428
+ userMessages: 0,
429
+ assistantMessages: 0,
430
+ toolCalls: 0,
431
+ toolResults: 0,
432
+ totalMessages: 0,
433
+ inputTokens: 0,
434
+ outputTokens: 0,
435
+ cacheRead: 0,
436
+ cacheWrite: 0,
437
+ totalTokens: 0,
438
+ contextTokens: 0,
439
+ };
440
+ for (const message of exported?.messages ?? []) {
441
+ const role = message.info?.role;
442
+ if (role === "user") {
443
+ stats.userMessages += 1;
444
+ stats.totalMessages += 1;
445
+ continue;
446
+ }
447
+ if (role !== "assistant")
448
+ continue;
449
+ stats.assistantMessages += 1;
450
+ stats.totalMessages += 1;
451
+ for (const part of message.parts ?? []) {
452
+ if (part.type !== "tool")
453
+ continue;
454
+ stats.toolCalls += 1;
455
+ const status = part.state?.status;
456
+ if (status === "completed" || status === "error" || status === "failed")
457
+ stats.toolResults += 1;
458
+ }
459
+ const tokens = message.info?.tokens;
460
+ if (!tokens)
461
+ continue;
462
+ const input = tokens.input ?? 0;
463
+ const cacheRead = tokens.cache?.read ?? 0;
464
+ const cacheWrite = tokens.cache?.write ?? 0;
465
+ const output = tokens.output ?? 0;
466
+ stats.inputTokens += input;
467
+ stats.outputTokens += output;
468
+ stats.cacheRead += cacheRead;
469
+ stats.cacheWrite += cacheWrite;
470
+ stats.totalTokens += tokens.total ?? input + output + cacheRead + cacheWrite;
471
+ // 最后一条 assistant 的输入侧 = 当前上下文占用
472
+ stats.contextTokens = input + cacheRead;
473
+ }
474
+ return stats;
475
+ }
476
+ // ---------------------------------------------------------------------------
477
+ // 错误 → 人话
478
+ // ---------------------------------------------------------------------------
479
+ export function friendlyOpencodeError(error) {
480
+ const message = error instanceof Error ? error.message : String(error);
481
+ if (/ENOENT|spawn .* ENOENT|command not found/i.test(message)) {
482
+ return "没有找到 opencode 可执行文件:先安装它(`npm i -g opencode-ai`),或设 AGENT_WEB_OPENCODE_BIN 指向它";
483
+ }
484
+ // 顺序有讲究:opencode 的拒绝理由常是「Invalid params: <东西> not found」,「<东西>」
485
+ // 到底是思考档 / 模型 / 会话,决定了该给人哪一句 —— 所以先认具体的关键字。
486
+ if (/effort|thought_level|thoughtLevel|config option/i.test(message)) {
487
+ return "这个模型不支持该思考档位:换一个模型,或换回默认档";
488
+ }
489
+ if (/model not found|unknown model|model.*invalid|provider.*not found/i.test(message)) {
490
+ return "opencode 里没有这个模型路由:换一个模型(列表来自 opencode 自己的 provider 配置)";
491
+ }
492
+ if (/api key|credential|unauthorized|401|403|no provider|permission denied to use model/i.test(message)) {
493
+ return "这条模型没有凭据或额度:换一个模型,或在终端里跑 `opencode auth login`(本机 opencode 的凭据要单独登录)";
494
+ }
495
+ if (/unknown session|no such session|session not found|not found: ses_/i.test(message)) {
496
+ return "opencode 里找不到这条会话(可能已在 CLI / TUI 里删除)";
497
+ }
498
+ return message;
499
+ }