import { Plugin } from "@opencode/plugin/tui" import { appendFileSync, mkdirSync, existsSync } from "fs" import { join } from "path" // ── 日志 ────────────────────────────────────────────────────────────── const LOG_DIR = "C:\\Users\\ypshidifu\\.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" } export default Plugin.define({ id: "oc-session-all", async setup(context) { log("[oc-session-all] v5 loaded — official slot({append}) + layer(()=>({})) pattern") // ── MCP error toast 拦截(monkey-patch toast.show,无未验证 API)── 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") } catch (e) { log("[oc-session-all] toast interceptor install ERROR: " + e.message) } // ── 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 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 v5 loaded — /sessions-all or Ctrl+O", variant: "info", duration: 20000, }) } catch (e) { log("[oc-session-all] toast.show ERROR: " + e.message) } }, })