Files
oc_session/tui-plugins/oc-session-all.js
T

1444 lines
58 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}
},
})