import { Plugin } from "@opencode/plugin/tui" import { appendFileSync, mkdirSync, existsSync, readFileSync, writeFileSync, readdirSync, statSync, rmSync, } from "fs" import { join, basename, dirname } from "path" import { homedir } from "os" import { spawn } from "node:child_process" // ── 日志(跨平台:Windows C:\Users\... / WSL /root)──────────────────── const LOG_DIR = join(homedir(), ".config", "opencode") const LOG_FILE = join(LOG_DIR, "oc-session-all.log") if (!existsSync(LOG_DIR)) { mkdirSync(LOG_DIR, { recursive: true }) } function log(msg) { try { appendFileSync(LOG_FILE, `[${new Date().toISOString()}] ${msg}\n`) } catch (e) {} console.log(msg) } function delay(ms) { return new Promise((resolve) => setTimeout(resolve, ms)) } // ── Helpers ──────────────────────────────────────────────────────────── function formatDateTime(ms) { const d = new Date(ms) const pad = (v) => String(v).padStart(2, "0") return `${d.getFullYear()}/${pad(d.getMonth() + 1)}/${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}` } function projectDisplayName(session, projectMap) { const project = session.projectID ? projectMap.get(session.projectID) : undefined if (project?.name?.trim()) return project.name.trim() const worktree = project?.worktree?.replace(/\/+$/, "").split(/[\\/]/).pop() if (worktree) return worktree const dir = session.directory?.replace(/\/+$/, "").split(/[\\/]/).pop() if (dir) return dir const locDir = session.location?.directory?.replace(/\/+$/, "").split(/[\\/]/).pop() if (locDir) return locDir return session.projectID ? session.projectID.slice(0, 8) : "unknown" } // ── MCP 配置对调(mcpcfg.txt 键值驱动,小白只改这个 txt)───────────────── // 推荐格式(键值,带注释): // # MCP 开关配置:改完保存,重启 OC 生效 // 模式: 自动 ← 自动 / 手动 / 关闭(也认 auto/manual/off) // 含MCP的配置: C:\...\opencode.json // 安静配置: C:\...\opencode.json.2 // 兼容旧「裸三行」格式(第1行模式、第2行含MCP路径、第3行安静路径), // 读到旧格式会自动迁移写回键值格式。 // 自动 = 无显式开启标记时,启动把活配置归位为安静版(零 toast); // /mcpon /mcpoff 与大模型 server 插件都可切换(命令=手动切换一次状态) // 手动 = 启动不动文件,仅 /mcpon /mcpoff 触发对调(大模型插件会拒绝) // 关闭 = 本功能整体禁用(不注册命令、不 connect、不动文件) // 对调 = 交换两文件的内容,活文件永远是名为 opencode.json 的那份。 // 路径失效时自动扫描同目录 opencode.json.* 自愈并写回,避免配置错指向 // 把功能锁死(历史 bug:指向的备份文件被删 → 命令整个消失)。 const CFG_TXT = join(LOG_DIR, "mcpcfg.txt") const ON_FLAG = join(LOG_DIR, "mcp.on.flag") // 显式开启标记(/mcpon 写入,/mcpoff 删除) function parseMode(raw) { 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, onPath, quietPath) { 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) { log("[oc-session-all] mcpcfg 写回失败: " + e.message) return false } } function jsonFileHasMcp(p) { try { const j = JSON.parse(readFileSync(p, "utf8")) return !!(j && j.mcp && j.mcp.servers && Object.keys(j.mcp.servers).length > 0) } catch (e) { return false } } // 路径自愈:给定活文件与缺失行的原值,扫同目录找互补的影子文件 function healShadowPath(activePath, missingVal) { try { const dir = dirname(activePath) const activeHas = jsonFileHasMcp(activePath) const cands = readdirSync(dir) .filter((n) => n.startsWith("opencode.json.") && n !== "opencode.json.jsonc") .filter((n) => !n.endsWith(".jsonc") && !n.startsWith(".")) .map((n) => join(dir, n)) .filter((p) => existsSync(p)) // 优先:内容与活文件互补的那份(活含 mcp → 影子必须不含,反之亦然) 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 (e) { return missingVal } } function readMcpCfg() { try { if (!existsSync(CFG_TXT)) { return { mode: "off", missing: true, error: "缺少 mcpcfg.txt(配置缺失,功能视为关闭)" } } const text = readFileSync(CFG_TXT, "utf8") const allLines = text.split(/\r?\n/) let mode = null let onPath = "" let quietPath = "" let isLegacy = false // 先按键值解析(认中英文冒号与中英文键名) const kv = {} 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 { // 旧「裸三行」格式:非注释行按顺序 = 模式 / 含MCP路径 / 安静路径 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() } // 活文件 = 两行中文件名为 opencode.json 的那份(OC 只读这个名字) let activePath = null let shadowPath = 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 = { mode, onPath, quietPath, activePath, shadowPath, missing: false, healed: false, error: null } if (mode !== "off") { // 路径自愈1:没有活文件名 → 找「存在且名为 opencode.json」的那行 if (!activePath) { let guess = 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)) { log("[oc-session-all] mcpcfg 已" + (isLegacy && !healed ? "迁移为键值格式" : "自愈修复路径")) result.migrated = isLegacy } } } else if (!activePath) { result.error = "mcpcfg.txt 两行路径都不是 opencode.json(请把「含MCP的配置」或「安静配置」指向名为 opencode.json 的文件)" if (healed || isLegacy) writeMcpCfg(mode, onPath, quietPath) } else if (!existsSync(activePath)) { result.error = "mcpcfg.txt 指向的活配置不存在: " + activePath } else { result.error = "mcpcfg.txt 缺少第二个配置文件(同目录需要两份 opencode.json 配置才能对调)" } } return result } catch (e) { return { mode: "off", error: "读取 mcpcfg.txt 失败: " + e.message } } } function activeHasMcp(activePath) { try { const j = JSON.parse(readFileSync(activePath, "utf8")) return !!(j.mcp && j.mcp.servers && Object.keys(j.mcp.servers).length > 0) } catch (e) { return false } } // 交换两文件内容(对调)。状态判定只看活文件是否含 mcp,与行语义无关 function swapFiles(a, b) { const ca = readFileSync(a, "utf8") const cb = readFileSync(b, "utf8") writeFileSync(a, cb) writeFileSync(b, ca) } // reload 热重载:POST /api/location/reload(重建 location,重读磁盘配置 → MCP 生效) async function doReload(context) { const attempts = [ () => context.client.location.reload({ location: context.location }), () => context.client.location.reload({}), () => context.client.location.reload(), ] let lastErr = null for (const fn of attempts) { try { await fn() log("[oc-session-all] location.reload OK") return true } catch (e) { lastErr = e } } log("[oc-session-all] location.reload ERROR: " + (lastErr && lastErr.message)) return false } // P2 兜底:reload 之前先把 mcp 配置 PATCH 进运行中的 OC。 // v10 改为 GET+merge:PATCH 的 zod schema 里 shell 是必填键(string|null), // 只送 mcp 会 400「Missing key at ["shell"]」—— 这就是推送通道必须先补 shell 的根因。 // 链路:GET 当前 config 取 shell(容错 document 数组 / 扁平对象两形态)→ // 补 shell(GET 没带则用活文件值,再缺则 null)→ PATCH { shell, mcp }。 async function pushMcpConfig(context, activePath) { try { 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 = [] const cfgApi = context.client && context.client.config // 1) GET 当前 config(进程内 handler)→ 提取 document 的 info 作 base let base = null if (cfgApi && typeof cfgApi.get === "function") { try { const got = await cfgApi.get() if (Array.isArray(got)) { const doc = got.find((d) => d && d.type === "document" && d.info) || got.find((d) => d && d.info) base = (doc && doc.info) || null } else if (got && typeof got === "object") { base = got.info && typeof got.info === "object" ? got.info : got } } catch (e) { errs.push("config.get: " + e.message) } } else { errs.push("client.config.get 不存在") } // 2) 补 shell(必填键):GET 结果 → 活文件 → null,三级兜底 let shell = 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 (cfgApi && typeof cfgApi.update === "function") { // SDK 签名:parameters.config -> body(进程内直调同一套 zod 校验) try { await cfgApi.update({ config: payload }) log( "[oc-session-all] mcp 配置已推送进 OC(GET+merge → PATCH /config,shell=" + JSON.stringify(shell) + ")", ) return true } catch (e) { errs.push("config.update: " + e.message) } try { await cfgApi.update({ body: payload }) log("[oc-session-all] mcp 配置已推送进 OC(body 变体)") return true } catch (e) { errs.push("config.update(body): " + e.message) } } else { errs.push("client.config.update 不存在") } const exp = context.client && context.client.experimental && context.client.experimental.config if (exp && typeof exp.update === "function") { try { await exp.update(payload) log("[oc-session-all] mcp 配置已推送进 OC(experimental.config.update)") return true } catch (e) { errs.push("experimental: " + e.message) } } log("[oc-session-all] mcp 配置推送未成功(reload 主通道继续): " + errs.join("; ")) return false } catch (e) { log("[oc-session-all] mcp 配置推送异常(reload 主通道继续): " + e.message) return false } } // 切换 MCP 开/关(对调 + reload)。返回 { ok, unchanged?, reloaded?, why } async function setMcp(context, wantOn) { const cfg = readMcpCfg() if (cfg.error) return { ok: false, why: cfg.error } if (cfg.mode === "off") return { ok: false, why: "MCP 开关功能已被关闭(配置「模式: 关闭」)" } const cur = activeHasMcp(cfg.activePath) if (cur === wantOn) { // 状态已一致,但显式意图要落标记(避免下次 setup 归位把它换走) try { if (wantOn) writeFileSync(ON_FLAG, String(process.pid)) else if (existsSync(ON_FLAG)) rmSync(ON_FLAG) } catch (e) {} return { ok: true, unchanged: true, why: wantOn ? "MCP 已是开启状态" : "MCP 已是关闭(安静)状态" } } swapFiles(cfg.onPath, cfg.quietPath) const now = activeHasMcp(cfg.activePath) if (now !== wantOn) { log("[oc-session-all] swap verify FAILED want=" + wantOn + " got=" + now) return { ok: false, why: "配置对调后状态校验失败(请检查 mcpcfg.txt 两行路径)" } } // 记录显式意图:/mcpon 写标记,reload 重建 setup 或下次启动见标记不再归位 try { if (wantOn) writeFileSync(ON_FLAG, String(process.pid)) else if (existsSync(ON_FLAG)) rmSync(ON_FLAG) } catch (e) { log("[oc-session-all] mcp.on.flag 写入失败: " + e.message) } log("[oc-session-all] config swapped -> " + (wantOn ? "WITH mcp" : "QUIET (no mcp)")) // P2 兜底:开启时先推送再 reload(双保险;关闭无需推送) const pushed = wantOn ? await pushMcpConfig(context, cfg.activePath) : undefined const reloaded = await doReload(context) return { ok: true, reloaded, pushed, why: reloaded ? null : "配置已对调,但热重载调用失败(重启 OC 生效)", } } function toastResult(context, res, actionLabel) { let message if (!res.ok) message = actionLabel + "失败:" + res.why else if (res.unchanged) message = res.why else if (res.reloaded && res.pushed === true) message = actionLabel + "成功(mcp 配置已先送入 OC + 对调 + 热重载,双保险)" else if (res.reloaded && res.pushed === false) message = actionLabel + "成功(已对调并热重载)" else if (res.reloaded) message = actionLabel + "成功(配置已对调并热重载)" else message = actionLabel + "成功,但热重载未完成:" + res.why try { context.ui.toast.show({ message, variant: res.ok ? "info" : "error", duration: 4000 }) } catch (e) {} log("[oc-session-all] " + message) } // ── /manage 模型管理面板 ─────────────────────────────────────────────── // model-manager 的 TUI 入口。两层 dialog.select: // 第一层:选 provider(+ 顶部的「恢复出厂」全局项) // 第二层:对该 provider 选 停用 / 启用(逐模型);选「恢复出厂」则整 provider 还原 // 操作不直接改 DB(OC 持写锁),而是写 model-manager.outbox.jsonl, // 由 model-manager server 插件的 daemon 消费 → fork Python 子进程写 DB → 热重载。 const MM_OUTBOX = join(homedir(), ".config", "opencode", "model-manager.outbox.jsonl") function mmEnqueue(op, payload) { try { const line = JSON.stringify({ op, payload: payload || {}, at: Date.now() }) if (!existsSync(join(homedir(), ".config", "opencode"))) { mkdirSync(join(homedir(), ".config", "opencode"), { recursive: true }) } appendFileSync(MM_OUTBOX, line + "\n") log("[oc-session-all] /manage enqueue op=" + op + " payload=" + JSON.stringify(payload)) } catch (e) { log("[oc-session-all] /manage enqueue ERROR: " + e.message) } } // 读 DB catalog 拿 provider 列表(直接读只读,OC 主进程持锁但只读不冲突; // 用 node 内联 sqlite 读不可靠 → 改用 Python 子进程读,与 model-manager 同路径) function mmReadCatalog() { return new Promise((resolve) => { try { const py = process.platform === "win32" ? "python" : "python3" // 注意两点坑: // 1. Python `-c` 单行里 `if row: import sys;` 非法(import 不能跟 if 同行)→ 提前 import sys // 2. Windows 控制台默认 GBK 编码,catalog 含非 GBK 字符(如 U+2011)会 UnicodeEncodeError // → 用 sys.stdout.buffer.write 走原始字节(utf-8 编码),Node 侧按 utf-8 解码 const script = "import sqlite3,os,sys;" + "db=os.path.join(os.path.expanduser('~'),'.local','share','opencode','opencode.db');" + "con=sqlite3.connect(db);" + "row=con.execute(\"SELECT value FROM kv WHERE key='models-dev:catalog'\").fetchone();" + "con.close();" + "sys.stdout.buffer.write(row[0].encode('utf-8')) if row else None" const r = spawn(py, ["-c", script], { stdio: ["ignore", "pipe", "pipe"], env: { ...process.env, PYTHONIOENCODING: "utf-8", PYTHONUTF8: "1" }, }) let out = "" let err = "" r.stdout.on("data", (d) => (out += d.toString("utf-8"))) r.stderr.on("data", (d) => (err += d.toString("utf-8"))) r.on("close", () => { try { if (out.trim()) resolve(JSON.parse(out.trim())) else { log("[oc-session-all] mmReadCatalog 空结果 err:" + err.slice(0, 200)) resolve(null) } } catch (e) { log("[oc-session-all] mmReadCatalog parse 失败: " + e.message + " err:" + err.slice(0, 120)) resolve(null) } }) r.on("error", (e) => { log("[oc-session-all] mmReadCatalog spawn error: " + e.message) resolve(null) }) } catch (e) { log("[oc-session-all] mmReadCatalog 异常: " + e.message) resolve(null) } }) } async function openModelManager(context) { try { const cat = await mmReadCatalog() if (!cat || !cat.body) { context.ui.toast.show({ message: "模型目录读取失败(DB 路径或 catalog 行缺失)", variant: "error", }) return } const providers = Object.keys(JSON.parse(cat.body)) if (providers.length === 0) { context.ui.toast.show({ message: "模型目录为空", variant: "warning" }) return } // 第一层:选 provider(顶置「恢复出厂」全局操作) // 注意:OC dialog.select 的 options 字段是 title/value(不是 label/value,label 会 undefined→崩溃) const providerOptions = [ { title: "🏭 恢复出厂(全部 provider)", value: "__reset_all__", }, ...providers.map((p) => ({ title: p, value: p })), ] const pickProvider = await context.ui.dialog.select({ title: "模型管理 · 选 provider", placeholder: "选一个 provider 管理其模型…", options: providerOptions, }) if (!pickProvider) return if (pickProvider === "__reset_all__") { for (const p of providers) mmEnqueue("reset", { provider: p }) context.ui.toast.show({ message: "恢复出厂已入队(" + providers.length + " 个 provider),等 daemon 消费后 /models 生效", variant: "info", }) return } // 该 provider 的当前 models const pbody = JSON.parse(cat.body)[pickProvider] || {} const models = Object.keys(pbody.models || {}) if (models.length === 0) { context.ui.toast.show({ message: pickProvider + " 下没有模型", variant: "warning" }) return } // 第二层:逐模型选 停用/启用 const modelOptions = models.map((m) => ({ title: m, value: m, })) const pickModel = await context.ui.dialog.select({ title: "模型管理 · " + pickProvider, placeholder: "选一个模型 停用/启用…", options: modelOptions, }) if (!pickModel) return const actOptions = [ { title: "⛔ 停用(不在 /models 显示)", value: "disable" }, { title: "✅ 启用(恢复显示)", value: "enable" }, { title: "🏭 恢复该 provider 出厂", value: "reset" }, ] const action = await context.ui.dialog.select({ title: "对 " + pickModel + " 执行", options: actOptions, }) if (!action) return if (action === "disable") mmEnqueue("disable", { provider: pickProvider, modelID: pickModel }) else if (action === "enable") mmEnqueue("enable", { provider: pickProvider, modelID: pickModel }) else mmEnqueue("reset", { provider: pickProvider }) context.ui.toast.show({ message: "已入队:" + action + " " + pickProvider + "/" + pickModel + "(daemon 5s 内消费,/models 将更新)", variant: "info", }) } catch (e) { log("[oc-session-all] openModelManager ERROR: " + (e.stack || e.message)) try { context.ui.toast.show({ message: "模型管理面板异常:" + e.message, variant: "error" }) } catch (e2) {} } } export default Plugin.define({ id: "oc-session-all", async setup(context) { log("[oc-session-all] v10 loaded — 会话选择器 + MCP配置对调(mcpcfg.txt) + 推送兜底(GET+merge补shell) + 热重载 + skill热加载") // ── 读取 mcpcfg.txt(键值小白配置)──────────────────────────────── const mcfg = readMcpCfg() const hasMcpNow = mcfg.activePath ? activeHasMcp(mcfg.activePath) : false const featureOn = !mcfg.error && mcfg.mode !== "off" // 命令注册门槛与 featureOn 分离:只要模式没被显式「关闭」就注册命令—— // 否则路径写错(cfg.error)会让 /mcpon /mcpoff 一起消失,用户失去自救入口 const commandsOn = mcfg.mode !== "off" && !mcfg.missing log( "[oc-session-all] mcpcfg: mode=" + mcfg.mode + " active=" + (mcfg.activePath || "(none)") + " hasMcp=" + hasMcpNow + " healed=" + !!mcfg.healed + " commandsOn=" + commandsOn + (mcfg.error ? " ERROR=" + mcfg.error : ""), ) // ── 自动模式:启动归位(把活配置换回安静版,保持平时零 toast)──────── // 例外:存在显式开启标记(用户 /mcpon 开过且没关)→ 保留开启态,不归位 const onFlagged = existsSync(ON_FLAG) let bootSwapReload = false if (featureOn && mcfg.mode === "auto" && hasMcpNow && !onFlagged) { try { swapFiles(mcfg.onPath, mcfg.quietPath) const still = activeHasMcp(mcfg.activePath) if (!still) { bootSwapReload = true log("[oc-session-all] 自动模式启动归位:活配置已对调为安静版(无 mcp)") } else { log("[oc-session-all] 启动归位校验失败(活配置仍含 mcp)") } } catch (e) { log("[oc-session-all] 启动归位 ERROR: " + e.message) } } else if (mcfg.mode === "auto" && hasMcpNow && onFlagged) { log("[oc-session-all] 检测到 mcp.on.flag:保留上次显式开启状态,本次不归位") } // ── MCP error toast 拦截(monkey-patch 兜底层)──────────────────── try { const toast = context.ui.toast const orig = toast.show.bind(toast) toast.show = (opts) => { try { const msg = String(opts?.message ?? "").toLowerCase() if ( opts?.variant === "error" && (msg.includes("mcp") || msg.includes("failed to connect") || msg.includes("connection refused")) ) { return orig({ ...opts, variant: "info", message: "MCP 服务暂时不可用(恢复后自动重连)", duration: 2000, }) } } catch (e) { log("[oc-session-all] toast interceptor check ERROR: " + e.message) } return orig(opts) } log("[oc-session-all] MCP toast 拦截已安装(兜底层)") } catch (e) { log("[oc-session-all] toast 拦截安装 ERROR: " + e.message) } // ── 启动归位后热重载(让本次会话立即回到安静态)───────────────────── if (bootSwapReload) { void (async () => { await delay(6000) // 等 location/config 就绪(约4~5s) try { await doReload(context) } catch (e) { log("[oc-session-all] 启动归位 reload ERROR: " + e.message) } })() } // ── MCP 运行时 connect(仅当活配置含 mcp 时才跑,避免 404 空转)────── const enableMcp = async () => { await delay(1500) const cfg = readMcpCfg() if (cfg.error || cfg.mode === "off") { log("[oc-session-all] enableMcp skipped: 功能关闭或配置缺失" + (cfg.error ? "(" + cfg.error + ")" : "")) return } if (!activeHasMcp(cfg.activePath)) { log("[oc-session-all] enableMcp skipped: 当前为安静状态(活配置无 mcp)") return } // 从活配置动态读 MCP server 名(不再写死 baota-mcp) let wanted = [] try { const j = JSON.parse(readFileSync(cfg.activePath, "utf8")) wanted = Object.keys((j.mcp && j.mcp.servers) || {}) } catch (e) { log("[oc-session-all] enableMcp 读取 server 名失败: " + e.message) } if (!wanted.length) { log("[oc-session-all] enableMcp skipped: 活配置 mcp.servers 为空") return } const deadline = Date.now() + 90000 let attempt = 0 let pending = wanted.slice() while (pending.length && Date.now() < deadline) { attempt++ try { const list = await context.client.mcp.list({ location: context.location }) const data = list && list.data !== undefined ? list.data : list const names = Array.isArray(data) ? data.map((s) => s && s.name).filter(Boolean) : [] log("[oc-session-all] mcp.list attempt=" + attempt + " names=[" + names.join(",") + "]") } catch (e) { log("[oc-session-all] mcp.list attempt=" + attempt + " ERROR: " + e.message) } for (const name of pending.slice()) { try { await context.client.mcp.connect({ server: name, location: context.location }) log("[oc-session-all] mcp.connect(" + name + ") OK attempt=" + attempt) pending = pending.filter((n) => n !== name) } catch (e) { const msg = String((e && e.message) || e) log("[oc-session-all] mcp.connect(" + name + ") attempt=" + attempt + " ERROR: " + msg) if (/already/i.test(msg)) pending = pending.filter((n) => n !== name) } } if (pending.length) await delay(2000) } if (pending.length) log("[oc-session-all] enableMcp gave up, pending: " + pending.join(",")) else log("[oc-session-all] enableMcp done, all connected") } void enableMcp() // ── Skill 目录热加载(原生 watch 不覆盖“新增 skill 目录”,此处轮询兜底)── const projDir = (context.location && context.location.directory) || process.cwd() const skillDirs = [ join(LOG_DIR, "skill"), join(LOG_DIR, "skills"), join(projDir, "skill"), join(projDir, "skills"), ] const skillSignature = () => { const parts = [] for (const d of skillDirs) { if (!existsSync(d)) { parts.push(d + ":absent") continue } try { for (const ent of readdirSync(d, { withFileTypes: true })) { const p = join(d, ent.name) try { parts.push(p + ":" + statSync(p).mtimeMs) } catch (e) {} if (ent.isDirectory()) { try { for (const f of readdirSync(p)) { const fp = join(p, f) try { parts.push(fp + ":" + statSync(fp).mtimeMs) } catch (e) {} } } catch (e) {} } } } catch (e) { parts.push(d + ":err") } } return parts.join("|") } // ── 一手防御:mcpcfg.txt 实时监测 + 自动重建(2026-10-12)───────────── // 背景:OC 升级/更新会删掉 ~/.config/opencode 下的非官方文件(实测 // mcpcfg.txt、同步脚本都被删过)→ 插件判 mode=off → /mcpon /mcpoff // 整个不注册。这里在 watcher tick 里做实时兜底:文件丢了立刻按同目录 // 现存配置重建(模式=自动);重建后需重启 OC 才会重注册命令(toast 提示)。 const mcpcfgRebuiltAt = { ts: 0 } function ensureMcpcfgAlive() { try { if (existsSync(CFG_TXT)) return true const active = join(LOG_DIR, "opencode.json") if (!existsSync(active)) { log("[oc-session-all] 防御: mcpcfg.txt 与 opencode.json 均缺失,无法自动重建") return false } const has = activeHasMcp(active) const shadow = healShadowPath(active, "") if (!shadow || !existsSync(shadow)) { log("[oc-session-all] 防御: mcpcfg.txt 丢失且未找到影子配置,无法自动重建(shadow=" + shadow + ")") return false } const onPath = has ? active : shadow const quietPath = has ? shadow : active if (!writeMcpCfg("auto", onPath, quietPath)) return false log("[oc-session-all] 防御: mcpcfg.txt 丢失已自动重建(模式=自动 on=" + onPath + " quiet=" + quietPath + ")") const now = Date.now() if (now - mcpcfgRebuiltAt.ts > 60000) { mcpcfgRebuiltAt.ts = now try { context.ui.toast.show({ message: "mcpcfg.txt 已自动重建(重启 OC 后 /mcpon /mcpoff 恢复)", variant: "warning", duration: 6000, }) } catch (e) {} } return true } catch (e) { log("[oc-session-all] ensureMcpcfg ERROR: " + e.message) return false } } const presentDirs = skillDirs.filter((d) => existsSync(d)) let lastSkillSig = skillSignature() log( "[oc-session-all] skill watcher armed (5s poll), present dirs: " + (presentDirs.length ? presentDirs.join(", ") : "(none yet)"), ) const forceSkillReload = async (reason) => { log("[oc-session-all] skill change detected (" + reason + ") -> forcing reload") let triggered = "none" try { try { const maybePatch = (context.client.experimental && context.client.experimental.config && context.client.experimental.config.update) || (context.client.config && context.client.config.experimentalUpdate) || null if (typeof maybePatch === "function") { await maybePatch({}) triggered = "patch" } } catch (e) { log("[oc-session-all] config patch attempt ERROR: " + e.message) } if (triggered === "none") { const cfg = join(LOG_DIR, "opencode.json") if (existsSync(cfg)) { const raw = readFileSync(cfg, "utf8") writeFileSync(cfg, raw) triggered = "rewrite" } } await delay(600) const r = await context.client.skill.list({ location: context.location }) const arr = (r && r.data) || r const n = Array.isArray(arr) ? arr.length : -1 log("[oc-session-all] skill reload via " + triggered + " -> skill.list = " + n + " skills") try { context.ui.toast.show({ message: "技能已重载(" + n + " 个,方式:" + triggered + ")", variant: "info", duration: 3000, }) } catch (e) {} } catch (e) { log("[oc-session-all] skill reload ERROR: " + e.message) } } setInterval(() => { try { // 一手防御:mcpcfg.txt 被 OC 更新删除时自动重建(健康时仅一次 existsSync,开销可忽略) ensureMcpcfgAlive() const sig = skillSignature() if (sig === lastSkillSig) return lastSkillSig = sig void forceSkillReload("poll") } catch (e) { log("[oc-session-all] skill watcher tick ERROR: " + e.message) } }, 5000) // ── Session 选择器(v2 dialog.select/router API)─────────────────── const openPicker = async () => { log("[oc-session-all] openPicker called") try { let sessions = [] let projects = [] try { const r = await context.client.session.list({ limit: 1000, order: "desc" }) sessions = (r && r.data) || r || [] log("[oc-session-all] client.session.list -> " + (Array.isArray(sessions) ? sessions.length + " items" : "non-array")) } catch (e) { log("[oc-session-all] client.session.list ERROR: " + e.message) } if (!Array.isArray(sessions) || sessions.length === 0) { try { sessions = context.data.session.list() || [] log("[oc-session-all] data.session.list fallback -> " + sessions.length + " items") } catch (e) { log("[oc-session-all] data.session.list ERROR: " + e.message) } } try { const r = await context.client.project.list({}) projects = (r && r.data) || r || [] } catch (e) { log("[oc-session-all] client.project.list ERROR: " + e.message) } if (!Array.isArray(sessions) || sessions.length === 0) { context.ui.toast.show({ message: "未找到任何会话", variant: "warning" }) return } const projectMap = new Map( (Array.isArray(projects) ? projects : []).map((p) => [p.id, p]), ) const visible = sessions.filter((s) => !s.time?.archived && !s.parentID) if (visible.length === 0) { context.ui.toast.show({ message: "未找到任何会话", variant: "warning" }) return } // v11 排版(2026-10-19 用户明令): // ① 全部会话按日期从近到远排序(不再按项目分组 + 按项目字母序) // ② title 里插入 session ID(短前缀 8 字符,避免整行被 ID 挤爆) const visibleSorted = visible.sort( (a, b) => ((b.time?.updated ?? 0) - (a.time?.updated ?? 0)), ) const shortID = (id) => { const s = String(id || "") return s.length > 12 ? s.slice(0, 8) + "…" : s } const options = visibleSorted.map((session) => { const project = projectDisplayName(session, projectMap) const ts = session.time?.updated ? formatDateTime(session.time.updated) : "未知时间" const idTag = shortID(session.id) return { title: `${project} ${ts} │ ${session.title || "无标题"} [${idTag}]`, value: session.id, category: project, } }) log("[oc-session-all] picker options built: " + options.length) const selected = await context.ui.dialog.select({ title: "全部会话", placeholder: "跨项目搜索并切换任意会话…", options, }) log("[oc-session-all] dialog.select resolved: " + String(selected)) if (selected) { context.ui.router.navigate({ type: "session", sessionID: selected }) } } catch (e) { log("[oc-session-all] openPicker ERROR: " + (e.stack || e.message)) try { context.ui.toast.show({ message: "会话列表加载失败:" + e.message, variant: "error", }) } catch (e2) {} } } // ── 命令注册(全部中文描述;模式=关闭时不注册 MCP 开关命令)─────────── try { context.ui.slot({ append: "app", render() { log("[oc-session-all] slot render called — registering keymap layer") try { const commands = [ { id: "sessions.all", title: "浏览全部会话", description: "跨项目浏览并切换到任意历史会话(快捷键 Ctrl+O)", group: "会话", palette: true, bind: "ctrl+o", slash: { name: "sessions-all", aliases: ["resume-all", "continue-all"], }, run() { void openPicker() }, }, { id: "model.manage", title: "模型管理", description: "管理 /models 目录:停用 / 启用模型,或恢复出厂设置(写入 model-manager outbox)", group: "模型", palette: true, slash: { name: "manage", aliases: ["managent", "model-manager"] }, run() { void openModelManager(context) }, }, ] if (featureOn) { commands.push( { id: "mcp.on", title: "开启 MCP", description: "把配置对调为含 MCP 的原始版并热重载(大模型可用 /mcpon 按需开启)", group: "MCP", palette: true, slash: { name: "mcpon", aliases: ["mcp-on"] }, run() { void setMcp(context, true).then((r) => toastResult(context, r, "开启 MCP")) }, }, { id: "mcp.off", title: "关闭 MCP", description: "把配置对调回无 MCP 的安静版并热重载(恢复零提示)", group: "MCP", palette: true, slash: { name: "mcpoff", aliases: ["mcp-off"] }, run() { void setMcp(context, false).then((r) => toastResult(context, r, "关闭 MCP")) }, }, ) } return context.keymap.layer(() => ({ mode: "global", commands, })) } catch (e) { log("[oc-session-all] layer inside render ERROR: " + e.message) return null } }, }) log("[oc-session-all] ui.slot({append:'app'}) registered OK (mode=" + mcfg.mode + ")") } catch (e) { log("[oc-session-all] ui.slot ERROR: " + e.message) } // ── 验证命令注册结果(写日志,不依赖 toast)──────────────────────── await delay(4000) try { const cmds = context.keymap.commands() const s = JSON.stringify(cmds) const ok = s.includes("sessions.all") || s.includes("sessions-all") log("[oc-session-all] VERIFY commands() contains sessions-all: " + ok) if (featureOn) log("[oc-session-all] VERIFY mcpon present: " + s.includes("mcp.on")) log("[oc-session-all] VERIFY manage present: " + s.includes("model.manage")) if (!ok) log("[oc-session-all] VERIFY commands() dump: " + s.substring(0, 600)) } catch (e) { log("[oc-session-all] VERIFY commands() ERROR: " + e.message) } // toast 延时显示(避免被其他插件覆盖) await delay(3000) try { context.ui.toast.show({ message: "oc-session-all v10 已加载 — /sessions-all 浏览全部会话(Ctrl+O)" + (featureOn ? ",/mcpon 开启 MCP、/mcpoff 关闭 MCP" : ""), variant: "info", duration: 20000, }) } catch (e) { log("[oc-session-all] toast.show ERROR: " + e.message) } }, })