Compare commits
73
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f49ff8ef36 | ||
|
|
a41cf92379 | ||
|
|
546fc07779 | ||
|
|
6b65ec50e7 | ||
|
|
f2b7303cd2 | ||
|
|
3c768343e4 | ||
|
|
afdafb0ed1 | ||
|
|
5cd94dc4e1 | ||
|
|
f7688bf27e | ||
|
|
884d53b619 | ||
|
|
b911a43940 | ||
|
|
19e16a42e0 | ||
|
|
874e69fb14 | ||
|
|
cd25c0a3d0 | ||
|
|
0001fc7d64 | ||
|
|
7ab1b9a9b0 | ||
|
|
38c2b20e3d | ||
|
|
aa9b3281f1 | ||
|
|
8e07bd8af8 | ||
|
|
2f64df6a9b | ||
|
|
90eb09b2a4 | ||
|
|
fd8cfa8887 | ||
|
|
043fd55ae1 | ||
|
|
0d2d1144eb | ||
|
|
250e71c48c | ||
|
|
b7537988f9 | ||
|
|
9dfe04f221 | ||
|
|
fcf188bf28 | ||
|
|
2f91a612bb | ||
|
|
ee4be54d0f | ||
|
|
4431ed8335 | ||
|
|
1ba3ad6bd3 | ||
|
|
c593f94a5e | ||
|
|
6215144fb5 | ||
|
|
0f099c88d0 | ||
|
|
6e0ed1743a | ||
|
|
20a3b487fd | ||
|
|
b65d3d6879 | ||
|
|
831e3de8d8 | ||
|
|
ffc0e8d743 | ||
|
|
3c0a8971a6 | ||
|
|
b74d87cfbb | ||
|
|
c1cb45bed4 | ||
|
|
d6048da95f | ||
|
|
92fee7511f | ||
|
|
6d59c24126 | ||
|
|
f66cde6494 | ||
|
|
ba26fd512a | ||
|
|
3d8f093d7e | ||
|
|
459f038f8e | ||
|
|
fd1a475abc | ||
|
|
dc1a27e88e | ||
|
|
ec7b461147 | ||
|
|
39efb3aa0f | ||
|
|
9f17ac9936 | ||
|
|
057e698f22 | ||
|
|
028af01b3d | ||
|
|
952b31127c | ||
|
|
a5a8c5e011 | ||
|
|
76d641b17f | ||
|
|
6c165dadd5 | ||
|
|
47c668f446 | ||
|
|
d201e3bb97 | ||
|
|
6bbbc5ea0b | ||
|
|
258a40b0d0 | ||
|
|
6c4d97362f | ||
|
|
bfe683d7e2 | ||
|
|
37df56c6d9 | ||
|
|
289ff13132 | ||
|
|
45e68bfecf | ||
|
|
0d19143065 | ||
|
|
5d43b5ed76 | ||
|
|
02f3ded256 |
+21
-4
@@ -1,5 +1,22 @@
|
|||||||
# node_modules / 日志 / 临时产物一律不入库
|
|
||||||
node_modules/
|
node_modules/
|
||||||
*.log
|
!tui-plugins/oc-session-all-debug.js
|
||||||
*.outbox.jsonl
|
!tui-plugins/oc-session-all.js
|
||||||
*.pristine.json
|
src/
|
||||||
|
|
||||||
|
# 独立 git 仓库(分支名 = 文件夹名),各自独立推送到共用远程根目录,不纳入主仓库
|
||||||
|
ai-mcp/
|
||||||
|
wsl-round-mcp/
|
||||||
|
compaction-cn/
|
||||||
|
oc-retry-guard/
|
||||||
|
model-manager/
|
||||||
|
|
||||||
|
# Python 运行产物
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
|
||||||
|
# 备份与本地回滚产物
|
||||||
|
*.bak-*
|
||||||
|
skills-backup/
|
||||||
|
*-debug.js
|
||||||
|
*-test.js
|
||||||
|
*.test.mjs
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
# oc-session-all
|
||||||
|
|
||||||
|
OpenCode TUI 插件:弹出全项目会话选择器,跨所有项目浏览和切换会话。
|
||||||
|
|
||||||
|
## 功能
|
||||||
|
|
||||||
|
- `Ctrl+o` 或 `/sessions-all` 打开可搜索的会话选择面板
|
||||||
|
- 按项目分组、按更新时间倒序排列
|
||||||
|
- 过滤掉已归档和子代理会话
|
||||||
|
- 对调2份`opencode.json`实现拦截mcp报错的toast
|
||||||
|
- 热更新skill,新的skill丢入目录,插件自动发现并更新
|
||||||
|
- **MCP 推送兜底(v10)**:`config.get` 取 shell(GET→活文件→null 三级兜底)→
|
||||||
|
`config.update({config})` → `({body})` → `experimental.config.update` 三跳推进
|
||||||
|
运行中的 OC,配合 reload 双保险;`/mcpon` `/mcpoff` 可用
|
||||||
|
|
||||||
|
## 安装
|
||||||
|
|
||||||
|
在 `~/.config/opencode/tui.json` 中添加(现役源码是 `.js`,部署副本放 `tui-plugins/` 下):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"$schema": "https://opencode.ai/tui.json",
|
||||||
|
"plugin": [
|
||||||
|
"file:///C:/Users/<你>/.config/opencode/tui-plugins/oc-session-all.js"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
WSL 侧同理:`file:///root/.config/opencode/tui-plugins/oc-session-all.js`。
|
||||||
|
源码改动后需同步部署副本(本机 + WSL 两处,MD5 应与源码一致),重启 OpenCode 生效。
|
||||||
|
|
||||||
|
## 快捷键
|
||||||
|
|
||||||
|
| 快捷键 | 命令 | 说明 |
|
||||||
|
|--------|------|------|
|
||||||
|
| `Ctrl+o` | — | 打开会话选择器 |
|
||||||
|
| `/sessions-all` | `resume-all`, `continue-all` | 斜杠命令 |
|
||||||
|
| — | `/mcpon` `/mcpoff` | MCP 开关(对调 + 推送兜底 + 热重载) |
|
||||||
|
| — | 任意位置 `/home` 等 | CLI 菜单(`wsl-round-mcp/oc.py`)跳回顶层,见下节 |
|
||||||
|
|
||||||
|
## skill 推拉(wsl-round-mcp)
|
||||||
|
|
||||||
|
菜单入口 `oc.py`(任意位置 `/home` 回顶层),skill 管理简化为三动作,**输出路径用户不可选**:
|
||||||
|
|
||||||
|
- **推送 push**:只输入源路径(带引号自动剥离成对引号,单侧引号不动)→ 输出固定为 WSL OC skill 目录;
|
||||||
|
先只读探查同名文件夹:**不存在直接放,存在才询问是否覆盖**(`--force` 跳过确认)。
|
||||||
|
- **拉取 pull**:只输入 skill 名 → 输出固定为本机 `~/.config/opencode/skills`,同样探查式覆盖。
|
||||||
|
- **删除 delete**:`--local` 删本机 / 缺省删 WSL,`--yes` 跳过确认;不存在时报错退出码非 0。
|
||||||
|
- 覆盖与删除统一先移入 `<skills根>/.trash/<时间戳>/` 备份(点前缀目录,OC 不会当技能加载)。
|
||||||
|
- 推送前过标准 skill 校验(SKILL.md 结构等),不合规拒推。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
## 分支结构
|
||||||
|
|
||||||
|
| 分支 | 内容 |
|
||||||
|
| :---: | :---: |
|
||||||
|
| main | 主插件,会话切换、skill热加载、用户端mcp配置对调等 |
|
||||||
|
| ai-mcp | 大模型操作mcp配置对调的插件 |
|
||||||
|
| wsl-round-mcp | wsl和win之间的skill互推、mcp配置互推等 |
|
||||||
|
| compaction-cn | 让模型的压缩消息内容使用中文,提供常驻化中文引导,让模型用中文沟通 |
|
||||||
|
| oc-retry-guard | 流式错误引导,让模型报错时,自主接着干活 |
|
||||||
|
| model-manager | 大模型管理器,允许配置禁用(`/models`列表不显示)、启用(重新让模型出现在`/models`列表)和恢复出厂(抹除模型的key,让它重新进入/connet的连接通道) |
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
{
|
|
||||||
"停用清单": "持久化标记停用模型",
|
|
||||||
"以下是停用列表": [],
|
|
||||||
"配置格式": "一行一个,比如Xiaomi,deepseek等,*表示通配符,比如xiaomi-*则表示停用小米家族的全部模型(Xiaomi、Xiaomi-token-plan等)",
|
|
||||||
"示例参数":[
|
|
||||||
"xiaomi",
|
|
||||||
"DEEPSEEK",
|
|
||||||
"longcat-*"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -1,870 +0,0 @@
|
|||||||
// model-manager —— 模型目录管理 server 插件(2026-10-19)
|
|
||||||
//
|
|
||||||
// 背景:OC 新版把模型目录存在 SQLite 里:
|
|
||||||
// 数据库 : ~/.local/share/opencode/opencode.db (WAL 模式)
|
|
||||||
// 表 : kv
|
|
||||||
// key : models-dev:catalog
|
|
||||||
// value : JSON 字符串 { updatedAt, digest, body }
|
|
||||||
// body : 嵌套 JSON 字符串,结构 = provider → { id, npm, api, models: { modelID: entry } }
|
|
||||||
// /models 命令读的就是这份 catalog。
|
|
||||||
//
|
|
||||||
// 用户要求:选中某个模型后,能 ① 停用(不在 /models 显示) ② 完全删除(恢复出厂)。
|
|
||||||
//
|
|
||||||
// ── 技术难点 ──────────────────────────────────────────────────────────────
|
|
||||||
// OC 进程自己持有 opencode.db 的 SQLite 写锁(WAL 模式)。插件跑在 OC 内部,
|
|
||||||
// 若直接 require('better-sqlite3') 写同一文件 → 死锁 / SQLITE_BUSY。
|
|
||||||
// 因此采用「外部子进程执行 DB 写」方案:
|
|
||||||
// 1. OC 内部 daemon 每 5s 轮询 outbox 队列文件(普通 JSON 文件,不碰 DB)
|
|
||||||
// 2. 命中操作 → 把 SQLite 写 fork 给外部 Python 子进程执行(独立连接,安全)
|
|
||||||
// 3. 队列排空 → 写 flush 标记 → 由 TUI 侧 tui.js 执行 location.reload 热重载
|
|
||||||
// (2026-10-19 实测定论:V2 server 插件 ctx 没有 client.location / serverUrl,
|
|
||||||
// 进程内 reload 无通道 —— ai-mcp 2026-09-24 也报过同款「没有可用的 reload 通道」;
|
|
||||||
// 而 TUI 插件 ctx 的 client.location 实测可用,见 oc-session-all.log 的
|
|
||||||
// 「location.reload OK」。所以热重载的正主在 tui.js,本插件只负责写 DB + 发标记)
|
|
||||||
//
|
|
||||||
// ── 操作语义 ──────────────────────────────────────────────────────────────
|
|
||||||
// disable(provider, modelID) = 从 catalog body 里 DELETE provider.models[modelID]
|
|
||||||
// (/models 不再显示;数据仍在 catalog 备份可恢复)
|
|
||||||
// enable(provider, modelID) = 把 modelID 加回 provider.models(恢复显示)
|
|
||||||
// reset(provider) = 恢复出厂:把该 provider 的 models 换回出厂默认
|
|
||||||
// (出厂默认从 opencode.db.backup 读,或保留一份 pristine)
|
|
||||||
//
|
|
||||||
// ── 契约(与 ai-mcp / compaction-cn / oc-retry-guard 同款 V2 插件铁律)────
|
|
||||||
// - named export 必须是逗号表达式安全形态(返回 hooks 对象会崩)
|
|
||||||
// - default 导出 { id, setup },setup 必须返回 void、绝不抛
|
|
||||||
//
|
|
||||||
// 部署(三份同步 + node --experimental-strip-types 校验):
|
|
||||||
// 源码真源 : D:\桌面\源码\oc_session\model-manager\model-manager.ts
|
|
||||||
// 本机生效 : C:\Users\ypshidifu\.config\opencode\plugins\model-manager.ts
|
|
||||||
// WSL 生效 : /root/.config/opencode/plugins/model-manager.ts
|
|
||||||
|
|
||||||
import {
|
|
||||||
appendFileSync,
|
|
||||||
existsSync,
|
|
||||||
readFileSync,
|
|
||||||
writeFileSync,
|
|
||||||
readdirSync,
|
|
||||||
} from "node:fs"
|
|
||||||
import { spawn } from "node:child_process"
|
|
||||||
import { homedir } from "node:os"
|
|
||||||
import { join } from "node:path"
|
|
||||||
|
|
||||||
const LOG_FILE = join(homedir(), ".config", "opencode", "model-manager.log")
|
|
||||||
// outbox 队列(OC daemon 轮询;外部脚本 / 用户也可手动写这里触发操作)
|
|
||||||
const OUTBOX = join(homedir(), ".config", "opencode", "model-manager.outbox.jsonl")
|
|
||||||
// 出厂备份(reset 用:首次操作前自动把 pristine catalog 存一份)
|
|
||||||
const PRISTINE = join(homedir(), ".config", "opencode", "model-manager.pristine.json")
|
|
||||||
// flush 标记:daemon 把队列排空并写完 DB 后落盘 { at, lastOp, serverReload };
|
|
||||||
// tui.js 常驻 poller 轮询到新 at 就执行 location.reload(TUI 侧才有 reload 通道)
|
|
||||||
const FLUSH = join(homedir(), ".config", "opencode", "model-manager.last-flush.json")
|
|
||||||
// ── 持久化停用配置 cfg.json(2026-10-19 用户指路引进)──────────────────────
|
|
||||||
// 运行时路径:~/.config/opencode/model-manager.cfg.json(源码仓根目录 cfg.json 为模板)
|
|
||||||
// 只认一个键:「以下是停用列表」(键名不区分大小写),值 = provider 级数组,支持 * 通配;
|
|
||||||
// 其余键全是说明文档,全部忽略、写回时原样保留。
|
|
||||||
// 防御:opencode 更新可能删除我们的配置文件 → ensureCfg() 检测到丢失/损坏立即释放新配置,
|
|
||||||
// 并从镜像 STOPLIST_MIRROR 恢复停用列表(停用状态不因 OC 更新丢失)。
|
|
||||||
const CFG_PATH = join(homedir(), ".config", "opencode", "model-manager.cfg.json")
|
|
||||||
const STOPLIST_MIRROR = join(homedir(), ".config", "opencode", "model-manager.stoplist.bak.json")
|
|
||||||
const STOP_KEY = "以下是停用列表"
|
|
||||||
const CFG_TEMPLATE = `{
|
|
||||||
"停用清单": "持久化标记停用模型",
|
|
||||||
"以下是停用列表": [],
|
|
||||||
"配置格式": "一行一个,比如Xiaomi,deepseek等,*表示通配符,比如xiaomi-*则表示停用小米家族的全部模型(Xiaomi、Xiaomi-token-plan等)",
|
|
||||||
"示例参数":[
|
|
||||||
"xiaomi",
|
|
||||||
"DEEPSEEK",
|
|
||||||
"longcat-*"
|
|
||||||
]
|
|
||||||
}`
|
|
||||||
// modelID 级镜像清单(单模型停用的持久化;整厂级走 cfg 的停用列表)
|
|
||||||
const DISABLED_MIRROR = join(homedir(), ".config", "opencode", "model-manager.disabled.json")
|
|
||||||
// 已彻底删除的厂商列表(remove_provider 标记;面板据此显示「已删除·可找回」)
|
|
||||||
const REMOVED_LIST = join(homedir(), ".config", "opencode", "model-manager.removed.json")
|
|
||||||
// 数据库路径
|
|
||||||
const DB_PATH = join(homedir(), ".local", "share", "opencode", "opencode.db")
|
|
||||||
// SQLite 写执行器(Python,OC 外部子进程跑,绕 OC 自身 DB 写锁)
|
|
||||||
const DB_EXEC = join(homedir(), ".config", "opencode", "model-manager-db-exec.py")
|
|
||||||
|
|
||||||
// 热重载通道见 doReload()(抄 ai-mcp:SDK client.location.reload 三级降级 + ctx.serverUrl 兜底,
|
|
||||||
// 不再写死 4096 —— 用户环境 4096 无监听导致 reload 全挂、/models 不刷新)
|
|
||||||
|
|
||||||
function fileLog(msg: string) {
|
|
||||||
try {
|
|
||||||
appendFileSync(LOG_FILE, `[${new Date().toISOString()}] ${msg}\n`)
|
|
||||||
} catch {
|
|
||||||
/* 日志失败绝不阻塞 */
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── cfg.json:键名大小写不敏感定位「以下是停用列表」,其它键只保留不解读 ─────
|
|
||||||
function stopKeyOf(obj: any): string | null {
|
|
||||||
if (!obj || typeof obj !== "object") return null
|
|
||||||
const target = STOP_KEY.toLowerCase()
|
|
||||||
for (const k of Object.keys(obj)) {
|
|
||||||
if (String(k).toLowerCase() === target) return k
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
// 防御性保证:cfg 存在且可解析、含停用列表键;否则立即释放新配置
|
|
||||||
function ensureCfg(): void {
|
|
||||||
try {
|
|
||||||
if (existsSync(CFG_PATH)) {
|
|
||||||
const raw = readFileSync(CFG_PATH, "utf-8")
|
|
||||||
let j: any = null
|
|
||||||
try {
|
|
||||||
j = JSON.parse(raw)
|
|
||||||
} catch {
|
|
||||||
j = null
|
|
||||||
}
|
|
||||||
if (j && typeof j === "object" && !Array.isArray(j)) {
|
|
||||||
if (!stopKeyOf(j)) {
|
|
||||||
j[STOP_KEY] = []
|
|
||||||
writeFileSync(CFG_PATH, JSON.stringify(j, null, 2), "utf-8")
|
|
||||||
fileLog("cfg 配置缺少「" + STOP_KEY + "」键,已补上(其余说明键原样保留)")
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// 损坏:备份后重建
|
|
||||||
try {
|
|
||||||
writeFileSync(CFG_PATH + ".bad", raw, "utf-8")
|
|
||||||
} catch {}
|
|
||||||
fileLog("cfg 配置损坏,已备份 .bad,立即释放新配置")
|
|
||||||
} else {
|
|
||||||
fileLog("cfg 配置文件丢失(可能被 opencode 更新清除),立即释放新配置: " + CFG_PATH)
|
|
||||||
}
|
|
||||||
let restored: string[] = []
|
|
||||||
try {
|
|
||||||
if (existsSync(STOPLIST_MIRROR)) {
|
|
||||||
const m = JSON.parse(readFileSync(STOPLIST_MIRROR, "utf-8"))
|
|
||||||
if (Array.isArray(m)) restored = m.filter((x: any) => typeof x === "string")
|
|
||||||
}
|
|
||||||
} catch {}
|
|
||||||
const tpl: any = JSON.parse(CFG_TEMPLATE)
|
|
||||||
tpl[STOP_KEY] = restored
|
|
||||||
writeFileSync(CFG_PATH, JSON.stringify(tpl, null, 2), "utf-8")
|
|
||||||
fileLog(
|
|
||||||
"新配置已释放" + (restored.length ? ",停用列表从镜像恢复 " + restored.length + " 项" : "(停用列表为空)"),
|
|
||||||
)
|
|
||||||
} catch (e: any) {
|
|
||||||
fileLog("ensureCfg 异常: " + (e && e.message ? e.message : String(e)))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 读停用列表(只认目标键,大小写不敏感;其它键全部忽略)
|
|
||||||
function readStopList(): string[] {
|
|
||||||
ensureCfg()
|
|
||||||
try {
|
|
||||||
const j = JSON.parse(readFileSync(CFG_PATH, "utf-8"))
|
|
||||||
const k = stopKeyOf(j)
|
|
||||||
const v = k ? j[k] : null
|
|
||||||
if (Array.isArray(v)) return v.filter((x: any) => typeof x === "string" && x.trim())
|
|
||||||
if (typeof v === "string") return v.split(/[\n,]/).map((s: string) => s.trim()).filter(Boolean)
|
|
||||||
return []
|
|
||||||
} catch {
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 写回停用列表(只改目标键,其余说明键原样保留)+ 同步镜像(防 OC 更新删文件丢状态)
|
|
||||||
function writeStopList(list: string[]): void {
|
|
||||||
try {
|
|
||||||
ensureCfg()
|
|
||||||
const j = JSON.parse(readFileSync(CFG_PATH, "utf-8"))
|
|
||||||
const k = stopKeyOf(j) || STOP_KEY
|
|
||||||
j[k] = list
|
|
||||||
writeFileSync(CFG_PATH, JSON.stringify(j, null, 2), "utf-8")
|
|
||||||
try {
|
|
||||||
writeFileSync(STOPLIST_MIRROR, JSON.stringify(list, null, 2), "utf-8")
|
|
||||||
} catch {}
|
|
||||||
} catch (e: any) {
|
|
||||||
fileLog("停用列表写回失败: " + (e && e.message ? e.message : String(e)))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 通配匹配(大小写不敏感):pattern 支持 * → 正则 fullmatch
|
|
||||||
function patMatch(pattern: string, text: string): boolean {
|
|
||||||
const p = String(pattern).toLowerCase()
|
|
||||||
const t = String(text || "").toLowerCase()
|
|
||||||
if (!p) return false
|
|
||||||
if (p === t) return true
|
|
||||||
if (p.includes("*")) {
|
|
||||||
const re = new RegExp("^" + p.split("*").map((s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join(".*") + "$", "i")
|
|
||||||
return re.test(t)
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// 该 provider 是否被停用列表命中(id 或显示名 任一被精确/通配命中)
|
|
||||||
function stopListHits(list: string[], id: string, name?: string): boolean {
|
|
||||||
for (const pat of list) {
|
|
||||||
if (patMatch(pat, id)) return true
|
|
||||||
if (name && patMatch(pat, name)) return true
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── modelID 级镜像清单(单模型停用的持久化载体;与 cfg 的 provider 级列表互补)──
|
|
||||||
type MirrorMap = Record<string, string[]>
|
|
||||||
function loadMirror(): MirrorMap {
|
|
||||||
try {
|
|
||||||
if (!existsSync(DISABLED_MIRROR)) return {}
|
|
||||||
const j = JSON.parse(readFileSync(DISABLED_MIRROR, "utf-8"))
|
|
||||||
const out: MirrorMap = {}
|
|
||||||
for (const k of Object.keys(j || {})) {
|
|
||||||
if (Array.isArray(j[k])) out[k] = j[k].filter((x: any) => typeof x === "string")
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
} catch {
|
|
||||||
return {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
function saveMirror(map: MirrorMap): void {
|
|
||||||
try {
|
|
||||||
writeFileSync(DISABLED_MIRROR, JSON.stringify(map, null, 2), "utf-8")
|
|
||||||
} catch (e: any) {
|
|
||||||
fileLog("停用镜像写入失败: " + (e && e.message ? e.message : String(e)))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
function mirrorMark(provider: string, modelID: string): void {
|
|
||||||
const map = loadMirror()
|
|
||||||
const arr = map[provider] || []
|
|
||||||
if (!arr.includes(modelID)) {
|
|
||||||
arr.push(modelID)
|
|
||||||
map[provider] = arr.sort()
|
|
||||||
saveMirror(map)
|
|
||||||
fileLog(`停用镜像已记录 ${provider}/${modelID}(共 ${arr.length} 项,重启/刷新后自动压制)`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
function mirrorUnmark(provider: string, modelID: string): void {
|
|
||||||
const map = loadMirror()
|
|
||||||
if (!map[provider]) return
|
|
||||||
map[provider] = map[provider].filter((x) => x !== modelID)
|
|
||||||
if (!map[provider].length) delete map[provider]
|
|
||||||
saveMirror(map)
|
|
||||||
fileLog(`停用镜像已移除 ${provider}/${modelID}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 已删除厂商列表(面板显示「已删除·可找回」用)
|
|
||||||
function loadRemoved(): string[] {
|
|
||||||
try {
|
|
||||||
if (!existsSync(REMOVED_LIST)) return []
|
|
||||||
const j = JSON.parse(readFileSync(REMOVED_LIST, "utf-8"))
|
|
||||||
return Array.isArray(j) ? j.filter((x: any) => typeof x === "string") : []
|
|
||||||
} catch {
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
}
|
|
||||||
function saveRemoved(list: string[]): void {
|
|
||||||
try {
|
|
||||||
writeFileSync(REMOVED_LIST, JSON.stringify(list, null, 2), "utf-8")
|
|
||||||
} catch (e: any) {
|
|
||||||
fileLog("已删除厂商列表写入失败: " + (e && e.message ? e.message : String(e)))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 读 catalog(只读 SQLite,OC 自身持锁但只读不冲突;用 Python 子进程读保证一致)
|
|
||||||
async function readCatalog(): Promise<any | null> {
|
|
||||||
try {
|
|
||||||
const py = process.platform === "win32" ? "python" : "python3"
|
|
||||||
const r = spawn(
|
|
||||||
py,
|
|
||||||
[
|
|
||||||
"-c",
|
|
||||||
`
|
|
||||||
import sqlite3, json, sys, os
|
|
||||||
db = os.path.join(os.path.expanduser("~"), ".local", "share", "opencode", "opencode.db")
|
|
||||||
con = sqlite3.connect(db)
|
|
||||||
row = con.execute("SELECT value FROM kv WHERE key='models-dev:catalog'").fetchone()
|
|
||||||
con.close()
|
|
||||||
if not row:
|
|
||||||
sys.stdout.buffer.write(json.dumps({"error": "catalog row missing"}).encode("utf-8"))
|
|
||||||
sys.exit(0)
|
|
||||||
sys.stdout.buffer.write(row[0].encode("utf-8"))
|
|
||||||
`,
|
|
||||||
],
|
|
||||||
{
|
|
||||||
stdio: ["ignore", "pipe", "pipe"],
|
|
||||||
windowsHide: true, // 2026-10-20:Windows 下 spawn python 会弹黑色控制台窗,必须隐藏
|
|
||||||
env: { ...process.env, PYTHONIOENCODING: "utf-8", PYTHONUTF8: "1" },
|
|
||||||
},
|
|
||||||
)
|
|
||||||
let out = ""
|
|
||||||
r.stdout.on("data", (d) => (out += d.toString("utf-8")))
|
|
||||||
await new Promise((res) => r.on("close", res))
|
|
||||||
if (!out.trim()) return null
|
|
||||||
return JSON.parse(out.trim())
|
|
||||||
} catch (e: any) {
|
|
||||||
fileLog("readCatalog 异常(已吞掉): " + (e && e.message ? e.message : String(e)))
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 执行 DB 写(fork Python 子进程,独立 SQLite 连接)
|
|
||||||
async function execDbWrite(op: string, payload: any): Promise<string> {
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
// ★ 跨平台可执行名:WSL(Ubuntu 24.04) 无 "python" 只有 "python3",
|
|
||||||
// 写死 "python" 会 spawn-fail(Executable not found in $PATH)→ 停用/恢复全失效,
|
|
||||||
// 且 outbox 条目永不清队 → daemon 每 5s 重试失败 → 反复写 flush → TUI 反复 toast。
|
|
||||||
// 与 readCatalog / readCatalogTime 的分流保持一致(2026-10-20 截图实锤)。
|
|
||||||
const py = process.platform === "win32" ? "python" : "python3"
|
|
||||||
const r = spawn(py, [DB_EXEC, op, JSON.stringify(payload)], {
|
|
||||||
stdio: ["ignore", "pipe", "pipe"],
|
|
||||||
windowsHide: true, // 防 Windows 弹黑色控制台窗
|
|
||||||
})
|
|
||||||
let out = ""
|
|
||||||
let err = ""
|
|
||||||
r.stdout.on("data", (d) => (out += d))
|
|
||||||
r.stderr.on("data", (d) => (err += d))
|
|
||||||
r.on("close", (code) => {
|
|
||||||
resolve(out.trim() || `rc=${code}` + (err ? " err=" + err.trim() : ""))
|
|
||||||
})
|
|
||||||
r.on("error", (e) => resolve("spawn-fail " + e.message))
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// 热重载(进程内尽力通道,2026-10-19 实测:V2 server 插件 ctx 两条通道都没有,
|
|
||||||
// 保留尝试只为将来宿主补上 client/serverUrl 时能自动生效):
|
|
||||||
// 进程内 SDK client.location.reload 三种调用形态 → HTTP POST ctx.serverUrl /api/location/reload
|
|
||||||
// 正常路径见 processOutbox 尾部:写 flush 标记 → tui.js 用 TUI ctx 的 client.location 热重载
|
|
||||||
// V2 上下文可能有 client 属性:优先 ctx.client(若宿主提供),否则回退 ctx 本身(抄 ai-mcp pickClient)
|
|
||||||
function pickClient(ctx: any) {
|
|
||||||
if (!ctx) return ctx
|
|
||||||
if (ctx.client && (ctx.client.location || ctx.client.config)) return ctx.client
|
|
||||||
return ctx
|
|
||||||
}
|
|
||||||
async function doReload(ctx: any): Promise<boolean> {
|
|
||||||
const attempts: Array<() => Promise<any>> = []
|
|
||||||
const client = pickClient(ctx)
|
|
||||||
if (client && client.location && typeof client.location.reload === "function") {
|
|
||||||
attempts.push(() => client.location.reload({ location: undefined as any }))
|
|
||||||
attempts.push(() => client.location.reload({}))
|
|
||||||
attempts.push(() => client.location.reload())
|
|
||||||
}
|
|
||||||
const serverUrl = ctx && (ctx as any).serverUrl
|
|
||||||
if (serverUrl) {
|
|
||||||
attempts.push(async () => {
|
|
||||||
const res = await fetch(new URL("/api/location/reload", serverUrl), {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "content-type": "application/json" },
|
|
||||||
body: "{}",
|
|
||||||
})
|
|
||||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
|
||||||
return res
|
|
||||||
})
|
|
||||||
}
|
|
||||||
let lastErr: any = null
|
|
||||||
for (const fn of attempts) {
|
|
||||||
try {
|
|
||||||
await fn()
|
|
||||||
fileLog("hot reload 已触发(/models 应立即生效)")
|
|
||||||
return true
|
|
||||||
} catch (e: any) {
|
|
||||||
lastErr = e
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const msg = lastErr ? lastErr.message : "进程内无 reload 通道(V2 server ctx 无 client.location/serverUrl,已实测)"
|
|
||||||
fileLog("进程内 hot reload 未成功: " + msg + " → 接下来写 flush 标记,由 TUI 侧 tui.js 热重载(看 oc-session-all.log)")
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// 队列深度(只数行,不改文件)
|
|
||||||
function queueDepth(): number {
|
|
||||||
try {
|
|
||||||
if (!existsSync(OUTBOX)) return 0
|
|
||||||
return readFileSync(OUTBOX, "utf-8").split("\n").filter((l) => l.trim()).length
|
|
||||||
} catch {
|
|
||||||
return -1 // 读失败时按「未知」处理,宁可多等一轮
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 从 outbox 消费一条操作并执行
|
|
||||||
async function processOutbox(ctx: any): Promise<void> {
|
|
||||||
if (!existsSync(OUTBOX)) return
|
|
||||||
let lines: string[]
|
|
||||||
try {
|
|
||||||
lines = readFileSync(OUTBOX, "utf-8").split("\n").filter((l) => l.trim())
|
|
||||||
} catch {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (lines.length === 0) return
|
|
||||||
// 一次只处理第一条(避免并发写 DB)
|
|
||||||
const line = lines.shift()!
|
|
||||||
writeFileSync(OUTBOX, lines.join("\n")) // 清空已消费行
|
|
||||||
let op: string
|
|
||||||
let payload: any
|
|
||||||
try {
|
|
||||||
const obj = JSON.parse(line)
|
|
||||||
op = obj.op
|
|
||||||
payload = obj.payload || {}
|
|
||||||
} catch {
|
|
||||||
fileLog("outbox 行解析失败(已跳过): " + line.slice(0, 80))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
fileLog(`outbox 命中 op=${op} payload=${JSON.stringify(payload).slice(0, 120)}`)
|
|
||||||
|
|
||||||
// reset 前确保有 pristine 备份(恢复厂商要靠它把出厂模型拉回来)
|
|
||||||
if (op === "reset" || op === "remove_provider") {
|
|
||||||
if (!existsSync(PRISTINE)) {
|
|
||||||
const cat = await readCatalog()
|
|
||||||
if (cat) writeFileSync(PRISTINE, JSON.stringify(cat))
|
|
||||||
fileLog("pristine 备份已建立")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const res = await execDbWrite(op, payload)
|
|
||||||
const depth = queueDepth()
|
|
||||||
fileLog(`DB 写结果 op=${op}: ${res},队列剩余=${depth}`)
|
|
||||||
|
|
||||||
// ── 堵假成功(2026-10-20 修):DB 写失败时不维护状态、不写「成功 flush」──────
|
|
||||||
// 否则 TUI 侧照常热重载并 toast「已重载」,但 catalog 压根没改(模型安然无恙)。
|
|
||||||
const s = String(res)
|
|
||||||
const failed =
|
|
||||||
s.startsWith("unknown op") ||
|
|
||||||
s.startsWith("spawn-fail") ||
|
|
||||||
s.startsWith("Traceback") ||
|
|
||||||
/^rc=[1-9]/.test(s)
|
|
||||||
if (failed) {
|
|
||||||
fileLog(`✗ DB 写未生效 op=${op}: ${s.slice(0, 300)}`)
|
|
||||||
if (s.startsWith("unknown op")) {
|
|
||||||
ensureDbExec()
|
|
||||||
fileLog("已按当前插件版本重写 DB 执行器(磁盘为旧版本缺新 op),请重新执行该操作")
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
writeFileSync(
|
|
||||||
FLUSH,
|
|
||||||
JSON.stringify({ at: Date.now(), lastOp: op, serverReload: false, error: `DB 写未生效: ${s.slice(0, 160)}` }),
|
|
||||||
)
|
|
||||||
fileLog("已写「失败 flush」标记(TUI 会提示失败,不报已重载): tag=" + op)
|
|
||||||
} catch (e: any) {
|
|
||||||
fileLog("失败 flush 标记写入失败: " + (e && e.message ? e.message : String(e)))
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── 维护持久化停用状态(只有 DB 真实进入目标态才动)────────────────────
|
|
||||||
// provider 级 → cfg.json 的「以下是停用列表」(用户可见可手改)
|
|
||||||
// modelID 级 → model-manager.disabled.json 镜像
|
|
||||||
try {
|
|
||||||
const prov = payload.provider
|
|
||||||
if (op === "disable_all" && (s.startsWith("disabled_all ") || s.startsWith("already empty"))) {
|
|
||||||
const list = readStopList()
|
|
||||||
if (!list.some((p) => patMatch(p, prov))) {
|
|
||||||
list.push(prov)
|
|
||||||
writeStopList(list)
|
|
||||||
fileLog(`停用列表已记录整厂 ${prov}(cfg 共 ${list.length} 项,持久化)`)
|
|
||||||
}
|
|
||||||
} else if (op === "disable" && (s.startsWith("disabled ") || s.startsWith("model not present"))) {
|
|
||||||
mirrorMark(prov, payload.modelID)
|
|
||||||
} else if (op === "enable" && s.startsWith("enabled ")) {
|
|
||||||
mirrorUnmark(prov, payload.modelID)
|
|
||||||
} else if (op === "enable" && s.startsWith("model not in pristine")) {
|
|
||||||
mirrorUnmark(prov, payload.modelID)
|
|
||||||
} else if (op === "remove_provider" && (s.startsWith("removed_provider ") || s.startsWith("provider not found"))) {
|
|
||||||
// 恢复出厂设置(格式化)= 彻底删除该厂 → 记进 cfg 停用列表,OC 刷新 catalog 回潮时自动再压
|
|
||||||
const list = readStopList()
|
|
||||||
if (!list.some((p) => patMatch(p, prov))) {
|
|
||||||
list.push(prov)
|
|
||||||
writeStopList(list)
|
|
||||||
fileLog(`已删除厂商 ${prov},并记入 cfg 停用列表(cfg 共 ${list.length} 项,持久化)`)
|
|
||||||
}
|
|
||||||
const rm = loadRemoved()
|
|
||||||
if (!rm.includes(prov)) {
|
|
||||||
rm.push(prov)
|
|
||||||
saveRemoved(rm)
|
|
||||||
fileLog(`已删除厂商列表已记录 ${prov}(面板可显示「已删除·可找回」)`)
|
|
||||||
}
|
|
||||||
} else if (op === "reset" && (s.startsWith("reset ") || s.startsWith("provider not in pristine"))) {
|
|
||||||
// 恢复该厂商(撤销删除/停用):从停用列表摘除,单模型镜像清空
|
|
||||||
const list = readStopList()
|
|
||||||
const kept = list.filter((p) => !patMatch(p, prov))
|
|
||||||
const removed = list.length - kept.length
|
|
||||||
if (removed) {
|
|
||||||
writeStopList(kept)
|
|
||||||
fileLog(`恢复出厂 ${prov}:停用列表移除 ${removed} 项(命中该厂的通配项一并移除)`)
|
|
||||||
}
|
|
||||||
const map = loadMirror()
|
|
||||||
if (map[prov]) {
|
|
||||||
delete map[prov]
|
|
||||||
saveMirror(map)
|
|
||||||
fileLog(`恢复出厂 ${prov}:单模型停用镜像已清空`)
|
|
||||||
}
|
|
||||||
const rm = loadRemoved()
|
|
||||||
if (rm.includes(prov)) {
|
|
||||||
saveRemoved(rm.filter((x) => x !== prov))
|
|
||||||
fileLog(`恢复出厂 ${prov}:已从「已删除厂商」列表移除(可再被 /manage 看到)`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e: any) {
|
|
||||||
fileLog("停用状态维护异常(已吞掉): " + (e && e.message ? e.message : String(e)))
|
|
||||||
}
|
|
||||||
|
|
||||||
if (depth !== 0) {
|
|
||||||
// 还有排队的操作(如逐模型停用多条):先不 reload,等全部写完一次性热重载
|
|
||||||
fileLog("队列未排空,暂不 reload(等最后一条写完再一次性热重载)")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
await finishBatch(ctx, op)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 队列排空后的收尾:进程内 reload 尝试 + 落 flush 标记(TUI 常驻 poller 据此热重载)
|
|
||||||
async function finishBatch(ctx: any, tag: string): Promise<void> {
|
|
||||||
const serverReload = await doReload(ctx)
|
|
||||||
try {
|
|
||||||
writeFileSync(FLUSH, JSON.stringify({ at: Date.now(), lastOp: tag, serverReload: !!serverReload }))
|
|
||||||
fileLog("flush 标记已写(TUI 侧将据此触发热重载): tag=" + tag + " serverReload=" + !!serverReload)
|
|
||||||
} catch (e: any) {
|
|
||||||
fileLog("flush 标记写入失败: " + (e && e.message ? e.message : String(e)))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// SQLite 写执行器(Python,子进程独立连接)
|
|
||||||
// 注意:Windows 控制台默认 GBK,provider/modelID 可能含非 GBK 字符 → print 会 UnicodeEncodeError。
|
|
||||||
// 所有 stdout 输出统一走 sys.stdout.buffer.write(s.encode('utf-8'))。
|
|
||||||
const DB_EXEC_SRC = `
|
|
||||||
import sqlite3, json, sys, os, hashlib
|
|
||||||
from datetime import datetime
|
|
||||||
|
|
||||||
def out(s):
|
|
||||||
sys.stdout.buffer.write(str(s).encode("utf-8"))
|
|
||||||
|
|
||||||
DB = os.path.join(os.path.expanduser("~"), ".local", "share", "opencode", "opencode.db")
|
|
||||||
KEY = "models-dev:catalog"
|
|
||||||
PRISTINE = os.path.join(os.path.expanduser("~"), ".config", "opencode", "model-manager.pristine.json")
|
|
||||||
|
|
||||||
def load():
|
|
||||||
con = sqlite3.connect(DB, timeout=30)
|
|
||||||
row = con.execute("SELECT value FROM kv WHERE key=?", (KEY,)).fetchone()
|
|
||||||
con.close()
|
|
||||||
if not row:
|
|
||||||
out("catalog row missing"); sys.exit(0)
|
|
||||||
return json.loads(row[0])
|
|
||||||
|
|
||||||
def save(cat):
|
|
||||||
cat["updatedAt"] = int(datetime.now().timestamp() * 1000)
|
|
||||||
import hashlib as h
|
|
||||||
cat["digest"] = h.sha256(cat["body"].encode("utf-8")).hexdigest()
|
|
||||||
con = sqlite3.connect(DB, timeout=30)
|
|
||||||
con.execute(
|
|
||||||
"UPDATE kv SET value=?, time_updated=? WHERE key=?",
|
|
||||||
(json.dumps(cat, ensure_ascii=False, separators=(",", ":")), cat["updatedAt"], KEY),
|
|
||||||
)
|
|
||||||
con.commit()
|
|
||||||
con.close()
|
|
||||||
|
|
||||||
def get_provider_models(cat, provider):
|
|
||||||
body = json.loads(cat["body"])
|
|
||||||
p = body.get(provider)
|
|
||||||
if not p or "models" not in p:
|
|
||||||
return body, None
|
|
||||||
return body, p
|
|
||||||
|
|
||||||
op = sys.argv[1]
|
|
||||||
payload = json.loads(sys.argv[2])
|
|
||||||
provider = payload.get("provider", "")
|
|
||||||
model_id = payload.get("modelID", "")
|
|
||||||
|
|
||||||
if op == "disable":
|
|
||||||
cat = load()
|
|
||||||
body, p = get_provider_models(cat, provider)
|
|
||||||
if not p:
|
|
||||||
out("provider not found: " + provider); sys.exit(0)
|
|
||||||
if model_id in p["models"]:
|
|
||||||
p["models"].pop(model_id, None)
|
|
||||||
cat["body"] = json.dumps(body, ensure_ascii=False, separators=(",", ":"))
|
|
||||||
save(cat)
|
|
||||||
out("disabled " + provider + "/" + model_id)
|
|
||||||
else:
|
|
||||||
out("model not present (already disabled?): " + model_id)
|
|
||||||
|
|
||||||
elif op == "disable_all":
|
|
||||||
# 整厂下架:models 清空(恢复走 reset/enable 从 pristine 拉回)
|
|
||||||
cat = load()
|
|
||||||
body, p = get_provider_models(cat, provider)
|
|
||||||
if not p:
|
|
||||||
out("provider not found: " + provider); sys.exit(0)
|
|
||||||
n = len(p["models"])
|
|
||||||
if n == 0:
|
|
||||||
out("already empty: " + provider)
|
|
||||||
else:
|
|
||||||
p["models"] = {}
|
|
||||||
cat["body"] = json.dumps(body, ensure_ascii=False, separators=(",", ":"))
|
|
||||||
save(cat)
|
|
||||||
out("disabled_all " + provider + " n=" + str(n))
|
|
||||||
|
|
||||||
elif op == "enable":
|
|
||||||
# 从 pristine 恢复该 model
|
|
||||||
cat = load()
|
|
||||||
body, p = get_provider_models(cat, provider)
|
|
||||||
prist = json.load(open(PRISTINE, encoding="utf-8"))
|
|
||||||
pbody = json.loads(prist["body"])
|
|
||||||
pmodels = pbody.get(provider, {}).get("models", {})
|
|
||||||
if model_id in pmodels:
|
|
||||||
if p is None:
|
|
||||||
body[provider] = {"id": provider, "models": {model_id: pmodels[model_id]}}
|
|
||||||
else:
|
|
||||||
p["models"][model_id] = pmodels[model_id]
|
|
||||||
cat["body"] = json.dumps(body, ensure_ascii=False, separators=(",", ":"))
|
|
||||||
save(cat)
|
|
||||||
out("enabled " + provider + "/" + model_id)
|
|
||||||
else:
|
|
||||||
out("model not in pristine: " + model_id)
|
|
||||||
|
|
||||||
elif op == "reset":
|
|
||||||
# 整个 provider 的 models 换回 pristine
|
|
||||||
cat = load()
|
|
||||||
prist = json.load(open(PRISTINE, encoding="utf-8"))
|
|
||||||
body = json.loads(cat["body"])
|
|
||||||
pbody = json.loads(prist["body"])
|
|
||||||
if provider in pbody:
|
|
||||||
body[provider] = pbody[provider]
|
|
||||||
cat["body"] = json.dumps(body, ensure_ascii=False, separators=(",", ":"))
|
|
||||||
save(cat)
|
|
||||||
out("reset " + provider)
|
|
||||||
else:
|
|
||||||
out("provider not in pristine: " + provider)
|
|
||||||
|
|
||||||
elif op == "remove_provider":
|
|
||||||
# 真删除:把整个 provider 节点从 catalog body 里摘掉(/models 里该厂彻底消失)
|
|
||||||
# 恢复靠 reset(pristine 里有完整节点)
|
|
||||||
cat = load()
|
|
||||||
body = json.loads(cat["body"])
|
|
||||||
if provider not in body:
|
|
||||||
out("provider not found: " + provider); sys.exit(0)
|
|
||||||
n = len(body[provider].get("models", {}))
|
|
||||||
body.pop(provider, None)
|
|
||||||
cat["body"] = json.dumps(body, ensure_ascii=False, separators=(",", ":"))
|
|
||||||
save(cat)
|
|
||||||
out("removed_provider " + provider + " models=" + str(n))
|
|
||||||
|
|
||||||
else:
|
|
||||||
out("unknown op: " + op)
|
|
||||||
`
|
|
||||||
|
|
||||||
// 把 DB 执行器落盘(首次 setup 时写一次,幂等)
|
|
||||||
function ensureDbExec(): void {
|
|
||||||
// 内容感知落盘(2026-10-20 修):旧版只判 existsSync → 插件升级后磁盘残留旧执行器,
|
|
||||||
// 新 op(disable_all/remove_provider)打过去报 unknown op,DB 没写却照报「已重载」= 假成功。
|
|
||||||
try {
|
|
||||||
let cur: string | null = null
|
|
||||||
if (existsSync(DB_EXEC)) {
|
|
||||||
try {
|
|
||||||
cur = readFileSync(DB_EXEC, "utf-8")
|
|
||||||
} catch {
|
|
||||||
cur = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (cur !== DB_EXEC_SRC) {
|
|
||||||
writeFileSync(DB_EXEC, DB_EXEC_SRC, "utf-8")
|
|
||||||
fileLog(cur === null ? "DB 执行器已落盘 " + DB_EXEC : "DB 执行器已更新(磁盘为旧版本,已强制重写)")
|
|
||||||
}
|
|
||||||
} catch (e: any) {
|
|
||||||
fileLog("DB 执行器落盘失败: " + (e && e.message ? e.message : String(e)))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 出厂备份:setup 时就拍一次(幂等)。
|
|
||||||
// 为什么提前拍:若等到第一次 reset/remove_provider 才拍,那时的 catalog 可能已被
|
|
||||||
// 改过(模型被删过),拍到的就不是「出厂态」,恢复出来的会缺模型。
|
|
||||||
async function ensurePristine(): Promise<void> {
|
|
||||||
try {
|
|
||||||
if (existsSync(PRISTINE)) return
|
|
||||||
const cat = await readCatalog()
|
|
||||||
if (!cat || !cat.body) {
|
|
||||||
fileLog("pristine 暂未建立(catalog 读不到,稍后重试)")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
writeFileSync(PRISTINE, JSON.stringify(cat))
|
|
||||||
fileLog("pristine 出厂备份已建立(setup 阶段)")
|
|
||||||
} catch (e: any) {
|
|
||||||
fileLog("pristine 建立失败(已吞掉): " + (e && e.message ? e.message : String(e)))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 读 catalog 的 time_updated(单值,便宜;用于判断 OC 是否刷新覆盖过 catalog)
|
|
||||||
async function readCatalogTime(): Promise<number> {
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
try {
|
|
||||||
const py = process.platform === "win32" ? "python" : "python3"
|
|
||||||
const script =
|
|
||||||
"import sqlite3,os,sys;" +
|
|
||||||
"db=os.path.join(os.path.expanduser('~'),'.local','share','opencode','opencode.db');" +
|
|
||||||
"con=sqlite3.connect(db);" +
|
|
||||||
"row=con.execute(\"SELECT time_updated FROM kv WHERE key='models-dev:catalog'\").fetchone();" +
|
|
||||||
"con.close();" +
|
|
||||||
"sys.stdout.buffer.write(str(row[0] if row else 0).encode())"
|
|
||||||
const r = spawn(py, ["-c", script], { stdio: ["ignore", "pipe", "pipe"], windowsHide: true })
|
|
||||||
let out = ""
|
|
||||||
r.stdout.on("data", (d) => (out += d.toString("utf-8")))
|
|
||||||
r.on("close", () => resolve(parseInt(out.trim(), 10) || 0))
|
|
||||||
r.on("error", () => resolve(0))
|
|
||||||
} catch {
|
|
||||||
resolve(0)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── 对账重放:持久化停用的正主 ────────────────────────────────────────────
|
|
||||||
// OC 会从 models.dev 重新拉取并整体覆盖 kv catalog(2026-10-19 18:05 实测:
|
|
||||||
// updatedAt 被刷新、xiaomi 9 模型全部回潮)。本函数按
|
|
||||||
// ① cfg 的「以下是停用列表」(provider 级,通配)② modelID 级镜像
|
|
||||||
// 把回潮的停用项重新压掉,写完再 finishBatch 触发 TUI 热重载。
|
|
||||||
let recLastTime = -1
|
|
||||||
let recTicks = 0
|
|
||||||
async function reconcile(ctx: any): Promise<void> {
|
|
||||||
ensureCfg()
|
|
||||||
const stopList = readStopList()
|
|
||||||
const removed = loadRemoved() // 2026-10-20:已删除厂商也纳入压制(修 agnes 残留在 catalog)
|
|
||||||
const mirror = loadMirror()
|
|
||||||
const hasMirror = Object.values(mirror).some((ids) => ids.length > 0)
|
|
||||||
if (stopList.length === 0 && removed.length === 0 && !hasMirror) return
|
|
||||||
|
|
||||||
recTicks++
|
|
||||||
const t = await readCatalogTime()
|
|
||||||
const force = recTicks % 12 === 0 // 每 ~60s 强制全量对账一次,兜底 time_updated 不可靠
|
|
||||||
if (!force && t === recLastTime && t !== 0) return // catalog 没被写过 → 不花 6.5MB 解析
|
|
||||||
recLastTime = t
|
|
||||||
|
|
||||||
const cat = await readCatalog()
|
|
||||||
if (!cat || !cat.body) return
|
|
||||||
let body: any
|
|
||||||
try {
|
|
||||||
body = typeof cat.body === "string" ? JSON.parse(cat.body) : cat.body
|
|
||||||
} catch {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const plan: Array<{ op: string; payload: any }> = []
|
|
||||||
for (const id of Object.keys(body)) {
|
|
||||||
const p = body[id] || {}
|
|
||||||
const name = p.name || ""
|
|
||||||
const models = p.models || {}
|
|
||||||
// cfg 停用列表 或 removed.json 命中 → 真删除厂商节点(不能只清空 models:
|
|
||||||
// OC 刷新 catalog 会把 provider 节点连同模型一起加回来,只有摘掉节点才算真删除)
|
|
||||||
const inRemoved = removed.includes(id)
|
|
||||||
if ((stopList.length && stopListHits(stopList, id, name)) || inRemoved) {
|
|
||||||
plan.push({ op: "remove_provider", payload: { provider: id } })
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
// modelID 级镜像命中 → 只摘掉那一个模型
|
|
||||||
const ids = mirror[id]
|
|
||||||
if (ids && ids.length) {
|
|
||||||
for (const mid of ids) {
|
|
||||||
if (models[mid]) plan.push({ op: "disable", payload: { provider: id, modelID: mid } })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (plan.length === 0) return
|
|
||||||
|
|
||||||
fileLog(`回潮检测:OC 刷新 catalog 使 ${plan.length} 项压制失效,开始重放(cfg=${stopList.length} + removed=${removed.length} + 镜像 modelID 级)`)
|
|
||||||
let okCount = 0
|
|
||||||
for (const step of plan) {
|
|
||||||
const r = await execDbWrite(step.op, step.payload)
|
|
||||||
fileLog(`重放 ${step.op} ${JSON.stringify(step.payload)}: ${r}`)
|
|
||||||
// ★ 堵假成功(2026-10-20 截图实锤):重放失败绝不能写成功 flush。
|
|
||||||
// 否则 WSL 侧 spawn-fail(无 python)→ catalog 从未真改 → 每 60s force 轮
|
|
||||||
// 又发现"回潮" → 又失败 → 又 flush → TUI 每 60s reload + toast 死循环。
|
|
||||||
const rs = String(r)
|
|
||||||
const stepFailed =
|
|
||||||
rs.startsWith("unknown op") ||
|
|
||||||
rs.startsWith("spawn-fail") ||
|
|
||||||
rs.startsWith("Traceback") ||
|
|
||||||
/^rc=[1-9]/.test(rs)
|
|
||||||
if (!stepFailed) okCount++
|
|
||||||
}
|
|
||||||
if (okCount === 0) {
|
|
||||||
fileLog("✗ reconcile 重放全部失败(DB 一个都没改成),不写 flush、不触发 TUI 重载")
|
|
||||||
recLastTime = await readCatalogTime() // 防止下一轮立刻重试刷屏;等 catalog 真变化再试
|
|
||||||
return
|
|
||||||
}
|
|
||||||
recLastTime = await readCatalogTime()
|
|
||||||
await finishBatch(ctx, "reconcile")
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ModelManager: any = async (_ctx: any) => (await ({} as any), undefined as any)
|
|
||||||
|
|
||||||
export default {
|
|
||||||
id: "model-manager",
|
|
||||||
async setup(ctx: any): Promise<void> {
|
|
||||||
try {
|
|
||||||
fileLog("model-manager setup 启动")
|
|
||||||
// ctx 形态诊断(一次性):回答「为什么进程内 reload 无通道」——
|
|
||||||
// 2026-10-19 实测 keys 里没有 client/serverUrl/location,热重载只能靠 TUI 侧
|
|
||||||
try {
|
|
||||||
const c: any = ctx
|
|
||||||
fileLog(
|
|
||||||
"setup ctx 诊断: keys=" +
|
|
||||||
JSON.stringify(Object.keys(c || {})) +
|
|
||||||
" | client=" + typeof (c && c.client) +
|
|
||||||
" | location=" + typeof (c && c.location) +
|
|
||||||
" | serverUrl=" + typeof (c && c.serverUrl) +
|
|
||||||
" | app=" + typeof (c && c.app),
|
|
||||||
)
|
|
||||||
} catch {
|
|
||||||
/* 诊断失败不影响功能 */
|
|
||||||
}
|
|
||||||
ensureDbExec()
|
|
||||||
ensureCfg() // 防御:OC 更新删掉 cfg 时立即释放新配置(含停用列表镜像恢复)
|
|
||||||
await ensurePristine() // 出厂备份提前拍,避免首次操作时 catalog 已被改
|
|
||||||
|
|
||||||
// 后台 daemon:每 5s 轮询 outbox(普通文件读写,不碰 DB 锁)+ 对账压制回潮
|
|
||||||
// 2026-10-20 修 daemon 泄漏:每次 location.reload 都会重建 ctx 并重跑 setup,
|
|
||||||
// 旧 daemon 不销毁就会累积(N 个 daemon 每 5s 各 spawn 一次 python → cmd 弹窗刷屏)。
|
|
||||||
// 全进程只保留一个 daemon:新 setup 启动前先停掉上一个。
|
|
||||||
const g: any = globalThis as any
|
|
||||||
if (g.__mmDaemonHandle) {
|
|
||||||
try {
|
|
||||||
clearInterval(g.__mmDaemonHandle)
|
|
||||||
fileLog("旧 daemon 已停止(setup 重建,避免多 daemon 并发弹窗)")
|
|
||||||
} catch {}
|
|
||||||
g.__mmDaemonHandle = null
|
|
||||||
}
|
|
||||||
let stopped = false
|
|
||||||
const daemon = setInterval(async () => {
|
|
||||||
if (stopped) return
|
|
||||||
try {
|
|
||||||
await processOutbox(ctx)
|
|
||||||
} catch (e: any) {
|
|
||||||
fileLog("daemon tick 异常(已吞掉): " + (e && e.message ? e.message : String(e)))
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
await reconcile(ctx)
|
|
||||||
} catch (e: any) {
|
|
||||||
fileLog("reconcile tick 异常(已吞掉): " + (e && e.message ? e.message : String(e)))
|
|
||||||
}
|
|
||||||
}, 5000)
|
|
||||||
g.__mmDaemonHandle = daemon
|
|
||||||
;(ctx as any).__modelManagerDaemon = daemon // 挂到 ctx,便于外部清理
|
|
||||||
|
|
||||||
// 暴露操作入口(供 TUI 插件 / 外部脚本调用)
|
|
||||||
;(ctx as any).modelManager = {
|
|
||||||
async disable(provider: string, modelID: string) {
|
|
||||||
const line = JSON.stringify({ op: "disable", payload: { provider, modelID }, at: Date.now() })
|
|
||||||
appendFileSync(OUTBOX, line + "\n")
|
|
||||||
fileLog("disable 入队 " + provider + "/" + modelID)
|
|
||||||
},
|
|
||||||
async disableAll(provider: string) {
|
|
||||||
const line = JSON.stringify({ op: "disable_all", payload: { provider }, at: Date.now() })
|
|
||||||
appendFileSync(OUTBOX, line + "\n")
|
|
||||||
fileLog("disable_all 入队 " + provider)
|
|
||||||
},
|
|
||||||
async enable(provider: string, modelID: string) {
|
|
||||||
const line = JSON.stringify({ op: "enable", payload: { provider, modelID }, at: Date.now() })
|
|
||||||
appendFileSync(OUTBOX, line + "\n")
|
|
||||||
fileLog("enable 入队 " + provider + "/" + modelID)
|
|
||||||
},
|
|
||||||
async reset(provider: string) {
|
|
||||||
const line = JSON.stringify({ op: "reset", payload: { provider }, at: Date.now() })
|
|
||||||
appendFileSync(OUTBOX, line + "\n")
|
|
||||||
fileLog("reset 入队 " + provider)
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
fileLog("model-manager daemon 已启动(outbox 轮询 5s)")
|
|
||||||
} catch (e: any) {
|
|
||||||
fileLog("setup 异常(已吞掉,不影响其它插件): " + (e && e.message ? e.message : String(e)))
|
|
||||||
}
|
|
||||||
// setup 必须返回 void
|
|
||||||
return
|
|
||||||
},
|
|
||||||
}
|
|
||||||
Generated
+422
@@ -0,0 +1,422 @@
|
|||||||
|
{
|
||||||
|
"name": "oc-session-all",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"lockfileVersion": 3,
|
||||||
|
"requires": true,
|
||||||
|
"packages": {
|
||||||
|
"": {
|
||||||
|
"name": "oc-session-all",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@opencode-ai/plugin": "latest"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"typescript": "^5.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@ai-sdk/provider": {
|
||||||
|
"version": "3.0.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/@ai-sdk/provider/-/provider-3.0.8.tgz",
|
||||||
|
"integrity": "sha512-oGMAgGoQdBXbZqNG0Ze56CHjDZ1IDYOwGYxYjO5KLSlz5HiNQ9udIXsPZ61VWaHGZ5XW/jyjmr6t2xz2jGVwbQ==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"json-schema": "^0.4.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@msgpackr-extract/msgpackr-extract-darwin-arm64": {
|
||||||
|
"version": "3.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.4.tgz",
|
||||||
|
"integrity": "sha512-LCkGo6JDfaBhgST7UpPWgNgLINpcpabaHfyz5OBx75nUYxBsaEPxjnyNjWpeb/xBup/682QnBfRBy2/LvPutZQ==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/@msgpackr-extract/msgpackr-extract-darwin-x64": {
|
||||||
|
"version": "3.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-x64/-/msgpackr-extract-darwin-x64-3.0.4.tgz",
|
||||||
|
"integrity": "sha512-zExlW9zUJKZH/tOtVMttwjKa4Xm/3KcNjnE3dPN92uCktwavMxpgCA3MoJK/DOnTWsQgo224OaST27/mPNAf+w==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/@msgpackr-extract/msgpackr-extract-linux-arm": {
|
||||||
|
"version": "3.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm/-/msgpackr-extract-linux-arm-3.0.4.tgz",
|
||||||
|
"integrity": "sha512-Tg3yX65f5GbtXLkrYEHE5oibZG9epyYWas7FogTTEJeDEF9JlXJzKgXaNhT3UXlTOeA+AfZpYZYZ0uPj7Cfquw==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/@msgpackr-extract/msgpackr-extract-linux-arm64": {
|
||||||
|
"version": "3.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm64/-/msgpackr-extract-linux-arm64-3.0.4.tgz",
|
||||||
|
"integrity": "sha512-dgX0P/9wGPJeHFBG+ZmhgE6bmtMt7NP5CRBGyyktpopdk/mW4POnrpQsSLtKI1dwpc+pPLuXHDh6vvskyQE/sw==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/@msgpackr-extract/msgpackr-extract-linux-x64": {
|
||||||
|
"version": "3.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-x64/-/msgpackr-extract-linux-x64-3.0.4.tgz",
|
||||||
|
"integrity": "sha512-8TNXMEjJc3QEy7R/x1INhgiU+XakDAFUzBhaz7+Rbrs8NH5UQeHQxxmzsSBJGyV6I1jW79undiQm8tOI+D+8FQ==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/@msgpackr-extract/msgpackr-extract-win32-x64": {
|
||||||
|
"version": "3.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-win32-x64/-/msgpackr-extract-win32-x64-3.0.4.tgz",
|
||||||
|
"integrity": "sha512-CmCXPQrkbwExx3j946/PtHWHbYJiCRBRDl4BlkRQcJB/YOwQxJRTpoo7aTsortjgoJ1x7opzTSxn7C+ASSLVjQ==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/@opencode-ai/plugin": {
|
||||||
|
"version": "1.18.31",
|
||||||
|
"resolved": "https://registry.npmjs.org/@opencode-ai/plugin/-/plugin-1.18.31.tgz",
|
||||||
|
"integrity": "sha512-Rdc1bPK06PByaGyGd0kf7JUZ4pTkexz2OOUNlqZWLpHOiMEZ+/rFGjt46ypZ3QwA697gNdWwwwQbHbKG5NMwGA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@ai-sdk/provider": "3.0.8",
|
||||||
|
"@opencode-ai/sdk": "1.18.31",
|
||||||
|
"effect": "4.0.0-beta.83",
|
||||||
|
"zod": "4.1.8"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@opentui/core": ">=0.4.5",
|
||||||
|
"@opentui/keymap": ">=0.4.5",
|
||||||
|
"@opentui/solid": ">=0.4.5"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@opentui/core": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@opentui/keymap": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@opentui/solid": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@opencode-ai/sdk": {
|
||||||
|
"version": "1.18.31",
|
||||||
|
"resolved": "https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.18.31.tgz",
|
||||||
|
"integrity": "sha512-Raouthf8Lhe9edjvYeeSK7SgvdoU6bBjH9qV3f70dHoa6h+z0X2TMz/e22/wKp/StlFUZ4kIRpYYxFnY8/k01w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"cross-spawn": "7.0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@standard-schema/spec": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
|
||||||
|
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/cross-spawn": {
|
||||||
|
"version": "7.0.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||||
|
"integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"path-key": "^3.1.0",
|
||||||
|
"shebang-command": "^2.0.0",
|
||||||
|
"which": "^2.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/detect-libc": {
|
||||||
|
"version": "2.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
||||||
|
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/effect": {
|
||||||
|
"version": "4.0.0-beta.83",
|
||||||
|
"resolved": "https://registry.npmjs.org/effect/-/effect-4.0.0-beta.83.tgz",
|
||||||
|
"integrity": "sha512-0wsak8RtgGAr9UWSbVDgJHZcUqMSvicHcvaZv1MbMM7MCGgW4Rn/137J1MHQbwYPcwYGxT/IqehFd+UbYuj78w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@standard-schema/spec": "^1.1.0",
|
||||||
|
"fast-check": "^4.8.0",
|
||||||
|
"find-my-way-ts": "^0.1.6",
|
||||||
|
"ini": "^7.0.0",
|
||||||
|
"kubernetes-types": "^1.30.0",
|
||||||
|
"msgpackr": "^2.0.1",
|
||||||
|
"multipasta": "^0.2.7",
|
||||||
|
"toml": "^4.1.1",
|
||||||
|
"uuid": "^14.0.0",
|
||||||
|
"yaml": "^2.9.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/fast-check": {
|
||||||
|
"version": "4.10.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/fast-check/-/fast-check-4.10.2.tgz",
|
||||||
|
"integrity": "sha512-iK2f+YrcmoeGqk6fA0ea2bptcu/itMIm4NfEozq6N25+aG6h7s5HZbB/k1aV7b5w5sFLMCbbtRUsTVR+BgC3xw==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "individual",
|
||||||
|
"url": "https://github.com/sponsors/dubzzz"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/fast-check"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"pure-rand": "^8.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12.17.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/find-my-way-ts": {
|
||||||
|
"version": "0.1.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/find-my-way-ts/-/find-my-way-ts-0.1.6.tgz",
|
||||||
|
"integrity": "sha512-a85L9ZoXtNAey3Y6Z+eBWW658kO/MwR7zIafkIUPUMf3isZG0NCs2pjW2wtjxAKuJPxMAsHUIP4ZPGv0o5gyTA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/ini": {
|
||||||
|
"version": "7.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/ini/-/ini-7.0.0.tgz",
|
||||||
|
"integrity": "sha512-ifK0CgjALofS5bkrcTy4RaQ9Vx2Knf/eLeIO+NaswQEpH1UblrtTSCIvN71qQDMq0PeQ/SSPojvEJp9vvvfr+w==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": "^22.22.2 || ^24.15.0 || >=26.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/isexe": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
|
"node_modules/json-schema": {
|
||||||
|
"version": "0.4.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz",
|
||||||
|
"integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==",
|
||||||
|
"license": "(AFL-2.1 OR BSD-3-Clause)"
|
||||||
|
},
|
||||||
|
"node_modules/kubernetes-types": {
|
||||||
|
"version": "1.30.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/kubernetes-types/-/kubernetes-types-1.30.0.tgz",
|
||||||
|
"integrity": "sha512-Dew1okvhM/SQcIa2rcgujNndZwU8VnSapDgdxlYoB84ZlpAD43U6KLAFqYo17ykSFGHNPrg0qry0bP+GJd9v7Q==",
|
||||||
|
"license": "Apache-2.0"
|
||||||
|
},
|
||||||
|
"node_modules/msgpackr": {
|
||||||
|
"version": "2.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/msgpackr/-/msgpackr-2.1.0.tgz",
|
||||||
|
"integrity": "sha512-p/pBCVO63CsvvpkomUnNNag6+n38rULuDA6HHe70o2gtC8ODI52foF/4ko2qQcp6OiErJXTmrZeXmsGGHsIQNQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"optionalDependencies": {
|
||||||
|
"msgpackr-extract": "^3.0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/msgpackr-extract": {
|
||||||
|
"version": "3.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/msgpackr-extract/-/msgpackr-extract-3.0.4.tgz",
|
||||||
|
"integrity": "sha512-4kmO/MdyUIkLIvTPr8VHLil4AtoKIoniWPIEk5+CDy0xnWC84azhSFmuJ7PxZdsYtiP5kEeQsORAVIeMgxT+Hw==",
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"node-gyp-build-optional-packages": "5.2.2"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"download-msgpackr-prebuilds": "bin/download-prebuilds.js"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.4",
|
||||||
|
"@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.4",
|
||||||
|
"@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.4",
|
||||||
|
"@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.4",
|
||||||
|
"@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.4",
|
||||||
|
"@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/multipasta": {
|
||||||
|
"version": "0.2.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/multipasta/-/multipasta-0.2.8.tgz",
|
||||||
|
"integrity": "sha512-ZPWuMKyv0cSO29f7hozp+k6+crZbQijV8ipMvxNxRf2SwtYGTX1ZX89Kd20VV4H9Znonx+EQn+iy1wGQsJ+b+Q==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/node-gyp-build-optional-packages": {
|
||||||
|
"version": "5.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.2.2.tgz",
|
||||||
|
"integrity": "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"detect-libc": "^2.0.1"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"node-gyp-build-optional-packages": "bin.js",
|
||||||
|
"node-gyp-build-optional-packages-optional": "optional.js",
|
||||||
|
"node-gyp-build-optional-packages-test": "build-test.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/path-key": {
|
||||||
|
"version": "3.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
|
||||||
|
"integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/pure-rand": {
|
||||||
|
"version": "8.4.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-8.4.2.tgz",
|
||||||
|
"integrity": "sha512-vvuOGgcuPJAirlHvuQw1TrOiw7ptaIXXmIbNuiNOY6lNGJJH49PQ1Kj4nd783nPdQhQdicgOjVI2yI/9BD6/Ng==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "individual",
|
||||||
|
"url": "https://github.com/sponsors/dubzzz"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/fast-check"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/shebang-command": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"shebang-regex": "^3.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/shebang-regex": {
|
||||||
|
"version": "3.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
|
||||||
|
"integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/toml": {
|
||||||
|
"version": "4.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/toml/-/toml-4.3.0.tgz",
|
||||||
|
"integrity": "sha512-lVb8X9BsPVuH0M4BKeS91tXAmJvCjQ5UIyAbQFaxkKGyUFK2RPkhwaFSQH8vbpl1d23eu/IBH+dwVMHWaq9A5A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/typescript": {
|
||||||
|
"version": "5.9.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||||
|
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"bin": {
|
||||||
|
"tsc": "bin/tsc",
|
||||||
|
"tsserver": "bin/tsserver"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.17"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/uuid": {
|
||||||
|
"version": "14.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/uuid/-/uuid-14.0.2.tgz",
|
||||||
|
"integrity": "sha512-xZe/16rV4aa+HGSOCiY2YeLT1OybRLrrkL/Rqaq7p7GMVXjFh+6wN4oMYgjFmnSnhY8t6Xpdl2l9qmnHYuMHwQ==",
|
||||||
|
"funding": [
|
||||||
|
"https://github.com/sponsors/broofa",
|
||||||
|
"https://github.com/sponsors/ctavan"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"uuid": "dist-node/bin/uuid"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/which": {
|
||||||
|
"version": "2.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||||
|
"integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"isexe": "^2.0.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"node-which": "bin/node-which"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/yaml": {
|
||||||
|
"version": "2.9.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.1.tgz",
|
||||||
|
"integrity": "sha512-3NxN8+78OdzbT7C/WjGsyfPAtJaN3FNDsWxv7Y7mcDsT/oOmgW8BpyQQFFBnvZE3j9Y2Sdz1ULFLezL7Eb2yFw==",
|
||||||
|
"license": "ISC",
|
||||||
|
"bin": {
|
||||||
|
"yaml": "bin.mjs"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 14.6"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/eemeli"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/zod": {
|
||||||
|
"version": "4.1.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/zod/-/zod-4.1.8.tgz",
|
||||||
|
"integrity": "sha512-5R1P+WwQqmmMIEACyzSvo4JXHY5WiAFHRMg+zBZKgKS+Q1viRa0C1hmUKtHltoIFKtIdki3pRxkmpP74jnNYHQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/colinhacks"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"name": "oc-session-all",
|
||||||
|
"version": "2.1.0",
|
||||||
|
"description": "OpenCode v2 TUI plugin: browse and switch to any session across all projects",
|
||||||
|
"main": "tui.js",
|
||||||
|
"type": "module",
|
||||||
|
"exports": {
|
||||||
|
".": "./tui.js",
|
||||||
|
"./tui": "./tui.js"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"test": "echo \"Error: no test specified\" && exit 1"
|
||||||
|
},
|
||||||
|
"keywords": [
|
||||||
|
"opencode",
|
||||||
|
"tui",
|
||||||
|
"plugin",
|
||||||
|
"session",
|
||||||
|
"picker"
|
||||||
|
],
|
||||||
|
"author": "",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"opencode": ">=2.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,96 +0,0 @@
|
|||||||
# model-manager
|
|
||||||
|
|
||||||
模型目录管理插件(OpenCode TUI):停用/恢复/恢复出厂。
|
|
||||||
|
|
||||||
## 背景
|
|
||||||
|
|
||||||
OpenCode 的 `/models` 命令读取模型目录,新版目录存在 **SQLite 数据库**里:
|
|
||||||
|
|
||||||
- DB 文件:`~/.local/share/opencode/opencode.db`(WAL 模式)
|
|
||||||
- 表:`kv`
|
|
||||||
- key:`models-dev:catalog`
|
|
||||||
- value:JSON 字符串 `{updatedAt, digest, body}`,其中 `body` 本身又是嵌套 JSON 字符串:
|
|
||||||
```
|
|
||||||
{ providerID: { id, npm, api, models: { modelID: entry, ... }, ... }, ... }
|
|
||||||
```
|
|
||||||
|
|
||||||
`/models` 渲染的就是这份 catalog。要"停用某模型"(不在 `/models` 显示)或"恢复出厂"(还原所有被停用的模型),就得改这份 DB。
|
|
||||||
|
|
||||||
## 难点:OC 进程持有 SQLite 写锁
|
|
||||||
|
|
||||||
OC 的 main 进程常驻打开这个 DB(WAL 模式)。插件跑在 OC 内部(Bun 运行时),如果在插件进程里直接 `better-sqlite3` 写同一个文件,会撞上 OC 主进程的写锁 → `SQLITE_BUSY` / 死锁。
|
|
||||||
|
|
||||||
**解法**:插件不直接写 DB,而是走 **outbox 队列 + fork 外部 Python 子进程** 执行 DB 写:
|
|
||||||
|
|
||||||
1. 插件收到操作请求(CLI / 外部脚本触发)→ 写一行 JSON 到 `~/.config/opencode/model-manager.outbox.jsonl`(outbox 队列)
|
|
||||||
2. 插件后台 daemon(`setup` 里 spawn,每 5s 轮询)发现新行 → fork `python` 子进程执行 `model-manager-db-exec.py`(OC `setup` 时自动落盘到 `~/.config/opencode/`)
|
|
||||||
3. Python 子进程用 `sqlite3` 标准库(Python 对 WAL 模式无独占锁问题)读写 DB
|
|
||||||
4. 写完后调 `POST http://127.0.0.1:4096/api/location/reload` 让 OC 热重载目录
|
|
||||||
5. 结果回写到 outbox 的响应字段,daemon 记录日志
|
|
||||||
|
|
||||||
## 三种操作
|
|
||||||
|
|
||||||
| 操作 | 含义 | 实现 |
|
|
||||||
|---|---|---|
|
|
||||||
| `disable` | 停用某模型(不在 `/models` 显示) | 在 `body` 的对应 provider.models 里把该 modelID 条目标记 `disabled:true`(或移出 models 字典,视 catalog schema) |
|
|
||||||
| `enable` | 恢复某被停用的模型 | 清除 `disabled` 标记 / 恢复出厂 entry |
|
|
||||||
| `reset` | 恢复出厂(全部模型还原) | 用首次启用时自动拍的 `~/.config/opencode/model-manager.pristine.json` 备份覆盖 DB catalog |
|
|
||||||
|
|
||||||
## 触发方式
|
|
||||||
|
|
||||||
### 方式一:TUI 命令 `/manage`(推荐)
|
|
||||||
|
|
||||||
OC TUI 里输入 `/manage`(别名 `/managent`)打开模型管理面板,操作不直接改 DB,而是写 outbox 队列,由本插件 daemon 消费:
|
|
||||||
|
|
||||||
- 第一层:选 provider(顶置「恢复出厂」全局项)
|
|
||||||
- 第二层:对该 provider 选 停用 / 启用 / 恢复出厂
|
|
||||||
- 选中模型 → 选 停用 / 启用
|
|
||||||
|
|
||||||
命令注册在 `tui-plugins/oc-session-all.js`(`model.manage` 命令),写的是同一个 outbox 文件,daemon 统一消费。
|
|
||||||
|
|
||||||
### 方式二:外部脚本写 outbox
|
|
||||||
|
|
||||||
直接写一行 JSON 到 `~/.config/opencode/model-manager.outbox.jsonl`:
|
|
||||||
|
|
||||||
```jsonl
|
|
||||||
{"op":"disable","payload":{"provider":"anthropic","modelID":"claude-opus-4.6"},"ts":1700000000}
|
|
||||||
{"op":"enable","payload":{"provider":"anthropic","modelID":"claude-opus-4.6"},"ts":1700000000}
|
|
||||||
{"op":"reset","payload":{"provider":"anthropic"},"ts":1700000000}
|
|
||||||
```
|
|
||||||
|
|
||||||
(daemon 5s 轮询,操作有最多 5s 延迟。)
|
|
||||||
|
|
||||||
## 日志
|
|
||||||
|
|
||||||
`~/.config/opencode/model-manager.log`
|
|
||||||
|
|
||||||
关键行:
|
|
||||||
|
|
||||||
- `OUTBOX 收到 op=disable provider=anthropic model=claude-opus-4.6`
|
|
||||||
- `DB 写成功,digest 更新`
|
|
||||||
- `reload 200 热重载完成`
|
|
||||||
- `reset 已还原 pristine 备份`
|
|
||||||
- `pristine 备份已建立`(首次启用时)
|
|
||||||
|
|
||||||
## 部署
|
|
||||||
|
|
||||||
| 端 | 路径 |
|
|
||||||
|---|---|
|
|
||||||
| Windows 本机 | `~/.config/opencode/plugins/model-manager.ts` |
|
|
||||||
| WSL | `/root/.config/opencode/plugins/model-manager.ts` |
|
|
||||||
|
|
||||||
依赖:Python 3(用 `sqlite3` 标准库,无需 `pip install`)。
|
|
||||||
|
|
||||||
由 `tools/sync_wsl.py` 的 ITEMS 清单管理。
|
|
||||||
|
|
||||||
## 已知取舍
|
|
||||||
|
|
||||||
1. **不直接写 DB**:OC 主进程持写锁,插件进程内直接 `better-sqlite3` 会 `SQLITE_BUSY`。outbox + fork Python 是绕开锁的可靠路径。
|
|
||||||
2. **reset 依赖 pristine 备份**:首次启用时自动拍 `model-manager.pristine.json`;若该文件被手动删了,`reset` 会失败(日志报 `pristine not found`),需重新拍一份。
|
|
||||||
3. **5s 轮询**:daemon 每 5s 扫一次 outbox,操作有最多 5s 延迟。实为 fire-and-forget,不影响 OC 主流程。
|
|
||||||
4. **目录 reload 用 4096 端口**:假设 OC serve 跑在默认端口;若用了 `--port` 自定义端口,reload 调用需带对端口(插件默认 4096,改 `OPENCODE_PORT` 环境变量可覆盖)。
|
|
||||||
|
|
||||||
## 文件
|
|
||||||
|
|
||||||
- `model-manager.ts` — 插件主文件(TypeScript)
|
|
||||||
- `model-manager-db-exec.py` — DB 写执行器(Python,OC `setup` 时自动落盘到 `~/.config/opencode/`,本仓库也保留一份供 sync_wsl 同步)
|
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
_wsl_one_shot_fix.py — 一次性把 Win 侧模型数据 + 插件推给 WSL(立即生效,不等收尸)。
|
||||||
|
★ 用户终端跑: python <本脚本> (wsl.exe 实际调用在用户终端可执行; 本环境沙箱拦 wsl.exe)
|
||||||
|
|
||||||
|
对齐 2026-10-20 重定位后的 oc-model-sync.py:
|
||||||
|
1. 读 Win opencode.db 的 models-dev:catalog + credential(xiaomi 原厂直连密钥) + model.json
|
||||||
|
2. 推 catalog + credential + model.json + 插件 blob(tui.js v12 / model-manager.ts / mcpcfg.txt) 给 WSL
|
||||||
|
3. WSL import: 备份后 catalog 整份覆盖(删旧加新), credential 并集灌入(保留 WSL 独有),
|
||||||
|
model.json 覆盖, 插件写回 → /models 主列表 + /manage + xiaomi 原厂模型全部对齐 Win
|
||||||
|
复用 oc-model-sync.py 的导出/导入逻辑, 不重复造轮子。
|
||||||
|
"""
|
||||||
|
import importlib.util
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
HOME = os.path.expanduser("~")
|
||||||
|
MSYN = os.path.join(HOME, ".config", "opencode", "oc-model-sync.py")
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
spec = importlib.util.spec_from_file_location("ms", MSYN)
|
||||||
|
m = importlib.util.module_from_spec(spec)
|
||||||
|
spec.loader.exec_module(m)
|
||||||
|
|
||||||
|
if not os.path.exists(m.WIN_DB):
|
||||||
|
print("Win opencode.db 缺失: %s" % m.WIN_DB)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
catalog, creds = m.read_win_data()
|
||||||
|
print("导出: catalog=%dB credentials=%d" % ((len(catalog) if catalog else 0), len(creds)))
|
||||||
|
if not catalog:
|
||||||
|
print("Win db 无 catalog, 无需推。")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
# 1) 推 catalog
|
||||||
|
rc = m.wsl_push(catalog.encode("utf-8"), m.REMOTE_CATALOG, timeout=200)
|
||||||
|
print("catalog push rc=%d" % rc)
|
||||||
|
if rc != 0:
|
||||||
|
return 1
|
||||||
|
# 2) 推 credentials
|
||||||
|
rc = m.wsl_push(json.dumps(creds, ensure_ascii=False).encode("utf-8"), m.REMOTE_CREDS, timeout=120)
|
||||||
|
print("creds push rc=%d" % rc)
|
||||||
|
if rc != 0:
|
||||||
|
return 1
|
||||||
|
# 3) 推 model.json
|
||||||
|
if os.path.exists(m.WIN_STATE):
|
||||||
|
rc = m.wsl_push(open(m.WIN_STATE, "rb").read(), m.REMOTE_STATE, timeout=120)
|
||||||
|
print("state push rc=%d" % rc)
|
||||||
|
if rc != 0:
|
||||||
|
return 1
|
||||||
|
# 4) 推插件 blob
|
||||||
|
rc = m.wsl_push(json.dumps(m.build_plugin_blob(), ensure_ascii=False).encode("utf-8"),
|
||||||
|
m.REMOTE_PLUGINS, timeout=120)
|
||||||
|
print("plugins push rc=%d" % rc)
|
||||||
|
if rc != 0:
|
||||||
|
return 1
|
||||||
|
# 5) WSL import(覆盖 catalog + 并集 credential + 写 model.json + 写插件, 带备份回滚)
|
||||||
|
rc, _ = m.wsl_py(m.IMPORT_SRC, timeout=200)
|
||||||
|
print("import rc=%d" % rc)
|
||||||
|
res = m.wsl_cat(m.REMOTE_RESULT)
|
||||||
|
print("result:", res[:500])
|
||||||
|
print("OK. 重启 WSL 侧 OC(收尸 terminate) 后 /models 与 /mcpon /mcpoff /manage 即对齐 Win。")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -0,0 +1,418 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
oc-model-sync.py - 模型数据同步: 本机(Win) -> WSL。
|
||||||
|
收尸链路 step 6a 调用(在 session-sync 之前)。设计(2026-10-02; 2026-10-12; 2026-10-20 二次重定位):
|
||||||
|
|
||||||
|
★ 2026-10-20 二次修正(用户实锤「大错特错」): 上一版把数据推到 WSL 的 *config provider 文件*
|
||||||
|
(.oc-model-sync.json / opencode.json), 但 OC 真正读的是三处不同数据源:
|
||||||
|
① /models 主列表 <- DB catalog (models-dev:catalog)
|
||||||
|
② /manage 面板 <- config provider (opencode.json) ∩ catalog ∩ credential
|
||||||
|
③ xiaomi 原厂模型 <- credential 直连(active 的 integration), OC 动态拉取
|
||||||
|
推错树 = /models 不变、/manage 只显示 1/localhost 两个 config 源、xiaomi 读不出。
|
||||||
|
且「只增不删」合并让你 cc-switch 已移除的模型在 WSL 残留。
|
||||||
|
|
||||||
|
本版(彻底对齐 Win 现状):
|
||||||
|
- 数据源 = Win 的 opencode.db(唯一真源):
|
||||||
|
catalog: kv 表 key='models-dev:catalog'(OC 动态拉取的全部 provider/models)
|
||||||
|
credential: credential 表全量(含 xiaomi 原厂密钥, 决定 /manage 能否命中 xiaomi)
|
||||||
|
model.json: ~/.local/state/opencode/model.json (recent/favorite 使用历史)
|
||||||
|
- 传输: ① catalog(6.6MB) 推远端 ② credential(小) 推远端 ③ model.json 推远端
|
||||||
|
④ 插件 blob(tui.js v12 / model-manager.ts / mcpcfg.txt) 推远端
|
||||||
|
- WSL import(★ 覆盖式, 删旧加新):
|
||||||
|
* 备份 WSL db -> /root/.local/share/opencode/opencode.db.bak-modelsync
|
||||||
|
* 覆盖 WSL 的 models-dev:catalog = Win catalog(WSL /models 与 Win 完全一致, 已删的厂商消失)
|
||||||
|
* 覆盖 WSL credential = Win credential(保留 WSL 独有的 active credential? 否: 镜像 Win)
|
||||||
|
* 写 WSL model.json = Win model.json(recent/favorite 对齐)
|
||||||
|
* 写插件到 WSL 生效路径(修 /mcpon /mcpoff /manage 失效)
|
||||||
|
* 任何一步失败 -> 回滚(用备份恢复 WSL db), 绝不写坏 WSL
|
||||||
|
- 幂等: 指纹 = Win catalog sha + credential sha + model.json sha + 插件 sha, 无变化秒退
|
||||||
|
- 预算 150s; 异常吞掉只写日志([model-sync] 前缀), 绝不阻塞退出链路
|
||||||
|
"""
|
||||||
|
import base64
|
||||||
|
import hashlib
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sqlite3
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
|
||||||
|
try:
|
||||||
|
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", errors="replace")
|
||||||
|
sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding="utf-8", errors="replace")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
TAG = "[model-sync]"
|
||||||
|
HOME = os.path.expanduser("~")
|
||||||
|
WIN_DB = os.path.join(HOME, ".local", "share", "opencode", "opencode.db")
|
||||||
|
WIN_STATE = os.path.join(HOME, ".local", "state", "opencode", "model.json")
|
||||||
|
STATE = os.path.join(HOME, ".config", "opencode", ".oc_model_state.json")
|
||||||
|
LOG = os.path.join(HOME, ".config", "opencode", "oc-session-all.log")
|
||||||
|
DISTRO = "Ubuntu-24.04"
|
||||||
|
BUDGET = 150
|
||||||
|
REMOTE_CATALOG = "/root/.oc_catalog.json"
|
||||||
|
REMOTE_CREDS = "/root/.oc_creds.json"
|
||||||
|
REMOTE_STATE = "/root/.oc_state.json"
|
||||||
|
REMOTE_PLUGINS = "/root/.oc_plugins.json"
|
||||||
|
REMOTE_RESULT = "/root/.oc_model_result.json"
|
||||||
|
|
||||||
|
# Win 生效份插件 -> WSL 生效路径(修「插件对不上」: WSL 落后导致 /mcpon /mcpoff /manage 失效)
|
||||||
|
PLUGINS = [
|
||||||
|
(os.path.join(HOME, ".config", "opencode", "plugins", "oc-session-all", "tui.js"),
|
||||||
|
"/root/.config/opencode/plugins/oc-session-all/tui.js"),
|
||||||
|
(os.path.join(HOME, ".config", "opencode", "plugins", "model-manager.ts"),
|
||||||
|
"/root/.config/opencode/plugins/model-manager.ts"),
|
||||||
|
(os.path.join(HOME, ".config", "opencode", "mcpcfg.txt"),
|
||||||
|
"/root/.config/opencode/mcpcfg.txt"),
|
||||||
|
# ── 2026-10-20 治本:provider + MCP 配置同步(替代 model-manager 压制清单)──
|
||||||
|
# 事实定论(用户纠正):
|
||||||
|
# ① cc-switch 只往 opencode.json 写(增删模型时写 provider 字段),完全不碰 opencode.json.2
|
||||||
|
# ② opencode.json.2 是用户自己从 opencode.json 复制来的副本,给 ai-mcp 对调机制用,非 cc-switch 管
|
||||||
|
# ③ MCP server 手动加在 opencode.json,不能用 cc-switch 添加
|
||||||
|
# 同步策略:
|
||||||
|
# 推 opencode.json -> /root/.config/opencode/opencode.json(provider + MCP 完整同步)
|
||||||
|
# cc-switch 删/加模型改 opencode.json.provider -> 收尸同步 -> WSL 一致,无需手动压制
|
||||||
|
# 注意:opencode.json 不能进 build_plugin_blob 的「Win 路径转 WSL 路径」逻辑(provider 是相对 key,无绝对路径)
|
||||||
|
(os.path.join(HOME, ".config", "opencode", "opencode.json"),
|
||||||
|
"/root/.config/opencode/opencode.json"),
|
||||||
|
(os.path.join(HOME, ".config", "opencode", "model-manager.cfg.json"),
|
||||||
|
"/root/.config/opencode/model-manager.cfg.json"),
|
||||||
|
# ── 2026-10-20 补:model-manager 在 WSL 失效的根因 ──
|
||||||
|
# ① model-manager-db-exec.py(Python DB 写执行器)是 model-manager.ts 的硬依赖,
|
||||||
|
# 它 fork 这个子进程去写 SQLite catalog。WSL 没有 → execDbWrite spawn-fail
|
||||||
|
# → 停用/恢复/删除厂商全不生效(WSL 侧「无法停用/恢复出厂」的直接根因)。
|
||||||
|
(os.path.join(HOME, ".config", "opencode", "model-manager-db-exec.py"),
|
||||||
|
"/root/.config/opencode/model-manager-db-exec.py"),
|
||||||
|
# ② tui.json(注册 TUI 插件 oc-session-all.js)+ tui-plugins 本体。
|
||||||
|
# WSL 侧缺 tui.json → TUI 插件没注册 → /mcpon /mcpoff 斜杠命令不存在
|
||||||
|
# → 截图 3「/mcp-off No matching commands」。
|
||||||
|
(os.path.join(HOME, ".config", "opencode", "tui.json"),
|
||||||
|
"/root/.config/opencode/tui.json"),
|
||||||
|
(os.path.join(HOME, ".config", "opencode", "tui-plugins", "oc-session-all.js"),
|
||||||
|
"/root/.config/opencode/tui-plugins/oc-session-all.js"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def log(m):
|
||||||
|
try:
|
||||||
|
with open(LOG, "a", encoding="utf-8") as f:
|
||||||
|
f.write("[%s] %s %s\n" % (time.strftime("%Y-%m-%dT%H:%M:%S"), TAG, m))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def fingerprint():
|
||||||
|
"""Win 侧数据源 + 插件 的内容指纹(全量 sha, 任何变都触发重推)。"""
|
||||||
|
h = hashlib.sha256()
|
||||||
|
try:
|
||||||
|
h.update(hashlib.sha256(open(WIN_DB, "rb").read()).digest())
|
||||||
|
except Exception:
|
||||||
|
h.update(b"no-win-db")
|
||||||
|
try:
|
||||||
|
h.update(hashlib.sha256(open(WIN_STATE, "rb").read()).digest())
|
||||||
|
except Exception:
|
||||||
|
h.update(b"no-win-state")
|
||||||
|
for src, _ in PLUGINS:
|
||||||
|
try:
|
||||||
|
h.update(hashlib.sha256(open(src, "rb").read()).digest())
|
||||||
|
except Exception:
|
||||||
|
h.update(b"no-plugin")
|
||||||
|
return h.hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def read_win_data():
|
||||||
|
"""只读 Win db + opencode.json.2:返回 (catalog_value, credentials_json, n_creds)。"""
|
||||||
|
import sqlite3 as _s
|
||||||
|
con = _s.connect("file:%s?mode=ro" % WIN_DB.replace("\\", "/"), uri=True)
|
||||||
|
try:
|
||||||
|
cur = con.cursor()
|
||||||
|
row = cur.execute("SELECT value FROM kv WHERE key='models-dev:catalog'").fetchone()
|
||||||
|
catalog = row[0] if row else None
|
||||||
|
cols = [r[1] for r in cur.execute("PRAGMA table_info(credential)")]
|
||||||
|
cre_rows = cur.execute("SELECT * FROM credential").fetchall()
|
||||||
|
creds = [dict(zip(cols, r)) for r in cre_rows]
|
||||||
|
finally:
|
||||||
|
con.close()
|
||||||
|
return catalog, creds
|
||||||
|
|
||||||
|
|
||||||
|
def build_plugin_blob():
|
||||||
|
"""Win 生效份插件 + opencode.json -> {dst_path: base64}。
|
||||||
|
|
||||||
|
mcpcfg.txt / tui.json 特殊处理:把 Win 绝对路径(反斜杠或 file:///C:/)转 WSL 绝对路径。
|
||||||
|
model-manager 压制清单:Win 原样推(Win 空则 WSL 也空)。
|
||||||
|
注意:opencode.json 的 provider 是相对 key(如 '1'),无绝对路径,直接 base64 推。
|
||||||
|
"""
|
||||||
|
blob = {}
|
||||||
|
# Win 侧各路径前缀 -> WSL 前缀(用 chr(92) 拼反斜杠,绕开源码转义踩坑)
|
||||||
|
bk = chr(92)
|
||||||
|
win_prefix_bk = "C:" + bk + "Users" + bk + "ypshidifu" + bk + ".config" + bk + "opencode" + bk
|
||||||
|
win_prefix_sl = "C:/Users/ypshidifu/.config/opencode/"
|
||||||
|
win_prefix_file = "file:///C:/Users/ypshidifu/.config/opencode/"
|
||||||
|
wsl_prefix = "/root/.config/opencode/"
|
||||||
|
wsl_prefix_file = "file:///root/.config/opencode/"
|
||||||
|
for src, dst in PLUGINS:
|
||||||
|
if not os.path.exists(src):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
data = open(src, "rb").read()
|
||||||
|
# mcpcfg.txt / tui.json:Win 路径前缀 -> WSL 路径前缀
|
||||||
|
# - 普通反斜杠路径(mcpcfg.txt)
|
||||||
|
# - file:// 绝对路径(tui.json 里 TUI 插件注册)
|
||||||
|
if os.path.basename(src) in ("mcpcfg.txt", "tui.json"):
|
||||||
|
text = data.decode("utf-8")
|
||||||
|
text = text.replace(win_prefix_file, wsl_prefix_file) # 先替换 file:// 长前缀
|
||||||
|
text = text.replace(win_prefix_bk, wsl_prefix)
|
||||||
|
text = text.replace(win_prefix_sl, wsl_prefix)
|
||||||
|
data = text.encode("utf-8")
|
||||||
|
blob[dst] = base64.b64encode(data).decode("ascii")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
# model-manager 压制清单(Win 原样推,Win 空则 WSL 也空)
|
||||||
|
for fname in ["model-manager.removed.json", "model-manager.disabled.json",
|
||||||
|
"model-manager.stoplist.bak.json"]:
|
||||||
|
fsrc = os.path.join(HOME, ".config", "opencode", fname)
|
||||||
|
fdst = "/root/.config/opencode/" + fname
|
||||||
|
if os.path.exists(fsrc):
|
||||||
|
try:
|
||||||
|
blob[fdst] = base64.b64encode(open(fsrc, "rb").read()).decode("ascii")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return blob
|
||||||
|
|
||||||
|
|
||||||
|
def wsl_push(data_bytes, remote_path, timeout=150):
|
||||||
|
cmd = 'cat > "%s"' % remote_path
|
||||||
|
r = subprocess.run(
|
||||||
|
["wsl.exe", "-d", DISTRO, "--", "bash", "-c", cmd],
|
||||||
|
input=data_bytes,
|
||||||
|
capture_output=True,
|
||||||
|
timeout=timeout,
|
||||||
|
)
|
||||||
|
return r.returncode
|
||||||
|
|
||||||
|
|
||||||
|
def wsl_py(src, timeout=150):
|
||||||
|
r = subprocess.run(
|
||||||
|
["wsl.exe", "-d", DISTRO, "--", "python3", "-"],
|
||||||
|
input=src.encode("utf-8"),
|
||||||
|
capture_output=True,
|
||||||
|
timeout=timeout,
|
||||||
|
)
|
||||||
|
return r.returncode, r.stdout
|
||||||
|
|
||||||
|
|
||||||
|
def wsl_cat(remote_path, timeout=60):
|
||||||
|
r = subprocess.run(
|
||||||
|
["wsl.exe", "-d", DISTRO, "--", "cat", remote_path],
|
||||||
|
capture_output=True,
|
||||||
|
timeout=timeout,
|
||||||
|
)
|
||||||
|
raw = r.stdout or b""
|
||||||
|
if raw[:1] == b"{":
|
||||||
|
return raw.decode("utf-8", errors="replace")
|
||||||
|
try:
|
||||||
|
t = raw.decode("utf-16-le")
|
||||||
|
if "{" in t:
|
||||||
|
return t[t.index("{"):]
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return raw.decode("utf-8", errors="replace")
|
||||||
|
|
||||||
|
|
||||||
|
# ── WSL 侧 import 脚本(★ 覆盖式 + 备份回滚)────────────────────────────────
|
||||||
|
IMPORT_SRC = r'''
|
||||||
|
import json, os, sqlite3, shutil, time, base64
|
||||||
|
res = {"ok": False, "steps": []}
|
||||||
|
try:
|
||||||
|
WSL_DB = "/root/.local/share/opencode/opencode.db"
|
||||||
|
WSL_STATE = "/root/.local/state/opencode/model.json"
|
||||||
|
BACKUP = WSL_DB + ".bak-modelsync"
|
||||||
|
|
||||||
|
catalog_raw = open("/root/.oc_catalog.json", encoding="utf-8").read()
|
||||||
|
creds = json.load(open("/root/.oc_creds.json", encoding="utf-8"))
|
||||||
|
state = open("/root/.oc_state.json", encoding="utf-8").read()
|
||||||
|
plugin_blob = json.load(open("/root/.oc_plugins.json", encoding="utf-8"))
|
||||||
|
|
||||||
|
# ① 备份 WSL db(覆盖写前必做)
|
||||||
|
if os.path.exists(WSL_DB):
|
||||||
|
shutil.copy2(WSL_DB, BACKUP)
|
||||||
|
res["steps"].append("backup:" + BACKUP)
|
||||||
|
else:
|
||||||
|
res["steps"].append("no-wsl-db")
|
||||||
|
|
||||||
|
# ② 覆盖 catalog + credential(删旧加新, 镜像 Win)
|
||||||
|
con = sqlite3.connect(WSL_DB)
|
||||||
|
cur = con.cursor()
|
||||||
|
# 表缺失才建(按 OC 真实 schema: credential.id 是 TEXT, 不是 INTEGER)
|
||||||
|
cur.execute("""CREATE TABLE IF NOT EXISTS kv (
|
||||||
|
key TEXT PRIMARY KEY, value TEXT, time_updated INTEGER)""")
|
||||||
|
cur.execute("""CREATE TABLE IF NOT EXISTS credential (
|
||||||
|
id TEXT, integration_id TEXT, label TEXT, value TEXT,
|
||||||
|
connector_id TEXT, method_id TEXT, active INTEGER,
|
||||||
|
time_created INTEGER, time_updated INTEGER)""")
|
||||||
|
if catalog_raw:
|
||||||
|
cur.execute("DELETE FROM kv WHERE key='models-dev:catalog'")
|
||||||
|
# ★ 按 WSL kv 表实际列动态插入(补 time_created 等, 避免 NOT NULL 约束失败)
|
||||||
|
kv_cols = [r[1] for r in cur.execute("PRAGMA table_info(kv)")]
|
||||||
|
now = int(time.time())
|
||||||
|
row = {"key": "models-dev:catalog", "value": catalog_raw,
|
||||||
|
"time_created": now, "time_updated": now}
|
||||||
|
cols = [k for k in row.keys() if k in kv_cols]
|
||||||
|
# WSL kv 有但上面没给的列 -> 补 NULL/0
|
||||||
|
for k in kv_cols:
|
||||||
|
if k not in row:
|
||||||
|
row[k] = now if k.startswith("time_") else None
|
||||||
|
cols.append(k)
|
||||||
|
cur.execute(
|
||||||
|
"INSERT INTO kv (%s) VALUES (%s)" % (",".join(cols), ",".join("?" * len(cols))),
|
||||||
|
[row[k] for k in cols])
|
||||||
|
res["steps"].append("catalog-cleared+overwritten(cols=%s)" % cols)
|
||||||
|
else:
|
||||||
|
res["steps"].append("catalog-missing-skip")
|
||||||
|
# credential: 并集语义 —— Win 的 credential 整份灌入, 但 WSL 独有的 integration_id
|
||||||
|
# 保留不动(不破坏 WSL 本地连的别家厂商); Win 同名的按 Win 覆盖(值/active 以 Win 为准)。
|
||||||
|
cred_cols = [r[1] for r in cur.execute("PRAGMA table_info(credential)")]
|
||||||
|
win_by_integ = {}
|
||||||
|
for c in creds:
|
||||||
|
win_by_integ[c.get("integration_id")] = c
|
||||||
|
existing = cur.execute("SELECT * FROM credential").fetchall()
|
||||||
|
ex_cols = [r[1] for r in cur.execute("PRAGMA table_info(credential)")]
|
||||||
|
wsl_only_integs = []
|
||||||
|
for r in existing:
|
||||||
|
ed = dict(zip(ex_cols, r))
|
||||||
|
integ = ed.get("integration_id")
|
||||||
|
if integ in win_by_integ:
|
||||||
|
# WSL 同名的删掉, 下面用 Win 值重灌
|
||||||
|
cur.execute("DELETE FROM credential WHERE id=?", (ed.get("id"),))
|
||||||
|
else:
|
||||||
|
wsl_only_integs.append(integ) # WSL 独有, 保留
|
||||||
|
for integ, c in win_by_integ.items():
|
||||||
|
row = dict(c)
|
||||||
|
cols = [k for k in row.keys() if k in cred_cols]
|
||||||
|
# WSL credential 有但 Win 没给的列 -> 按类型补默认值, 避免 NOT NULL 约束失败
|
||||||
|
for k in cred_cols:
|
||||||
|
if k not in row:
|
||||||
|
if k.startswith("time_"):
|
||||||
|
row[k] = int(time.time())
|
||||||
|
elif k == "active":
|
||||||
|
row[k] = 0
|
||||||
|
elif k == "id":
|
||||||
|
# 无 id 或 id 缺失时生成稳定 id(避免 PRIMARY KEY 冲突与 NOT NULL)
|
||||||
|
row[k] = "win_" + (integ or "unknown").replace("/", "_")
|
||||||
|
else:
|
||||||
|
row[k] = None
|
||||||
|
cols.append(k)
|
||||||
|
# label 是 NOT NULL:Win 可能给空串/None,确保非 None
|
||||||
|
if row.get("label") is None:
|
||||||
|
row["label"] = row.get("id") or row.get("integration_id") or "win"
|
||||||
|
if not cols:
|
||||||
|
continue
|
||||||
|
# 防重复: 若 WSL 里已存在同名(可能上面没删净), 先清
|
||||||
|
cur.execute("DELETE FROM credential WHERE integration_id=?", (integ,))
|
||||||
|
cur.execute(
|
||||||
|
"INSERT INTO credential (%s) VALUES (%s)" % (",".join(cols), ",".join("?" * len(cols))),
|
||||||
|
[row.get(k) for k in cols])
|
||||||
|
con.commit()
|
||||||
|
con.close()
|
||||||
|
res["steps"].append("credential-merged(win=%d, wsl_only_kept=%s)" %
|
||||||
|
(len(win_by_integ), wsl_only_integs))
|
||||||
|
|
||||||
|
# ③ 写 model.json(recent/favorite 对齐)
|
||||||
|
try:
|
||||||
|
os.makedirs(os.path.dirname(WSL_STATE), exist_ok=True)
|
||||||
|
open(WSL_STATE, "w", encoding="utf-8").write(state)
|
||||||
|
res["steps"].append("model.json-overwritten")
|
||||||
|
except Exception as e:
|
||||||
|
res["steps"].append("model.json-err:" + str(e))
|
||||||
|
|
||||||
|
# ④ 写插件(修 /mcpon /mcpoff /manage 失效)
|
||||||
|
for dst, b64 in plugin_blob.items():
|
||||||
|
try:
|
||||||
|
os.makedirs(os.path.dirname(dst), exist_ok=True)
|
||||||
|
open(dst, "wb").write(base64.b64decode(b64))
|
||||||
|
except Exception as e:
|
||||||
|
res["steps"].append("plugin-err:" + dst + ":" + str(e))
|
||||||
|
res["steps"].append("plugins-written(%d)" % len(plugin_blob))
|
||||||
|
res["ok"] = True
|
||||||
|
except Exception as e:
|
||||||
|
# 任何一步失败 -> 回滚 WSL db
|
||||||
|
res["err"] = str(e)
|
||||||
|
try:
|
||||||
|
if os.path.exists(BACKUP) and os.path.exists(WSL_DB):
|
||||||
|
shutil.copy2(BACKUP, WSL_DB)
|
||||||
|
res["rolled_back"] = True
|
||||||
|
res["steps"].append("ROLLBACK:" + BACKUP)
|
||||||
|
except Exception as e2:
|
||||||
|
res["rollback_err"] = str(e2)
|
||||||
|
open("/root/.oc_model_result.json", "w", encoding="utf-8").write(json.dumps(res))
|
||||||
|
'''
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
t0 = time.time()
|
||||||
|
fp = fingerprint()
|
||||||
|
try:
|
||||||
|
state = json.load(open(STATE, encoding="utf-8")).get("fp")
|
||||||
|
except Exception:
|
||||||
|
state = None
|
||||||
|
if state == fp:
|
||||||
|
log("unchanged, skip (fp=%s)" % fp[:12])
|
||||||
|
return
|
||||||
|
|
||||||
|
if not os.path.exists(WIN_DB):
|
||||||
|
log("win db missing: " + WIN_DB)
|
||||||
|
return
|
||||||
|
catalog, creds = read_win_data()
|
||||||
|
log("export: catalog=%dB credentials=%d (fp=%s)" %
|
||||||
|
((len(catalog) if catalog else 0), len(creds), fp[:12]))
|
||||||
|
if not catalog:
|
||||||
|
log("no catalog in win db, nothing to push")
|
||||||
|
return
|
||||||
|
|
||||||
|
# 推 catalog(raw 字符串)
|
||||||
|
try:
|
||||||
|
rc = wsl_push(catalog.encode("utf-8"), REMOTE_CATALOG, timeout=200)
|
||||||
|
if rc != 0:
|
||||||
|
log("catalog push rc=%d" % rc)
|
||||||
|
return
|
||||||
|
# 推 credentials
|
||||||
|
rc = wsl_push(json.dumps(creds, ensure_ascii=False).encode("utf-8"),
|
||||||
|
REMOTE_CREDS, timeout=120)
|
||||||
|
if rc != 0:
|
||||||
|
log("creds push rc=%d" % rc)
|
||||||
|
return
|
||||||
|
# 推 model.json
|
||||||
|
rc = 0
|
||||||
|
if os.path.exists(WIN_STATE):
|
||||||
|
rc = wsl_push(open(WIN_STATE, "rb").read(), REMOTE_STATE, timeout=120)
|
||||||
|
if rc != 0:
|
||||||
|
log("state push rc=%d" % rc)
|
||||||
|
return
|
||||||
|
# 推插件 blob
|
||||||
|
rc = wsl_push(json.dumps(build_plugin_blob(), ensure_ascii=False).encode("utf-8"),
|
||||||
|
REMOTE_PLUGINS, timeout=120)
|
||||||
|
if rc != 0:
|
||||||
|
log("plugins push rc=%d" % rc)
|
||||||
|
return
|
||||||
|
# 跑 import(覆盖 WSL db + model.json + 插件, 带备份回滚)
|
||||||
|
rc, _ = wsl_py(IMPORT_SRC, timeout=200)
|
||||||
|
if rc != 0:
|
||||||
|
log("import rc=%d" % rc)
|
||||||
|
return
|
||||||
|
res = json.loads(wsl_cat(REMOTE_RESULT))
|
||||||
|
log("import result: " + json.dumps(res, ensure_ascii=False)[:400])
|
||||||
|
json.dump({"fp": fp}, open(STATE, "w", encoding="utf-8"))
|
||||||
|
log("state updated in %ds" % int(time.time() - t0))
|
||||||
|
except Exception as e:
|
||||||
|
log("exception: " + str(e))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
try:
|
||||||
|
main()
|
||||||
|
except Exception as e:
|
||||||
|
log("FATAL: " + str(e))
|
||||||
|
sys.exit(0)
|
||||||
@@ -0,0 +1,445 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
oc-session-sync.py - 对话数据同步: 本机(Win) -> WSL。
|
||||||
|
收尸链路 step 7 调用。设计(2026-10-02 首版; 2026-10-12 重建, 适配 2.0.27 schema):
|
||||||
|
- 幂等: .oc_sync_state.json 存 {session_id: time_updated} 快照, 无变化秒退。
|
||||||
|
- 并集语义: 绝不删 WSL 数据, 只补缺:
|
||||||
|
* WSL 缺的 session -> 整行插 + 其全部 message/part
|
||||||
|
* 两边都有但本机 time_updated 更新 -> 只补 WSL 缺的 message/part;
|
||||||
|
time_updated 只增不减 (max 更新)。
|
||||||
|
- 传输: export gzip -> `wsl -- bash -c "cat > file"` stdin 管道(绕 UNC OSError 22);
|
||||||
|
WSL 侧脚本一律经 stdin 喂 python3 -, 脚本只读本地文件(避免 stdin 冲突)。
|
||||||
|
- 跨版本 schema: pragma table_info 取目标表列交集, INSERT OR IGNORE。
|
||||||
|
- 预算 150s; 异常吞掉只写日志([sync] 前缀), 绝不抛出阻塞退出链路。
|
||||||
|
"""
|
||||||
|
import gzip
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sqlite3
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
|
||||||
|
try:
|
||||||
|
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", errors="replace")
|
||||||
|
sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding="utf-8", errors="replace")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
TAG = "[sync]"
|
||||||
|
HOME = os.path.expanduser("~")
|
||||||
|
WIN_DB = os.path.join(HOME, ".local", "share", "opencode", "opencode.db")
|
||||||
|
STATE = os.path.join(HOME, ".config", "opencode", ".oc_sync_state.json")
|
||||||
|
LOG = os.path.join(HOME, ".config", "opencode", "oc-session-all.log")
|
||||||
|
REMOTE_IMPORT = "/root/.oc_sync_import.json.gz"
|
||||||
|
REMOTE_PROBE = "/root/.oc_sync_probe.json"
|
||||||
|
REMOTE_RESULT = "/root/.oc_sync_result.json"
|
||||||
|
DISTRO = "Ubuntu-24.04"
|
||||||
|
BUDGET = 150
|
||||||
|
# 同步的表(WSL 侧缺表/缺列自动跳过)
|
||||||
|
TABLES = ["project", "session", "session_v2", "session_message", "part"]
|
||||||
|
# ★ project.worktree 路径转换(Win 绝对路径 -> WSL 绝对路径)
|
||||||
|
# Win 侧 session 的 project.worktree = C:/Users/ypshidifu/... (OC 启动 cwd)
|
||||||
|
# 推给 WSL 后原样是 Win 路径 → WSL 侧 session 面板提示
|
||||||
|
# 「Session location unavailable / C:/Users/ypshidifu / Choose another directory」。
|
||||||
|
# 必须把 C:/Users/ypshidifu 前缀转成 WSL 的 /root。
|
||||||
|
_WIN_HOME_PREFIX_SL = "C:/Users/ypshidifu"
|
||||||
|
_WSL_HOME_PREFIX = "/root"
|
||||||
|
|
||||||
|
|
||||||
|
def log(m):
|
||||||
|
try:
|
||||||
|
with open(LOG, "a", encoding="utf-8") as f:
|
||||||
|
f.write("[%s] %s %s\n" % (time.strftime("%Y-%m-%dT%H:%M:%S"), TAG, m))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def wsl_py(src, timeout=120):
|
||||||
|
"""经 stdin 喂脚本给 wsl python3 - (脚本内禁止再读 stdin)。返回 (rc, out_bytes)。"""
|
||||||
|
r = subprocess.run(
|
||||||
|
["wsl.exe", "-d", DISTRO, "--", "python3", "-"],
|
||||||
|
input=src.encode("utf-8"),
|
||||||
|
capture_output=True,
|
||||||
|
timeout=timeout,
|
||||||
|
)
|
||||||
|
return r.returncode, r.stdout
|
||||||
|
|
||||||
|
|
||||||
|
def wsl_push(data, remote_path, timeout=120):
|
||||||
|
cmd = 'cat > "%s"' % remote_path
|
||||||
|
r = subprocess.run(
|
||||||
|
["wsl.exe", "-d", DISTRO, "--", "bash", "-c", cmd],
|
||||||
|
input=data,
|
||||||
|
capture_output=True,
|
||||||
|
timeout=timeout,
|
||||||
|
)
|
||||||
|
return r.returncode
|
||||||
|
|
||||||
|
|
||||||
|
def wsl_cat(remote_path, timeout=60):
|
||||||
|
r = subprocess.run(
|
||||||
|
["wsl.exe", "-d", DISTRO, "--", "cat", remote_path],
|
||||||
|
capture_output=True,
|
||||||
|
timeout=timeout,
|
||||||
|
)
|
||||||
|
raw = r.stdout or b""
|
||||||
|
if raw[:1] == b"{":
|
||||||
|
return raw.decode("utf-8", errors="replace")
|
||||||
|
try:
|
||||||
|
t = raw.decode("utf-16-le")
|
||||||
|
if "{" in t:
|
||||||
|
return t[t.index("{"):]
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return raw.decode("utf-8", errors="replace")
|
||||||
|
|
||||||
|
|
||||||
|
PROBE_SRC = r'''
|
||||||
|
import json, os, sqlite3, glob
|
||||||
|
out = {"ok": False}
|
||||||
|
try:
|
||||||
|
cands = ["/root/.local/share/opencode/opencode.db"]
|
||||||
|
cands += glob.glob("/root/.local/share/**/opencode.db", recursive=True)
|
||||||
|
db = next((c for c in cands if os.path.exists(c)), None)
|
||||||
|
if not db:
|
||||||
|
out["err"] = "wsl db not found"
|
||||||
|
else:
|
||||||
|
con = sqlite3.connect(db)
|
||||||
|
def tu(t):
|
||||||
|
try:
|
||||||
|
return {r[0]: (r[1] or 0) for r in con.execute("select id, time_updated from " + t)}
|
||||||
|
except Exception:
|
||||||
|
return {}
|
||||||
|
out["db"] = db
|
||||||
|
# 2026-10-20:双表驱动(新版 OC 活跃会话写 session_v2,旧版写 session)
|
||||||
|
out["sessions"] = tu("session")
|
||||||
|
out["sessions_v2"] = tu("session_v2")
|
||||||
|
out["msgs"] = tu("session_message")
|
||||||
|
out["ok"] = True
|
||||||
|
con.close()
|
||||||
|
except Exception as e:
|
||||||
|
out["err"] = str(e)
|
||||||
|
open("/root/.oc_sync_probe.json", "w", encoding="utf-8").write(json.dumps(out))
|
||||||
|
'''
|
||||||
|
|
||||||
|
IMPORT_SRC = r'''
|
||||||
|
import gzip, json, os, sqlite3
|
||||||
|
res = {"ok": False}
|
||||||
|
try:
|
||||||
|
raw = gzip.decompress(open("/root/.oc_sync_import.json.gz", "rb").read())
|
||||||
|
payload = json.loads(raw.decode("utf-8"))
|
||||||
|
con = sqlite3.connect("/root/.local/share/opencode/opencode.db")
|
||||||
|
cur = con.cursor()
|
||||||
|
stats = {}
|
||||||
|
for t, rows in payload.get("tables", {}).items():
|
||||||
|
try:
|
||||||
|
tgt_cols = [r[1] for r in cur.execute("pragma table_info(%s)" % t)]
|
||||||
|
except Exception:
|
||||||
|
tgt_cols = []
|
||||||
|
if not tgt_cols:
|
||||||
|
stats[t] = "missing-table"
|
||||||
|
continue
|
||||||
|
ins = 0
|
||||||
|
upd = 0
|
||||||
|
for row in rows:
|
||||||
|
cols = [c for c in tgt_cols if c in row]
|
||||||
|
if not cols or "id" not in cols:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
cur.execute(
|
||||||
|
"INSERT OR IGNORE INTO %s (%s) VALUES (%s)"
|
||||||
|
% (t, ",".join(cols), ",".join("?" * len(cols))),
|
||||||
|
[row[c] for c in cols],
|
||||||
|
)
|
||||||
|
if cur.rowcount > 0:
|
||||||
|
ins += cur.rowcount
|
||||||
|
elif "time_updated" in tgt_cols and "time_updated" in row and row["time_updated"]:
|
||||||
|
cur.execute(
|
||||||
|
"UPDATE %s SET time_updated=? WHERE id=? AND (time_updated IS NULL OR time_updated<?)"
|
||||||
|
% t,
|
||||||
|
[row["time_updated"], row["id"], row["time_updated"]],
|
||||||
|
)
|
||||||
|
upd += cur.rowcount
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
stats[t] = {"ins": ins, "upd": upd}
|
||||||
|
con.commit()
|
||||||
|
con.close()
|
||||||
|
res = {"ok": True, "stats": stats}
|
||||||
|
except Exception as e:
|
||||||
|
res = {"err": str(e)}
|
||||||
|
open("/root/.oc_sync_result.json", "w", encoding="utf-8").write(json.dumps(res))
|
||||||
|
'''
|
||||||
|
|
||||||
|
# ★ 存量 Win 路径洗白(2026-10-20 二次实锤:OC 2.x 的 "Session location" 读的是
|
||||||
|
# session/session_v2 表自身的 directory 列,不只是 project.worktree!
|
||||||
|
# 目标会话 ses_eb074c0daffe 的 session_v2.directory = "C:/Users/ypshidifu" 即截图来源。
|
||||||
|
# 且 IMPORT 用 INSERT OR IGNORE —— WSL 已存在的行永远不会被 UPDATE,
|
||||||
|
# 推送侧转换只对「新插入」的行生效。此脚本每次同步无条件跑(幂等 UPDATE),
|
||||||
|
# 把三张表里所有 Win home 前缀的路径洗成 /root。
|
||||||
|
FIX_WORKTREE_SRC = r'''
|
||||||
|
import sqlite3
|
||||||
|
BK = chr(92)
|
||||||
|
PRE_SL = "C:/Users/ypshidifu"
|
||||||
|
PRE_BK = "C:" + BK + "Users" + BK + "ypshidifu"
|
||||||
|
res = {"fixed": {}, "err": None}
|
||||||
|
def conv(p):
|
||||||
|
if not p or not isinstance(p, str):
|
||||||
|
return None
|
||||||
|
if p.startswith(PRE_SL):
|
||||||
|
return "/root" + p[len(PRE_SL):]
|
||||||
|
if p.startswith(PRE_BK):
|
||||||
|
return "/root" + p[len(PRE_BK):]
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
con = sqlite3.connect("/root/.local/share/opencode/opencode.db")
|
||||||
|
cur = con.cursor()
|
||||||
|
# project.worktree
|
||||||
|
n = 0
|
||||||
|
for pid, wt in cur.execute("select id, worktree from project").fetchall():
|
||||||
|
nw = conv(wt)
|
||||||
|
if nw:
|
||||||
|
cur.execute("update project set worktree=? where id=?", (nw, pid))
|
||||||
|
n += 1
|
||||||
|
res["fixed"]["project.worktree"] = n
|
||||||
|
# session.directory(列存在才跑)
|
||||||
|
def fix_dir(table):
|
||||||
|
cols = [r[1] for r in cur.execute("PRAGMA table_info(%s)" % table)]
|
||||||
|
if "directory" not in cols:
|
||||||
|
return -1
|
||||||
|
n = 0
|
||||||
|
for sid, d in cur.execute("select id, directory from %s" % table).fetchall():
|
||||||
|
nw = conv(d)
|
||||||
|
if nw:
|
||||||
|
cur.execute("update %s set directory=? where id=?" % table, (nw, sid))
|
||||||
|
n += 1
|
||||||
|
return n
|
||||||
|
res["fixed"]["session.directory"] = fix_dir("session")
|
||||||
|
res["fixed"]["session_v2.directory"] = fix_dir("session_v2")
|
||||||
|
con.commit()
|
||||||
|
con.close()
|
||||||
|
except Exception as e:
|
||||||
|
res["err"] = str(e)
|
||||||
|
open("/root/.oc_fix_wt_result.json", "w", encoding="utf-8").write(json.dumps(res))
|
||||||
|
'''
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
t0 = time.time()
|
||||||
|
if not os.path.exists(WIN_DB):
|
||||||
|
log("win db missing: " + WIN_DB)
|
||||||
|
return
|
||||||
|
# 2026-10-20:双表驱动。新版 OC(2.0.27) 活跃会话写 session_v2,旧表 session 是历史遗留。
|
||||||
|
# 只盯 session 旧表会导致「Win 新建会话全在 v2 → 旧表无变化 → 永远 skip → WSL 读不到」。
|
||||||
|
win = sqlite3.connect("file:%s?mode=ro" % WIN_DB.replace("\\", "/"), uri=True)
|
||||||
|
try:
|
||||||
|
win_sessions = {r[0]: (r[1] or 0) for r in win.execute("select id, time_updated from session")}
|
||||||
|
except Exception as e:
|
||||||
|
log("win session read ERR: " + str(e))
|
||||||
|
return
|
||||||
|
finally:
|
||||||
|
win.close()
|
||||||
|
|
||||||
|
try:
|
||||||
|
rc, _ = wsl_py(PROBE_SRC, timeout=90)
|
||||||
|
if rc != 0:
|
||||||
|
log("wsl probe rc=" + str(rc) + " (vm cold-boot or python missing?)")
|
||||||
|
return
|
||||||
|
probe = json.loads(wsl_cat(REMOTE_PROBE))
|
||||||
|
if not probe.get("ok"):
|
||||||
|
log("wsl probe ERR: " + str(probe.get("err")))
|
||||||
|
return
|
||||||
|
except Exception as e:
|
||||||
|
log("wsl probe exception: " + str(e))
|
||||||
|
return
|
||||||
|
|
||||||
|
wsl_sessions = probe.get("sessions") or {}
|
||||||
|
wsl_sessions_v2 = probe.get("sessions_v2") or {}
|
||||||
|
wsl_msgs = probe.get("msgs") or {}
|
||||||
|
|
||||||
|
# ★ 存量 worktree 洗白:每次无条件跑(幂等,修 INSERT OR IGNORE 洗不掉存量行的洞)
|
||||||
|
try:
|
||||||
|
rc_fw, _ = wsl_py(FIX_WORKTREE_SRC, timeout=60)
|
||||||
|
if rc_fw == 0:
|
||||||
|
fw = json.loads(wsl_cat("/root/.oc_fix_wt_result.json"))
|
||||||
|
if fw.get("fixed"):
|
||||||
|
log("worktree fixed: %d rows -> /root" % fw["fixed"])
|
||||||
|
if fw.get("err"):
|
||||||
|
log("worktree fix err: " + str(fw["err"]))
|
||||||
|
except Exception as e:
|
||||||
|
log("worktree fix exception: " + str(e))
|
||||||
|
# WSL 侧合并双表(id -> 最新 time_updated)
|
||||||
|
wsl_all = dict(wsl_sessions)
|
||||||
|
for k, v in (wsl_sessions_v2 or {}).items():
|
||||||
|
wsl_all[k] = max(wsl_all.get(k, 0), v)
|
||||||
|
|
||||||
|
# 读 Win session_v2(新版主表;缺表当空)
|
||||||
|
try:
|
||||||
|
win = sqlite3.connect("file:%s?mode=ro" % WIN_DB.replace("\\", "/"), uri=True)
|
||||||
|
win_sessions_v2 = {r[0]: (r[1] or 0) for r in win.execute("select id, time_updated from session_v2")}
|
||||||
|
except Exception:
|
||||||
|
win_sessions_v2 = {}
|
||||||
|
finally:
|
||||||
|
win.close()
|
||||||
|
# Win 侧合并双表
|
||||||
|
win_all = dict(win_sessions)
|
||||||
|
for k, v in win_sessions_v2.items():
|
||||||
|
win_all[k] = max(win_all.get(k, 0), v)
|
||||||
|
|
||||||
|
# 幂等快照(按合并后的双表快照判断,任一表变都触发重推)
|
||||||
|
snapshot = {
|
||||||
|
"session": win_sessions,
|
||||||
|
"session_v2": win_sessions_v2,
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
state = json.load(open(STATE, encoding="utf-8"))
|
||||||
|
except Exception:
|
||||||
|
state = {}
|
||||||
|
# 兼容旧版快照(旧版直接存 {id: time} 平铺)
|
||||||
|
if not isinstance(state, dict) or "session" not in state:
|
||||||
|
state = {"session": state if isinstance(state, dict) else {}, "session_v2": {}}
|
||||||
|
if state == snapshot:
|
||||||
|
log("unchanged, skip (session=%d v2=%d)" % (len(win_sessions), len(win_sessions_v2)))
|
||||||
|
return
|
||||||
|
|
||||||
|
# diff:合并双表后的缺失/过期
|
||||||
|
need_sessions = [s for s in win_all if s not in wsl_all]
|
||||||
|
stale = [s for s in win_all if s in wsl_all and win_all[s] > wsl_all[s]]
|
||||||
|
targets = set(need_sessions) | set(stale)
|
||||||
|
if not targets:
|
||||||
|
json.dump(snapshot, open(STATE, "w", encoding="utf-8"))
|
||||||
|
log("no missing/stale sessions; state refreshed (session=%d v2=%d)" %
|
||||||
|
(len(win_sessions), len(win_sessions_v2)))
|
||||||
|
return
|
||||||
|
|
||||||
|
win = sqlite3.connect("file:%s?mode=ro" % WIN_DB.replace("\\", "/"), uri=True)
|
||||||
|
win.row_factory = sqlite3.Row
|
||||||
|
try:
|
||||||
|
# collect message ids per target session (need full set for new, missing-only handled at import by OR IGNORE)
|
||||||
|
msg_rows = []
|
||||||
|
for r in win.execute(
|
||||||
|
"select * from session_message where session_id in (%s)"
|
||||||
|
% ",".join("?" * len(targets)),
|
||||||
|
list(targets),
|
||||||
|
):
|
||||||
|
msg_rows.append(dict(r))
|
||||||
|
msg_ids = [m["id"] for m in msg_rows if m["id"] not in wsl_msgs]
|
||||||
|
missing_msg = [m for m in msg_rows if m["id"] not in wsl_msgs]
|
||||||
|
|
||||||
|
tables = {}
|
||||||
|
# project rows referenced by target sessions(双表并集查 project_id)
|
||||||
|
pids = set()
|
||||||
|
q1 = "select distinct project_id from session where id in (%s)" % ",".join("?" * len(targets))
|
||||||
|
for r in win.execute(q1, list(targets)):
|
||||||
|
if r[0]:
|
||||||
|
pids.add(r[0])
|
||||||
|
try:
|
||||||
|
for r in win.execute(
|
||||||
|
"select distinct project_id from session_v2 where id in (%s)" % ",".join("?" * len(targets)),
|
||||||
|
list(targets),
|
||||||
|
):
|
||||||
|
if r[0]:
|
||||||
|
pids.add(r[0])
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
if pids:
|
||||||
|
tables["project"] = [
|
||||||
|
dict(r)
|
||||||
|
for r in win.execute(
|
||||||
|
"select * from project where id in (%s)" % ",".join("?" * len(pids)),
|
||||||
|
list(pids),
|
||||||
|
)
|
||||||
|
]
|
||||||
|
tables["session"] = [
|
||||||
|
dict(r)
|
||||||
|
for r in win.execute(
|
||||||
|
"select * from session where id in (%s)" % ",".join("?" * len(targets)),
|
||||||
|
list(targets),
|
||||||
|
)
|
||||||
|
]
|
||||||
|
# ★ Win 路径转换(C:/Users/ypshidifu / C:\Users\ypshidifu -> /root)
|
||||||
|
# 修 WSL 侧「Session location unavailable / C:/Users/ypshidifu」
|
||||||
|
# 2026-10-20 二次实锤:OC 2.x 的 location 显示读 session/session_v2 自己的
|
||||||
|
# directory 列,project.worktree 也要转,三处一起。
|
||||||
|
def _conv_win_path(p):
|
||||||
|
if not isinstance(p, str):
|
||||||
|
return p
|
||||||
|
if p.startswith(_WIN_HOME_PREFIX_SL):
|
||||||
|
return _WSL_HOME_PREFIX + p[len(_WIN_HOME_PREFIX_SL):]
|
||||||
|
if p.startswith("C:" + chr(92) + "Users" + chr(92) + "ypshidifu"):
|
||||||
|
return _WSL_HOME_PREFIX + p[len("C:" + chr(92) + "Users" + chr(92) + "ypshidifu"):]
|
||||||
|
return p
|
||||||
|
|
||||||
|
if tables.get("project"):
|
||||||
|
for _pr in tables["project"]:
|
||||||
|
_pr["worktree"] = _conv_win_path(_pr.get("worktree"))
|
||||||
|
for _t in ("session", "session_v2"):
|
||||||
|
for _r in tables.get(_t) or []:
|
||||||
|
if "directory" in _r:
|
||||||
|
_r["directory"] = _conv_win_path(_r.get("directory"))
|
||||||
|
try:
|
||||||
|
tables["session_v2"] = [
|
||||||
|
dict(r)
|
||||||
|
for r in win.execute(
|
||||||
|
"select * from session_v2 where id in (%s)" % ",".join("?" * len(targets)),
|
||||||
|
list(targets),
|
||||||
|
)
|
||||||
|
]
|
||||||
|
except Exception:
|
||||||
|
tables["session_v2"] = []
|
||||||
|
tables["session_message"] = missing_msg
|
||||||
|
# parts of missing messages
|
||||||
|
if msg_ids:
|
||||||
|
parts = []
|
||||||
|
chunk = 400
|
||||||
|
for i in range(0, len(msg_ids), chunk):
|
||||||
|
sub = msg_ids[i : i + chunk]
|
||||||
|
for r in win.execute(
|
||||||
|
"select * from part where message_id in (%s)" % ",".join("?" * len(sub)),
|
||||||
|
sub,
|
||||||
|
):
|
||||||
|
parts.append(dict(r))
|
||||||
|
tables["part"] = parts
|
||||||
|
else:
|
||||||
|
tables["part"] = []
|
||||||
|
except Exception as e:
|
||||||
|
win.close()
|
||||||
|
log("win export ERR: " + str(e))
|
||||||
|
return
|
||||||
|
win.close()
|
||||||
|
|
||||||
|
payload = json.dumps({"tables": tables}, ensure_ascii=False).encode("utf-8")
|
||||||
|
blob = gzip.compress(payload, 1)
|
||||||
|
nmsg = len(tables.get("session_message") or [])
|
||||||
|
npart = len(tables.get("part") or [])
|
||||||
|
log(
|
||||||
|
"diff: new=%d stale=%d msgs=%d parts=%d payload=%dKB"
|
||||||
|
% (len(need_sessions), len(stale), nmsg, npart, len(blob) // 1024)
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
rc = wsl_push(blob, REMOTE_IMPORT, timeout=120)
|
||||||
|
if rc != 0:
|
||||||
|
log("push rc=" + str(rc))
|
||||||
|
return
|
||||||
|
rc, _ = wsl_py(IMPORT_SRC, timeout=120)
|
||||||
|
if rc != 0:
|
||||||
|
log("import rc=" + str(rc))
|
||||||
|
return
|
||||||
|
res = json.loads(wsl_cat(REMOTE_RESULT))
|
||||||
|
log("import result: " + json.dumps(res, ensure_ascii=False)[:300])
|
||||||
|
json.dump(snapshot, open(STATE, "w", encoding="utf-8"))
|
||||||
|
log("state updated (session=%d v2=%d) in %ds" %
|
||||||
|
(len(win_sessions), len(win_sessions_v2), int(time.time() - t0)))
|
||||||
|
except Exception as e:
|
||||||
|
log("sync exception: " + str(e))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
try:
|
||||||
|
main()
|
||||||
|
except Exception as e:
|
||||||
|
log("FATAL: " + str(e))
|
||||||
|
sys.exit(0)
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
sync_wsl.py — WSL 插件/配置批量同步(Win 生效份 -> WSL 生效份)。
|
||||||
|
用法: python tools/sync_wsl.py [--only 名字 ...]
|
||||||
|
机制: 每个文件经 `wsl -- bash -c "cat > dst && sha256sum dst"` stdin 管道推送
|
||||||
|
(绕 UNC OSError 22), 双侧 sha256 校验, 逐个报告, 任一失败退出码 1。
|
||||||
|
同步清单(按「实际加载路径」对齐, 不是源码路径):
|
||||||
|
compaction-cn.ts / ai-mcp.ts / oc-retry-guard.ts / model-manager.ts /
|
||||||
|
oc-session-all/tui.js / mcpcfg.txt
|
||||||
|
"""
|
||||||
|
import argparse
|
||||||
|
import hashlib
|
||||||
|
import io
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
|
||||||
|
try:
|
||||||
|
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", errors="replace")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
DISTRO = "Ubuntu-24.04"
|
||||||
|
WIN = os.path.join(os.path.expanduser("~"), ".config", "opencode")
|
||||||
|
ITEMS = [
|
||||||
|
("compaction-cn", os.path.join(WIN, "plugins", "compaction-cn.ts"),
|
||||||
|
"/root/.config/opencode/plugins/compaction-cn.ts"),
|
||||||
|
("ai-mcp", os.path.join(WIN, "plugins", "ai-mcp.ts"),
|
||||||
|
"/root/.config/opencode/plugins/ai-mcp.ts"),
|
||||||
|
("tui.js", os.path.join(WIN, "plugins", "oc-session-all", "tui.js"),
|
||||||
|
"/root/.config/opencode/plugins/oc-session-all/tui.js"),
|
||||||
|
("mcpcfg", os.path.join(WIN, "mcpcfg.txt"),
|
||||||
|
"/root/.config/opencode/mcpcfg.txt"),
|
||||||
|
("oc-retry-guard", os.path.join(WIN, "plugins", "oc-retry-guard.ts"),
|
||||||
|
"/root/.config/opencode/plugins/oc-retry-guard.ts"),
|
||||||
|
("model-manager", os.path.join(WIN, "plugins", "model-manager.ts"),
|
||||||
|
"/root/.config/opencode/plugins/model-manager.ts"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def sha256(b):
|
||||||
|
return hashlib.sha256(b).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def sync_one(name, local, remote):
|
||||||
|
if not os.path.exists(local):
|
||||||
|
print("[%s] SKIP 本地不存在: %s" % (name, local))
|
||||||
|
return False
|
||||||
|
data = open(local, "rb").read()
|
||||||
|
local_sha = sha256(data)
|
||||||
|
cmd = 'cat > "%s" && sha256sum "%s"' % (remote, remote)
|
||||||
|
try:
|
||||||
|
r = subprocess.run(
|
||||||
|
["wsl.exe", "-d", DISTRO, "--", "bash", "-c", cmd],
|
||||||
|
input=data, capture_output=True, timeout=120,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
print("[%s] FAIL 启动 wsl.exe 异常: %s" % (name, e))
|
||||||
|
return False
|
||||||
|
if r.returncode != 0:
|
||||||
|
err = (r.stderr or b"").decode("utf-8", errors="replace")[:200]
|
||||||
|
print("[%s] FAIL push rc=%d %s" % (name, r.returncode, err))
|
||||||
|
return False
|
||||||
|
out = r.stdout or b""
|
||||||
|
text = out.decode("utf-8", errors="replace")
|
||||||
|
if "sha256" not in text and len(text.split()) < 2:
|
||||||
|
text = out.decode("utf-16-le", errors="replace")
|
||||||
|
parts = text.split()
|
||||||
|
remote_sha = parts[0].strip() if parts else ""
|
||||||
|
ok = remote_sha == local_sha
|
||||||
|
print("[%s] %s %d bytes sha=%s" % (name, "OK " if ok else "FAIL", len(data), local_sha[:12]))
|
||||||
|
return ok
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
ap = argparse.ArgumentParser()
|
||||||
|
ap.add_argument("--only", nargs="*", default=None, help="只同步指定名字")
|
||||||
|
args = ap.parse_args()
|
||||||
|
items = [it for it in ITEMS if not args.only or it[0] in args.only]
|
||||||
|
ok = 0
|
||||||
|
for name, local, remote in items:
|
||||||
|
if sync_one(name, local, remote):
|
||||||
|
ok += 1
|
||||||
|
print("result: %d/%d OK" % (ok, len(items)))
|
||||||
|
return 0 if ok == len(items) else 1
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,208 @@
|
|||||||
|
/**
|
||||||
|
* OpenCode TUI Plugin: Show All Sessions
|
||||||
|
*
|
||||||
|
* Registers /sessions-all command to show all sessions
|
||||||
|
* across all projects via a searchable picker dialog.
|
||||||
|
* Selecting a session navigates directly to it.
|
||||||
|
*
|
||||||
|
* TUI format: export default { id, tui }
|
||||||
|
*
|
||||||
|
* Also intercepts MCP connection error toasts to keep them quiet.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ── Helpers ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function formatDateTime(ms: number): string {
|
||||||
|
const d = new Date(ms);
|
||||||
|
const pad = (v: number) => String(v).padStart(2, "0");
|
||||||
|
const month = pad(d.getMonth() + 1);
|
||||||
|
const day = pad(d.getDate());
|
||||||
|
const hour = pad(d.getHours());
|
||||||
|
const min = pad(d.getMinutes());
|
||||||
|
return `${d.getFullYear()}/${month}/${day} ${hour}:${min}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function projectDisplayName(
|
||||||
|
session: any,
|
||||||
|
projectMap: Map<string, any>,
|
||||||
|
): string {
|
||||||
|
const project = session.projectID
|
||||||
|
? projectMap.get(session.projectID)
|
||||||
|
: undefined;
|
||||||
|
// Try project name first
|
||||||
|
if (project?.name?.trim()) return project.name.trim();
|
||||||
|
// Try worktree basename
|
||||||
|
const worktree = project?.worktree
|
||||||
|
?.replace(/\/+$/, "")
|
||||||
|
.split(/[\\/]/)
|
||||||
|
.pop();
|
||||||
|
if (worktree) return worktree;
|
||||||
|
// Try directory basename
|
||||||
|
const dir = session.directory
|
||||||
|
?.replace(/\/+$/, "")
|
||||||
|
.split(/[\\/]/)
|
||||||
|
.pop();
|
||||||
|
if (dir) return dir;
|
||||||
|
// Fallback to truncated project ID
|
||||||
|
return session.projectID ? session.projectID.slice(0, 8) : "unknown";
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Plugin ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const tui = async (api: any) => {
|
||||||
|
// ─── MCP Error Toast Suppression ─────────────────────────────────────
|
||||||
|
// Listen for toast events and suppress MCP-related error toasts.
|
||||||
|
// This keeps the UI clean when baota-mcp server is offline.
|
||||||
|
// When the server comes back online, tools auto-reconnect.
|
||||||
|
api.event.on("tui.toast.show", (event: any) => {
|
||||||
|
const props = event.properties;
|
||||||
|
if (
|
||||||
|
props.variant === "error" &&
|
||||||
|
typeof props.message === "string" &&
|
||||||
|
(props.message.toLowerCase().includes("mcp") ||
|
||||||
|
props.message.toLowerCase().includes("mcp server") ||
|
||||||
|
props.message.toLowerCase().includes("mcp error") ||
|
||||||
|
props.message.toLowerCase().includes("failed to connect") ||
|
||||||
|
props.message.toLowerCase().includes("connection refused"))
|
||||||
|
) {
|
||||||
|
// Replace the error message with a silent info toast
|
||||||
|
props.variant = "info";
|
||||||
|
props.message = "MCP server offline (auto-reconnect when available)";
|
||||||
|
props.duration = 2000;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Session Picker Dialog ────────────────────────────────────────────
|
||||||
|
const openPicker = async () => {
|
||||||
|
// Only work in base mode (not in a permission dialog etc.)
|
||||||
|
if (api.mode.current() !== "base") return;
|
||||||
|
|
||||||
|
let sessions: any[] = [];
|
||||||
|
let projects: any[] = [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Use experimental.session.list to get all sessions across projects
|
||||||
|
const [sessionResult, projectResult] = await Promise.all([
|
||||||
|
api.client.experimental.session.list({
|
||||||
|
limit: 1000,
|
||||||
|
roots: true,
|
||||||
|
directory: "",
|
||||||
|
}),
|
||||||
|
api.client.project.list({}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
sessions = sessionResult?.data ?? sessionResult ?? [];
|
||||||
|
projects = projectResult?.data ?? projectResult ?? [];
|
||||||
|
} catch (err) {
|
||||||
|
api.ui.toast({
|
||||||
|
message: "Failed to load sessions",
|
||||||
|
variant: "error",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Array.isArray(sessions) || !Array.isArray(projects)) return;
|
||||||
|
|
||||||
|
// Build project lookup map
|
||||||
|
const projectMap = new Map(projects.map((p: any) => [p.id, p]));
|
||||||
|
|
||||||
|
// Filter: hide archived and subagent sessions
|
||||||
|
const visible = sessions.filter(
|
||||||
|
(s: any) => !s.time?.archived && !s.parentID,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (visible.length === 0) {
|
||||||
|
api.ui.toast({
|
||||||
|
message: "No sessions found",
|
||||||
|
variant: "warning",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Group by project
|
||||||
|
const grouped = new Map<string, any[]>();
|
||||||
|
for (const session of visible) {
|
||||||
|
const name = projectDisplayName(session, projectMap);
|
||||||
|
const list = grouped.get(name) ?? [];
|
||||||
|
list.push(session);
|
||||||
|
grouped.set(name, list);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort sessions within each project by update time (newest first)
|
||||||
|
for (const list of grouped.values()) {
|
||||||
|
list.sort(
|
||||||
|
(a: any, b: any) => (b.time?.updated ?? 0) - (a.time?.updated ?? 0),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort projects alphabetically
|
||||||
|
const orderedProjects = [...grouped.keys()].sort((a, b) =>
|
||||||
|
a.localeCompare(b),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Build dialog options
|
||||||
|
const options = orderedProjects.flatMap((project) =>
|
||||||
|
grouped.get(project)!.map((session: any) => {
|
||||||
|
const updated = session.time?.updated
|
||||||
|
? formatDateTime(session.time.updated)
|
||||||
|
: "unknown";
|
||||||
|
const title = session.title || "Untitled";
|
||||||
|
return {
|
||||||
|
title: `${project} ${updated} \u2502 ${title}`,
|
||||||
|
value: session.id,
|
||||||
|
category: project,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Show the dialog
|
||||||
|
api.ui.dialog.setSize("xlarge");
|
||||||
|
api.ui.dialog.replace(
|
||||||
|
() =>
|
||||||
|
api.ui.DialogSelect({
|
||||||
|
title: "All Sessions",
|
||||||
|
placeholder: "Search sessions across all projects\u2026",
|
||||||
|
options,
|
||||||
|
onSelect: (option: any) => {
|
||||||
|
api.ui.dialog.clear();
|
||||||
|
api.route.navigate("session", { sessionID: option.value });
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
() => {},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── Register Command ─────────────────────────────────────────────────
|
||||||
|
// Register /sessions-all as a slash command
|
||||||
|
if (api.command) {
|
||||||
|
api.command.register(() => [
|
||||||
|
{
|
||||||
|
title: "All Sessions",
|
||||||
|
value: "sessions.all",
|
||||||
|
description: "Browse and switch to any session across all projects",
|
||||||
|
category: "Session Picker",
|
||||||
|
slash: { name: "sessions-all", aliases: ["resume-all", "continue-all"] },
|
||||||
|
onSelect: () => {
|
||||||
|
void openPicker();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Keybinding ───────────────────────────────────────────────────────
|
||||||
|
// Ctrl+o: dedicated fallback keybinding to open the picker
|
||||||
|
api.keymap.registerLayer({
|
||||||
|
bindings: [
|
||||||
|
{
|
||||||
|
key: "ctrl+o",
|
||||||
|
desc: "All sessions picker",
|
||||||
|
preventDefault: true,
|
||||||
|
cmd: () => void openPicker(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Export ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export default { id: "oc-session-all", tui };
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
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) {
|
||||||
|
const timestamp = new Date().toISOString()
|
||||||
|
const line = `[${timestamp}] ${msg}\n`
|
||||||
|
try {
|
||||||
|
appendFileSync(LOG_FILE, line)
|
||||||
|
} catch (e) {
|
||||||
|
// 忽略写入错误
|
||||||
|
}
|
||||||
|
console.log(msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Plugin.define({
|
||||||
|
id: "oc-session-all",
|
||||||
|
setup(context) {
|
||||||
|
log("[oc-session-all] v2 Plugin loaded! Attempting command registration...")
|
||||||
|
|
||||||
|
// ── 探测 context 结构 ──────────────────────────────────────
|
||||||
|
const report = []
|
||||||
|
report.push("context keys: " + Object.keys(context).join(", "))
|
||||||
|
|
||||||
|
// 探测 context.command
|
||||||
|
if (context.command) {
|
||||||
|
const cmdKeys = Object.getOwnPropertyNames(context.command)
|
||||||
|
report.push("command props: " + cmdKeys.join(", "))
|
||||||
|
const cmdMethods = cmdKeys.filter(k => typeof context.command[k] === "function")
|
||||||
|
report.push("command methods: " + cmdMethods.join(", "))
|
||||||
|
|
||||||
|
// 尝试 command.register
|
||||||
|
if (typeof context.command.register === "function") {
|
||||||
|
try {
|
||||||
|
const unregister = context.command.register(() => [
|
||||||
|
{
|
||||||
|
title: "Show All Sessions",
|
||||||
|
value: "sessions-all",
|
||||||
|
slash: { name: "sessions-all", aliases: ["sa"] },
|
||||||
|
description: "Display all session records in a dialog",
|
||||||
|
onSelect: async () => {
|
||||||
|
log("[oc-session-all] /sessions-all triggered")
|
||||||
|
context.ui.toast.show({
|
||||||
|
message: "Sessions-all command executed!",
|
||||||
|
variant: "success",
|
||||||
|
duration: 3000,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
|
report.push("command.register: SUCCESS, unregister type: " + typeof unregister)
|
||||||
|
log("[oc-session-all] command.register succeeded")
|
||||||
|
} catch (e) {
|
||||||
|
report.push("command.register ERROR: " + e.message)
|
||||||
|
log("[oc-session-all] command.register ERROR: " + e.message)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
report.push("command.register: NOT A FUNCTION")
|
||||||
|
log("[oc-session-all] command.register is not a function")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
report.push("context.command: undefined")
|
||||||
|
log("[oc-session-all] context.command is undefined")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 探测 context.keymap
|
||||||
|
if (context.keymap) {
|
||||||
|
const kmKeys = Object.getOwnPropertyNames(context.keymap)
|
||||||
|
report.push("keymap props: " + kmKeys.join(", "))
|
||||||
|
const kmMethods = kmKeys.filter(k => typeof context.keymap[k] === "function")
|
||||||
|
report.push("keymap methods: " + kmMethods.join(", "))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 探测 context.ui
|
||||||
|
if (context.ui) {
|
||||||
|
const uiKeys = Object.keys(context.ui)
|
||||||
|
report.push("ui keys: " + uiKeys.join(", "))
|
||||||
|
}
|
||||||
|
|
||||||
|
const msg = report.join(" | ")
|
||||||
|
log("[oc-session-all] FULL PROBE: " + msg)
|
||||||
|
try {
|
||||||
|
context.ui.toast.show({
|
||||||
|
message: "Full probe: " + msg,
|
||||||
|
variant: "info",
|
||||||
|
duration: 20000,
|
||||||
|
})
|
||||||
|
} catch (e) {
|
||||||
|
log("[oc-session-all] toast.show ERROR: " + e.message)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user