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 → // 排空后写 flush 标记 → 本插件(TUI 侧)执行 location.reload 热重载。 // ★ 热重载为什么在 TUI 侧:2026-10-19 实测 V2 server 插件 ctx 没有 client.location / // serverUrl(model-manager 与 ai-mcp 都报「没有可用的 reload 通道」), // 而 TUI 插件 ctx 的 client.location 一直可用(本日志大量 location.reload OK)。 const MM_OUTBOX = join(homedir(), ".config", "opencode", "model-manager.outbox.jsonl") const MM_FLUSH = join(homedir(), ".config", "opencode", "model-manager.last-flush.json") const MM_CFG = join(homedir(), ".config", "opencode", "model-manager.cfg.json") const MM_REMOVED = join(homedir(), ".config", "opencode", "model-manager.removed.json") const MM_PRISTINE = join(homedir(), ".config", "opencode", "model-manager.pristine.json") // 读 cfg.json 的「以下是停用列表」(键名大小写不敏感,其它键全部忽略) function mmReadStopList() { try { if (!existsSync(MM_CFG)) return [] const j = JSON.parse(readFileSync(MM_CFG, "utf-8")) const target = "以下是停用列表".toLowerCase() for (const k of Object.keys(j || {})) { if (String(k).toLowerCase() === target) { const v = j[k] if (Array.isArray(v)) return v.filter((x) => typeof x === "string" && x.trim()) } } return [] } catch { return [] } } // 读「已彻底删除」的厂商列表(remove_provider 标记,供面板显示与找回) function mmReadRemovedList() { try { if (!existsSync(MM_REMOVED)) return [] const j = JSON.parse(readFileSync(MM_REMOVED, "utf-8")) return Array.isArray(j) ? j.filter((x) => typeof x === "string") : [] } catch { return [] } } // 读 pristine(出厂 catalog)→ 用于显示「被停用/删除的厂商」还能找回多少模型 function mmReadPristineBody() { try { if (!existsSync(MM_PRISTINE)) return {} const j = JSON.parse(readFileSync(MM_PRISTINE, "utf-8")) const b = j && j.body if (typeof b === "string") return JSON.parse(b) return b && typeof b === "object" ? b : {} } catch { return {} } } // ── 「最近使用/收藏」历史清理 ─────────────────────────────────────────── // /models 顶部的 Recent/Favorites 不是 catalog,而是 OC TUI 的使用历史: // ~/.local/state/opencode/model.json 的 recent/favorite 数组(providerID/modelID)。 // 停用/删除厂商只改 DB catalog,历史不动 → 画面出现「说重载完了,模型还在」 // 的前后矛盾(2026-10-20 实锤:xiaomi 已从 catalog 删除,但 model.json 的 // recent 里还躺着 {providerID:"xiaomi",modelID:"mimo-v2.6-flash"})。 // 每次热重载前/启动时按停用清单把命中条目清出去: // ① cfg「以下是停用列表」(provider 级,支持 * 通配) // ② model-manager.disabled.json(modelID 级镜像) // ③ model-manager.removed.json(已彻底删除的厂商) // 通配语义与 model-manager.ts 的 patMatch 完全一致(大小写不敏感,* 通配)。 function mmPatMatch(pattern, text) { const p = String(pattern).toLowerCase() const t = String(text || "").toLowerCase() if (!p) return false if (p === t) return true if (p.includes("*")) { const re = new RegExp("^" + p.split("*").map((s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join(".*") + "$", "i") return re.test(t) } return false } function mmPruneModelState(reason) { try { const stop = mmReadStopList() const removed = mmReadRemovedList() let disabled = {} // modelID 级:provider -> [modelID] try { const dp = join(homedir(), ".config", "opencode", "model-manager.disabled.json") if (existsSync(dp)) { const j = JSON.parse(readFileSync(dp, "utf-8")) for (const k of Object.keys(j || {})) { if (Array.isArray(j[k])) disabled[k] = j[k].filter((x) => typeof x === "string") } } } catch {} if (!stop.length && !removed.length && !Object.keys(disabled).length) return 0 const mp = join(homedir(), ".local", "state", "opencode", "model.json") if (!existsSync(mp)) return 0 const st = JSON.parse(readFileSync(mp, "utf-8")) const hitProv = (pid) => { if (!pid) return false if (removed.includes(pid)) return true return stop.some((pat) => mmPatMatch(pat, pid)) } const hit = (e) => { if (!e || typeof e !== "object") return false if (hitProv(e.providerID)) return true const arr = disabled[e.providerID] return Array.isArray(arr) && arr.includes(e.modelID) } const recent = Array.isArray(st.recent) ? st.recent : [] const favorite = Array.isArray(st.favorite) ? st.favorite : [] const nextRecent = recent.filter((e) => !hit(e)) const nextFav = favorite.filter((e) => !hit(e)) const n = recent.length - nextRecent.length + (favorite.length - nextFav.length) if (n <= 0) return 0 st.recent = nextRecent st.favorite = nextFav writeFileSync(mp, JSON.stringify(st), "utf-8") log("[oc-session-all] 历史清理(" + reason + "): 从「最近使用/收藏」剔除停用条目 " + n + " 条 → " + mp) return n } catch (e) { log("[oc-session-all] 历史清理失败: " + (e.message || e)) return 0 } } 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" // 读 catalog + credential(credential 表存 /connect 传 key 的内置 provider,如 xiaomi) // label = /connect 时选的 service 名(如 "Xiaomi"),用于精确区分同 integration 的不同 service // 坑:① Python -c 单行里 if row: import sys 非法 ② Windows GBK 编码 → 全走 buffer.write utf-8 const script = "import sqlite3,os,sys,json;" + "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();" + "creds=[{'i':r[0],'l':r[1]} for r in con.execute(\"SELECT integration_id,label FROM credential WHERE active=1\").fetchall()];" + "con.close();" + "obj={'catalog':(row[0] if row else None),'credentials':creds};" + "sys.stdout.buffer.write(json.dumps(obj, ensure_ascii=False).encode('utf-8'))" 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) } }) } // ── 常驻 flush 轮询:daemon(server 插件)每次把队列/对账写完会落 flush 标记, // 这里每 2s 看一眼,发现新标记就执行 location.reload(TUI ctx 的 client.location // 是本环境唯一实测可用的 reload 通道;server 插件 ctx 无 client/serverUrl)。 // 覆盖两类来源:① /manage 入队操作消费完 ② 后台对账压制 catalog 回潮后。 // 启动时以当前 flush.at 为基线,不重放历史标记。 let mmFlushSeenAt = 0 function mmStartFlushPoller(context) { try { if (existsSync(MM_FLUSH)) { try { mmFlushSeenAt = JSON.parse(readFileSync(MM_FLUSH, "utf-8")).at || 0 } catch {} } } catch {} log("[oc-session-all] flush 常驻轮询已启动(2s,基线 at=" + mmFlushSeenAt + ")") setInterval(async () => { try { if (!existsSync(MM_FLUSH)) return let f = null try { f = JSON.parse(readFileSync(MM_FLUSH, "utf-8")) } catch { return } if (!f || typeof f.at !== "number" || f.at <= mmFlushSeenAt) return mmFlushSeenAt = f.at if (f.error) { // 2026-10-20:server 侧 DB 写失败会写 error flush —— 提示失败,绝不报「已重载」 log("[oc-session-all] flush 失败标记 tag=" + f.lastOp + " error=" + f.error) try { context.ui.toast.show({ message: "操作未生效:" + f.error, variant: "error", duration: 6000, }) } catch {} return } if (f.serverReload) { log("[oc-session-all] flush 新标记 tag=" + f.lastOp + "(server 侧已 reload,TUI 免重复触发)") return } log("[oc-session-all] flush 新标记 tag=" + f.lastOp + " → 执行 TUI 热重载") // 先清「最近使用/收藏」历史中的停用条目,再 reload —— 让画面与 catalog 一致 // (修「前面说重载完毕,后面模型还在」:那条躺在 model.json 历史里,不归 catalog 管) const pruned = mmPruneModelState("flush:" + f.lastOp) await delay(500) // DB 写可见性缓冲 const ok = await doReload(context) log( "[oc-session-all] 模型目录热重载 " + (ok ? "OK" : "ERROR") + " tag=" + f.lastOp + (pruned ? "(历史清理 " + pruned + " 条)" : ""), ) try { context.ui.toast.show({ message: ok ? "模型目录已热重载" + (pruned ? ",清出停用历史 " + pruned + " 条" : "") + ",/models 立即生效" : "热重载失败(详见 oc-session-all.log)", variant: "info", duration: 4000, }) } catch {} } catch (e) { log("[oc-session-all] flush poller ERROR: " + (e.stack || e.message)) } }, 2000) } // ── /manage 模型管理面板(状态机:每层都有「← 返回上级」,不靠 ESC)──────── // 说明:OC 原生 dialog 的 ESC 直接关整个面板,无法逐层回退, // 所以每层菜单第一项固定是「← 返回上级」,由我们自己维护层级栈。 const MM_BACK = "__mm_back__" const MM_CLOSE = "__mm_close__" // 收集「已连接 provider」(config provider + catalog∧credential 精确匹配) async function mmCollectProviders() { const connectedProviders = [] const seen = new Set() const addProvider = (id, name, models, source) => { if (!id || seen.has(id)) return seen.add(id) connectedProviders.push({ id, name: name || id, models: models || [], source }) } // 来源 A:opencode.json 的 provider 字段(cc-switch 自定义加的,如 id=1) try { const cfgPath = join(homedir(), ".config", "opencode", "opencode.json") if (existsSync(cfgPath)) { const cfg = JSON.parse(readFileSync(cfgPath, "utf-8")) const prov = (cfg && cfg.provider) || {} for (const pid of Object.keys(prov)) { const pv = prov[pid] || {} addProvider(pid, pv.name || pid, Object.keys((pv.models && pv.models) || {}), "config") } } } catch (e) { log("[oc-session-all] /manage 读 config provider 失败: " + e.message) } // 来源 B:catalog 里「已连接内置 provider」。 // ★ 精确匹配(前缀匹配已废弃——会把没订阅的 Xiaomi Token Plan * 变体也拖进来): // ① credential.label 精确等于 provider 显示名 ② 回退 cid === integration_id const cat = await mmReadCatalog() if (cat && cat.catalog && cat.credentials) { let body = {} try { // 坑:models-dev:catalog 的 value 是外层包装 {updatedAt, digest, body}, // body 才是真正的 provider JSON 字符串 —— 必须二次解包 const outer = JSON.parse(cat.catalog) if (outer && typeof outer.body === "string") body = JSON.parse(outer.body) else if (outer && outer.body && typeof outer.body === "object") body =outer.body else body = outer } catch (e) { log("[oc-session-all] /manage catalog body parse 失败: " + e.message) } const credentials = Array.isArray(cat.credentials) ? cat.credentials : [] const hits = [] const hitSet = new Set() for (const c of credentials) { const integ = (c && c.i) || "" const label = (c && c.l) || "" let matched = label ? Object.keys(body).filter((cid) => (((body[cid] || {}).name) || cid) === label) : [] if (matched.length === 0 && integ) matched = Object.keys(body).filter((cid) => cid === integ) for (const cid of matched) { if (hitSet.has(cid)) continue hitSet.add(cid) hits.push(cid) } } for (const cid of hits) { const cv = body[cid] || {} addProvider(cid, cv.name || cid, Object.keys(cv.models || {}), "catalog") } log("[oc-session-all] /manage catalog 命中: " + JSON.stringify(hits)) } return connectedProviders } // 已停用/已删除的厂商(可找回):cfg 停用列表 + 已删除列表,去掉仍在线的 function mmFindDisabled(connected) { const online = new Set(connected.map((p) => p.id)) const stop = mmReadStopList() const removed = mmReadRemovedList() const pristine = mmReadPristineBody() const ids = [] for (const id of stop.concat(removed)) { if (!id || online.has(id) || ids.includes(id)) continue ids.push(id) } return ids.map((id) => { const p = pristine[id] || {} return { id, name: p.name || id, models: Object.keys(p.models || {}), deleted: removed.includes(id), } }) } async function openModelManager(context) { try { // 层级状态机:view = list | scope | provider | model | action | find | found let view = "list" let target = null // 当前厂商 {id,name,models} let pendingModel = null for (;;) { // ── 第 1 层:选厂商 ──────────────────────────────────────────────── if (view === "list") { const connectedProviders = await mmCollectProviders() log( "[oc-session-all] /manage 列表(" + connectedProviders.length + "): " + JSON.stringify(connectedProviders.map((p) => ({ id: p.id, n: p.name, m: p.models.length, s: p.source }))), ) const disabled = mmFindDisabled(connectedProviders) const options = [ { title: "✕ 关闭面板", value: MM_CLOSE }, ...connectedProviders.map((p) => ({ title: p.name + "(" + p.models.length + " 个模型 · " + p.source + ")", value: p.id, })), ] if (disabled.length) { options.push({ title: "♻️ 已停用/已删除的厂商(" + disabled.length + ")", value: "__find__" }) } if (connectedProviders.length === 0 && disabled.length === 0) { context.ui.toast.show({ message: "没有已连接 provider(config 和 catalog 都为空),先 /connect 一个再 /manage", variant: "warning", }) return } const pick = await context.ui.dialog.select({ title: "模型管理 · 选厂商", placeholder: "选一个厂商管理其模型…", options, }) if (!pick || pick === MM_CLOSE) return if (pick === "__find__") { view = "find" continue } const t = connectedProviders.find((p) => p.id === pick) if (!t) { context.ui.toast.show({ message: "未找到该 provider", variant: "warning" }) continue } target = t view = "scope" continue } // ── 第 2 层:选粒度(进厂后的第一层,带返回上级)──────────────────── if (view === "scope") { const dispName = target.name const pick = await context.ui.dialog.select({ title: "模型管理 · " + dispName + " · 选择操作", placeholder: "选操作(← 返回上一级)", options: [ { title: "← 返回上级(选厂商)", value: MM_BACK }, { title: "🧩 单个模型(停用/启用其中一个)", value: "model" }, { title: "🏭 整个厂商(全部停用 / 全部启用)", value: "provider" }, { title: "♻️ 恢复出厂设置(彻底删除该厂商)", value: "factory" }, ], }) if (!pick || pick === MM_BACK) { view = "list" target = null continue } if (pick === "model") { view = "model" continue } if (pick === "provider") { view = "provider" continue } if (pick === "factory") { // 恢复出厂设置 = 彻底删除该厂商(/models 里整厂消失),二次确认 const ok = await context.ui.dialog.select({ title: "确认恢复出厂设置(删除 " + dispName + ")", placeholder: "该厂商将从 /models 彻底移除(可再找回)", options: [ { title: "← 返回上级(不删)", value: MM_BACK }, { title: "🗑 确定删除 " + dispName, value: "yes" }, ], }) if (!ok || ok === MM_BACK) { view = "scope" continue } mmEnqueue("remove_provider", { provider: target.id }) context.ui.toast.show({ message: "已入队:删除 " + dispName + "(恢复出厂设置),daemon 写完自动热重载,/models 里该厂消失", variant: "info", }) view = "list" target = null continue } } // ── 第 3 层:整厂操作 ───────────────────────────────────────────── if (view === "provider") { const dispName = target.name const n = (target.models || []).length const pick = await context.ui.dialog.select({ title: "对整个 " + dispName + " 执行", placeholder: "选操作(← 返回上一级)", options: [ { title: "← 返回上级", value: MM_BACK }, { title: "⛔ 停用整个厂商(全部模型下架 · 持久化)", value: "disable_all" }, { title: "✅ 启用整个厂商(恢复全部模型)", value: "enable_all" }, ], }) if (!pick || pick === MM_BACK) { view = "scope" continue } if (pick === "disable_all") { if (n === 0) { context.ui.toast.show({ message: dispName + " 的模型已全部停用", variant: "warning" }) } else { mmEnqueue("disable_all", { provider: target.id }) context.ui.toast.show({ message: "已入队:停用整个 " + dispName + "(" + n + " 个模型 · 持久化),daemon 写完自动热重载", variant: "info", }) } } else { mmEnqueue("reset", { provider: target.id }) context.ui.toast.show({ message: "已入队:启用整个 " + dispName + "(恢复全部模型),daemon 写完自动热重载", variant: "info", }) } view = "list" target = null continue } // ── 第 4 层:选模型 ─────────────────────────────────────────────── if (view === "model") { const dispName = target.name const models = target.models || [] if (models.length === 0) { context.ui.toast.show({ message: dispName + " 下没有可管理的模型(已全部停用?用「整个厂商 → 启用」恢复)", variant: "warning", }) view = "scope" continue } const pick = await context.ui.dialog.select({ title: "模型管理 · " + dispName + " · 选模型", placeholder: "选一个模型(← 返回上一级)", options: [ { title: "← 返回上级", value: MM_BACK }, ...models.map((m) => ({ title: m, value: m })), ], }) if (!pick || pick === MM_BACK) { view = "scope" continue } pendingModel = pick view = "action" continue } // ── 第 5 层:对某模型执行 ───────────────────────────────────────── if (view === "action") { const pick = await context.ui.dialog.select({ title: "对 " + pendingModel + " 执行", placeholder: "选操作(← 返回上一级)", options: [ { title: "← 返回上级", value: MM_BACK }, { title: "⛔ 停用(不在 /models 显示)", value: "disable" }, { title: "✅ 启用(恢复显示)", value: "enable" }, ], }) if (!pick || pick === MM_BACK) { view = "model" continue } if (pick === "disable") mmEnqueue("disable", { provider: target.id, modelID: pendingModel }) else mmEnqueue("enable", { provider: target.id, modelID: pendingModel }) context.ui.toast.show({ message: "已入队:" + pick + " " + target.name + "/" + pendingModel + ",daemon 写完自动热重载,/models 随即更新", variant: "info", }) view = "scope" continue } // ── 找回:已停用/已删除的厂商 ───────────────────────────────────── if (view === "find") { const connected = await mmCollectProviders() const disabled = mmFindDisabled(connected) if (!disabled.length) { context.ui.toast.show({ message: "没有已停用/已删除的厂商", variant: "info" }) view = "list" continue } const pick = await context.ui.dialog.select({ title: "已停用/已删除的厂商", placeholder: "选一个进入(← 返回上一级)", options: [ { title: "← 返回上级", value: MM_BACK }, ...disabled.map((d) => ({ title: d.name + "(" + d.models.length + " 个出厂模型 · " + (d.deleted ? "已删除" : "已停用") + ")", value: d.id, })), ], }) if (!pick || pick === MM_BACK) { view = "list" continue } // 2026-10-20:选中不再直接入队+弹回最外层(用户反馈「点不进去,复原不了」), // 改为进入该厂商的确认层,由用户明确点「恢复」才入队 const d = disabled.find((x) => x.id === pick) if (!d) { view = "list" continue } target = d view = "found" continue } // ── 已停用/已删除厂商 · 恢复确认层 ─────────────────────────────── if (view === "found") { const d = target if (!d) { view = "list" continue } const status = d.deleted ? "已删除(彻底摘除节点)" : "已停用(models 已清空)" const pick = await context.ui.dialog.select({ title: "找回厂商 · " + d.name, placeholder: status + ",可从出厂集恢复…", options: [ { title: "← 返回上级(已停用列表)", value: MM_BACK }, { title: "♻️ 恢复该厂商(从出厂集拉回 " + d.models.length + " 个模型)", value: "__restore__", }, { title: "📋 出厂模型:" + (d.models.length ? d.models.slice(0, 6).join(", ") + (d.models.length > 6 ? " …" : "") : "(无)"), value: "__info__", }, ], }) if (!pick || pick === MM_BACK) { view = "find" continue } if (pick === "__info__") { try { context.ui.toast.show({ message: d.name + " 出厂模型(" + d.models.length + "):" + d.models.join(", ").slice(0, 300), variant: "info", duration: 6000, }) } catch {} continue // 停留在本层 } mmEnqueue("reset", { provider: d.id }) context.ui.toast.show({ message: "已入队:恢复 " + d.name + "(从出厂集拉回),daemon 写完自动热重载", variant: "info", }) view = "list" continue } return } } 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] v12 loaded — 会话选择器 + MCP配置对调(mcpcfg.txt) + 推送兜底(GET+merge补shell) + flush常驻热重载 + 模型停用持久化(cfg) + skill热加载 + Recent历史清理") mmStartFlushPoller(context) // 常驻轮询 flush 标记:/manage 操作与后台对账回潮都靠它热重载 mmPruneModelState("startup") // 启动即清「最近使用/收藏」历史中的停用条目(修「重载了但模型还在」) // ── 读取 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 v12 已加载 — /sessions-all 浏览全部会话(Ctrl+O)" + (featureOn ? ",/mcpon 开启 MCP、/mcpoff 关闭 MCP" : ""), variant: "info", duration: 20000, }) } catch (e) { log("[oc-session-all] toast.show ERROR: " + e.message) } }, })