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

1030 lines
41 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 → 热重载。
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"
// 注意:Python `-c` 单行脚本里 `if row: import sys;` 是非法语法(import 不能跟在 if 同行)
// 提前 import sys,用 `sys.stdout.write(row[0]) if row else None` 替代 if 行
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.write(row[0]) if row else None"
const r = spawn(py, ["-c", script], { stdio: ["ignore", "pipe", "pipe"] })
let out = ""
let err = ""
r.stdout.on("data", (d) => (out += d))
r.stderr.on("data", (d) => (err += d))
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(顶置「恢复出厂」全局操作)
const providerOptions = [
{
label: "🏭 恢复出厂(全部 provider)",
hint: "把整个模型目录换回 pristine 备份",
value: "__reset_all__",
},
...providers.map((p) => ({ label: 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) => ({
label: m,
value: m,
}))
const pickModel = await context.ui.dialog.select({
title: "模型管理 · " + pickProvider,
placeholder: "选一个模型 停用/启用…",
options: modelOptions,
})
if (!pickModel) return
const actOptions = [
{ label: "⛔ 停用(不在 /models 显示)", value: "disable" },
{ label: "✅ 启用(恢复显示)", value: "enable" },
{ label: "🏭 恢复该 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)
}
},
})