dsh-groups
给 DeepSeek Harness 装一个插件分组一键开关。
DSH 的插件是否加载由 profile 的 package.json → dsh.profile.bundles 决定,改完必须重启才生效。
这个插件把这个清单编成几套命名组合,点一下整组切换,不用再手动编辑 JSON。
面板
侧栏「插件分组」(order 60)。打开后:
- 顶部显示当前生效的分组(可能同时有好几组;一组都没满足时显示「尚未启用任何分组」)。
- 每张卡片显示点它之后会新增多少 / 已装但清单里没有的包 / 会顺带关掉哪些不属于任何分组的插件。
- 「启用这组」→ 只做加法,把这一组的插件并入 profile 的
dsh.profile.bundles,你正在用的其它插件不会被关掉。
- 「仅保留这组」→ 覆盖,关掉其它所有插件只留这一组。
- 「停用」→ 减法,只把这一组加进来的插件拿出去;别的组(含常驻组)不受影响。确认框里会先告诉你移除几个、是哪些。
- 三处都会提示重启 DSH。
- 「新建分组」/「编辑」→ 勾选式界面,从全部已装插件里挑,带搜索;分组里存着但已经不在目录里的包会单独列出,可以一键移除。
- 「预设」→ 把这一组写成一个 agent 预设,新建会话时可以选它单独生效(见下节)。
- 「删除」→ 二次确认。
写盘前会备份:package.json.bak-before-groups-<时间戳>、cordis.patch.yml.bak-before-groups-<时间戳>。
多组叠加
两个开关决定一个分组怎么和别的分组共存:
| 标记 | 含义 | 典型用途 |
|---|
常驻(persist) | 应用任何别的分组时,这一组会自动一起叠加上去 | 「常驻基础」——字体、通知、顺滑流式这类天天要开的 |
兜底(solo) | 不吃任何叠加,应用它就是干干净净只有它自己 | 「全关」——排查问题时关掉一切 |
- 点「启用这组」= 加法:当前清单 ∪ 这一组(所有常驻组也会一起叠上来)。
- 点「仅保留这组」= 覆盖:只剩这一组 + 核心,其它插件全关。
- 点「停用」= 减法:只减掉这一组自己 enable 里的插件,跳过核心包、官方包、以及任何常驻组也在用的包。
- 勾两张卡上的多选框 → 出现组合条 → 「同时启用这两组」= 两组一起加进来(A 和 B 一起执行)。最多同时勾两个。
- 卡片上会提示「应用这一组时会叠加:base + taskA」,点之前就知道会上去什么。
叠加只影响选哪些插件;最终清单仍是扁平的一层 dsh.profile.bundles,顺序保持用户原有顺序、新项追加在尾部。
两个标记都在编辑器里勾(「设为常驻组」/「设为兜底组」),也可以直接写 plugin-groups.json 的 persist / solo 字段。都不勾就是普通任务组。
保存时没带的标记沿用旧值,所以用脚本 PUT /group 只改 enable 不会把兜底标记抹掉;要显式清除就传 solo: false。
单个对话只开一组(预设)
点卡片上的「预设」按钮,就能把这一组写成一个 agent 预设:
- 勾「生成预设」+ 选能力 → 保存 → 重启 DSH → 新建会话的预设列表里出现
group-<分组id>。
- 选它开的新对话,只带这一组勾选的官方 agent 能力(基础工具 / 计划模式 / 上下文压缩 / 子代理 / 联网 / 定时任务)。
- 取消勾选「生成预设」就把它从补丁层里移除(受管块整体重写,不会留残渣)。
常驻组的能力会自动并进来:如果你有标了「常驻」的分组(persist: true),它勾的能力会
自动加进其它每一组的预设——常驻组的语义就是「任何场景都要有」。所以给「任务 A」开预设时,
常驻组勾的「定时任务」也会一起写进去,不用重复勾。预设编辑器里会明说「常驻组也会带上它们勾的
能力:常驻基础(定时)」。兜底组(solo: true)不吃这套,它的预设有且只有自己勾的能力。
id 撞车会自动区分:分组 id 会折成预设 id(a: b → group-a-b)。如果两个分组折出同一个,
后一个会带上稳定后缀,例如 group-a-b--ec916c(原始 id 的 sha1 前 6 位)——不是随机数,
所以同样的分组永远得到同样的 id,重复同步不会反复重写。
产物写在 <profile>/cordis.patch.yml 里的一段受管块,形如:
# >>> dsh-groups presets (managed) >>>
- insert:
- id: preset-group-task-a
name: '@deepseek-ai/dsh-agent-preset'
config:
id: group-task-a
order: 100
name: 任务 A
plugins: [ ... ]
# <<< dsh-groups presets (managed) <<<
块外的手写补丁不动;内容没变时不会写盘;写之前留 .bak-before-preset-<时间戳>。
两个安全阀:
- 受管块必须有完整的首尾标记,而且只能有一份。缺结束标记、或者出现第二份,同步会直接拒绝(HTTP 409
BLOCK_CORRUPT)并保持文件逐字节不动——宁可不动,也不吞掉块外你手写的补丁。
- 卸载插件之后的残留项:分组里引用了已卸载的包时,卡片会提示「未安装(会被跳过)」,点「编辑」可以在清单顶部看到逐条列出的残留项,每条带一个 × 按钮单独删掉(也有「全部移除」一次清空)。
- 标签分两种:「(未安装)」是真的装不上,保存时会被丢掉;「(不可选)」是内置官方包(
@deepseek-ai/* 由宿主自带、不进 profile 的 dependencies),保存时照常保留。判据来自后端 GET /state 的 missing 字段(走 isInstalled()),前端不自己猜。
- 如果当前 DSH 进程是旧版、
/state 里还没有 missing,标签会显示「(未知)」并提示重启,而不是把官方包误标成「未安装」。
- 分组 id 只允许字母、数字、点、下划线、连字符(1–64 位)。像
a: b 这种会折成 a-b 生成预设 id,避免把整份 cordis.patch.yml 写成解析不了的形态。
- 历史遗留的脏 id 能救回来:旧版本写进
plugin-groups.json 的不合规 id(如 a: b)会被标成 legacyKey,卡片上给出警告,点「编辑」可以把 id 改成合规形态并保存(按钮文案变成「改名并保存」);这类分组也能正常删除。改名时目标必须是合规 id、不能撞上已有分组。
没开任何预设时:如果块里本来就空,同步直接返回「无变化」,不写盘,也不会凭空创建 cordis.patch.yml。
删了分组不忘清理:删除分组时会顺手重同步一次,它生成的预设会一起消失。如果补丁层里还留着没有任何分组对应的预设,面板顶部会提示,并给一个「清理孤儿预设」按钮。
老进程上不白屏:如果当前 DSH 进程还没加载到带预设能力的这一版(GET /presets 返回 404),面板会明说「重启 DSH 后按钮才可用」,分组的启用 / 停用仍然照常工作。
两条硬限制:
- 预设只管 agent 平面。第三方插件是宿主平面(挂
webServer/llm/systemPrompt/skills),
工具在全局注册,塞进预设会触发 Preset services require isolate realms 检查失败。
所以「这个对话装载哪些第三方插件」仍然只能靠顶部的分组开关(要重启)。
- 预设只在会话空白时能换。官方判据是「还没发过任何消息」,第一句话发出去后就锁定(
agent-preset/locked)。
永远保留的核心
@deepseek-ai/dsh-base、@deepseek-ai/dsh-web-app 以及官方 17 个受保护模块,加上
dsh-groups 自己、dsh-m、dshmarket 不会被任何分组关掉。
官方包不进勾选清单:@deepseek-ai/* 全部不列在插件选择里,分组也不会开关它们。
应用分组时,当前已启用的官方 bundle(dsh-experimental-agent-team-profile、
dsh-experimental-auto-review、dsh-experimental-voice-input-bundle 等)会被原样保留。
要开关这些,请用 DSH 自带的插件管理器。
为什么需要重启
DSH 的 HMR 只监视显式配置,node_modules 与 profile manifest 不在监视范围内(hmr 行 config.root = [])。
所以本插件不做热重载,只负责把清单改对,重启由用户自己决定何时做。
数据文件
- 分组定义:
<profile>/plugin-groups.json(不内置任何预设分组,首次打开是空的,由你自己建)
- 日志:
~/.dsh/dsh-groups/plugin-groups.log(超 1 MB 轮转为 .1)
- 锁:
<profile>/package.json.lock、<profile>/cordis.patch.yml.lock(写到锁里带 pid + 实例 id,可接管死进程留下的锁)
安全
所有 HTTP 路由只接受回环地址,且要求 Host 回环、Origin 同源、Sec-Fetch-Site 不是 cross-site。
测试
npm test # check + check:hooks + 扫描器自检,三段串跑
npm run check # 语法 + package.json 合法性
npm run check:hooks # 只跑 hook 位置扫描
check:hooks 挡的是 node --check 结构上抓不到的一类问题:hook 写在了组件的 return 之后。1.4.4 就是这样白屏的——第一次渲染走 early return 只跑了 15 个 hook,state 到货后跑到第 16 个,React 抛 #310 把整个面板判崩溃,而语法完全合法。
判定标准是「hook 必须位于该组件所有 return 之前」。回调里的 return 不算,因为它不属于组件本身:
const save = async () => {
if (!confirmDialog('覆盖?')) return // 这是回调的 return,不算组件的早退
}
if (state === null) return h('div', null, '加载中…') // 这才是
识别范围:组件认 function Name(…)、function* Name(…)、const Name = (…) =>、const Name = function (…)(含 async);hook 认 react.useXxx( 与解构引入后的裸调用 useXxx(;文件里自己声明的 useXxx 不算 hook。
--selftest 用内置样本自检扫描器本身(真违规必须报、回调 return 必须不报、箭头组件必须被识别……共 6 项),防止它哪天退化成只认字符串位置——那种检查比没有还危险。