v6: MCP改config禁用+插件mcp.connect运行时启用;skill目录5s轮询热加载兜底新增目录盲区
This commit is contained in:
@@ -1,5 +1,13 @@
|
||||
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 { homedir } from "os"
|
||||
|
||||
@@ -38,12 +46,17 @@ function projectDisplayName(session, projectMap) {
|
||||
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({
|
||||
id: "oc-session-all",
|
||||
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 {
|
||||
const toast = context.ui.toast
|
||||
const orig = toast.show.bind(toast)
|
||||
@@ -68,11 +81,154 @@ export default Plugin.define({
|
||||
}
|
||||
return orig(opts)
|
||||
}
|
||||
log("[oc-session-all] MCP toast interceptor installed")
|
||||
log("[oc-session-all] MCP toast interceptor installed (backup layer)")
|
||||
} catch (e) {
|
||||
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)──
|
||||
const openPicker = async () => {
|
||||
log("[oc-session-all] openPicker called")
|
||||
@@ -222,7 +378,7 @@ export default Plugin.define({
|
||||
await delay(3000)
|
||||
try {
|
||||
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",
|
||||
duration: 20000,
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user