1444 lines
58 KiB
JavaScript
1444 lines
58 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 →
|
||
// 排空后写 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 {}
|
||
}
|
||
}
|
||
|
||
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 热重载")
|
||
await delay(500) // DB 写可见性缓冲
|
||
const ok = await doReload(context)
|
||
log("[oc-session-all] 模型目录热重载 " + (ok ? "OK" : "ERROR") + " tag=" + f.lastOp)
|
||
try {
|
||
context.ui.toast.show({
|
||
message: ok ? "模型目录已热重载,/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] v11 loaded — 会话选择器 + MCP配置对调(mcpcfg.txt) + 推送兜底(GET+merge补shell) + flush常驻热重载 + 模型停用持久化(cfg) + skill热加载")
|
||
mmStartFlushPoller(context) // 常驻轮询 flush 标记:/manage 操作与后台对账回潮都靠它热重载
|
||
|
||
// ── 读取 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)
|
||
}
|
||
},
|
||
})
|