390 lines
14 KiB
JavaScript
390 lines
14 KiB
JavaScript
import { Plugin } from "@opencode/plugin/tui"
|
||
import {
|
||
appendFileSync,
|
||
mkdirSync,
|
||
existsSync,
|
||
readFileSync,
|
||
writeFileSync,
|
||
readdirSync,
|
||
statSync,
|
||
} from "fs"
|
||
import { join } from "path"
|
||
import { homedir } from "os"
|
||
|
||
// ── 日志(跨平台: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(移植自 v1)────────────────────────────────────────────────
|
||
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 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] v6 loaded — sessions picker + MCP runtime connect + skill hot-reload")
|
||
|
||
// ── MCP error toast 拦截(monkey-patch 兜底;主方案已改为 config 禁用)──
|
||
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 server offline (auto-reconnect when available)",
|
||
duration: 2000,
|
||
})
|
||
}
|
||
} catch (e) {
|
||
log("[oc-session-all] toast interceptor check ERROR: " + e.message)
|
||
}
|
||
return orig(opts)
|
||
}
|
||
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")
|
||
try {
|
||
let sessions = []
|
||
let projects = []
|
||
|
||
// 数据源 1:client SDK
|
||
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)
|
||
}
|
||
|
||
// 数据源 2:data store 回退(内置 /sessions 也用它)
|
||
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: "No sessions found", 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: "No sessions found", variant: "warning" })
|
||
return
|
||
}
|
||
|
||
// 按项目分组
|
||
const grouped = new Map()
|
||
for (const session of visible) {
|
||
const name = projectDisplayName(session, projectMap)
|
||
const list = grouped.get(name) ?? []
|
||
list.push(session)
|
||
grouped.set(name, list)
|
||
}
|
||
// 组内按更新时间倒序
|
||
for (const list of grouped.values()) {
|
||
list.sort((a, b) => (b.time?.updated ?? 0) - (a.time?.updated ?? 0))
|
||
}
|
||
// 项目名排序
|
||
const orderedProjects = [...grouped.keys()].sort((a, b) => a.localeCompare(b))
|
||
|
||
const options = orderedProjects.flatMap((project) =>
|
||
grouped.get(project).map((session) => ({
|
||
title: `${project} ${
|
||
session.time?.updated ? formatDateTime(session.time.updated) : "unknown"
|
||
} │ ${session.title || "Untitled"}`,
|
||
value: session.id,
|
||
category: project,
|
||
})),
|
||
)
|
||
log("[oc-session-all] picker options built: " + options.length)
|
||
|
||
// v2 dialog.select 返回 Promise<value|undefined>
|
||
const selected = await context.ui.dialog.select({
|
||
title: "All Sessions",
|
||
placeholder: "Search sessions across all projects…",
|
||
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: "Failed to load sessions: " + e.message,
|
||
variant: "error",
|
||
})
|
||
} catch (e2) {}
|
||
}
|
||
}
|
||
|
||
// ── 命令注册:官方 opencode.btw 插件同款写法 ──────────────────────
|
||
try {
|
||
context.ui.slot({
|
||
append: "app",
|
||
render() {
|
||
log("[oc-session-all] slot render called — registering keymap layer")
|
||
try {
|
||
return context.keymap.layer(() => ({
|
||
mode: "global",
|
||
commands: [
|
||
{
|
||
id: "sessions.all",
|
||
title: "All Sessions",
|
||
description: "Browse and switch to any session across all projects",
|
||
group: "Session",
|
||
palette: true,
|
||
bind: "ctrl+o",
|
||
slash: {
|
||
name: "sessions-all",
|
||
aliases: ["resume-all", "continue-all"],
|
||
},
|
||
run() {
|
||
void openPicker()
|
||
},
|
||
},
|
||
],
|
||
}))
|
||
} catch (e) {
|
||
log("[oc-session-all] layer inside render ERROR: " + e.message)
|
||
return null
|
||
}
|
||
},
|
||
})
|
||
log("[oc-session-all] ui.slot({append:'app'}) registered OK")
|
||
} 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 (!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 v6 loaded — /sessions-all or Ctrl+O",
|
||
variant: "info",
|
||
duration: 20000,
|
||
})
|
||
} catch (e) {
|
||
log("[oc-session-all] toast.show ERROR: " + e.message)
|
||
}
|
||
},
|
||
})
|