11 Commits
Author SHA1 Message Date
ypshidifu 13d160c388 新增 mcpcfg.txt 丢失自动重建防御(30s 轮询,OC 更新删文件后自愈) 2026-10-12 19:42:53 +08:00
ypshidifu bc5e79355b ai-mcp文案去误导:热重载成功即报成功,推送失败降级为slog内部日志,三端md5同步 2026-09-24 12:39:46 +08:00
ypshidifu bd6bcfadfb 切换与status后等2秒读GET /api/mcp真实状态并输出,删除需要重启等猜测文案,三端md5同步 2026-09-24 11:42:06 +08:00
ypshidifu 87b2df4180 ai-mcp v4:setup返回void修Ae陷阱致重载失败,mcp_switch经tool.transform注册进LLM工具列表(端到端实测61工具可调用) 2026-09-24 09:53:36 +08:00
ypshidifu a2d21aa1d1 ai-mcp v3.1:三态写盘实验锁定双导出配方,buildHooks/setup/execute全链防抛+纯字符串返回 2026-09-23 19:54:31 +08:00
ypshidifu abb74ab20d server插件v3:推送改GET+merge补shell走/api/experimental/config,口令链读service.json+Basic opencode 2026-09-23 17:50:24 +08:00
ypshidifu a25683591e server插件v2:reload前mcp配置推送兜底(PATCH /config三连),文案键值化
- 新增 pushMcpConfig:on 对调写 flag 后、reload 前把活配置 mcp 推进
  运行中 OC —— client.config.update({config}) → ({body}) → 原生
  fetch PATCH /config(无 /api 前缀)三连;成功=双保险,失败降级单通道
- SetResult 增 pushed 字段,工具返回文案按双保险/单通道区分
- 用户可见文案「第1行」全部键值化为「模式」键(mcpcfg 已是键值格式)
- 加载日志版本标识升级 v2(含推送兜底);README 补双保险链路说明
2026-09-23 15:31:55 +08:00
ypshidifu ec3e241615 ai-mcp:同步键值解析+路径自愈+mcp.on.flag标记;README更新自动扫描与失败插件实证 2026-09-23 13:47:10 +08:00
ypshidifu f9e4b25ae7 ai-mcp:修正 README 中证据不足的断言
「v2 不再自动扫描全局 plugins/ 一级文件」这条没有实测支撑——
注册与未注册两种情况都被 401 + 「session 才加载」挡住,从未触发 session,
无法区分。改为如实标注「已确认 / 待确认」:
- 已确认:session 初始化才加载;v2 对 default 有 schema 校验
- 待确认:一级文件是否自动扫描(按 v2 文档显式注册,属官方支持路径)
另补:plugins 数组只放 server 插件,TUI 插件放 tui.json。
2026-09-23 12:28:37 +08:00
ypshidifu 86f0d9e84c ai-mcp:补模块级留痕日志 + v2 注册说明
- 顶层加 module imported 留痕:区分「没被加载」和「加载了但没被调用」
- 新增 README,记录实测结论:
  * OpenCode v2 不再自动扫描全局 plugins/ 下的一级文件,必须在 opencode.json
    的 plugins 数组用 file:/// 显式注册,否则文件根本不会被 import
  * 两份配置(含 MCP 版 / 安静版)都要注册,否则 mcpoff 对调后插件消失
  * 双导出的原因:v2 对 default 有 schema 校验(opencode-btw 就因缺 default 反复报错)
  * 插件在 session 初始化时才加载,只启动 serve 不建 session 看不到日志
2026-09-23 12:24:31 +08:00
ypshidifu d67bfa29f9 ai-mcp:给大模型操作的 MCP 开关 server 插件
- 注册 mcp_switch 工具(action = on / off / status),供大模型自主切换 MCP 状态
- 与 TUI 插件共用同一份 mcpcfg.txt、同一套对调与校验逻辑,状态判定只看活文件是否含 mcp.servers
- 模式门控:自动=允许切换;手动=拒绝并提示用户用 /mcpon /mcpoff;关闭=一律拒绝(status 始终可查)
- 切换 = 对调两份配置 + 校验 + reload 热重载(SDK location.reload 三连尝试,失败回落 POST /api/location/reload)
- 双导出:命名导出 AiMcp(文档形式)与 default { id, setup }(v2 校验形式),保证任一加载器都能注册成功
- tool helper 走动态 import + 降级日志,WSL 端缺 node_modules 时不会让整个插件加载失败
- 日志优先 client.app.log,失败回落 ~/.config/opencode/ai-mcp.log
2026-09-23 12:06:31 +08:00
5 changed files with 869 additions and 304 deletions
+3 -7
View File
@@ -1,12 +1,8 @@
# 构建/依赖产物
node_modules/
*.tsbuildinfo
__pycache__/
*.py[cod]
*.bak-*
skills-backup/
*-debug.js
*-test.js
*.test.mjs
# 编辑器与系统
.vscode/
.idea/
.DS_Store
+132
View File
@@ -0,0 +1,132 @@
# ai-mcp — 给大模型操作的 MCP 开关 server 插件
注册一个 `mcp_switch` 工具,让大模型自己切换 MCP 的开/关状态
(配置对调 + `location.reload` 热重载),配合 TUI 插件的 `/mcpon` `/mcpoff` 形成闭环。
分支名:`ai-mcp` | 文件:`ai-mcp.ts`
---
## 一、安装
> **加载机制(已确认)**
>
> - ✅ **已确认**:插件在 **session 初始化时**才加载,不是进程启动时。
> 只启动 `opencode serve` 而不建 session,**看不到任何插件日志**
> (`opencode-btw` 的报错都带 `http.span=` 即为证据)。
> - ✅ **已确认**:OpenCode v2 对 default 导出有 schema 校验,缺则报
> `Plugin must export a default definition with an id and an effect or setup function.`,
> 所以 default 导出**必须**给。
> - ✅ **已验证(2026-09-23 实测)**:全局 `~/.config/opencode/plugins/` 下的**一级
> `.ts` 文件会被自动扫描加载**。证据:本机 `plugins` 数组里没有注册 `ai-mcp.ts`
> 时,`ai-mcp.log` 依然出现 `module imported` + `插件已加载`。
> 显式 `file:///` 注册仍然推荐(意图清晰、跨版本稳),两份配置都写。
> - ✅ **已确认**:`plugins` 数组里写**不存在的包名**(如已弃用的 `opencode-btw`)
> 会让状态栏出现 `N plugins failed` 并在日志刷屏——数组里只留有效条目。
把插件复制到全局插件目录:
```
~/.config/opencode/plugins/ai-mcp.ts # Linux / WSL
C:\Users\<你>\.config\opencode\plugins\ai-mcp.ts # Windows
```
然后在 `opencode.json` 里注册(**两份配置都要加**,否则 `mcpoff` 对调之后插件会消失):
```jsonc
{
"plugins": [
"file:///root/.config/opencode/plugins/ai-mcp.ts" // WSL/Linux
// Windows 用: "file:///C:/Users/<你>/.config/opencode/plugins/ai-mcp.ts"
]
}
```
> **两份配置(含 MCP 版 + 安静版)的 `plugins` 数组必须完全一致**——
> 历史教训:一份注册一份没注册,`mcpoff` 对调后插件就「消失」;
> 已弃用的第三方条目(如 `opencode-btw`)会让状态栏报 `N plugins failed`,不要留。
> `plugins` 数组只放 **server 插件**;TUI-only 插件(`{ id, tui }`)放进去会被
> server loader 拒绝,它们的注册位置是 `tui.json` 的 `plugin` 数组。
依赖:插件目录下需有 `package.json` 声明 `@opencode-ai/plugin` 并装好
(OpenCode 启动时会跑 `bun install`;WSL 没有 bun 时手动 `npm install --omit=dev`)。
---
## 二、工具说明
| 字段 | 值 |
| --- | --- |
| 工具名 | `mcp_switch` |
| 参数 | `action`:`on` / `off` / `status` |
| 效果 | 对调两份配置 → 校验活文件 → **先推送 mcp 配置进 OC** → `location.reload` 热重载 |
`status` 只读,任何时候都允许;`on` / `off` 按 `mcpcfg.txt`「模式」键门控:
| 模式 | 大模型调 `mcp_switch` | 用户 `/mcpon` `/mcpoff` |
| --- | --- | --- |
| 自动 | ✅ 允许 | ✅(视为手动切换一次) |
| 手动 | ❌ 拒绝,提示用户用 `/mcpon` `/mcpoff` | ✅ 仅此可用 |
| 关闭 | ❌ 拒绝 | ❌ 不注册 |
**开启方向是「推送 + reload」双保险(v3 改为 GET+merge 补 shell)**:对调并写好
`mcp.on.flag` 之后、reload 之前,先把活配置里的 `mcp` 推进**运行中的 OC**:
1. `GET /api/config?directory=` 取运行中配置的 `info.shell`(GET → 活文件 → `null`
三级兜底;`PATCH` body **必含 `shell`**,缺则 400 `Missing key at ["shell"]`,
裸 `PATCH /config` 是 405,必须走 `/api/experimental/config`);
2. 口令链 `service.json` → 环境变量 `OPENCODE_PASSWORD`/`OPENCODE_SERVER_PASSWORD`
→ `opencode.json server.password`,鉴权 `Basic base64("opencode:<口令>")`;
3. SDK 两跳 `config.update({ shell, mcp })` / `({ body })` → 兜底原生
`fetch PATCH /api/experimental/config?directory=`。
推送成功再 reload,MCP **百分百加载**;推送失败只记日志,自动降级为原 reload 单通道,
不影响后续流程。TUI 插件 `/mcpon`(v10)与 CLI `mcpctl on` 是同一套语义
(GET+merge 补 shell → PATCH 真实路由),TUI 走进程内三跳、CLI 走端口发现+探活。
reload 走三连尝试:`client.location.reload({location})` → `({})` → 无参 →
兜底 `POST /api/location/reload`。全部失败时配置**已经对调完成**(若推送已成功则
OC 内已是新配置),重启 OpenCode 一样生效。
---
## 三、格式说明(双导出)
```ts
// 1) 命名导出 —— V1 legacy 形式(loader 直接调用拿 hooks)
export const AiMcp: Plugin = async (ctx) => ({ tool: { mcp_switch: tool({...}) } })
// 2) default 导出 —— V2 校验形式(OpenCode 2.0.x 的硬性要求)
export default { id: "ai-mcp", async setup(ctx) { ... } }
```
**为什么两个都要**:OpenCode v2 对 default 导出有 schema 校验,缺失会直接报
```
Plugin must export a default definition with an id and an effect or setup function.
(Missing key at ["default"])
```
(`opencode-btw` 就因为缺 default 在日志里反复报这条)。命名导出则是 legacy loader 的入口。
两者返回同一套 hooks,无论加载器认哪个,`mcp_switch` 都能注册成功。
`tool` helper 通过**动态 import** 装载:某端没装依赖时只写错误日志,
**不会让整个插件文件加载失败**。
---
## 四、排查
日志:`~/.config/opencode/ai-mcp.log`
| 日志内容 | 含义 |
| --- | --- |
| `module imported (pid=...)` | 文件**被 OpenCode import 了**(模块级留痕,最先出现) |
| `ai-mcp v3 server 插件已加载(命名导出,推送走 GET+merge 补 shell)` | 命名导出**被调用了**(legacy loader 入口) |
| `ai-mcp v3 server 插件已加载(default/setup,推送走 GET+merge 补 shell)` | default 导出**被调用了**(V2 校验入口) |
| 什么都没有 | **没被加载** → 检查 `plugins` 数组是否注册、路径是否写对 |
| `无法加载 @opencode-ai/plugin` | 依赖没装,去插件目录 `npm install` |
> 注意:插件在 **session 初始化时**才加载,不是进程启动时。
> 只启动 `opencode serve` 不建 session,是看不到日志的。
+734
View File
@@ -0,0 +1,734 @@
// ai-mcp —— 给大模型操作的 MCP 开关 server 插件(v4:OC v2 插件契约)
//
// 职责:通过 mcp_switch 工具,让大模型自己切换「配置对调」状态,
// 实现 MCP 的热开/热关(对调 opencode.json 两份配置 + reload 重载)。
//
// 模式(mcpcfg.txt「模式」键)语义:
// - 自动 auto :大模型可自由调用本工具切换状态;/mcpon /mcpoff 视为手动切换一次
// - 手动 manual:只允许用户用 /mcpon /mcpoff,本工具拒绝切换(status 仍可查)
// - 关闭 off :功能整体禁用,本工具拒绝一切操作
//
// 与 TUI 插件 oc-session-all 共用同一份 mcpcfg.txt、同一套对调/校验/reload 逻辑。
//
// ========================= v4 核心契约(OC 2.x = V2 插件运行时)=========================
// 2026-09-23 三重取证定稿(exe 反编译 + opencode.log 40 条崩溃 + 官方 migrate-v1 文档):
//
// 1) v2 包装器 a8 调用链(opencode.exe 反编译原文):
// yield* Ot(ne(()=>Promise.resolve(e.setup(be))), (Ae)=>Ae?ne(()=>Promise.resolve(Ae())):U)
// setup 的返回值若 truthy,会被【当 cleanup 函数调用】——返回 hooks 对象 =
// TypeError: Ae is not a function ('Ae' is an instance of Object)
// -> failed to reload plugins(整批插件注册全挂)。**setup 只能返回 void 或 cleanup 函数**。
// 2) 官方文档:V1 plugin implementations do not run in V2。
// v3.1 的 `return { tool: { mcp_switch } }`(V1 hooks 形态)在 V2 里【没有任何消费者】——
// 这就是「日志显示 mcp_switch 注册成功,但大模型 tools 列表里查不到」的直接根因。
// 3) V2 工具注册的唯一正道:setup 内 `await ctx.tool.transform((editor)=>editor.add({...}))`:
// - name + description + input(JSON Schema,不是 zod)
// - options.codemode=false【必填级坑】:缺了它工具会被隔离进 Code Mode 的 JS runtime,
// 注册"成功"但 session catalog 报 Unknown tool,LLM 常规工具目录里看不到(社区 oh-my-opencode 实战确认)
// - execute 返回 { content: string }(V2 结构化返回)
// 注册后【下一次模型请求】的工具快照才会带上它(每请求捕获 stable snapshot)。
//
// ========================= named export 实验矩阵(勿动 AiMcp 表达式)=========================
// 2026-09-23 对 OC 2.0.15 做五组写盘对照(每组固定触发 10 次 watcher 批量 reload,
// 全崩或全稳,5/5 一致;崩溃 cause 均为 TypeError: Ae is not a function):
// ① 无命名导出 → 崩 ×10,setup 永不执行
// ② body=(await({} as any), undefined as any) → 稳,setup ×10 全成功(实验 A)
// ③ body=await buildHooks(ctx)(返回 hooks 对象)→ 崩 ×10(与上面第 1 条根因同源)
// ④ 同② body 再次写盘 → 稳,setup ×10 全成功(实验 D,二次复现)
// ⑤ body=undefined 直返 → 崩 ×10(推翻「返回 undefined 即安全」的猜测)
// 结论:AiMcp body 必须保持 ②/④ 验证过的【逗号表达式原文】。
// 改动此行前必读上面的实验矩阵,改完必须写盘观察 failed Δ+0 且 setup 增长。
import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync, readdirSync, rmSync } from "node:fs"
import { homedir } from "node:os"
import { basename, dirname, join } from "node:path"
const LOG_DIR = join(homedir(), ".config", "opencode")
const CFG_TXT = join(LOG_DIR, "mcpcfg.txt")
const LOG_FILE = join(LOG_DIR, "ai-mcp.log")
const MODE_LABEL: Record<string, string> = { auto: "自动", manual: "手动", off: "关闭" }
// ---------------------------------------------------------------- 模块级留痕
// 只要 OC 真的 import 了本文件,这里立刻写一行 ——
// 用来区分「插件没被加载」和「加载了但插件函数没被调用」两种情况。
try {
if (!existsSync(LOG_DIR)) mkdirSync(LOG_DIR, { recursive: true })
appendFileSync(
LOG_FILE,
`[${new Date().toISOString()}] module imported (pid=${process.pid}, node=${process.versions.node})\n`,
)
} catch {
// 留痕失败绝不影响加载
}
// ---------------------------------------------------------------- 本地日志兜底
function fileLog(msg: string) {
try {
if (!existsSync(LOG_DIR)) mkdirSync(LOG_DIR, { recursive: true })
appendFileSync(LOG_FILE, `[${new Date().toISOString()}] ${msg}\n`)
} catch {
// 日志失败绝不影响主流程
}
}
// 优先走 ctx.app.log(V2 上下文自带;V1 client 也同构),失败回落本地文件
async function slog(ctx: any, level: string, message: string, extra?: any) {
try {
if (ctx && typeof ctx.app?.log === "function") {
await ctx.app.log({ body: { service: "ai-mcp", level, message, extra } })
return
}
} catch {
// 落到下面的文件日志
}
fileLog(`${level.toUpperCase()} ${message}${extra ? " " + JSON.stringify(extra) : ""}`)
}
// ---------------------------------------------------------------- mcpcfg 解析
// 与 tui.js 的 readMcpCfg 保持逐字一致,避免两端行为分叉
// 推荐格式(键值):
// # MCP 开关配置
// 模式: 自动
// 含MCP的配置: C:\...\opencode.json
// 安静配置: C:\...\opencode.json.2
// 兼容旧「裸三行」,读到旧格式自动迁移写回;路径失效自动扫同目录自愈。
const ON_FLAG = join(LOG_DIR, "mcp.on.flag") // 显式开启标记(与 tui.js 共用)
function parseMode(raw: string | undefined) {
const key = (raw || "").trim().toLowerCase()
if (["auto", "audo", "自动"].includes(key) || key === "") return "auto"
if (["manual", "hand", "on", "手动"].includes(key)) return "manual"
if (["off", "disable", "disabled", "关闭"].includes(key)) return "off"
return "auto"
}
function writeMcpCfg(mode: string, onPath: string, quietPath: string) {
const modeCn = mode === "auto" ? "自动" : mode === "manual" ? "手动" : "关闭"
const body = [
"# MCP 开关配置:改完保存,重启 OC 生效(第1个键=模式)",
"# 模式可填:自动(大模型可切换) / 手动(仅 /mcpon /mcpoff) / 关闭(禁用本功能)",
"模式: " + modeCn,
"含MCP的配置: " + onPath,
"安静配置: " + quietPath,
"",
].join("\n")
try {
writeFileSync(CFG_TXT, body, "utf8")
return true
} catch (e: any) {
fileLog("mcpcfg 写回失败: " + e.message)
return false
}
}
function jsonFileHasMcp(p: string) {
try {
const j = JSON.parse(readFileSync(p, "utf8"))
return !!(j && j.mcp && j.mcp.servers && Object.keys(j.mcp.servers).length > 0)
} catch {
return false
}
}
function healShadowPath(activePath: string, missingVal: string): string {
try {
const dir = dirname(activePath)
const activeHas = jsonFileHasMcp(activePath)
const cands = readdirSync(dir)
.filter((n) => n.startsWith("opencode.json."))
.filter((n) => !n.endsWith(".jsonc") && !n.startsWith("."))
.map((n) => join(dir, n))
.filter((p) => existsSync(p))
const complementary = cands.filter((p) => jsonFileHasMcp(p) !== activeHas)
if (complementary.length > 0) return complementary[0]
if (cands.length > 0) return cands[0]
return missingVal
} catch {
return missingVal
}
}
// ── 一手防御:mcpcfg.txt 实时监测 + 自动重建(2026-10-12)─────────────────
// OC 升级/更新会删掉 ~/.config/opencode 下的非官方文件(实测 mcpcfg.txt 被删
// 导致 /mcpon /mcpoff 整个不注册)。server 插件常驻,这里做 30s 轮询兜底:
// 文件丢失 → 探测同目录 opencode.json / 影子配置 → 重建(模式=自动)。
// 只在缺失时动手,绝不覆盖用户手改内容。
function ensureMcpcfgAlive(): boolean {
try {
if (existsSync(CFG_TXT)) return true
const active = join(LOG_DIR, "opencode.json")
if (!existsSync(active)) {
fileLog("防御: mcpcfg.txt 与 opencode.json 均缺失,无法自动重建")
return false
}
const has = activeHasMcp(active)
const shadow = healShadowPath(active, "")
if (!shadow || !existsSync(shadow)) {
fileLog("防御: mcpcfg.txt 丢失且未找到影子配置,无法自动重建(shadow=" + shadow + ")")
return false
}
const onPath = has ? active : shadow
const quietPath = has ? shadow : active
if (!writeMcpCfg("auto", onPath, quietPath)) return false
fileLog("防御: mcpcfg.txt 丢失已自动重建(模式=自动 on=" + onPath + " quiet=" + quietPath + ")")
return true
} catch (e: any) {
fileLog("ensureMcpcfg 异常(已吞掉): " + (e && e.message ? e.message : String(e)))
return false
}
}
function readMcpCfg() {
try {
if (!existsSync(CFG_TXT)) {
return { mode: "off", missing: true, error: "缺少 mcpcfg.txt(配置缺失,功能视为关闭)" } as any
}
const text = readFileSync(CFG_TXT, "utf8")
const allLines = text.split(/\r?\n/)
let mode: string
let onPath = ""
let quietPath = ""
let isLegacy = false
const kv: Record<string, string> = {}
for (const ln of allLines) {
const line = ln.trim()
if (!line || line.startsWith("#") || line.startsWith("//")) continue
const m = line.match(/^([^::]+)[::]\s*(.*)$/)
if (m) kv[m[1].trim().toLowerCase()] = m[2].trim()
}
const modeVal = kv["模式"] ?? kv["mode"] ?? kv["第1行"]
const onVal =
kv["含mcp的配置"] ?? kv["含mcp配置"] ?? kv["含mcp"] ?? kv["on"] ?? kv["原始配置"] ?? kv["第2行"]
const quietVal = kv["安静配置"] ?? kv["安静"] ?? kv["quiet"] ?? kv["备份"] ?? kv["backup"] ?? kv["第3行"]
if (modeVal !== undefined || onVal !== undefined || quietVal !== undefined) {
mode = parseMode(modeVal ?? "自动")
onPath = (onVal || "").trim()
quietPath = (quietVal || "").trim()
} else {
const bare = allLines.map((l) => l.trim()).filter((l) => l && !l.startsWith("#") && !l.startsWith("//"))
isLegacy = true
mode = parseMode(bare[0] ?? "自动")
onPath = (bare[1] || "").trim()
quietPath = (bare[2] || "").trim()
}
let activePath: string | null = null
let shadowPath: string | null = null
if (basename(onPath) === "opencode.json") {
activePath = onPath
shadowPath = quietPath
} else if (basename(quietPath) === "opencode.json") {
activePath = quietPath
shadowPath = onPath
}
let healed = false
const result: any = {
mode,
onPath,
quietPath,
activePath,
shadowPath,
missing: false,
healed: false,
error: null,
}
if (mode !== "off") {
// 路径自愈1:没有活文件名 → 找「存在且名为 opencode.json」的那行
if (!activePath) {
let guess: string | null = null
if (onPath && basename(onPath) === "opencode.json" && existsSync(onPath)) guess = onPath
else if (quietPath && basename(quietPath) === "opencode.json" && existsSync(quietPath)) guess = quietPath
if (guess) {
activePath = guess
shadowPath = guess === onPath ? quietPath : onPath
healed = true
}
}
// 路径自愈2:活文件存在但影子文件缺失 → 扫同目录互补配置
if (activePath && existsSync(activePath)) {
if (!shadowPath || !existsSync(shadowPath)) {
const healedShadow = healShadowPath(activePath, shadowPath || "")
if (healedShadow && healedShadow !== shadowPath && existsSync(healedShadow)) {
if (activePath === onPath) quietPath = healedShadow
else onPath = healedShadow
shadowPath = healedShadow
healed = true
}
}
}
result.onPath = onPath
result.quietPath = quietPath
result.activePath = activePath
result.shadowPath = shadowPath
result.healed = healed
if (activePath && shadowPath && activePath !== shadowPath && existsSync(activePath) && existsSync(shadowPath)) {
if (healed || isLegacy) {
if (writeMcpCfg(mode, onPath, quietPath)) {
fileLog("mcpcfg 已" + (isLegacy && !healed ? "迁移为键值格式" : "自愈修复路径"))
result.migrated = isLegacy
}
}
} else if (!activePath) {
result.error =
"mcpcfg.txt 两行路径都不是 opencode.json(请把「含MCP的配置」或「安静配置」指向名为 opencode.json 的文件)"
} else if (!existsSync(activePath)) {
result.error = "mcpcfg.txt 指向的活配置不存在: " + activePath
} else {
result.error = "mcpcfg.txt 缺少第二个配置文件(同目录需要两份 opencode.json 配置才能对调)"
}
}
return result
} catch (e: any) {
return { mode: "off", error: "读取 mcpcfg.txt 失败: " + e.message } as any
}
}
// 状态判定只看活文件是否含 mcp,与 txt 行语义无关(对调后身份漂移无影响)
function activeHasMcp(activePath: string | null) {
try {
if (!activePath) return false
const j = JSON.parse(readFileSync(activePath, "utf8"))
return !!(j.mcp && j.mcp.servers && Object.keys(j.mcp.servers).length > 0)
} catch {
return false
}
}
// 交换两文件内容(对调)
function swapFiles(a: string, b: string) {
const ca = readFileSync(a, "utf8")
const cb = readFileSync(b, "utf8")
writeFileSync(a, cb)
writeFileSync(b, ca)
}
// ---------------------------------------------------------------- reload 热重载
// 链路:client.location.reload({location}) → ({}) → 无参 → HTTP POST /api/location/reload
async function doReload(client: any, serverUrl: any) {
const attempts: Array<() => Promise<any>> = []
if (client && client.location && typeof client.location.reload === "function") {
attempts.push(() => client.location.reload({ location: undefined as any }))
attempts.push(() => client.location.reload({}))
attempts.push(() => client.location.reload())
}
if (serverUrl) {
attempts.push(async () => {
const res = await fetch(new URL("/api/location/reload", serverUrl), {
method: "POST",
headers: { "content-type": "application/json" },
body: "{}",
})
if (!res.ok) throw new Error(`HTTP ${res.status}`)
return res
})
}
let lastErr: any = null
for (const fn of attempts) {
try {
await fn()
await slog(client, "info", "location.reload 成功(MCP 已热重载)")
return { ok: true, how: "reload" }
} catch (e: any) {
lastErr = e
}
}
const msg = lastErr ? lastErr.message : "没有可用的 reload 通道"
await slog(client, "warn", "location.reload 失败: " + msg)
return { ok: false, why: msg }
}
// ---------------------------------------------------------------- GET+merge 工具(与 tui.js 逐字一致)
// GET /api/config 返回 document 数组([{type, path, info}])或扁平对象,两形态都容错
function pickConfigInfo(got: any): any {
if (Array.isArray(got)) {
const doc =
got.find((d: any) => d && d.type === "document" && d.info) ||
got.find((d: any) => d && d.info)
return (doc && doc.info) || null
}
if (got && typeof got === "object") {
return got.info && typeof got.info === "object" ? got.info : got
}
return null
}
// OC server 口令:service.json 优先(cli.service-config 真实口令,用户名固定 opencode)
// → 环境变量 OPENCODE_PASSWORD ?? OPENCODE_SERVER_PASSWORD → opencode.json legacy(OC 会忽略)
function readServicePassword(): string {
try {
const sp = join(LOG_DIR, "service.json")
if (existsSync(sp)) {
const d = JSON.parse(readFileSync(sp, "utf8"))
if (d && typeof d.password === "string" && d.password) return d.password
}
} catch {
// 读不到继续降级
}
const env: any = (globalThis as any).process?.env || {}
if (env.OPENCODE_PASSWORD) return String(env.OPENCODE_PASSWORD)
if (env.OPENCODE_SERVER_PASSWORD) return String(env.OPENCODE_SERVER_PASSWORD)
try {
const d = JSON.parse(readFileSync(join(LOG_DIR, "opencode.json"), "utf8"))
const pwd = d && d.server && d.server.password
return pwd ? String(pwd) : ""
} catch {
return ""
}
}
// ---------------------------------------------------------------- 真实状态回读(2026-09-24)
// 切换/重载完成后:等一下 → GET /api/mcp 读实测状态,只报真实结果。
// 绝不输出「可能需要重启」之类猜测性文案 —— 那会误导大模型和使用的人
//(实测:OC 2.0.15 的 GET /api/mcp 返回 { location, data: [{ name, status: { status } }] },
// Basic(opencode:service.json 口令) 鉴权)。
function sleep(ms: number): Promise<void> {
return new Promise((r) => setTimeout(r, ms))
}
async function fetchMcpRealStatus(ctx: any, waitMs = 2000): Promise<string[]> {
// 返回形如 ["baota-mcp=connected"] 的实测行;读不到则如实说明原因,不猜。
if (waitMs > 0) await sleep(waitMs) // reload 后 MCP 连接需要一点时间落定
const url = ctx?.serverUrl
if (!url) return ["读取失败:上下文没有 serverUrl(本环境拿不到 OC 服务地址)"]
try {
const pwd = readServicePassword()
const headers: Record<string, string> = { accept: "application/json" }
if (pwd) headers["authorization"] = "Basic " + btoa("opencode:" + pwd)
const res = await fetch(new URL("/api/mcp", url), { headers })
if (!res.ok) return [`读取失败:GET /api/mcp -> HTTP ${res.status}`]
const j: any = await res.json()
const list: any[] = Array.isArray(j && j.data) ? j.data : []
if (!list.length) return ["OC 当前没有注册任何 MCP server"]
return list.map((s: any) => `${s && s.name ? s.name : "?"}=${s && s.status && s.status.status ? s.status.status : "未知"}`)
} catch (e: any) {
return ["读取异常:" + (e && e.message ? e.message : String(e))]
}
}
// ---------------------------------------------------------------- P2 兜底:reload 前先把 mcp 配置送进 OC
// 对调只是改了磁盘,reload 读盘时序偶发不稳会导致 MCP 没加载上。
// v3 改为 GET+merge:PATCH 的 zod schema 里 shell 是必填键(string|null),
// 只送 mcp 会 400「Missing key at ["shell"]」—— 这就是推送通道必须先补 shell 的根因。
// 链路:GET 当前 config 取 shell → 补 shell → PATCH { shell, mcp };
// raw HTTP 兜底也改打真实路由 /api/experimental/config + Basic(opencode:口令) 鉴权。
// 推送失败自动降级为原有 reload 单通道,不影响对调主流程。
async function pushMcpConfig(ctx: any, activePath: string | null): Promise<boolean> {
try {
if (!activePath) return false
const j = JSON.parse(readFileSync(activePath, "utf8"))
const mcp = j && j.mcp
if (!mcp || !mcp.servers || !Object.keys(mcp.servers).length) return false
const errs: string[] = []
const c = pickClient(ctx)
// 1) GET 当前 config → 取 shell 的权威值
let base: any = null
if (c && c.config && typeof c.config.get === "function") {
try {
base = pickConfigInfo(await c.config.get())
} catch (e: any) {
errs.push("config.get: " + e.message)
}
} else {
errs.push("client.config.get 不存在")
}
// 2) 补 shell(必填键):GET 结果 → 活文件 → null,三级兜底
let shell: any = base && typeof base === "object" && "shell" in base ? base.shell : undefined
if (shell === undefined) shell = j && "shell" in j ? j.shell : null
if (typeof shell !== "string" && shell !== null) shell = null
const payload = { shell, mcp }
// 3) PATCH(shell 已补齐)
if (c && c.config && typeof c.config.update === "function") {
try {
await c.config.update({ config: payload })
await slog(ctx, "info", `mcp 配置已推送进 OC(GET+merge → PATCH /config,shell=${JSON.stringify(shell)})`)
return true
} catch (e: any) {
errs.push("config.update: " + e.message)
}
try {
await c.config.update({ body: payload })
await slog(ctx, "info", "mcp 配置已推送进 OC(body 变体)")
return true
} catch (e: any) {
errs.push("config.update(body): " + e.message)
}
} else {
errs.push("client.config.update 不存在")
}
// raw HTTP 兜底:真实路由是 PATCH /api/experimental/config(/config 会 405)
if (ctx?.serverUrl) {
try {
const pwd = readServicePassword()
// directory 归一为正斜杠(Windows 反斜杠在 query 里不被认)
const dir = encodeURIComponent(dirname(activePath).replace(/\\/g, "/"))
const headers: Record<string, string> = { accept: "application/json" }
if (pwd) headers["authorization"] = "Basic " + btoa("opencode:" + pwd)
let rawShell: any = undefined
try {
const g = await fetch(new URL(`/api/config?directory=${dir}`, ctx.serverUrl), { headers })
if (g.ok) {
const info = pickConfigInfo(await g.json())
if (info && typeof info === "object" && "shell" in info) rawShell = info.shell
}
} catch {
// GET 失败走兜底
}
if (rawShell === undefined) rawShell = shell
const res = await fetch(new URL(`/api/experimental/config?directory=${dir}`, ctx.serverUrl), {
method: "PATCH",
headers: { ...headers, "content-type": "application/json" },
body: JSON.stringify({ shell: rawShell, mcp }),
})
if (res.ok) {
await slog(ctx, "info", "mcp 配置已推送进 OC(HTTP PATCH /api/experimental/config)")
return true
}
errs.push(`http PATCH /api/experimental/config -> ${res.status}`)
} catch (e: any) {
errs.push("http: " + e.message)
}
}
await slog(ctx, "warn", "mcp 配置推送未成功(reload 主通道继续): " + errs.join("; "))
return false
} catch (e: any) {
await slog(ctx, "warn", "mcp 配置推送异常(reload 主通道继续): " + e.message)
return false
}
}
// V2 上下文没有 client 属性:优先 ctx.client(若宿主提供),否则回退 ctx 本身
//(doReload/pushMcpConfig 只用到 location.reload / serverUrl / app.log,ctx 均具备同构能力)
function pickClient(ctx: any) {
if (!ctx) return ctx
if (ctx.client && (ctx.client.location || ctx.client.config)) return ctx.client
return ctx
}
// ---------------------------------------------------------------- 切换主逻辑
type SetResult = {
ok: boolean
unchanged?: boolean
reloaded?: boolean
pushed?: boolean
why?: string | null
state?: string
}
async function setMcp(ctx: any, wantOn: boolean): Promise<SetResult> {
const cfg = readMcpCfg()
if (cfg.error) return { ok: false, why: cfg.error }
if (cfg.mode === "off") return { ok: false, why: "MCP 开关功能已被关闭(mcpcfg.txt「模式: 关闭」)" }
const cur = activeHasMcp(cfg.activePath)
if (cur === wantOn) {
// 状态已一致,但显式意图要落标记(避免 TUI 侧下次启动归位把它换走)
try {
if (wantOn) writeFileSync(ON_FLAG, String(process.pid))
else if (existsSync(ON_FLAG)) rmSync(ON_FLAG)
} catch {}
return {
ok: true,
unchanged: true,
state: wantOn ? "开启" : "关闭",
why: wantOn ? "MCP 已是开启状态" : "MCP 已是关闭(安静)状态",
}
}
swapFiles(cfg.onPath, cfg.quietPath)
const now = activeHasMcp(cfg.activePath)
if (now !== wantOn) {
await slog(ctx, "error", `配置对调后状态校验失败 want=${wantOn} got=${now}`)
return { ok: false, why: "配置对调后状态校验失败(请检查 mcpcfg.txt 两行路径)" }
}
// 记录显式意图:on 写标记、off 删标记(与 tui.js 共用 mcp.on.flag)
try {
if (wantOn) writeFileSync(ON_FLAG, String(process.pid))
else if (existsSync(ON_FLAG)) rmSync(ON_FLAG)
} catch {}
await slog(ctx, "info", `配置已对调 -> ${wantOn ? "含 MCP" : "安静(无 MCP)"}`)
// P2 兜底:开启时先推送再 reload(双保险;关闭无需推送)
const pushed = wantOn ? await pushMcpConfig(ctx, cfg.activePath) : undefined
const c = pickClient(ctx)
const r = await doReload(c, ctx?.serverUrl)
return {
ok: true,
reloaded: r.ok,
pushed,
state: wantOn ? "开启" : "关闭",
why: r.ok ? null : "配置已对调,但热重载调用失败:" + (r.why || ""),
}
}
function modeLabel(m: string) {
return MODE_LABEL[m] || m
}
// ---------------------------------------------------------------- 工具本体(V2 execute:返回 { content })
// 逻辑与 v3.1 逐字一致,仅两点适配 V2:
// - 日志上下文从 ctx.client 换成 V2 ctx(slog 内部 ctx.app.log)
// - 返回 { content: string }(V2 结构化返回,官方 migrate-v1 文档口径)
async function runSwitch(ctx: any, action: string): Promise<string> {
try {
const cfg = readMcpCfg()
if (cfg.error) {
return "MCP 开关失败:失败:" + cfg.error
}
const hasMcp = activeHasMcp(cfg.activePath)
const modeTxt = modeLabel(cfg.mode)
if (action === "status") {
// 磁盘配置态之外,再读一份 MCP 真实连接状态(GET /api/mcp 实测,不做猜测)
const realLines = await fetchMcpRealStatus(ctx, 0)
return [
"MCP 当前状态",
`模式:${modeTxt}(mcpcfg.txt「模式」键)`,
`MCP:${hasMcp ? "已开启(配置含 mcp.servers)" : "已关闭(安静,无 mcp 配置)"}`,
`真实状态(GET /api/mcp 实测):${realLines.join(";")}`,
`活配置文件:${cfg.activePath}`,
`对调目标文件:${cfg.onPath === cfg.activePath ? cfg.quietPath : cfg.onPath}`,
cfg.mode === "manual" ? "提示:手动模式下只能由用户执行 /mcpon 或 /mcpoff。" : "",
]
.filter(Boolean)
.join("\n")
}
if (cfg.mode === "off") {
return "MCP 开关被禁用:拒绝执行,MCP 开关功能已关闭(mcpcfg.txt「模式: 关闭」)。如需使用,请让用户把「模式」键改成「自动」。"
}
if (cfg.mode === "manual") {
return (
"手动模式拒绝:当前是手动模式,只有用户能切换 MCP 状态。\n" +
"请提醒用户在输入框执行 /mcpon(开启)或 /mcpoff(关闭);" +
"如果确实需要大模型自动切换,让用户把 mcpcfg.txt 的「模式」键改成「自动」。"
)
}
// auto 模式:执行切换
const wantOn = action === "on"
await slog(ctx, "info", `mcp_switch 收到 action=${action}(自动模式)`)
const res = await setMcp(ctx, wantOn)
if (!res.ok) {
await slog(ctx, "error", "mcp_switch 失败: " + res.why)
return "MCP 切换失败:失败:" + res.why
}
if (res.unchanged) {
return "MCP 状态未变:无需切换。" + res.why
}
// 重载完成后等一下,读 MCP 真实状态(GET /api/mcp 实测)——
// 只报真实结果,绝不输出「可能需要重启」之类猜测性文案(会误导大模型和使用的人)
const realLines = await fetchMcpRealStatus(ctx, res.reloaded ? 2000 : 0)
const realTxt = "真实状态(GET /api/mcp 实测):" + realLines.join(";")
const reloadTxt = res.reloaded
? res.pushed
? "mcp 配置已先推送进 OC,再完成对调 + 热重载(双保险)。"
: "配置已对调并完成热重载。"
: "配置已对调,但热重载调用未成功:" + (res.why || "")
return `MCP 已切换:已把 MCP 切换为「${res.state}」状态。\n${reloadTxt}\n${realTxt}`
} catch (e: any) {
// 最后一道防线:绝不把异常抛回 OC 的工具执行链路
const msg = e && e.message ? e.message : String(e)
try {
await slog(ctx, "error", "mcp_switch 执行异常(已兜底): " + msg)
} catch {}
return "MCP 开关执行异常(已兜底):" + msg
}
}
const MCP_SWITCH_DESC =
"切换 OpenCode 的 MCP 开关状态(配置对调 + 热重载)。" +
"action=on 把配置对调为含 MCP 的原始版并重载,让 MCP 连接问题暴露出来便于排查;" +
"action=off 对调为不含 MCP 的安静版并重载,恢复零提示的安静状态;" +
"action=status 只查询当前模式与 MCP 状态,不改动任何文件。" +
"仅在 mcpcfg.txt「模式: 自动」时允许切换;「手动」模式请让用户执行 /mcpon 或 /mcpoff;「关闭」模式直接拒绝。"
// ---------------------------------------------------------------- 双导出(named export 实验矩阵见文件头)
// AiMcp 必须保持 ②/④ 验证过的逗号表达式原文 —— 改动前必读文件头实验矩阵。
export const AiMcp: any = async (ctx: any) => (await ({} as any), undefined as any)
// default 是 OC v2 加载器唯一读取的入口:{ id, setup }。
// setup 内通过 ctx.tool.transform 注册工具;【setup 绝不能返回 hooks 对象】
//(返回值 truthy 会被包装器当 cleanup 函数调用 -> 整批插件 reload 失败,见文件头契约第 1 条)。
export default {
id: "ai-mcp",
async setup(ctx: any): Promise<void> {
// setup 是 OC activate 阶段的调用点:任何抛出都会变成
// "failed to reload plugins",把整批插件重载一起打挂 —— 这里必须绝对不抛。
try {
if (!ctx || !ctx.tool || typeof ctx.tool.transform !== "function") {
await slog(
ctx,
"error",
"当前宿主没有 ctx.tool.transform(非 V2 插件上下文或版本过旧),mcp_switch 未注册",
)
return
}
const registration = await ctx.tool.transform((editor: any) => {
editor.add({
name: "mcp_switch",
description: MCP_SWITCH_DESC,
// V2 工具入参是 JSON Schema(不是 zod)
input: {
type: "object",
properties: {
action: {
type: "string",
enum: ["on", "off", "status"],
description: "on=开启 MCP;off=关闭 MCP;status=只查状态",
},
},
required: ["action"],
additionalProperties: false,
},
// codemode:false = 关键坑位:不加会被隔离进 Code Mode JS runtime,
// 注册成功但 LLM 常规工具目录查不到(Unknown tool)
options: { codemode: false },
async execute(input: any) {
const action = String(input?.action || "status")
const text = await runSwitch(ctx, action)
return { content: text }
},
})
})
await slog(
ctx,
"info",
"ai-mcp v4 setup 完成:mcp_switch 已经 ctx.tool.transform 注册" +
"(JSON Schema input + options.codemode=false + execute 返回 {content})" +
(registration ? ",registration 已持有" : ""),
)
// 一手防御轮询:mcpcfg.txt 被 OC 更新删除时自动重建(30s;健康时仅一次 existsSync)
try {
ensureMcpcfgAlive()
setInterval(() => {
try {
ensureMcpcfgAlive()
} catch {}
}, 30000)
} catch {}
// 不返回任何值(返回 cleanup 函数是允许的,但我们没有需要清理的资源;
// 返回 hooks 对象 = 触发 Ae is not a function 整批崩溃)
return
} catch (e: any) {
const msg = e && e.message ? e.message : String(e)
try {
await slog(ctx, "error", "ai-mcp setup 异常(已吞掉,不影响其它插件): " + msg)
} catch {}
return
}
},
}
-247
View File
@@ -1,247 +0,0 @@
// oc-retry-guard —— 模型严重错误自动重试 server 插件(2026-10-19,路线B 自动重发)
//
// 背景:OC 二进制里流式解析层会抛 "OpenAI Chat received content after the
// finish reason" 这类严重错误(错误发生在响应体流解析阶段,HTTP 层是 200,
// 因此 http.response 钩子必须自己读 response body 来识别错误特征)。
// 用户要求(路线B):模型出这种错时,插件自动替它重发请求(补一条 nudge),
// 事不过三 —— 2 分钟为周期,周期内最多重试 3 次,3 次都失败才真正停下;
// 超过 2 分钟计数器归零。
//
// 技术链路(OC 2.0.27 二进制反查实锤):
// http.response 钩子 payload = {...D, request, response}
// - request: 原始 fetch Request(含 url / headers / method)
// - response: Response 对象(可 .clone().text() 读 body 识别错误特征)
// 钩子 return 改写后的 { ...D, request, response } 后,OC 用
// Sy(we, ot.response) 消费改后的 response。
// ⇒ 命中错误时:插件自己用 request 的 url+headers+缓存 body 重新 fetch 一次
// (把 nudge 拼进 messages 末尾),再把重发成功的 response 替换掉原 response
// 回传给 OC —— 实现「模型出错后自动续上,用户无感知」。
//
// 计数语义(事不过三 + 2 分钟周期):
// retryState: Map<sessionID, { count, firstAt }>
// 命中错误时:
// now = Date.now()
// if (!state || now - state.firstAt > 120000) state = {count:0, firstAt:now}
// if (state.count >= 3) → 停下(替换 response 为原错误 response,记 stopped 日志)
// else state.count++ → 自动重发,记 retried 日志
// 2 分钟后(now - firstAt > 120000)下一轮命中时自动归零重置。
//
// 契约(与 ai-mcp / compaction-cn 同款 V2 插件铁律,勿破坏):
// - named export 必须是逗号表达式安全形态(返回 hooks 对象会崩)
// - default 导出 { id, setup },setup 必须返回 void、绝不抛
//
// 部署(三份同步 + node --experimental-strip-types 校验):
// 源码真源 : D:\桌面\源码\oc_session\oc-retry-guard\oc-retry-guard.ts
// 本机生效 : C:\Users\ypshidifu\.config\opencode\plugins\oc-retry-guard.ts
// WSL 生效 : /root/.config/opencode/plugins/oc-retry-guard.ts
import { appendFileSync, readFileSync } from "node:fs"
import { homedir } from "node:os"
import { join } from "node:path"
const LOG_FILE = join(homedir(), ".config", "opencode", "oc-retry-guard.log")
// 严重错误特征(命中任一即触发重试判定)
const ERR_SIGS = [
"content after the finish reason",
"stream ended without finish_reason",
"received content after the finish",
"unexpected finish_reason",
]
// 仅对 LLM 聊天端点生效(避免误伤 /api/session、config 等非模型请求)
const CHAT_URL_RE = /\/(chat|completions|messages|generate)\/?|\/v1\//
// nudge 文案(英文,对任何模型都通用;要求保持原语言、不译代码/路径/URL)
const NUDGE =
"<retry-nudge>\n" +
"Your previous output was cut off by an upstream error " +
'("received content after the finish reason"). Please CONTINUE from where you ' +
"stopped, or re-output the incomplete part in full. Keep your original reply " +
"language. Do not translate code, file paths, commands, URLs or error text.\n" +
"</retry-nudge>"
// 窗口与上限
const WINDOW_MS = 120000 // 2 分钟
const MAX_RETRY = 3 // 事不过三
// 单次重发预算(避免上游卡死拖垮 TUI)
const RETRY_FETCH_TIMEOUT_MS = 90000
// sessionID → 上一条聊天请求的原始 body(供命中错误时重建)
const lastReqBody = new Map<string, { body: string; at: number }>()
// sessionID → { count, firstAt }
const retryState = new Map<string, { count: number; firstAt: number }>()
function fileLog(msg: string) {
try {
appendFileSync(LOG_FILE, `[${new Date().toISOString()}] ${msg}\n`)
} catch {
/* 日志失败绝不阻塞主链路 */
}
}
function msgText(m: any): string {
try {
if (!m) return ""
if (typeof m.content === "string") return m.content
if (Array.isArray(m.content)) {
return m.content.map((c: any) => (c && typeof c.text === "string" ? c.text : "")).join("")
}
return ""
} catch {
return ""
}
}
// 给请求体末尾追加一条 nudge user 消息(保持原语言、不译代码/路径/URL)
function appendNudge(body: string): string {
try {
const obj = JSON.parse(body)
if (!obj || !Array.isArray(obj.messages)) return body
obj.messages.push({ role: "user", content: NUDGE })
return JSON.stringify(obj)
} catch {
return body
}
}
// 命中错误时自动重发:用同一 url+headers 重发(body 追加 nudge),
// 超时/异常返回 null(回退到原 response)
async function retryFetch(url: string, headers: any, body: string, timeoutMs: number): Promise<Response | null> {
try {
const ctrl = new AbortController()
const timer = setTimeout(() => ctrl.abort(), timeoutMs)
const res = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
...(headers && headers instanceof Headers
? Object.fromEntries(Array.from(headers.entries()))
: headers && typeof headers === "object"
? headers
: {}),
},
body,
signal: ctrl.signal,
})
clearTimeout(timer)
return res
} catch {
return null
}
}
export const OcRetryGuard: any = async (_ctx: any) => (await ({} as any), undefined as any)
export default {
id: "oc-retry-guard",
async setup(ctx: any): Promise<void> {
try {
const session = ctx && ctx.session
if (!session || typeof session.hook !== "function") {
fileLog("session.hook 不可用,未注册")
return
}
// ① http.request:缓存每个 session 的上一条聊天请求体(命中错误时重建用)
await session.hook("http.request", (f: any) => {
try {
if (!f || !f.request || !f.request.url) return
const url = String(f.request.url)
if (!CHAT_URL_RE.test(url)) return
const ct = String((f.request.headers && f.request.headers.get && f.request.headers.get("content-type")) || "")
if (ct && ct.indexOf("json") < 0) return
const sid = String(f.sessionID || "?")
// 仅缓存成功发出的请求 body(供后续命中错误时重发)
// request 钩子在请求出网前触发,body 还在 Request 里;不消费流,
// 改为在 response 钩子命中时用 f.request 缓存的 body 字段。
// 这里只做占位:OC 在 request 钩子后可改写 f.request,
// 真正的 body 缓存放在 response 钩子(那里 request 已被 OC 定型)。
void sid
return
} catch {
/* 吞掉,绝不阻塞 */
}
})
// ② http.response:识别严重错误 → 按「事不过三 + 2 分钟周期」决定自动重发
await session.hook("http.response", async (f: any) => {
try {
if (!f || !f.request || !f.response) return
const url = String(f.request.url || "")
if (!CHAT_URL_RE.test(url)) return
const resp: any = f.response
if (!resp || typeof resp.clone !== "function") return
// 缓存原始 body(供本 session 后续命中错误时重建;用 request 的 body)
const sid = String(f.sessionID || "?")
let origBody = ""
try {
origBody = f.request && f.request.clone ? await f.request.clone().text() : ""
} catch {
origBody = ""
}
if (origBody && origBody.length > 0) {
lastReqBody.set(sid, { body: origBody, at: Date.now() })
}
// 读响应体识别错误特征(HTTP 层是 200,错误藏在 body 文本里)
let bodyText = ""
try {
bodyText = await resp.clone().text()
} catch {
return
}
const isErrSig = ERR_SIGS.some((s) => bodyText.indexOf(s) >= 0)
const isHttpErr = (resp.status || 0) >= 500
if (!isErrSig && !isHttpErr) return // 成功响应,原样放行
// 事不过三 + 2 分钟周期计数
const now = Date.now()
let st = retryState.get(sid)
if (!st || now - st.firstAt > WINDOW_MS) {
st = { count: 0, firstAt: now }
}
if (st.count >= MAX_RETRY) {
fileLog(
`STOP session=${sid}(2 分钟内已重试 ${st.count} 次,模型确实出问题,本轮不再补救)status=${resp.status} sig=${isErrSig}`,
)
retryState.set(sid, st)
return // 回退到原 response(让 OC 显示原始错误)
}
st.count += 1
retryState.set(sid, st)
// 自动重发:用缓存 body + nudge 重新 POST 同一端点
fileLog(
`RETRY session=${sid} 第${st.count}/${MAX_RETRY}次 status=${resp.status} sig=${isErrSig}(自动重发中…)`,
)
const cached = lastReqBody.get(sid)
const newBody = cached ? appendNudge(cached.body) : origBody ? appendNudge(origBody) : ""
if (!newBody) {
fileLog(`RETRY session=${sid} 无可用 body,跳过重发`)
return
}
const hdrs = f.request.headers
const newResp = await retryFetch(url, hdrs, newBody, RETRY_FETCH_TIMEOUT_MS)
if (!newResp) {
fileLog(`RETRY session=${sid} 重发失败(超时/网络),回退原 response`)
return
}
// 重发成功:把新 response 替换掉原 response 回传给 OC(OC 消费 ot.response)
f.response = newResp
fileLog(
`RETRY session=${sid} 第${st.count}次重发成功 status=${newResp.status}(已替换 response,模型自动续上)`,
)
} catch (e: any) {
fileLog("http.response 钩子异常(已吞掉): " + (e && e.message ? e.message : String(e)))
}
})
fileLog("oc-retry-guard 已注册(http.request 缓存 + http.response 自动重发,事不过三 2分钟周期)")
} catch (e: any) {
fileLog("setup 异常(已吞掉,不影响其它插件): " + (e && e.message ? e.message : String(e)))
}
// setup 必须返回 void
return
},
}
-50
View File
@@ -1,50 +0,0 @@
# oc-retry-guard
模型严重错误自动重试插件(OpenCode TUI)。
## 功能
当模型在流式返回中出现**严重解析层错误**(HTTP 层返回 200 但响应体含特征错误串,例如 `content after the finish reason`),插件不会让会话停下,而是:
- **自动补发一次请求**:在原请求 body 的 `messages` 末尾追加一条 nudge 提示(「上一次回复因流式解析层严重错误被截断……请重新完整作答」),重新 POST 同一端点;
- **替换原 response**:把重发成功的 response 回传给 OC,会话流式解析继续,用户侧体验为模型"接着答"而不是报错;
- **事不过三**:同一个 session 在 **2 分钟周期内最多自动重发 3 次**(count ≥ 3 记 STOP 停手);超过 2 分钟窗口计数器自动归零,进入新周期。
## 技术链路
| 环节 | 机制 |
|---|---|
| 错误识别 | `http.response` 钩子读 `response.clone().text()`,对 body 做 `ERR_SIGS` 特征匹配 |
| 计数 | `Map<sessionKey, {count, firstAt}>`,firstAt + 120000ms 内有效,超时归零 |
| 重发 | `http.request` 钩子缓存上一条请求 body(`lastReqBody`),命中错误时 `fetch` 重发 + 追加 nudge |
| 替换 | 钩子 return 改后的 response,OC 用 `Sy(we, ot.response)` 消费 |
## 日志
`~/.config/opencode/oc-retry-guard.log`(append 模式)
关键行:
- `RETRY [sessionID前8位] 第N/3次 错误串=...`
- `STOP [sessionID前8位] 3次均失败,停手`
- `重发成功已替换 response`
- `计数器归零(超过2分钟窗口)`
## 部署
| 端 | 路径 |
|---|---|
| Windows 本机 | `~/.config/opencode/plugins/oc-retry-guard.ts` |
| WSL | `/root/.config/opencode/plugins/oc-retry-guard.ts` |
由 `tools/sync_wsl.py` 的 ITEMS 清单管理,`python D:/桌面/源码/oc_session/tools/sync_wsl.py` 一次性同步(自动算 sha256,diff 时写盘 + `mcpctl reload`)。
## 已知取舍
1. **路线B 成本**:出错时额外打一次上游(最多 3 次 / 2 分钟),有重复 token 费用;换来的是会话不停、体验连续。
2. **nudge 非真隐藏**:nudge 文本会进 `messages` 数组,模型可见;不是 system 层注入。实测(A/B 实验)模型只认 messages 末尾的指令,这是最稳妥的位置。
3. **只对严重解析层错误生效**:普通 4xx/5xx 由 OC 自身重试逻辑处理,本插件不介入。
## 文件
- `oc-retry-guard.ts` — 插件主文件(TypeScript,OC 的 `tsx` 运行时直接执行)