1035 lines
41 KiB
JavaScript
1035 lines
41 KiB
JavaScript
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)
|
||
}
|
||
},
|
||
})
|