DSH 的能力是全局的:装了什么插件,所有会话都一样。这个插件把选择权下放到单个会话—— 勾选即生效,取消即卸载,插件带来的工具、提示词和技能只在那个 agent 里存在。
深浅色自动跟随,因为没有一个写死的颜色
🚀 安装
一行命令(把 web 换成你要装进的那个 profile 名):
dsh plugin --profile web add git+https://github.com/kkaporn/dsh-workflow-plugins.git
装完重启一次应用——插件要重新加载才生效(之后改配置不用重启)。
Desktop 应用怎么装(不想敲命令)用应用内的插件管理器,把仓库地址粘进去:
https://github.com/kkaporn/dsh-workflow-plugins
它走的就是 dsh plugin add,装完会提示重启。
desktop 这个 profile 名保留给 Electron 应用持有:官方 CLI 会拒绝针对它的启动与配置导出,
npm CLI 也会拒绝它的插件管理请求——所以 Desktop 用户的入口就是应用内那个插件管理器。
⚙️ 配置
候选清单由包自带的 cordis.patch.yml 声明,装完就能用:
# cordis.patch.yml(随包分发)
- insert:
- id: session-plugin-picker
name: 'session-plugin-picker'
config:
candidates: ["dshmarket"]
想换成你自己的候选,就在你自己的 cordis.patch.yml 里按 id 覆盖它——
注意是整个 config 被替换,不是合并,所以要把完整的 candidates 都写出来:
# <DSH_HOME>/profiles/<profile>/cordis.patch.yml
- id: session-plugin-picker
config:
candidates: ["dshmarket", "mattpocock-skills-dsh-zh"]
| 字段 | 类型 | 作用 |
|---|---|---|
candidates |
string[] |
唯一可被挂载的包名集合。留空则面板里没有可选条目 |
💡 一个包只有"不在
dsh.profile.bundles里"时才值得放进candidates: bundle 本来就会挂进每个会话,勾它等于什么都没做。
✨ 特点
| 🎛️ 就在输入框旁边 | 挂在 conversation.input.left 这个槽位——一个发布时就是空列表的位置,所以不会顶掉任何内置控件 |
| ✅ 勾选即生效 | 每一次勾选立刻应用到那个会话活着的 agent,插件的工具和提示词随 agent 一起释放 |
| 🔒 白名单是构造性的 | config.candidates 就是全部可挂载集合。不在名单里的包,就算手工构造请求也到不了加载器 |
| 🚫 只认空会话 | DSH 的规则是"会话一开始,组合就固定"。所以只有还没发过消息的会话能改,发过消息的返回 409 并提示新开会话 |
| 🎨 不写死颜色 | 字体、圆角、阴影、配色全部读 DSH 主题变量,深浅色和任何主题覆盖自动跟随 |
| 📦 零依赖 | 宿主端只用 Node 自带能力;浏览器端只依赖 React 和 Cordis 客户端上下文 |
🧠 两条刻意划下的边界
白名单:构造上封闭config.candidates 就是全部可挂载集合。请求里出现别的包名,会在到达加载器之前就被过滤掉——
所以那个 HTTP 端点是不是一个"浏览器要什么就挂什么"的口子。
DSH 自己的组合规则是 "fixed once the session starts"(预设选择器同样只重绑空 agent)。
去改一个已经开始的会话的工具面,等于跟这条规则对着干。所以发过消息的会话会拿到 409,
面板上直接告诉你新开一个。
🔍 状态存在哪
| 选择本身 | $DSH_HOME/session-plugin-picker.json,按会话 id 存 |
| 文件坏了 / 不存在 | 当作空,不影响任何会话 |
| 写入失败 | 只丢这一次选择,不影响会话 |
| 内存里的挂载表 | 记录在 fiber 释放的那一刻就被丢掉;会话被销毁时整条忘掉——存储才是唯一的持久来源,会话回来了照样按存储重新挂上 |
🧪 检查
node lib/index.test.mjs # 纯逻辑 23 项,不用真环境
node verify/client-contract.mjs # 浏览器端契约 17 项(替身 React)
$env:DSH_NM = "<DSH 安装目录>\node_modules"
node verify/host-mount.mjs # 真 Cordis 14 项:挂载 / 重挂 / 清理
$env:DSH_THEME = "<DSH 安装目录>\node_modules\@deepseek-ai\dsh-client-ui-theme\lib\client.js"
node verify/.preview.mjs # 生成上面的预览图
环境变量故意没有默认值:猜一个路径只会在某一台机器上凑巧能用,然后表现成"挂载坏了"。
✅ 验证过什么(点开)在真实运行的 GUI 上验证(直接打 HTTP,不用重启):
| 检查 | 结果 |
|---|---|
| 插件条目加载且已启用 | fiberPhase: active |
GET /state 通 |
{"candidates":[...],"live":true,"blank":true,...} |
缺 sessionId |
400 sessionId is required |
| 未知会话 | 404 unknown session |
| 发过消息的会话 | 409 …start a new session… |
| 真实挂载 / 卸载 | {"ok":true,"mounted":["…"],"failed":[]} → {"ok":true,"mounted":[]} |
| 白名单边界 | 请求一个不在 candidates 里的包 → selected: [],永不挂载 |
| 浏览器端真的到了页面里 | 实时 Slots 检查:占用者 session-plugin-picker,active: true |
组件真的渲染了、sessionId 真的传到了 |
临时探针读到 spp-probe-session-985c9090-…(探针已删,仓库里没有残留) |
像素本身。 控件被证明能注册、能渲染、能拿到 sessionId、行为正确——但 DSH 的页面只发给
持有启动口令的浏览器,外部截不到真实运行中的那张图。
能验证的是主题解析:verify/.preview.mjs 把组件自己的样式表叠在从 ui-theme 提取的
--dsw-* 变量上,浅深两套都渲染一遍,每个 token 都解析到了真值、没有退化成兜底色。
至于在你的主题里好不好看,这个还是得你自己看。
⚠️ 已知限制
- 已经全局挂载过的插件,再按会话挂一次没有意义——这个选择器是给"还不是全局能力"的东西用的。
- 挂一个会话预设里已经声明过的包可能失败;失败会按包名报在
failed里,会话本身不受影响。 - 往单个 agent 作用域里挂任意第三方插件,这件事超出 DSH 的保证范围。白名单是让这个风险的边界明确且可控的东西。
- 一个自身不是单例的包,会在每个挂它的会话里多出一个实例。在
apply里注册服务、定时器或 HTTP 路由的包不适合当候选——cordis.patch.yml的注释里写明了为什么默认候选只有一个。 /state是本地 web 服务器上的普通 GET,没有按请求鉴权,所以任何能访问端口的页面都能读到哪些会话有选择。真正会改东西的 POST 需要 JSON 内容类型,跨站页面要发它就得过预检,而这个服务器不会放行。要收紧就是把端点按来源校验。
📁 文件
lib/index.js 宿主端:状态文件、两个 HTTP 端点、挂载 / 卸载
lib/client.js 浏览器端:输入框旁那个控件和它的面板
cordis.patch.yml 包自己声明怎么被挂进 profile
lib/index.test.mjs 纯逻辑自检
verify/client-contract.mjs 订阅 / 渲染契约(替身 React)
verify/host-mount.mjs 真 Cordis:挂载、重挂、清理
verify/.preview.mjs 用真主题变量渲染预览图
🔧 两个真 bug 和一条踩坑经验(给要读源码的人)bug 1:存储里的选择从来没被重新挂上。 选择存在文件里,但只在用户点击的那一刻应用过一次。
DSH 重启会重建会话的 agent,存储里的选择再也没人挂——/state 会报 selected 而 mounted 是空的。
bug 2:修了第一个之后,它其实还是坏的。 重挂的守卫问的是"这个会话有没有记录":
if ((mounted.get(agent.id) ?? []).length > 0) return; // 只数个数,从不问死活
会话的 agent 作用域拥有它挂载的一切、并跟着作用域一起释放,所以一个重建的 agent 会发现 上一个 agent 的记录还在表里。那些 fiber 已经死了,守卫照数不误,重挂被静默跳过。 原来的测试每次都开一个全新的根(也就是全新的表),所以看不到这个问题。
现在守卫问的是"还有活着的吗"(fiber.uid !== null,跟 Cordis 自己的 assertActive 读同一个字段),
顺手把死记录清掉。新增的测试段落专门复现这个场景,用一个独立的会话 id,
保证结论只依赖这一个场景。
踩坑经验:scope.dispose() 是异步的(dsh-scope 要等底层 fiber 收敛)。
不 await 就不会真正拆掉,旧 fiber 看起来还活着,重挂于是"合法地"什么都不做——
一个悄悄不再测试它所声称的东西的测试。
还有一条:Cordis 的配置是 ctx.plugin(plugin, config) 的第二个位置参数。
写成插件对象上的 config 属性,插件会收到空配置,然后被白名单过滤成空——
一个看起来和"挂载坏了"一模一样的问题。
📄 License
MIT © the authors
No comments yet. Be the first to write one.