dsh-plan-act-models
为了在plan和act两个模式下使用不同的模型或思考强度,参考RooCode的思路,在DeepSeek Harness内以插件方式实现这一自动切换
- Stars
- 0
- Language
- JavaScript
- Created
- Oct 25, 2026
- Updated
- Oct 26, 2026
Introduction
双轨 · Plan / Act 模型(dsh-plan-act-models)
按 RooCode 的 Plan/Act 思路给 DeepSeek Harness 加一层纯插件实现:
- 会话处于 Plan 模式时,模型请求使用你配置的 Plan 路由(provider + model + 思考复杂度)。
- 离开 Plan(Act)时,批准
exit_plan_mode之后的第一条模型请求就换成 Act 路由。 - 两组路由各自独立,可以指向不同的 provider / 连接;插件不存 API Key,全部复用宿主凭据。
- 手动在原生模型选择器里换模型 = 当前模式的临时覆盖,下一次真正的 Plan↔Act 切换时清除,不会写回配置。
- 对话框底部、原生模型选择器左侧有一个只读标签,直接显示
模式:模型(见对话框里的路由标签)。 - 配置非法时失败关闭:请求被阻止并报出是哪一项不合法,不静默回退、不自动换 provider。
- 只作用于 runtime-root 会话(Web/CLI);不接管 subagent / 后台委派任务。
安装
分发包是 dsh-plan-act-models-0.3.0.tgz(npm pack 产物,无运行期依赖、可离线安装)。
完整步骤、验证清单与排错见 INSTALL.md,安装路线摘录:
-
桌面版:应用内 设置 → 插件 → 从本地文件安装 选 tarball,然后重启应用。 桌面 profile 由应用独占管理,外部
dsh --profile desktop …会报managed exclusively by the Electron application。 -
CLI / Web 普通 profile:
dsh plugin --profile <profile> add "<绝对路径>\dsh-plan-act-models-0.3.0.tgz", Launcher 会自动把 bundle 写进dsh.profile.bundles,然后重启。 -
直接从本仓库装(不需要本地有 tarball):装最新 Release 附件,或按 git 引用装源码——
dsh plugin --profile <profile> add "https://github.com/CynthiaYX/dsh-plan-act-models/releases/latest/download/dsh-plan-act-models.tgz" # 或锁定到某个 tag 的源码 dsh plugin --profile <profile> add "github:CynthiaYX/dsh-plan-act-models#v0.3.0"两者都指向同一套文件;Release 附件是
npm pack产物,github:走整仓 tarball, 仓库根就是包根,没有需要裁剪的子目录。当前最新 Release:v0.3.0(附件
dsh-plan-act-models.tgz与dsh-plan-act-models-0.3.0.tgz,两者 sha256 相同)。更新到新版只需重跑一次add, 把latest换成新 tag 可以锁定版本。
配置入口:设置 → 双轨 · Plan / Act 模型(首次默认关闭)。
本机开发时的链接安装
源码目录本身就是包(dsh-plan-act-models),开发时按 @local/dsh-restart 的先例手写 profile 层:
- profile
package.json的dependencies加"dsh-plan-act-models": "link:<克隆目录>/dsh-plan-act-models"(Windows 上写成link:D:/private/DeepSeek-harness-plugin/dsh-plan-act-models这种正斜杠形式),dsh.profile.bundles里追加"dsh-plan-act-models" node_modules\dsh-plan-act-models目录联接指向本仓库(等价于 pnpmlink:)- 本仓库自带的
cordis.patch.yml由 bundle 机制自动插入 Loader 行;profile 的cordis.patch.yml只用来覆盖那一行的 config(同 id),不要再写第二条- insert: - 本包的
exports必须显式导出./package.json与./cordis.patch.yml:宿主@deepseek-ai/dsh-client-modules会用createRequire().resolve('<pkg>/package.json')读dsh.client声明,不导出就会ERR_PACKAGE_PATH_NOT_EXPORTED
发布流程
仓库 CynthiaYX/dsh-plan-act-models 就是分发仓库:仓库根 = npm 包根,tag 触发发布,
Release 附件同时提供 dsh-plan-act-models-<version>.tgz 与稳定名 dsh-plan-act-models.tgz
(后者让 releases/latest/download/dsh-plan-act-models.tgz 这种"永远最新"的安装/更新地址可用)。
-
改
package.json的version,提交并推送。 -
打 tag 并推送(注意:Windows PowerShell 5.1 不支持
&&,要用;或分成两行):git tag v<version> git push origin main --tags -
.github/workflows/release.yml会先校验 tag 与package.json一致,再npm test、npm pack, 最后把两个 tgz 挂到该 tag 的 Release 上。 -
装/更新的人用 设置 → 插件 或
dsh plugin add <Release 附件地址>即可,不需要本地构建。
上传前清单
本包尚未发布到 npm,GitHub Release 才是当前的发布通道。发布前依次确认:
node --test全绿(66 个用例)。npm pack --dry-run的文件列表里没有lib/*.js(该 glob 曾指向不存在的文件)。package.json无private,license与repository齐全。cordis.patch.yml的id与client.js的ENTRY逐字相同 ——client.test.mjs做静态比对,client-bundle.test.mjs真的执行apply()并断言传进configForms.get()的 namespace 就是 patch 的id。改一处必须改另一处。- 若要同时发 npm,上传后
npm view dsh-plan-act-models version应回显对应版本。
配置
| 字段 | 含义 |
|---|---|
enabled | 总开关。关闭后不再自动切换,也不再阻塞请求,当前模型保持不变。 |
plan | Plan 模式路由:provider、model、reasoningEffort(留空 = 跟随模型默认)。 |
act | Act 模式路由,同上。 |
projects | 按工作区根目录(agent.session.header.cwd)的覆盖表:inherit / override / off。 |
启用前 Plan 与 Act 必须都完整(provider + model)。项目覆盖是完整的一对,不做半合并;
项目设为 off 时该项目内不做任何切换。
保存后按设计只影响新会话与已存在会话的下一次真正的 Plan↔Act 切换;界面没有 「立即应用到当前会话」按钮(RooCode 同样不把临时选择写回配置)。
对话框里的路由标签
composer 底部、原生模型选择器紧左边有一个只读标签,文字就是 模式:模型(全角冒号):
| 情况 | 标签文字 |
|---|---|
| Plan 模式下路由完整 | Plan:DeepSeek V4.1 Flash |
| Act 模式下路由完整 | Act:DeepSeek V4.1 Flash |
| 插件总开关关闭 | Plan:已关闭 |
当前工作区在 projects 里被设为 off | Plan:此项目已停用 |
| 该模式的 provider/model 不完整 | Plan:未配置 |
- 模式取宿主已提交的 plan 状态(与宿主自身路由判定同源),不是消息里的
/plan文本; Plan↔Act 切换后标签跟着变。 - 模型按「当前会话工作区根目录 → 项目覆盖 → 全局配置」解析,语义与真正发请求时的
resolveTarget完全一致(test/client-view.test.mjs用输入矩阵逐项比对两套实现)。 - 显示名优先用模型目录里的
name,查不到退回 model id;思考复杂度不进正文,只在悬停提示里:Plan:DeepSeek V4.1 Flash — cline-pass/deepseek-v4.1-flash-1017 (max) · 全局配置(来自项目覆盖时末尾是· 项目覆盖:<工作区>)。 - 不显示的三种情况:subagent 会话(宿主从不给它们路由)、配置镜像尚未就绪
(
loading/unavailable/error)、没有绑定会话。 - 不反映原生选择器上的手动临时覆盖:那个覆盖发生在宿主侧,标签描述的是配置本身解析出的路由。
状态说明
设置页顶部会直接反映读取结果:
正在读取配置…:客户端的 settings 镜像还在拉取。宿主没有提供配置项「plan-act-models」:Loader 行没启用/没加载,此时页面不会给你一个 写了也存不进去的表单。读取配置失败:…:镜像报错,原样展示原因。
行为边界
- 新会话按当前模式解析:非 Plan 会话的第一条请求即使用 Act 路由。
- 已经存在的会话(重启后恢复、或插件刚启用)不会立刻被切换,等下一次真正的 Plan↔Act 切换才接管。
- 路由判定读的是宿主已提交的 plan 状态(
agentPresets.serviceFor(agent, 'planMode').get(agent), 退路是根平面的sessionProjections.stateOf(session, 'plan')),不是消息里的/plan文本。 - 不改写全局默认模型:请求在
agent/request里被改写,profile 里agent-default-model保持原样。 - 原生模型选择器上的显示可能滞后(架构优先),真实生效的路由以请求日志/插件状态为准;
对话框里的
模式:模型标签描述的是配置解析出的路由,可以拿它对照。
已知限制
- 手动改模型靠对比「会话自身选择解析出的路由」来识别,比较包含 provider、model 与思考复杂度; 若恰好选回与该值完全相同的三元组,无法区分(语义等价)。
system-prompt/assemble里的{{provider}}/{{model}}变量比请求路由晚一格;路由本身不滞后。- 插件会过滤宿主自动追加的
[model changed: …]提示,避免与实际路由矛盾;若该提示是某一步唯一的输入,则保留。 - 若被链接安装(目录 junction /
link:),lib/schema.mjs会依次尝试裸导入宿主安装里的@deepseek-ai/schemastery、DSH_SCHEMASTER_ROOT指定的副本、从process.execPath与启动参数 往上回溯找到的resources/app.asar/app.asar.unpacked副本(回溯 8 层,也接受相邻的普通node_modules),最后才是 profile 里的副本。每一份都会先校验能力:必须带.volatile()(schemastery ≥ 3.18.5)。profile 的上级node_modules里常残留旧副本(例如 3.18.1 的 陈旧 junction),旧副本能 resolve 但没有.volatile(),直接用会让插件在声明 Config 时就崩, 所以现在被跳过并在报错里点名版本。全都不可用时插件整体不加载——设置页显示「宿主没有提供配置项」, 报错列出每条试过的路径,可用DSH_SCHEMASTER_ROOT指向副本目录。 - 优先级靠注册顺序:插件用
{prepend:true}把监听器排到agent/request最外层,因此能盖过 宿主在 Agent 创建时装好的模型选择(它比agent/created更早注册)。宿主没有优先级 API; 若将来某个包在插件之后也用prepend装自己的选择,它会排在我们外面并接管路由 —— 此时插件 把这个值当作「手动选择」并停止改写。 - 插件不保证宿主原生模型选择器显示的就是实际请求的模型(隔离优先于显示),真实路由以请求日志为准。
- 读 plan 模式依赖宿主结构:
plan-mode装在 Agent preset 内部并带isolate: { planMode: true }, 因此插件自己的 fiber 上ctx.get('planMode')永远是undefined。插件改为依次尝试agentPresets.serviceFor(agent, 'planMode')与根平面的sessionProjections.stateOf(session, 'plan')。 两者都不可用时,插件会在日志里告警一次并退回「按 Act 路由」——不再静默。 enabled关闭期间不记录手动选择:重新打开后按当前模式恢复路由,不需要先做一次 Plan↔Act 切换。- 挂载失败只降级,不拖垮会话:
attach是在宿主的agent/created里同步执行的,而那条路径 不做 catch——一次抛出就会让该 Agent 永远 publish 不出来(对话框一直是禁用状态,必须关掉插件才能说话)。 所以插件现在把挂载/卸载自己的任何异常就地吞掉,只在日志里告警一次并跳过该会话;其余会话不受影响。 注意agent/request里的「配置不合法」仍然照常抛错,那是刻意保留的失败关闭。 - 标签不跟随原生选择器的手动临时覆盖:临时覆盖只存在于宿主侧的会话状态里,不写回配置; 标签显示的是配置解析出的路由,所以两者此时会不一致(这正是「配置 vs 临时覆盖」的语义差别)。
- 标签沿用硬编码中文:与设置页一致,没有走 Client locale 服务;
client.js里甚至不能出现locale字样(test/client.test.mjs有既有断言)。 conversation.input.right目前无人占用,所以我们的条目稳定落在模型选择器左侧;若将来有别的 包也注册到同一个座位,顺序由order决定(本插件是 100),届时可能需要重新排序。
开发
# 在仓库根执行;`npm test` 也会跑同样七个文件
$pkg = '<克隆目录>\dsh-plan-act-models'
& $node --test "$pkg\test\routing.test.mjs" "$pkg\test\host.test.mjs" "$pkg\test\index.test.mjs" "$pkg\test\client.test.mjs" "$pkg\test\client-view.test.mjs" "$pkg\test\client-bundle.test.mjs" "$pkg\test\schema.test.mjs"
$node 指向安装目录里的 Node(D:\DeepSeek Harness\resources\runtime\primary-runtime\dependencies\node\bin\node.exe),
或直接用系统 node(需 ≥ 20,仓库在 Node 24 上验证)。
- 七个用例文件都只依赖
node:test与包内源码,不需要运行中的 Host:routing.test.mjs跑纯决策表(模式切换、临时覆盖、项目覆盖、非法配置阻塞),host.test.mjs直接 importlib/host.mjs验证 plan 模式读取与模型目录容错,index.test.mjs真正加载index.js并驱动它的三个监听器,client.test.mjs对client.js做静态回归断言,client-view.test.mjs从client.js里抽出#region plan-act-view那块镜像实现,用 输入矩阵与lib/routing.mjs的resolveTarget逐项比对,并断言标签文字是模式:模型,client-bundle.test.mjs真的执行 bundle:伪造window.__ModuleLoader__与configForms/slots两个 Service,调用 factory 拿到 module 再跑apply(),断言 注册的 settings 页 id 与传进configForms.get()的 namespace 都等于 patch 的id, 并真的渲染 composer 标签(Plan/Act、项目覆盖、三种降级态、subagent 不渲染),schema.test.mjs校验 schemastery 解析(含「旧副本缺.volatile()必须被拒绝」)。schema.test.mjs在找不到可用副本的机器上会走「报错必须可执行」那一条分支,不会误报失败。 client.js无法 importlib/routing.mjs(它是发给页面的 classic script),所以路由判定在#region plan-act-view … #endregion之间有一份手写镜像。client-view.test.mjs靠这对哨兵 把镜像抽出来做等价断言:改resolveTarget就必须同步改镜像,否则测试会红; 哨兵被删会直接失败,不会静默空跑。client-bundle.test.mjs用?case=N查询串绕开 ESM 模块缓存:bundle 的全部效果就是那一次load()调用,模块被缓存后第二次 import 不会重新执行。index.test.mjs用DSH_SCHEMASTER_ROOT在系统临时目录里造一个假的 schemastery(版本号写成 3.18.5,并把所有 helper 挂在 default export 上——打包版schemastery只有export { Schema as default },没有任何具名导出),因此在没有宿主的机器上也能加载入口。 这个文件是必需的:曾经index.js引用了未定义的rootOf/resumed,而当时 40 个用例全绿, 因为没有任何测试 import 入口,结果发出去的包在宿主里fiberPhase: failed。lib/host.mjs把「读 plan 模式」与「枚举模型目录」从index.js拆了出来,便于无宿主测试; 但入口本身也已经被index.test.mjs覆盖,不再有「index.js无法测试」这个假设。lib/routing.mjs是纯逻辑状态机(不依赖宿主):新会话 Act、进入 Plan、退出 Plan 后第一条请求、 临时覆盖与清除、恢复会话不立即切换、项目覆盖/禁用、非法配置阻塞。tools/read-header.mjs从session.v4.jsonl.zstd里解出每次request/header与plan/mode, 用来确认「Plan 模式下真的走了 Plan 路由」:
注意该文件是多帧 zstd,一次& $node "$pkg\tools\read-header.mjs" "$env:USERPROFILE\.dsh\sessions\<dir>\session-<id>\session.v4.jsonl.zstd"zstdDecompressSync只能解出第一帧(244 字节的会话头)。