73 Commits
Author SHA1 Message Date
ypshidifu f49ff8ef36 tui.js 消除版本假象:toast 文案 v10 改 v12 + v12 覆盖 tui-plugins 旧版
- 真相:WSL toast 显示 v10 是 v12 文件里硬编码忘改的旧文案(v12 独有的
  「操作未生效:DB 写未生效」失败 toast 已在 WSL 出现 = v12 实际在跑)
- plugins/oc-session-all/tui.js(54KB v12) 覆盖 tui-plugins/oc-session-all.js(29KB 真 v10):
  tui.json 注册路径指向后者,此前是隐患(按注册路径加载会拿到 v10)
- 三份(plugins / tui-plugins / src 真源)sha 72f173454def 一致
2026-10-20 23:31:50 +08:00
ypshidifu a41cf92379 session-sync 洗白扩展到 session/session_v2.directory:OC 2.x location 显示读会话表自身 directory 列
- Win DB 实锤:目标会话 ses_eb074c0daffe 的 session_v2.directory = C:/Users/ypshidifu(截图报错来源)
- FIX_WORKTREE_SRC 扩展:project.worktree + session.directory + session_v2.directory 三表全洗(幂等,每次无条件跑)
- 推送侧转换统一 _conv_win_path,session/session_v2 行的 directory 列同步转换
2026-10-20 23:20:44 +08:00
ypshidifu 546fc07779 session-sync 修存量 worktree 洗白:INSERT OR IGNORE 洗不掉 WSL 已存在 project 行
- 新增 FIX_WORKTREE_SRC:每次同步无条件把 WSL project 表存量 C:/Users/ypshidifu
  与 C://Users//ypshidifu 前缀 worktree UPDATE 成 /root(幂等)
- 修 WSL 侧 Session location unavailable(截图实锤:推送侧转换只对新插入行生效,
  存量行永远不更新且无 missing/stale 时 import 根本不执行)
2026-10-20 23:13:30 +08:00
ypshidifu 6b65ec50e7 session-sync: project.worktree 路径转 WSL(修Session location unavailable);model-sync 已补 db-exec/tui.json 2026-10-20 22:12:17 +08:00
ypshidifu f2b7303cd2 model-sync: 补同步 model-manager-db-exec.py(修WSL停用失效) + tui.json/tui-plugins(修/mcp-off缺失),路径转换覆盖 file:// 前缀 2026-10-20 21:59:20 +08:00
ypshidifu 3c768343e4 model-sync: 同步 opencode.json(provider+MCP) 与 mcpcfg 路径转 WSL,修 mcpcfg 正则转义 bug 2026-10-20 21:16:32 +08:00
ypshidifu afdafb0ed1 model-sync: 加 opencode.json.2 provider 同步;session-sync: 双表驱动 2026-10-20 20:49:29 +08:00
ypshidifu 5cd94dc4e1 修 oc-model-sync:NOT NULL 动态补列 + 推 4 个插件状态文件
WSL import 因 kv.time_created NOT NULL 回滚,catalog/credential 全没写入:
- kv/credential 改按 WSL 实际表列 PRAGMA 动态补全默认值(time_* 补 now,active 补 0,
  id 缺时生成稳定 win_xxx,label NOT NULL 兜底),杜绝 NOT NULL 约束失败
- 新增 4 个 model-manager 状态文件进同步(cfg/removed/disabled/stoplist.bak),
  让 WSL 侧感知 Win 已停用 xiaomi、已删 Agnes;pristine 不推(WSL 自补)
- 同步 runtime -> tools/ 真源
2026-10-20 19:17:22 +08:00
ypshidifu f7688bf27e 重定位 WSL 模型同步:Win opencode.db 真源(catalog+credential)推 WSL DB 覆盖式对齐,修 xiaomi 挂错树/旧模型残留 2026-10-20 18:35:46 +08:00
ypshidifu 884d53b619 修「重载了但模型还在」:热重载/启动时从最近使用与收藏历史(model.json)剔除停用条目,toast同步显示清理条数 2026-10-20 10:32:29 +08:00
ypshidifu b911a43940 修复已停用厂商找回交互:选中先进确认层(可看出厂模型/返回),点恢复才入队reset 2026-10-20 10:08:58 +08:00
ypshidifu 19e16a42e0 flush轮询识别失败标记:DB写未生效时提示失败,不再假报已重载 2026-10-20 09:21:41 +08:00
ypshidifu 874e69fb14 tui.js:模型管理面板改状态机,每层带「← 返回上级」;恢复出厂设置=删除厂商并二次确认 2026-10-19 22:29:27 +08:00
ypshidifu cd25c0a3d0 tui.js:进厂补恢复出厂(格式化)入口、整厂停用改单条 disable_all、flush 改常驻轮询热重载 2026-10-19 18:38:40 +08:00
ypshidifu 0001fc7d64 tui.js:模型操作入队后等 daemon flush 落盘再执行 location.reload 热重载 2026-10-19 16:59:21 +08:00
ypshidifu 7ab1b9a9b0 tui.js:/manage 精确匹配替换前缀匹配,选厂后加单模型/整厂粒度层 2026-10-19 12:34:02 +08:00
ypshidifu 38c2b20e3d tui.js:修 /manage catalog 二次解包——models-dev:catalog 外层是 {updatedAt,digest,body},body 才是 provider JSON;此前只解一层只拿到3个key致前缀匹配恒空(日志实证 catalogProviders=3 命中[]) 2026-10-19 12:18:37 +08:00
ypshidifu aa9b3281f1 tui.js:/manage 加全链路诊断日志(来源A config、来源B credentials+命中、最终列表),便于实证排查内置 provider 是否出现 2026-10-19 12:02:56 +08:00
youpaishidifu 8e07bd8af8 tui.js:/manage 来源B改读 credential 表(active=1 的 integration_id)前缀匹配 catalog,能管到内置 /connect 的 provider(如 Xiaomi 全家族) 2026-10-19 11:46:33 +08:00
youpaishidifu 2f64df6a9b tui.js:/manage 来源B改只列已连接 catalog provider(env 任一 key 在 process.env),不再全量拉232个 2026-10-19 11:01:11 +08:00
youpaishidifu 90eb09b2a4 tui.js:/manage 改为只显示已连接 provider(读 opencode.json provider 字段),不再拉全量 232 个 2026-10-19 10:48:58 +08:00
youpaishidifu fd8cfa8887 tui.js:修 /manage 面板 dialog.select options 字段 label→title(label 不识别→undefined.length 崩 OC) 2026-10-19 10:42:30 +08:00
youpaishidifu 043fd55ae1 tui.js:修 mmReadCatalog Windows GBK 编码坑(sys.stdout.buffer.write + PYTHONUTF8) 2026-10-19 10:36:51 +08:00
ypshidifu 0d2d1144eb 补全各分支的基础情况 2026-10-19 10:29:40 +08:00
youpaishidifu 250e71c48c tui.js:修 /manage mmReadCatalog Python 单行语法错误(if row: import sys 非法)→ 提前 import sys 2026-10-19 10:25:32 +08:00
youpaishidifu b7537988f9 tui.js:新增 /manage 模型管理面板(/manage 别名 managent);sync_wsl.py 加 model-manager 项;.gitignore 拉黑 model-manager/ 2026-10-19 10:13:28 +08:00
ypshidifu 9dfe04f221 main: 移除 oc-retry-guard 跟踪,拉黑该子目录(专属分支独立维护)
- 移除 oc-retry-guard/oc-retry-guard.ts 的 main 跟踪(该插件只在 oc-retry-guard 分支维护)
- .gitignore 增加 oc-retry-guard/ 拉黑
- 保留 tui.js v11 排版改动 + sync_wsl.py ITEMS 增加 oc-retry-guard 项
2026-10-19 09:30:34 +08:00
ypshidifu fcf188bf28 oc-retry-guard 插件(严重错误自动重试·事不过三2分钟周期)+ 会话面板 v11 排版(日期降序+sessionID)
- 新增 oc-retry-guard.ts:http.response 钩子读 body 识别 finish reason 类错误,
  命中后按 2分钟窗口/最多3次 计数,自动用缓存 body 追加 nudge 重发同端点并替换 response;
  超 3 次或超 120s 自动归零,事不过三后 STOP。
- tui.js 会话选择器 v11:从按项目分组改为全局按 time.updated 降序,title 末尾加 [sessionID前8位]。
- sync_wsl.py ITEMS 增加 oc-retry-guard 同步项。
2026-10-19 09:24:20 +08:00
ypshidifu 2f91a612bb 新增 WSL 插件批量同步工具 tools/sync_wsl.py;TUI 插件为 mcpcfg.txt 增加实时监测与丢失自动重建防御 2026-10-12 19:42:44 +08:00
ypshidifu ee4be54d0f gitignore:忽略compaction-cn独立仓库,补忽略测试产物js 2026-09-25 15:40:26 +08:00
ypshidifu 4431ed8335 移除调试文件和目录 2026-09-24 12:43:18 +08:00
ypshidifu 1ba3ad6bd3 TUI插件MCP文案去误导:reload成功即报成功,推送失败仅留log不进结果toast 2026-09-24 12:39:47 +08:00
ypshidifu c593f94a5e README:补回skill推拉章节(并行编辑覆盖丢失回补) 2026-09-23 19:55:48 +08:00
ypshidifu 6215144fb5 README:新增skill推拉新流程章节,ai-mcp指路双导出配方三态实验结论 2026-09-23 19:54:31 +08:00
ypshidifu 0f099c88d0 TUI插件v10:MCP推送兜底改GET+merge补shell三跳,README同步安装路径与快捷键 2026-09-23 17:50:26 +08:00
ypshidifu 6e0ed1743a TUI插件v9:修复dirname导入缺失致自愈静默失效,新增reload前mcp配置推送兜底(双保险)
- 补回 node:path 的 dirname 导入:healShadowPath 引用它,缺导入时
  ReferenceError 被自身 catch 吞掉,影子路径自愈静默失效
- 新增 pushMcpConfig:swap+flag 之后、reload 之前把活配置 mcp 推进
  运行中 OC(SDK config.update → body 形参 → experimental 三连),
  推送成功+reload 双保险,失败降级原单通道
- toast 区分双保险/单通道文案;log/toast 版本标识升级 v9
- 单测新增 S0 回归断言:源文件 path 导入必须含 dirname
  (prelude 复制的导入曾掩盖源码缺失,15/15 假绿 -> 16/16)
2026-09-23 15:31:55 +08:00
ypshidifu 20a3b487fd TUI插件:mcpcfg键值化+路径自愈+命令无条件注册+mcp.on.flag防归位;附readMcpCfg单测15例 2026-09-23 13:47:10 +08:00
ypshidifu b65d3d6879 更新主分支的readme.md,说明其它分支的关联关系 2026-09-23 13:17:38 +08:00
ypshidifu 831e3de8d8 MCP 配置对调降级方案:热开关 + reload 热更新 + 命令全面中文化
- 新增 mcpcfg.txt 三行配置(模式/含MCP路径/安静路径),小白只改 txt
- 模式语义:自动=启动归位+命令可用+server插件可切换;手动=仅 /mcpon /mcpoff;关闭=全部禁用
- /mcpon /mcpoff:对调两份配置后调用 location.reload 热重载,MCP 立即生效
- auto 模式启动自动把含 MCP 的活配置归位为安静版(6 秒后重载),保持零 toast 零报错
- enableMcp 加门控:活配置不含 mcp 时跳过 90s 重试 connect,避免 404 空转
- 全部命令标题/描述/dialog/toast 改为简体中文
- .gitignore 排除 ai-mcp/ 与 wsl-round-mcp/ 两个独立子仓库
2026-09-23 12:05:16 +08:00
youpaishidifu ffc0e8d743 更新readme.md的功能介绍 2026-09-23 11:17:19 +08:00
ypshidifu 3c0a8971a6 v7: MCP字段修正——真正生效的开关是disabled不是enabled(会被server schema剥掉);enableMcp改90s重试(启动4~5s location才就绪,过早调用404) 2026-09-23 10:12:41 +08:00
ypshidifu b74d87cfbb v6: MCP改config禁用+插件mcp.connect运行时启用;skill目录5s轮询热加载兜底新增目录盲区 2026-09-23 00:05:43 +08:00
ypshidifu c1cb45bed4 跨平台改造:LOG_DIR改用os.homedir(),v5同步至WSL版(/root/.config/opencode)修复WSL插件failed 2026-09-22 23:00:15 +08:00
ypshidifu d6048da95f v5: 按官方 opencode.btw 模板注册命令 — ui.slot({append:app,render}) + keymap.layer(()=>({mode,commands})),恢复全功能(sessions-all slash命令+ctrl+o+选择器+MCP toast拦截) 2026-09-22 22:21:56 +08:00
ypshidifu 92fee7511f v4: 直接调用 ui.slot(component) 注册组件,在组件渲染阶段调用 keymap.layer 注册命令 2026-09-22 22:04:02 +08:00
ypshidifu 6d59c24126 v3: 尝试通过ui.slot注册组件来获取Keymap Provider上下文,探索在SolidJS渲染阶段注册命令的可行性 2026-09-22 21:50:57 +08:00
ypshidifu f66cde6494 更新探测版插件:探索 keymap.commands 和 dispatch 2026-09-22 21:29:34 +08:00
ypshidifu ba26fd512a 更新探测版插件:添加延时避免 toast 被覆盖 2026-09-22 21:23:54 +08:00
ypshidifu 3d8f093d7e 更新探测版插件:尝试 keymap.layer 注册命令 2026-09-22 21:13:07 +08:00
ypshidifu 459f038f8e 更新探测版插件:将探测结果写入日志文件 2026-09-22 21:02:06 +08:00
ypshidifu fd1a475abc 更新探测版插件:更详细的 context 结构探测 2026-09-22 20:55:42 +08:00
ypshidifu dc1a27e88e 更新tui.js 2026-09-22 20:50:48 +08:00
ypshidifu ec7b461147 更新探测版插件:尝试 context.command.register 注册命令 2026-09-22 20:48:33 +08:00
ypshidifu 39efb3aa0f 更新tui.js 2026-09-22 20:01:50 +08:00
ypshidifu 9f17ac9936 修复: keymap.layer → keymap.registerLayer(方法名修正) 2026-09-22 20:00:28 +08:00
ypshidifu 057e698f22 测试隔离版: keymap.layer commands(无 bindings、无 data.on) 2026-09-22 17:49:05 +08:00
ypshidifu 028af01b3d v2: 加回全部功能 - session列表/快捷键/MCP拦截 2026-09-22 15:58:26 +08:00
ypshidifu 952b31127c v2: 简化 oc-session-all 为最小化版本测试加载 2026-09-22 15:37:54 +08:00
ypshidifu a5a8c5e011 v2: 将 oc-session-all 从 v1 格式转为 v2 Plugin.define 格式
- 使用 import { Plugin } from "@opencode/plugin/tui"
- 使用 Plugin.define({ id, setup(context) }) 格式
- api.* 调用全部替换为 context.*: api.event.on → context.data.on, api.ui.toast → context.ui.toast.show, api.ui.dialog.* → context.ui.dialog.*, api.command.register + api.keymap.registerLayer 合并为 context.keymap.layer({commands, bindings})
- package.json exports 添加 ./tui 入口点,engines.opencode 升级为 >=2.0.0
- 已通过 minimal-v2-test 验证 v2 格式可正确加载
2026-09-22 15:19:14 +08:00
ypshidifu 76d641b17f 删除错误的node_modules目录。 2026-09-22 12:07:43 +08:00
youpaishidifu 6c165dadd5 创建最小化tui.json以便测试各项功能 2026-09-22 11:59:05 +08:00
ypshidifu 47c668f446 添加调试版本插件,修改gitignore允许提交js文件 2026-09-22 11:02:38 +08:00
ypshidifu d201e3bb97 更新tui.json 2026-09-22 10:46:22 +08:00
ypshidifu 6bbbc5ea0b 更新tui.json 2026-09-22 10:27:33 +08:00
ypshidifu 258a40b0d0 整理: 删除冗余的 src/ 目录,统一入口为 tui-plugins/,更新 README 和 package.json 2026-09-21 19:41:05 +08:00
ypshidifu 6c4d97362f 整合: 将 tui-plugins 目录移入项目根目录,统一代码管理 2026-09-21 19:31:08 +08:00
ypshidifu bfe683d7e2 修复: 将TUI插件从plugins/自动发现目录移出,避免server loader报错failed, local
- 将oc-session-all.ts从plugins/移至tui-plugins/目录
- 更新tui.json引用路径为./tui-plugins/oc-session-all.ts
- 移除空的@opencode-ai/plugin/tui类型导入
- 改用api.client.experimental.session.list获取会话
- 优化MCP错误toast拦截:将error替换为info静默提示
- 同步更新源码目录src/index.ts
2026-09-21 18:58:37 +08:00
ypshidifu 37df56c6d9 重写为TUI插件:弹出面板显示所有会话,点击跳转,拦截MCP报错 2026-09-21 18:19:18 +08:00
ypshidifu 289ff13132 修复:将插件从 Tool 改为 Command,支持用户输入 /sessions-all 命令 2026-09-21 17:29:21 +08:00
ypshidifu 45e68bfecf 更新插件:使用 node:sqlite 替代 sqlite3 命令行,提高 Windows 兼容性 2026-09-21 17:21:54 +08:00
ypshidifu 0d19143065 重写插件为V2格式:使用define+id+setup(ctx)+ctx.tool.transform注册工具 2026-09-21 17:04:34 +08:00
ypshidifu 5d43b5ed76 修复:使用 OpenCode 2.x Plugin API,正确导入类型和 tool helper 2026-09-20 23:45:41 +08:00
ypshidifu 02f3ded256 初始化插件:oc-session-all - 显示所有会话记录,绕过目录过滤 2026-09-20 23:35:05 +08:00
15 changed files with 3388 additions and 980 deletions
+21 -4
View File
@@ -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
+63
View File
@@ -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的连接通道) |
-10
View File
@@ -1,10 +0,0 @@
{
"停用清单": "持久化标记停用模型",
"以下是停用列表": [],
"配置格式": "一行一个,比如Xiaomi,deepseek等,*表示通配符,比如xiaomi-*则表示停用小米家族的全部模型(Xiaomi、Xiaomi-token-plan等)",
"示例参数":[
"xiaomi",
"DEEPSEEK",
"longcat-*"
]
}
-870
View File
@@ -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
},
}
+422
View File
@@ -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"
}
}
}
}
+26
View File
@@ -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"
}
}
-96
View File
@@ -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 同步)
+69
View File
@@ -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())
+418
View File
@@ -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)
+445
View File
@@ -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)
+89
View File
@@ -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
+208
View File
@@ -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 };
+101
View File
@@ -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)
}
},
})
+7
View File
@@ -0,0 +1,7 @@
{
"$schema": "https://opencode.ai/tui.json",
"plugin": [
"file:///C:/Users/ypshidifu/.config/opencode/tui-plugins/oc-session-all-debug.js",
"file:///C:/Users/ypshidifu/.config/opencode/tui-plugins/oc-session-all.js"
]
}