dsh-router-worker
在 DSH 的左侧会话列表里,把子代理会话画成主会话的缩进子树:连接线、编号 + 摘要、运行状态。
- 对任意 agent 模式生效:标准模式派的子代理、本插件自带的「Router 项目经理模式」派的子代理、你自己写的 preset 派的子代理,只要有父子关系就会出现在树里。
- 对皮肤 / 主题结构无关:只用宿主的业务属性
[data-row-key^="session:"]作锚点,宿主行一个字节都不动(不塞按钮、不改 padding、不改标题 margin),箭头和状态条全部画在自己那块子树里;缩进按主题字号--dsw-font-size-sm用em算,颜色取--dsw-alias-*语义变量。原生皮肤、claude style 这种会把行包进一层 box 并重排的皮肤、以及以后换密度 / 换主题都不会让它错位(详见兼容自定义皮肤)。 - 不修改宿主源码,不替换任何宿主槽位,卸载即恢复原样。
它解决什么问题
DSH 标准的会话列表是平铺的:主会话和子代理会话混在一起,而宿主又刻意把 origin === 'subagent' 的会话从列表里隐藏掉(sessionVisible() 对子代理返回 false)。结果是:
- 你看不到主对话底下到底派了几个子代理;
- 想知道某个子代理干到哪一步,只能去右侧任务管理面板一个个点开;
- 视图一关,子代理的痕迹就从左侧消失了。
装上本插件后,主会话那一行下面会多出它的子会话,缩进一级、用连接线连起来,每行带「运行中 / 已结束」和一句摘要,点一下就能打开它的过程记录。
长什么样
展开:
◈ 重构计费模块 2026-11-07 18:28
▾ 3 条子对话
├ ● #1 拆分价格计算器 运行中
├ ● #2 补齐单元测试 已结束
└ ● #3 更新接口文档 已结束
◈ 接入日志网关 2026-11-07 17:02
▾ 1 条子对话
└ ● #1 核对字段映射 运行中
注意箭头不在主行里:
▾ 3 条子对话是子树块自己的第一行(一条控制行), 点整行都能收起 / 展开。这是「结构无关」的关键 —— 不往宿主行里塞任何东西。
子对话很多时,默认只画最近 3 条,更早的折成一行提示(再点一次就全展开):
◈ 重构计费模块 2026-11-08 18:28
▾ 6 条子对话
还有 3 条更早的子对话
├ ● #4 迁移历史配置 已结束
├ ● #5 补充回归用例 已结束
└ ● #6 整理变更说明 已结束
收起(主对话之外只留那条控制行):
◈ 重构计费模块 2026-11-07 18:28
▸ 3 条子对话
排队中的工单也能看见(轻量版状态条)。原来的树只读「已存在的子会话」,而一张还在排队、没派出去的工单没有子会话,会整个消失。0.2.0 起在主行下面内联一条汇总:
◈ 重构计费模块 2026-11-08 18:28
▾ 2 条子对话
● 2 运行中 ● 1 排队
排队:梳理变更要点
├ ● #1 核对版本号一致性 运行中
└ ● #2 统计预设目录规模 运行中
- 状态条是「N 运行中 / M 排队」两个小圆点 chip;收起时也保留,等于「一眼看汇总」。
- 排队工单逐条列一行短名,只显示
queued|running的非敏感字段。 - 数据来自宿主半开的一个同源 GET 路由(
/plugins/dsh-router-worker/tickets),2.5 秒轮询一次;失败保留旧快照不闪空白,取不到数据时状态条静默不画,子对话树照常。 - 这是轻量版:没有独立面板、没有「重试 / 取消」按钮,只是在现有树里补一行 —— 排队项才是真缺口。
渲染截图(在模拟宿主 DOM 的验证页里跑真实 lib/client.js;每栏各画两遍:左边原生
list > row、右边 claude-style list > box > row,用来直观确认两种结构逐像素一致):
| 展开 | 收起(主行收起,只剩控制行) |
|---|---|
![]() |
![]() |
- ▾ / ▸ 在子树块自己的第一行(「N 条子对话」控制行),不画在宿主主行里。点整行收起 / 展开 —— 每个主行单独记状态(localStorage,重开应用还在);子对话超过 3 条时只显示最近的,更早的折成「还有 N 条更早的子对话」。
- #1 / #2 是编号,按子会话创建时间排。
- 标题优先取宿主派单时写下的短名(
subagentCatalog[].label,也就是主智能体给这次派活起的名字,如「拆分价格计算器」),拿不到才退回会话标题,再拿不到才用会话 id 前 8 位。 - 子行缩进按主题字号算,不写死像素:引导线
--sat-line = 4 × --dsw-font-size-sm、文字--sat-indent = 6 × --dsw-font-size-sm(13px 字号时分别是 52px / 78px,16px 时 64px / 96px)。换皮肤、换密度都跟着缩放。 - ● 的颜色表示状态:运行中是品牌色,已结束是中性色;连接线用
currentColor的淡色版本,任何主题下都协调。
兼容自定义皮肤
侧栏皮肤之间最大的差别不在配色,而在 DOM 结构。原生皮肤是 list > row;
dsh-claude-style 的 dateGroups 功能会把每个 [data-row-key^="session:"] 行包进一层
box(box = row.parentElement),用 appendChild(box) 按日期整体搬动、用
box.style.display="none" 整体隐藏、在 box 之间插日期标题、还往 row 里插
dsh-claude-date-chip —— DOM 变成 list > box > row。
凡是依赖「row 是列表直接子节点」「row 里只有标题」「行首留白是固定像素」的实现,在这套 结构下都会出错。本插件因此把这三条假设全部去掉:
| 旧假设 | 为什么崩 | 现在 |
|---|---|---|
子树用 insertAdjacentElement("afterend") 紧跟在 row 后 |
在 boxed 结构里子树掉进 box 内部,被重排逻辑当成杂项丢弃 → 父行与子行断开 | attachHolder() 仍紧跟 row,但要求 holder.parentElement === row.parentElement;holdersAligned() 在皮肤重建 DOM 时强制重挂。子树作为 box 的子节点,随 box 一起搬、一起藏 |
往主行里塞绝对定位箭头、改 paddingInlineStart / 标题 marginInlineStart |
皮肤往 row 里插 chip 后,量到的「标题左边界」不再是标题,标题被推没 | 宿主行一个字节都不动。箭头、控制行、状态条、子行全部画在自己的子树块里 |
CSS 写死 left: 48px / padding-left: 64px |
换主题 / 密度后行首留白不同,固定像素不再对齐 | 缩进用 em 与 --dsw-font-size-sm 计算;颜色全取 --dsw-alias-* |
所以「兼容其余 style」不是给每个皮肤写一套适配,而是去掉所有对结构的假设:
- 只认业务属性(
data-row-key),不认 class / 哈希 / 层级; - 不修改宿主行(不插子节点、不改内联样式);
- 子树挂在父行所在容器里,容器被整体搬动 / 隐藏时一起走;
- 缩进、颜色、字号都从主题变量推导,不写死像素。
这不是口头承诺:test/sidebar-structure.test.mjs 用 vm 驱动真实 lib/client.js,
同时模拟原生 list > row 与 claude-style 的 list > box > row 并复现 box 重排,
断言宿主行始终干净、子树始终与父行同容器、重排后仍连着文档;
test/platform.test.mjs 之外还有头无显示环境的 CDP 几何验证(原生与 boxed 两种
DOM 在 13px / 16px 字号下逐像素一致)。三平台 CI 每次都会跑这些回归。
安装
装完都要完全退出(⌘Q)再重开 DSH NEXT —— 客户端 bundle 与宿主半都只在启动时加载。
从 npm 装(最省事):
dsh plugin --profile <你的 profile> add dsh-router-worker
从 GitHub 直接装(跟着仓库最新代码走):
dsh plugin --profile <你的 profile> add github:zhuanglaihong/dsh-router-worker
从预构建 tarball 装(免掉构建授权,最快):
dsh plugin --profile <你的 profile> add \
https://github.com/zhuanglaihong/dsh-router-worker/releases/latest/download/dsh-router-worker.tgz
从本地 clone 装(改代码时用):
dsh plugin --profile <你的 profile> add /path/to/dsh-router-worker
也可以在已装好的 DSH NEXT 里用插件管理器安装这个目录(install_bundle)。
配置
设置里有独立的一级页面「Router 项目经理」(settings.section,id router-worker):
- 运行限制 —— 直接改宿主的
subagent.maxActiveSubagents/maxDepth,和 Multitask 设置页里那两行是同一份配置(改哪个都生效)。 - 子代理使用的模型 —— 给四类 worker(
subagent/subagent_fork/subagent_ptc/subagent_minimal)分别指定 provider + model,留「继承协调者」则不覆盖。这是本包subagentModels字段,保存后立即生效、不重启插件。 - 运行时与验证 —— 只读地显示 sqlite 落地路径、Watchdog 三档阈值,以及重启后该看 哪几行日志,方便核对而不必翻源码。
页面走一个独立的延迟 fiber(ctx.inject(["slots","locale","configForms"], …)):
万一这些服务没就绪,只影响设置页,侧栏子对话树照常加载。模型目录取不到时下拉框降级
成「目录不可用」,不会让整页崩掉。
runtime / stateDir 这两个调度层开关没有进设置页:它们不是 volatile 字段,从面板写会
触发整份配置重载、把插件重启一次。仍在 profile 的 cordis.patch.yml 里改(下表)。
没有必须配的项。两个显示开关在实现里已经做成默认值:
| 行为 | 默认 | 存在哪 |
|---|---|---|
| 默认展开还是收起 | 展开 | localStorage["dsh-router-worker:collapsed"],只记被收起的主会话 |
| 摘要截断长度 | 40 字 | 源码 clip(raw, 40) |
是否显示侧边对话(Side: 开头) |
不显示 | childrenOf() 里的过滤——那不是 agent 派的活 |
调度层(0.2.0 起的 Router 模式)还有两个可选配置,写在 profile 的 cordis.patch.yml 里那行 config 下:
| 配置 | 默认 | 说明 |
|---|---|---|
runtime |
true |
设成 false 就完全不装工单表 —— ticket_create 退回「直接派发」,不排队、不持久化、不回收重启残留,也不跑 Watchdog |
stateDir |
$DSH_HOME/state/router-worker |
换 sqlite 落地目录。$DSH_HOME 没设时是 ~/.dsh |
Watchdog 不需要单独配置:它随 runtime 一起启停,阈值(60 秒巡检 / 15 分钟静默 / 30 分钟排队提醒)写在 lib/watchdog.js 顶部的常量里。
它怎么拿到数据的
两路数据:显示树用宿主已有的会话快照;工单状态条用本包宿主半开的一个同源 HTTP 路由(见下)。
会话树(对 Multitask 零依赖):
- 父子关系 ——
ctx.sessions.list.getSnapshot().byId里每个会话摘要都带origin: 'subagent'和parentId。这是宿主的持久字段,与哪个插件派的活无关。 - 短名 —— 同一份快照里的
projectionsBySession[parentId].values.subagentCatalog[].label,就是主智能体派单时写下的那行短描述(subagent工具的description参数)。没有它才退回displayTitle。 - 运行状态 —— 同一份摘要上的
running;有useSessionStatus时以它为准。更细的过程文本走宿主的subagents.live路由(右侧任务管理面板用的就是它)。
工单状态条:工单表在宿主进程的 sqlite 里,renderer 进程读不到全局键,所以宿主半用
ctx.inject(['webServer'], cb) 另开子 fiber 注册 exact GET 路由
/plugins/dsh-router-worker/tickets,只外露 queued|running 工单的
{id, parent, title, status, startedAt}(不吐 brief / result / error /
worker_session)。客户端 URL 相对 document.baseURI 解析 —— 用注入的 streamBaseUrl
会跨源被 CORS 拒成 Failed to fetch。走子 fiber 而不是把 webServer 塞进主
inject,是为了服务缺席时只影响状态条、不拖垮整棵会话树。
它怎么打开子对话
按优先级三级降级:
- 宿主右侧的
subagentchat标签页(sidebarRight.openResource(address, {kind:'subagentchat'}),地址形如dsh-resource://subagentchat/session/<child>?parent=<parent>&mode=<mode>)——不离开当前主对话就能看子代理过程; uiWorkspace.openSession(childId)——普通会话跳转;sessions.open(childId)——最老的 API 形状。
宿主版本更新导致某一级失效时,会自动往下一级走,不会白屏。
已知边界
- 不是原生行。宿主把整个左侧浏览区声明成 single 独占插槽(
sidebar.workspaces,replaceRisk: shadows-shipped-ui),插件的 slots 挂不进去,所以本插件用 DOM 注入实现。代价是:子行没有置顶 / 重命名 / 归档 / 拖拽排序这些原生行才有的右键菜单能力,点击是「打开」,不是「选中」。 - 宿主重画会重建 DOM。插件用 MutationObserver 跟进,并且画之前先断开观察器,否则会自己触发自己形成死循环。极端情况下(宿主在同一个微任务里连续重建两次)可能抖动一帧。
- 侧边对话不进来。
Side:开头的是宿主自己的「侧边对话」功能,语义上不是子代理,故意排除。 - 嵌套只画一层。DSH 的委派深度默认是 1(worker 不能再派),所以一层就够用;真出现孙会话时,它会在自己的父行下被单独渲染。
卸载
dsh plugin --profile <profile> remove dsh-router-worker
卸载时 install() 返回的清理函数会移除样式表、所有子行和箭头,宿主列表恢复原样。
与「模式」(agent preset)的关系
本插件从 0.2.0 起自带一个模式:「Router 项目经理模式」(preset id router-worker),装一次就有了。它从 dsh-multitask 1.3.3 分叉而来,保留上游的协调者窄工具面、四类 worker、两级审批闸门与 Windows 工作区 ACL 修复,并在这个基础上加了工单语义:
- 主对话只派单,不干活:它的工具面被刻意收窄(没有 shell、不能写文件),所以它只能把活开成工单派出去。
- 每张工单必须有短名:
ticket_create的title是必填参数(2–40 字),原样写进宿主记录的subagentCatalog.label—— 也就是本插件侧栏树上显示的那一行。 - 工单会排队、会记住:工单落在
$DSH_HOME/state/router-worker/tickets.db(sqlite)。宿主名额满了不会把工单判死,只留在队列里等 —— 宿主自己的原文是「Admission does not queue」,排队这件事得由我们做。重启后正在跑的工单会去查子会话还在不在:还在就接着等,没了就标失败并补一条通知,不会让你对着一个永远「运行中」的行发呆。 - 结果回流:worker 干完把结论交回主对话,主对话只保留结论,不保留过程。宿主自己会发一条完成通知;本插件额外的一张 outbox 表补的是它补不了的两种情况 —— 进程重启后、以及父会话当时不在回合中。投递失败会退避重试(1 秒 → 5 秒 → 30 秒 → 5 分钟),10 次投不出去才放弃并告警。
- 卡死自动收尾(Watchdog):还有第三类异常 —— worker 没崩、没发
turn/end、却也不再产出(网络挂起、模型卡死、工具僵住)。watchdog 每 60 秒巡检,某张running工单静默超过 15 分钟就中断它的子会话、标failed并通知主对话,把占住的并发名额还回去;queued超 30 分钟只提醒一次,不改状态。心跳取session/event(不是updated_at),所以正在流式输出、连续调工具的 worker 不会被误杀;刚重启恢复的工单会先给一个完整宽限期。刻意不用 LLM 判断跑偏,只做「静默超时」这一条机械判据。
工单表是可选的:
node:sqlite打不开时自动降级成内存表并告警 —— 功能全在,只是重启即失忆。想让工单落地,把runtime保持默认(见下面的配置表)。
这个模式与侧栏树是同一个包里的两件事,互相不依赖:树只读宿主的父子关系(origin: 'subagent' + parentId),所以它照常会显示任何模式派出的子代理,包括标准模式、别的插件模式、你自己写的 preset。
三种组合方式:
| 做法 | 说明 | 代价 |
|---|---|---|
| 用自带的 Router 模式(推荐) | 新建会话时选「Router 项目经理模式」,装一次就同时得到派单能力与侧栏树 | 该模式刻意收窄主对话工具面,不适合想让主对话亲自写代码的场景 |
| 用别的派单模式 | 例如另外装 dsh-multitask 选「Multitask 减震器模式」;本插件照样把它的子代理画成树 |
两个插件,各自独立升级 |
| 在别的模式里写进提示词 | 自己的 preset 里加一句「遇到可并行的子任务就派子代理,派单时写好 description」 |
只是提示词,模型可以不照做 |
第三种里的 description(subagent 等委派工具的可选参数,3–5 个词)就是侧栏那一行短名。自带的 Router 模式用 ticket_create 把这件事变成必填,所以那边不需要靠提示词自觉。
兼容性
- 皮肤 / 主题:结构无关渲染,见上面「兼容自定义皮肤」。
- 操作系统:macOS / Windows / Linux 同一份代码;CI 在三个系统上跑语法、单测与打包。
- Node:
engines.node >= 24(依赖node:sqlite)。
详细的验证边界与真机清单在 docs/PLATFORM.md; 未完成事项与后续计划在 docs/ROADMAP.md。
许可
MIT


No comments yet. Be the first to write one.