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

390 lines
14 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,
} 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)
}
},
})