v6: MCP改config禁用+插件mcp.connect运行时启用;skill目录5s轮询热加载兜底新增目录盲区

This commit is contained in:
ypshidifu
2026-09-23 00:05:43 +08:00
parent c1cb45bed4
commit b74d87cfbb
+161 -5
View File
@@ -1,5 +1,13 @@
import { Plugin } from "@opencode/plugin/tui" import { Plugin } from "@opencode/plugin/tui"
import { appendFileSync, mkdirSync, existsSync } from "fs" import {
appendFileSync,
mkdirSync,
existsSync,
readFileSync,
writeFileSync,
readdirSync,
statSync,
} from "fs"
import { join } from "path" import { join } from "path"
import { homedir } from "os" import { homedir } from "os"
@@ -38,12 +46,17 @@ function projectDisplayName(session, projectMap) {
return session.projectID ? session.projectID.slice(0, 8) : "unknown" return session.projectID ? session.projectID.slice(0, 8) : "unknown"
} }
// MCP server 名单:opencode.json 里 enabled:false 的项(启动无 toast),
// 由本插件运行时 connect 启用(官方语义:overriding disabled config until restart)。
// 新增被禁用的 MCP server 时把名字加进来即可。
const MCP_NAMES = ["baota-mcp"]
export default Plugin.define({ export default Plugin.define({
id: "oc-session-all", id: "oc-session-all",
async setup(context) { async setup(context) {
log("[oc-session-all] v5 loaded — official slot({append}) + layer(()=>({})) pattern") log("[oc-session-all] v6 loaded — sessions picker + MCP runtime connect + skill hot-reload")
// ── MCP error toast 拦截(monkey-patch toast.show,无未验证 API)── // ── MCP error toast 拦截(monkey-patch 兜底;主方案已改为 config 禁用)──
try { try {
const toast = context.ui.toast const toast = context.ui.toast
const orig = toast.show.bind(toast) const orig = toast.show.bind(toast)
@@ -68,11 +81,154 @@ export default Plugin.define({
} }
return orig(opts) return orig(opts)
} }
log("[oc-session-all] MCP toast interceptor installed") log("[oc-session-all] MCP toast interceptor installed (backup layer)")
} catch (e) { } catch (e) {
log("[oc-session-all] toast interceptor install ERROR: " + e.message) log("[oc-session-all] toast interceptor install ERROR: " + e.message)
} }
// ── MCP 运行时启用(config enabled:false + 插件 connect)────────────
const enableMcp = async () => {
// 等服务端 MCP 子系统初始化完成
await delay(1500)
try {
// 1) dump 现状(供后续动态化:字段名/状态一并写日志)
try {
const list = await context.client.mcp.list({ location: context.location })
const data = list && list.data !== undefined ? list.data : list
log("[oc-session-all] mcp.list dump: " + JSON.stringify(data).substring(0, 800))
} catch (e) {
log("[oc-session-all] mcp.list ERROR: " + e.message)
}
// 2) 逐个 connect(POST /api/experimental/mcp/:server/connect)
for (const name of MCP_NAMES) {
try {
await context.client.mcp.connect({ server: name, location: context.location })
log("[oc-session-all] mcp.connect(" + name + ") OK")
} catch (e) {
log("[oc-session-all] mcp.connect(" + name + ") ERROR: " + e.message)
}
}
// 3) connect 后再拉一次 list(服务端状态已变,日志记录结果)
try {
const after = await context.client.mcp.list({ location: context.location })
const data = after && after.data !== undefined ? after.data : after
log("[oc-session-all] mcp.list after connect: " + JSON.stringify(data).substring(0, 800))
} catch (e) {
log("[oc-session-all] mcp.list(after) ERROR: " + e.message)
}
} catch (e) {
log("[oc-session-all] enableMcp ERROR: " + e.message)
}
}
void enableMcp()
// ── Skill 目录热加载(原生 watch 不覆盖"新增 skill 目录",此处轮询兜底)──
// 标准目录(逆向 ConfigSkillPlugin):每个 config entry 下的 skill/ 与 skills/
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()) {
// 第二层:skills/<name>/SKILL.md 或 skills/<name>.md 的兄弟文件
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("|")
}
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 {
// a) 优先尝试 PATCH /api/experimental/config(触发 config.updated →
// ConfigSkillPlugin 全量 rescan → skill.reload() 推送客户端 invalidate)
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)
}
// b) 回退:同内容重写 opencode.json(write 事件触发 config watcher)
if (triggered === "none") {
const cfg = join(LOG_DIR, "opencode.json")
if (existsSync(cfg)) {
const raw = readFileSync(cfg, "utf8")
writeFileSync(cfg, raw)
triggered = "rewrite"
}
}
// c) 等服务端 rescan 完成后拉新列表
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: "Skills reloaded (" + n + ") — " + triggered,
variant: "info",
duration: 3000,
})
} catch (e) {}
} catch (e) {
log("[oc-session-all] skill reload ERROR: " + e.message)
}
}
setInterval(() => {
try {
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 选择器(移植自 v1,改用 v2 dialog.select/router API)── // ── Session 选择器(移植自 v1,改用 v2 dialog.select/router API)──
const openPicker = async () => { const openPicker = async () => {
log("[oc-session-all] openPicker called") log("[oc-session-all] openPicker called")
@@ -222,7 +378,7 @@ export default Plugin.define({
await delay(3000) await delay(3000)
try { try {
context.ui.toast.show({ context.ui.toast.show({
message: "oc-session-all v5 loaded — /sessions-all or Ctrl+O", message: "oc-session-all v6 loaded — /sessions-all or Ctrl+O",
variant: "info", variant: "info",
duration: 20000, duration: 20000,
}) })