dsh
Click on "Deploy Server".
Wait a few minutes for the server to deploy. Once ready, it will show a "Started" state.
In the chat, type
@followed by the MCP server name and your instructions, e.g., "@dshhand off a task to DSH: fix the failing tests in ./api, ~10 min"
That's it! The server will respond to your query, and you can continue using it as needed.
Here is a step-by-step guide with screenshots.
deepseek-harnessed
把整个 DeepSeek Harness(DSH) 实例变成一个可被 Cursor / Claude Code / Codex / Gemini CLI / Antigravity / Kiro / Qoder / VS Code(Copilot)/ opencode 调用的 subagent,并给 DSH Desktop 加一块实时监控悬浮卡片。
仓库名的一句话解释:harness 反过来被 harnessed —— 让这些 harness 把子任务交给一台 真正的 DSH 实例去干,而不是在自己进程里开一个函数式子代理。
它由三部分组成:
部分 | 位置 | 作用 |
MCP 桥接层 |
| 一个 stdio MCP server,暴露 |
DSH |
| 让这个 DSH 进程成为"一次会话、无人值守、只做叶子"的执行体 |
GUI 宿主插件 |
| 观察器把外部任务的实时状态灌进会话投影;悬浮卡片按调用方分组显示 |
先看这三页
文档 | 内容 |
前置条件、一键安装、五分钟验证、卸载、常见坑 | |
全部环境变量(逐条来自源码)、profile 逐行解释、权限与叶子闸门、卡片偏好 | |
每个客户端的注册位置与 JSON 形状、手工接入、让内置 subagent 转调 DSH |
本文件往下是完整设计与实测记录(§1~§10),含每一条踩过的坑与真实测量数据。
Related MCP server: gpt-dsh-bridge
支持矩阵
客户端 | 注册方式 | 一键 |
Cursor(IDE + |
| ✅ |
Claude Code |
| ✅ |
Claude Desktop |
| ✅ |
Codex CLI / Desktop |
| ✅ |
Gemini CLI |
| ✅ |
Antigravity |
| ✅ |
Kiro |
| ✅ |
Qoder |
| ✅ |
VS Code / Copilot |
| ✅ |
opencode |
| ✅ |
Claude Code / Codex / Cursor 的内置 subagent | 写全局指令,让子代理委派转调 DSH | ✅ |
DSH 版本兼容性
桥接层跑在 DSH 内部(一个 profile + 两个宿主插件),所以 DSH 升级会直接打到我们身上。
下面这张表是实测结果,不是推测:宿主插件目录里的 app.asar.unpacked/ 会留下旧版同名包,
照它读会得出错误结论,正确做法是从 app.asar 里读运行时真正加载的那份源码。
DSH 版本 | 状态 | 说明 |
0.1.5-rc.1 | ✅ 已适配并全量实测 | 破坏性 API 变化 3 处,见下 |
0.1.4 及更早 | ✅ 仍兼容 | 三处变化都做了向前兼容(见下) |
0.1.5-rc.1 的三处破坏性变化(以及我们怎么处理)
变化 | 症状 | 处理 |
| 最凶的一个:profile 在插件树加载阶段就 | 去掉该 import,改成 runner 内的 |
| 传错形状不报"参数不对",而是从 DSH 内部炸出 | 新增 |
| 同上,取事件流会拿到 | 新增 |
同时 fail() 现在会打印完整栈(以前只打 error.message)。正是因为只打 message,
上面第 2 条最初只表现为一句没头没尾的报错;栈一出来立刻定位到是 presets.current() 的形状变了。
上游自身的问题(与本插件无关,但会影响你)
0.1.5-rc.1 里随版本发布的 lsp-stdio / tool-lsp 两个插件没有跟上 assertNever 的迁移,
import 时直接抛 does not provide an export named 'assertNever'。插件树是整体加载语义,
所以:
dsh --profile web完全起不来(实测,与是否装了本插件无关)。桌面宿主不受影响:
desktopprofile 的插件树里根本没有这两行(实测--dump-config, 宿主日志里也没有assertNever)。万一你需要
webprofile,临时绕开办法就是关掉这两行:- id: lsp-stdio disabled: true - id: tool-lsp disabled: true(
--patch传一个只含这两行的 overlay 即可,已验证能起来。)
升级后怎么快速自证没坏
dsh --version # 先看版本
node test/selftest.mjs # 协议级端到端:真拉 MCP + 真跑一轮(最能说明问题)
node test/leaf-only-probe.mjs # row id 有没有被改名/删掉
node test/monitor-live-probe.mjs # 观察器 + 宿主判活升版本必看的两类东西:row id(我们 patch 了 15 行,改名就静默失配)与服务方法签名。
test/leaf-only-probe.mjs 覆盖前者(它自己就抓到过一次:0.1.5 起 tool-subagent-report
不再是 loader 行,subagent-report 变成了协议里的消息 kind)。
安全与隐私(请先读)
桥接层不访问互联网,它只做两件事:拉起本机
dsh子进程、读写$DSH_HOME与本机 各 harness 的配置文件。默认权限是
danger-full-access(无审批、全盘读写)。这是刻意的:调用方本来就是有写权限的 harness,子代理要在你的工作空间里真干活。要收紧就用permission: "workspace-write"/"read-only",或用DSH_SUBAGENT_PERMISSION改默认档。state/是私有运行期数据,已在.gitignore中排除:观察器日志、心跳、任务记录 (task.json/meta.json/stderr.log)含本机绝对路径、工作区名、提示词与子代理输出。 仓库里不含任何真实任务现场。安装器对每个被改的配置文件都会先备份(
.bak-dshsubagent-<时间戳>),并且永不覆盖 解析不了的 JSON 配置。平台:Windows。进程判活/强杀依赖
taskkill与 PowerShell 进程快照,别的平台未验证。
它是怎么工作的
本目录是桥接层:本机(Cursor、Claude Code、Codex、Gemini CLI、Antigravity、Kiro、 Qoder、VS Code/Copilot、opencode)都可以把一项任务委托给一个真正的 DeepSeek Harness 实例执行,并把它在指定工作空间里的最终答复取回来。
Cursor / Claude Code / Codex / … 的对话
│ ① 调用 MCP 工具 dsh_task(prompt, workspace, expected_seconds)
▼
dsh-subagent-mcp.mjs (MCP stdio server,本目录 bin/)
│ ② 分配 job id,把提示词从 stdin 交给子进程
▼
"DSH Desktop.exe" --profile subagent ← 一台完整的 DSH 实例,一轮会话
│ ③ 在 workspace 里真干活:读写文件、跑命令、跑测试、联网检索
▼
$DSH_HOME/sessions/<workspace>/session-<uuid>/ (会话落盘,可在 DSH GUI 里复查)
│ ④ 最终答复 → result.txt / stdout
▼
dsh_task 返回 "status: ok" + DSH 的原文与内置 subagent 的关键区别:它是一个独立的 DSH 进程,有自己的上下文窗口、 自己的模型、自己的工具链和落盘的会话记录,而不是宿主 harness 里的一个函数调用。
1. 一分钟自检
# 桥接是否可用(等价于 harness 里的 dsh_health 工具)
dsh-subagent --where
# 真跑一轮:在指定工作空间里让 DSH 干一件可验证的事
dsh-subagent -w D:\some\repo "列出后端路由文件,汇总最近改动"
# 协议级自检(会真的拉起 MCP server 并跑一轮任务)
node $env:USERPROFILE\.dsh\subagent\test\selftest.mjs
# 监控窗口自动拉起(假 exe + 临时 DSH_HOME,**不会真的启动 GUI**)
node $env:USERPROFILE\.dsh\subagent\test\monitor-autostart-probe.mjs
# 台账幽灵记录(记录说在跑、pid 早没了)→ 必须按 pid 判活
node $env:USERPROFILE\.dsh\subagent\test\ledger-liveness-probe.mjs
# 叶子闸门:外部任务不能再生子代理(配置级 + 解出会话日志看真实工具表)
node $env:USERPROFILE\.dsh\subagent\test\leaf-only-probe.mjs <sessionId片段>
# 卡片底部状态条的数字:真实任务日志 → token 用量(逐帧解 zstd,只读)
node $env:USERPROFILE\.dsh\subagent\test\usage-fold-probe.mjs --all2. 对外暴露的五个 MCP 工具
服务器名统一叫 dsh,因此在各 harness 里工具名形如
mcp__dsh__dsh_task(Claude Code)或 dsh.dsh_task(Codex/Cursor)。
工具 | 作用 |
| 委托一项自包含任务。必须给 |
| 查询/继续等待某个 job;带上 |
| 优雅取消:标记取消并请求停下,让任务自己收尾 |
| 强制终止:按 |
| 探活:回报解析到的 dsh 启动器、DSH_HOME、默认工作空间、默认模型、每个调用方的并发数、每个实时任务的截止/进度、最近任务 |
dsh_task 的参数:
参数 | 说明 |
| 必须自包含:DSH 看不到宿主会话历史、看不到宿主打开的文件,也不会追问 |
| 调用方自己预估这次委派要多少秒(正整数)。缺失/非正数会被直接拒绝(不做默认值兜底),而且是以 |
| 可选的一句验收标准("做完 = ……"),落进 |
| 目标工作空间绝对路径;缺省取调用方工作空间(MCP roots),再缺省取 server cwd |
| 本次阻塞等待秒数,默认 30; |
| 外层绝对墙钟上限,默认 1800;比 |
| 单次调用换模型(如 |
|
|
|
|
调用方身份:每个 MCP 连接在
initialize里带的clientInfo.name会被记到该连接发起的 每个任务上(task.json的caller/callerVersion,以及工具结果里的caller:行)。 缺省为"unknown";命令行入口记"cli"。GUI 与dsh_health都按它分组。
轮询节奏:拿到
status: running后,请在同一轮里每 20~30s 调一次dsh_task_status(job_id, wait_seconds=25),直到ok/error/deadline/stalled/cancelled/killed。不要停在一个running上不动。
桥接层默认在提示词前加一段简短的「委托说明」:一次性会话、没人会回答追问、 结束时汇报做了什么/改了哪些文件/结论。这样 DSH 不会提问后卡死。
2.1 「工具调用建议」已经写进工具定义本身
以前这些约定只活在 README 里,harness 的模型看不到。现在它们搬进了 MCP 层的工具定义, 任何 harness 的模型只读 schema 就知道怎么用:
位置 | 内容 |
| 委派操作手册浓缩版(1258 字符 ≤ 1500):先估时长 → 写可机检验收 → 短轮询 → 状态语义 → 如何止损 → 失败排查 → 禁止项(MCP 规范支持,harness 会把它当系统提示) |
| 完整手册(约 1800 字符):硬承诺与经验值区间(单文件小改 60 |
各字段 |
|
| 逐字段解释 |
| 两种模式(按 |
|
|
自检会核对这套文案真的出现在 schema 里(见 §10 的 initialize 带委派操作手册、
dsh_task 描述含完整委派约定 等条目),避免以后被人无声改掉。
3. 本机已写入的配置
Harness | 配置文件 | 写入内容 |
Cursor(IDE + cursor-agent CLI) |
|
|
Cursor 用户级规则(尽力而为) |
| 「委派子任务优先用 DSH」 |
Claude Code |
| 用户级 |
Claude Code 子代理 |
|
|
Claude Code 全局记忆 |
| 「子代理委派:统一走 DSH」 |
Claude Code 权限 |
|
|
Claude Desktop(若安装) |
|
|
Codex CLI / Desktop |
|
|
Codex 全局记忆 |
| 「子代理委派:统一走 DSH」 |
Gemini CLI |
|
|
Antigravity |
|
|
Kiro |
|
|
Qoder |
|
|
VS Code / Copilot |
|
|
Copilot CLI 回退路径 |
|
|
opencode |
|
|
所有 harness 的 shell 路径 |
| 命令行入口 |
DSH 本体 |
| 一次性 subagent profile |
DSH Desktop(GUI 宿主) |
| 两块受管 insert: |
DSH Desktop(GUI 宿主) |
| 上面两块 insert 指向的插件本体(§6.4 / §6.5) |
所有写入都是幂等的,并且会先备份为 <原文件>.bak-dshsubagent-<时间戳>;
JSON 配置若解析失败则跳过不写,绝不覆盖用户配置。
重新装配 / 全部撤回:
node $env:USERPROFILE\.dsh\subagent\install.mjs # 幂等重装(推荐)
node $env:USERPROFILE\.dsh\subagent\install.mjs --dry-run # 只看会改什么
node $env:USERPROFILE\.dsh\subagent\uninstall.mjs # 摘掉所有 MCP 注册4. DSH 侧:subagent profile
$DSH_HOME/profiles/subagent/(源文件在 profile/,由 install.mjs 同步):
package.json bundles = dsh-base + dsh-headless,patchReload = startup
cordis.patch.yml 在 headless 基线上做的 7 处改动(逐条有注释)
subagent-startup.js 任务解析:--prompt / --prompt-stdin / --prompt-file / 位置参数
subagent-runner.js 一次性 runner:跑一轮 → 写 result.txt / meta.json → 退出相对官方 headless 的差异:
换掉只认 argv 的启动器,支持
--prompt-stdin(无长度上限)与--result-file/--metadata-file/--model;换掉 runner:能写出最终答复文件与
{sessionId, model, stopReason, durationMs}元数据,能在单次调用里换模型;关掉 LLM 起标题(省一次模型调用);
审批策略固定
never—— 子代理是非交互进程,没有人能点「同意」;沙箱模式由
DSH_SUBAGENT_PERMISSION控制,默认danger-full-access;显式声明「无人值守」权限预设表(否则
--permission workspace-write会崩,见 §5);叶子闸门:外部任务不允许再分叉(见下)。
4.1 叶子闸门(外部任务不许派子代理)
策略:由其它来源(Cursor / Claude Code / Codex …)经 dsh_task 调进来的任务,不能再造出更多 agent;
DSH 自己内部派活走的原生 subagent 不受影响。 实现方式是在 subagent profile 里关掉那几行
"能造 agent"的插件(只关工具,不关服务 —— 关服务会让 inject 它们的插件加载失败):
关掉的行 | 拿掉的工具 |
|
|
|
|
|
|
|
|
|
|
实测前后对比(解出子代理会话的 jsonl.zstd,看下发给模型的工具表):
工具数 | 分叉工具 | |
闸门前 | 25 |
|
闸门后 | 18 | 上面 7 个全部消失; |
自检(配置级 + 会话级两路证据):
node $env:USERPROFILE\.dsh\subagent\test\leaf-only-probe.mjs <sessionId片段>install.mjs 每次装 profile 时也会跑一遍配置级检查,没关上会直接报 ❌(实测踩过:patch 只改了
仓库里的副本、没同步到 $DSH_HOME/profiles/,--dump-config 里仍是启用 —— 所以必须问生效值)。
边界说明:这条闸门管的是进程内的分叉通道。任何有 shell 的 agent 都能自己起一个
dsh进程, 那属于"本来就有终端权限"的范畴,不作为可执行边界承诺。想恢复分叉:删掉profile/cordis.patch.yml里的第 7 条,再node install.mjs --only profile(无需重启,subagent profile 每次任务都是新进程)。
直接手工使用(不经过任何 harness):
cd D:\some\repo
dsh --profile subagent --prompt "跑一遍单测并汇总失败项"
# 长提示词走管道,不受命令行长度限制:
type task.md | dsh --profile subagent --prompt-stdin5. 权限与安全
关注点 | 现状 |
默认权限 |
|
收紧方式 | 工具参数 |
审批 | 子代理进程内一律 |
并发 | 每个 MCP server 进程默认最多 4 个并行 DSH 实例;超出者排队。详见 §6.1 |
审计 | 每次委托落盘: |
遥测 | 沿用 DSH 自身设置;如需关闭,给调用方环境加 |
权限档位怎么"真的生效"(这一块踩过两个坑,2026-09-11 修复,见 §8 第 1、2 条):
profile 里显式声明了一张"无人值守"预设表(三种沙箱模式 ×
approval: never)。DSH 自带的 表把workspace-write/read-only配成approval: ask,与无人值守的never组合不出任何 表项,dsh-permission-presets会在构造期抛composed sandbox and approval defaults match no preset—— 整棵插件树加载失败,子进程在 agent 起来之前就以退出码 1 结束(2.4 秒,什么都没干)。光有表还不够:权限预设值还存在全局
$DSH_HOME/settings.yaml的permission.defaultPreset(就是 GUI 里选的档位),子进程读同一个文件,会盖掉 profile 的config.defaultPreset。所以 profile 自带的 runner 会在发提示词之前,用permissionPresets.set()把调用方要求的档位写进本次会话的事件流 —— 文件/命令工具是 每次按会话事件解析沙箱策略的(ctx.sandboxPolicy.resolve({session})),这也是 GUI 里手动 切档位走的同一条路径。锁定失败(要求收窄却锁不住)时 runner 直接失败退出,绝不悄悄用 更宽的权限跑。
已知边界(实测,不是推测):workspace-write / read-only 下,Windows 上的 pwsh 工具会
完全不可用 —— 受限令牌 runner 起不来交互式 shell,工具返回全空(没有输出、没有
[exit code: N]、也没有 [sandbox: …] 标记),连把输出重定向到文件也不生效(文件不会出现)。
所以这类档位下,写文件要让子代理用 write / edit 文件工具(它们会返回结构化的
[sandbox: file access denied under … mode]),脚本类工作则用 danger-full-access。
6. 并发(多开)与在 GUI 里查看
6.1 能不能多开
能。同一时刻可以有多个 DSH 实例在跑,机制是:
维度 | 行为 |
一次 harness 回合里多次调用 | 可以并行。MCP 层对每个请求异步处理( |
单个 MCP server 进程的并发上限 | 默认 4( |
跨 harness | 每个 harness(Cursor / Claude / Codex / …)各有一个独立的 MCP server 进程,各自 4 个名额;CLI( |
怎么放大 | 在对应 harness 的 MCP 配置里给 |
查当前状态 |
|
代价 | 每个实例都是一次独立的模型调用(自己的上下文与 token),并且都打同一个网关;开到 8~16 以上时瓶颈通常变成 API 吞吐/限流,而不是本机 CPU |
实测(node test/concurrency-probe.mjs <工作空间> 3):3 路同时发起,总墙钟 9.2s,三个任务各自 ~8-9s 全部成功——是并行而非串行(串行应≈25s+)。
推荐的用法:不需要立刻要答案的任务,用 wait_seconds: 0 拿到 job_id 就放手,后面用
dsh_task_status 轮询(每 20~30s 一次)。
停止:
dsh_task_cancel= 优雅取消:对"正在跑"和"还在排队等名额"的任务都有效(排队中的直接标记cancelled且不会再启动),由任务的退出路径收尾。dsh_task_kill= 强制终止:job_id杀一个;caller一次停掉该调用方本进程内起的全部 运行中任务("停掉我起的全部东西")。排队中的任务会被立即落终态,跑着的直接杀整个进程树。 注意caller模式的作用域是当前 MCP server 进程——每个 harness 一个进程,跨进程请分别调用。
6.2 硬截止与停滞看门狗(防止无限挂住)
机制 | 触发条件 | 结果 |
外层上限 |
|
|
硬截止 |
|
|
停滞看门狗 | 每 | 杀进程树, |
停滞判定的信号模型(任一信号推进即算"有进展")
只看日志字节数是不够的,而且会误杀正常任务。本机实测反例:一个任务在 6 分钟里
stderr.log(18474B)与 session.jsonl.zstd(191279B)字节数一个字节都没变,但它当时正在
跑一个 133 步的循环脚本——后代进程 powershell.exe → bash.exe → bash.exe 都活着,
根进程 CPU 从 8.42s 涨到 8.50s。长工具调用期间 DSH 不写任何输出是设计使然。
因此每轮探测采集下列信号,任一推进即算有进展:
类别 | 信号 | 说明 |
日志 |
| 只用字节数,绝不使用 mtime —— mtime 在部分环境里不可靠(安全软件的文件过滤会让它停在创建时刻,实测 18KB 的 stderr.log mtime 没变过),而字节数变化是可靠的 |
进程树 | ① 根进程是否存活 ② 后代进程 pid 集合变化 ③ 整棵树累计 CPU( | 每轮只跑一次 |
调用方 | 轮询时看到的新增 | 调用方每次 |
快照取不到时按"未知"处理:进程树类信号一律不参与判定,由日志信号 + 静默窗口兜底。 测不到 ≠ 没有进展,绝不允许探测失败或测量缺口导致误杀。同理,快照里查不到某个 pid 时, CPU 相对增量被钉在 0 以下不取(避免出现负增量这种噪声)。
护栏与取舍:
排队中的任务不判停滞(它连子进程都还没起来),改用
status输出里的queuedSeconds观察。第一个探测窗口没走完之前绝不下手;已经结束/被取消的任务不会被看门狗碰。
指纹只看字节数增长,纯时间戳变化不算进展——所以"安静但仍在输出"的长任务不会被误杀。
静默的长工具调用被当作进展(只要进程树里还有后代进程、或树 CPU 在涨)。看门狗的判定是 "整棵树完全静止",而不是"没有日志"。要限制一个本来就很久的任务,请用
expected_seconds声明预估时间(它形成硬截止),而不是指望看门狗替你掐掉正常的长任务。两道门槛同时满足才杀:① 静默时长 ≥
DSH_SUBAGENT_STALL_MIN_SECONDS(默认 180s); ② 静默窗口内连续DSH_SUBAGENT_STALL_PROBES次探测所有信号零变化(中间只要有一次 "有进展",计数立刻归零)。最小静默窗口是必需的:一次慢模型响应既不写日志也不产生后代进程。第四道门槛:CPU 干活强度下限
DSH_SUBAGENT_STALL_CPU_WORK_FLOOR_MS(默认 1500ms)。 这条是本机实测逼出来的:一个六进程树(powershell → bash → bash → node hang.mjs → node(600s sleep)) 整棵树都阻塞在等一个 600s 子进程、日志 394 秒零增长,树 CPU 仍然以约 15~220ms / 每 10 秒 的速度在涨——那是 IO 完成端口/定时器/调度开销,不是产出。若"CPU 涨了就算有进展"一视同仁, 这类真挂起会永远攒不满静默窗口,看门狗形同失效。所以静默窗口内树 CPU 的累计增长低于下限时 不算"在干活";而真正在干活的任务(实测:122 秒烧 2750ms)远超下限,照样不会被误杀。对照实验(把下限关掉的那次运行,可复现):同一棵树,
stderr从 +24s 起冻在 1439 字节、 连续约 590 秒零增长(整轮 612 秒),但每次采样的cpuDeltaMs是 0~188ms(整轮累计 3391ms), 于是"树 CPU 累计 +2xx ms"每隔 3 次采样就把progressed翻成 true、计数反复归零 (stalledProbes轨迹在 0,1,2,0,1,2,3,4,0,… 之间打转,一次都没到阈值),最终任务被判成status:"ok"—— 真挂起被漏判(探针因此退出码 1)。 命令:DSH_SUBAGENT_WATCHDOG_INTERVAL=10 DSH_SUBAGENT_STALL_PROBES=2 DSH_SUBAGENT_STALL_MIN_SECONDS=60 DSH_SUBAGENT_STALL_CPU_MS=200(且不设..._CPU_WORK_FLOOR_MS)跑同一个探针; 加上下限(默认 1500)后同一条路径判stalled(见 §10)。要更快发现挂死:
DSH_SUBAGENT_WATCHDOG_INTERVAL=5+DSH_SUBAGENT_STALL_MIN_SECONDS=10;DSH_SUBAGENT_WATCHDOG=off可整体关掉看门狗。仍存在的误杀风险:一个整棵树真静止超过窗口的任务(例如子代理卡在一个 CPU 睡眠、 又没有后代进程的动作上)会被判
stalled。这正是需求要的行为,但请按上面的方式用expected_seconds兜住正常的长任务。
6.3 会话在 DSH GUI(本窗口)里看得见吗
看得见,但不是"实时看板"那种看得见。 事实依据:
子代理的会话是标准 DSH 会话,落盘在
$DSH_HOME/sessions/<工作空间key>/session-<uuid>/, 和 GUI 自己在同一工作空间下创建的会话同一个目录、同一份列表。例如工作空间D:\work\demo对应的目录是$DSH_HOME\sessions\--D-work-demo--\(Windows 路径会被转义成-开头的 key,中文字符再编码成~XXXX~形式)。会话文件在执行过程中就在增长(实测:探针任务运行中该会话文件 25 秒内从 38KB 涨到 89KB), 所以你在列表里点开就能看到已经发生的对话与工具调用。
但不会有"执行中"的跑马灯/徽标:GUI 的
running状态取自它自己进程内的 agent 注册表 (dsh-api-session-controller的summaryFor:running: this.ctx.agents.get(session.id)?.status === "running"); 子代理是另一个进程,GUI 不知道它活着,所以这类会话在列表里是普通(冷)会话。列表按最近活动排序,新会话会在重新进入该工作空间 / 刷新列表后出现(外部进程写入不会给 GUI 发通知)。
想实时看进度,直接盯任务现场(写入是即时的):
# 推理流(实时增长)
Get-Content -Wait $env:USERPROFILE\.dsh\subagent\state\tasks\<job-id>\stderr.log
# 当前状态 / 工作空间 / 产物
Get-Content $env:USERPROFILE\.dsh\subagent\state\tasks\<job-id>\task.jsonjob_id 由 dsh_task / dsh_task_status / dsh_health 给出。任务结束后,result.txt 就是
最终答复,meta.json 里有 sessionId,拿它就能在 GUI 里精确打开那次会话。
⚠️ 只对"已经结束"的会话点开。 在 GUI 里打开一个仍在运行的外部会话不是只读操作: 宿主会 resume 它、接管写权,并把合成的收尾事件写进子进程正在写的日志(实测已把 5 个会话 写坏,出现重复 seq)。详见 §6.6;悬浮卡片(§6.5)已经按这个结论做了防护。
6.4 GUI 实时监控(observer 插件,已装好)
上面 7.2 说的"没有跑马灯、要刷新才出现"已经解决:本机在 GUI 宿主进程里装了一个
观察器插件 ~/.dsh/subagent/monitor/observer.mjs,它把外部 subagent 任务投射成 GUI
认得的远程事件,于是:
时机 | 你在 GUI 里看到 |
任务开始 ~2 秒内 | 该会话自动出现在侧边栏(归到它的工作空间那一组),带"运行中"标记 |
运行中 | 会话被持续顶到列表最前;标题是 |
任务结束 | 运行中标记自动消失,会话留在列表里,这时点开内容完整可复查 |
观察器本身是安全的:标
running与activity都只影响客户端列表(running不会让宿主 attach,activity只重排列表),不读也不写会话文件。危险的是"点开"(§6.6)。
原理(都是宿主插件,不改前端、不改 DSH 源码):
桥接层在任务一开始就把
sessionId写进任务现场的meta.json(runner 一建立会话就写, 不等这一轮结束),task.json里有pid/status/workspace;观察器每 2 秒扫一遍
$DSH_HOME/subagent/state/tasks/*/,用pid存活性确认"真在跑" (避免留下永远"执行中"的僵尸会话),然后发三个官方声明转发的远程事件:api-session/added(入列)/api-session/status(运行中徽标)/api-session/activity(活动时间);客户端侧(
dsh-api-session-controller的 client 层)对这些事件的处理就是mergeSummary/session.handleRunning(running),所以侧边栏立刻生效;api-session/added里带projections.values['dsh-subagent'](调用方、任务号、工作区、 状态、截止时间、进度字节、最近输出行…),客户端每次列表快照都会把它带给 UI; 任务运行中每 20 秒(或进度字节变化、或 token 用量增长后 ≥5 秒)重播一次,这样刷新页面后卡片也不会空 (客户端重建列表时投影会丢,重播把它灌回去);用量也走这个提前重播,所以底部状态条最多滞后约 5 秒, 不必等满 20 秒的常规节奏。
装了哪些文件:
~/.dsh/subagent/monitor/observer.mjs—— 插件本体(纯node:fs+ctx.emit);~/.dsh/profiles/desktop/cordis.patch.yml—— 被>>> dsh-subagent-observer >>>标记包住的 insert 条目(由install.mjs托管,删掉该块即关闭监控;uninstall.mjs会自动摘掉它);日志:
~/.dsh/subagent/state/observer.log(每 2 秒一轮只记"有变化"的事; 想看每轮心跳就设DSH_SUBAGENT_OBSERVER_DEBUG=1)。
生效条件:desktop profile 声明的是 patchReload: live,但打包版实测不会热应用
patch 文件(宿主的 patch 监视在启动那一刻没建立起来,静默退化),所以首次装好后要重启
一次 DSH Desktop(设置面板里的「重启」按钮即可)。重启后永久生效 —— 之后任何 Cursor /
Claude Code / Codex 拉起的 dsh_task,都在这个窗口里实时可见。
心跳(给桥接层用的信号):观察器每 ~5 秒写一次
$DSH_HOME/subagent/state/observer-heartbeat.json:
{ "at": "2026-09-11T10:31:02.123Z", "epochMs": 1762331462123, "pid": 12345,
"engine": "dsh-desktop-observer", "profile": "desktop", "pollMs": 2000,
"trackedTasks": 3, "activeTasks": 1 }自动拉起监控窗口(桥接层 lib/monitor-host.mjs):任何 harness 调 dsh_task 时,桥接层
会顺手确认"有没有带监控的宿主在跑",没有就 best-effort 把 GUI 拉起来 —— 于是"用 Cursor /
Claude Code / Codex 派活"这件事本身就能激活监控窗口,哪怕你压根没开 DSH UI。
判活协议与决策顺序(dsh_task 里非阻塞完成,失败绝不影响任务)。判活绝不只看心跳 ——
心跳文件只有在 DSH Desktop 重启后才会被写(观察器是宿主进程内的插件,内存里跑的是启动时加载的那份
代码),而且它会被自检写成"pid 早已死掉"的残留文件。只看心跳就会拉起第二个宿主,踩中 §6.6:
顺序 | 条件 | 动作 |
1 | 心跳新鲜( |
|
2 | 心跳存在但 pid 已消失 / 时间过期 | 判为残留文件:既不当作"宿主在",也不当作"没宿主",继续往下判 |
3 | 总开关 |
|
4 | 非 Windows 平台 |
|
5 | 枚举 |
|
6 | 进程表查不到(探测失败两次) |
|
7 | 距上次尝试 < |
|
8 | 以上都不成立 |
|
— | 找不到可执行文件 |
|
— | 本进程还没做过判断 |
|
第 5 步的命令行分类(与 test/live-audit.mjs 同一口径):
命令行含 | 判定 | 要不要阻止拉起 |
| Chromium 渲染/GPU 子进程 | 不阻止(噪声) |
| 以 CLI 方式跑的 dsh —— 我们自己的子代理任务 | 不阻止 |
两者都没有 | 真正的 GUI 宿主(有窗口) | 阻止( |
命令行是用 base64 从 PowerShell 传回来的:命令行里可能有换行(本机实测有多行
node -e "…"脚本),直接"$pid|$cmdline"会被换行切成多行、解析出pid=NaN的假条目。
为什么要"心跳 + pid"两道一起看:踩过两次——① 观察器自检把心跳写进了真
$DSH_HOME,文件在、 pid 早死了;② 当前宿主 17:44 启动,内存里是旧版观察器,根本没有心跳文件。只信心跳 = 每次都 再开一个 GUI。想强制拉起:DSH_SUBAGENT_MONITOR_FORCE=1;想彻底关掉第 5 步的闸门(即回到 只信心跳):DSH_SUBAGENT_MONITOR_HOST_CHECK=off(它同时也是自检用来验证"该拉起时真的会拉起"的开关)。exe 定位顺序:
DSH_SUBAGENT_MONITOR_CMD(可自定义,支持带参数,另加DSH_SUBAGENT_MONITOR_ARGS追加)→C:\Program Files\DSH Desktop\DSH Desktop.exe→%LOCALAPPDATA%\Programs\DSH Desktop\DSH Desktop.exe。日志:
$DSH_HOME/subagent/state/monitor-host.log(每次"拉起/跳过"都写一行,含原因、 心跳时间与 pid 是否存活、命中的 exe / GUI 宿主 pid、探测失败原因)。在哪儿能看到:
dsh_health的monitorHost(running/state(与活跃审计同口径的三态:host-with-heartbeat/host-older-observer/no-host)/guiOpen/guiHostPids/cliProcessPids/heartbeatAt/heartbeatAgeMs/heartbeatPidAlive/heartbeatResidue/processProbeFailed/autostart/lastLaunch),以及每次dsh_task/dsh_task_status结果里的monitor_host: <action>(<reason>)一行。dsh_task_status只读、绝不触发拉起 (不能"看一眼状态就冒出个 GUI")。成本:第 5 步要跑一次
Get-CimInstance Win32_Process(约 1.3~1.8 秒,只在"没有可信心跳"这条路上 才会走),结果按冷却窗口缓存;心跳新鲜时完全不跑任何子进程。
自检(用假 exe + 临时 DSH_HOME,不会真的启动 GUI):
node $env:USERPROFILE\.dsh\subagent\test\monitor-autostart-probe.mjs # 26 项:三步判活逐条 + 命令行分类可调:DSH_SUBAGENT_OBSERVER_INTERVAL(轮询毫秒,默认 2000)、
DSH_SUBAGENT_OBSERVER_ANNOUNCE_AGE_MS(只推送"正在跑或刚结束"的窗口,默认 10 分钟,
更早的历史任务不重播,免得把你在 GUI 里删掉的会话又拽回来)、
DSH_SUBAGENT_OBSERVER_REANNOUNCE_MS(运行中会话的重播间隔,默认 20000)、
DSH_SUBAGENT_OBSERVER_MAX_AGE_MS(任务现场的最大回溯窗口,默认 12 小时)、
DSH_SUBAGENT_OBSERVER_DEBUG=1(每轮都写日志)。
已知边界:GUI 显示的是"会话 + 运行状态"。远程事件里没有逐字增量,所以不打开会话时
不会实时滚字;而且运行中的外部会话不应该打开(§6.6)。要看实时的推理流,用悬浮卡片
的实时详情页(§6.5,它读任务现场的 stderr.log),或者 Get-Content -Wait …\stderr.log。
自检:
node $env:USERPROFILE\.dsh\subagent\test\observer-selftest.mjs # 34 项,含投影、僵尸/半成品判活、心跳+口径版本、token 折叠、吞吐窗口
node $env:USERPROFILE\.dsh\subagent\test\live-audit.mjs # 现场审计:真的在跑几个 / 幽灵几个 / 宿主状态6.5 悬浮卡片 Subagent(独立的实时视图,已装好)
侧边栏里外部任务和普通会话混在一起,不适合当"看板"。所以另装了一个独立的客户端插件
dsh-subagent-panel,在窗口右上角渲染一张悬浮卡片,和其他会话在观感上分开:
能力 | 说明 |
按调用方分组 | Cursor / Claude Code / Codex / 命令行 / 自检 / 未知来源各成一组,组标是该调用方的字形(⌖ ✳ ⬢ >_ ◎ ◈) |
本机 DSH 子代理也在里面 | DSH 自己的 |
手机式展开 | 点组标题像点手机上的应用文件夹一样展开成磁贴网格;有活跃任务的组默认就是展开的 |
默认只看活跃 | 卡片头部默认只显示正在跑的会话(外部 |
看得清 | 自带配色(不依赖宿主 token):浅色/深色都按 AA 以上对比度取值,跟随宿主的 |
可缩放(字号) | 头部 |
自由改尺寸(像真窗口) | 三个把手:右边 = 只改宽、下边 = 只改高、右下角 = 宽高一起改(位移除以 |
拉宽就多放几列 | 网格是 |
底部状态条 |
|
空态 | 无活跃子代理时居中显示雷达图 + 一行「无活跃子代理」 |
一眼看出区别 | HUD 四角、扫描线、脉动的运行指示灯、运行中磁贴的流光与进度条、等宽字体的倒计时;磁贴第二行还带 |
运行中 → 实时详情 | 点运行中的磁贴进只读详情页:调用方 / 任务号 / 工作区 / 已耗时 / 预计剩余 / 输出字节与"上次增长多久前" / 验收条件 / tokens 明细(输入·输出·缓存命中,以及这份数字的来源) / 子代理最近几行推理输出(来自任务现场的 |
已结束 → 普通打开 | 点已结束的磁贴 = |
可拖可收 | 头部可拖动(位置记在 |
头部为什么允许换行:卡片默认 300px,而头部有标题、活跃计数、
全部、− 120% +、收起六个控件 —— 挤不下时换行(flex-wrap: wrap),而不是把Subagent裁成SUBAGENT…。 踩过两次:只写flex: none而卡片宽度固定时,标题只是从"省略号"变成了"被卡片裁掉",都没修好; 真正的修法是让它换行,再把卡片做成可拉宽(拉宽后一行放得下)。
7.5.1 底部状态条的 token 数字是从哪来的
和宿主自己的对话统计同口径,但两条数据源:
会话类型 | 数据源 | 为什么 |
本机子代理 / 宿主自己的会话 | 宿主的 | token-meter 是按会话投影的,这就是宿主 UI 里那份数 |
外部 | 观察器自己折叠子代理的会话日志 | 那些会话宿主从没加载过 ⇒ 标准投影不存在,只能自己算 |
观察器的折叠口径抄宿主 token-meter:同一 (turn, step) 的 usage 样本替换而不是累加
(实测一条日志里 assistant/chunk(chunk.type=usage) 与 assistant/message(data.usage) 各 36 次 ——
不替换就会翻倍),llm/retry-started 会关掉替换槽。缓存命中率 = 缓存读 / prompt 侧总量,
部分命中绝不显示成 100%(四舍五入撞到 100 就退一位小数,还是 100 就写 99.9)。
⚠️ 必须逐帧解 zstd。踩过:
zstdDecompressSync(整个文件)只解第一帧就返回,而且不报错 —— 699K / 1282 帧的真实日志整块解只出 151 个字符。用它算用量会得到一份"看着正常、其实几乎没有" 的假数字。逐帧解同一份是 1.5M 字符 / 33ms,所以折叠结果按(size, mtime)缓存 + 每会话 5 秒节流。
7.5.2 吞吐(tok/s)的窗口口径:为什么客户端不再自己算
第一版把 tok/s 放在客户端算:每次重绘采一次 Δ输出/Δt。用户实测反馈是
"每秒几千 token,显然是错的" —— 这个数是错的,而且是算法错的,不是数据错的:
外部任务的用量是成块到达的:观察器折叠节流 5 秒,投影重播最快也要 20 秒(现在改成用量涨了 就按
ACTIVITY_MS提前推);客户端每秒采样一次,于是"2 秒里输出跳了 6000 token"被算成 3000 tok/s;
同一份数据按真实跨度算:50207 输出 token 摊在任务时长上是 ~250 tok/s 一档 (和 DSH 自己界面上那个样例数字同量级)。
现在只在观察器里算(rateOf):Δ输出 / Δ真实采样时间,跨度 < 3 秒不算,
再取最近 4 次采样的平均(≈20~40 秒窗口);客户端只负责显示,自己一个采样窗口都不留。
自检把用户那个场景钉住了:2 秒里跳 6000 token 必须不给速率(而不是给 3000 tok/s),
跨 29 秒的 6000 token 才给 ~207 tok/s。
实测(node test/observer-selftest.mjs):日志在长 → 投影里真的带出 tokensPerSecond,
且落在"几百 tok/s"一档(< 2000 断言);只有在跑的会话才显示速率,跑完就回到 —。
实测(node test/usage-fold-probe.mjs --all,真实任务日志):
1282 帧 / 1727 行 / 699.4K 输入 92103 · 输出 50207 · 缓存读 1949440 · 36 次调用 → 命中率 95.5%
1053 帧 / 1465 行 / 594.6K 输入 75871 · 输出 44498 · 缓存读 2522880 · 38 次调用 → 命中率 97.1%生效条件:状态条里的宿主侧数字(本机子代理)刷新页面就能看到;外部任务的 token 数与
吞吐(tokensPerSecond)需要观察器重新加载。实测:file: 插件在这个桌面宿主里
不会热重载 —— 改完 monitor/observer.mjs 必须重启一次 DSH Desktop(判定办法见下)。
怎么当场判断宿主里跑的是哪一版观察器:看
$DSH_HOME/subagent/state/observer-heartbeat.json。 新版心跳带usageRate: { minSpanMs, samples, foldThrottleMs }字段; 实测宿主在 20:00 启动、代码 20:06 改完,心跳一直更新却始终没有这个字段 ⇒ 跑的是旧代码 ⇒ 必须重启。自检里也钉了这条断言。
客户端侧(卡片本身)改完只要刷新页面。实测 bundle 的响应头是
cache-control: public, max-age=31536000, immutable,而 URL 带的是内容哈希
(.../client.js&rev=c06568fbaf88308f-47,组合 URL 上是 rev=da77570553a2)——
内容一变 URL 就变,浏览器自然会重新取。但宿主启动之后再改客户端代码时,
URL 里的 rev 可能还是旧的 ⇒ 浏览器不会回源,这时按一次 Ctrl+F5 强刷即可。
为什么自带配色(踩过的坑):宿主 token 有两处不适合做这张卡片 —— 浅色主题下
--dsw-alias-border-inverted 是 #0000(全透明,卡片干脆没有边框),而
--dsw-alias-state-warn-primary 在深浅两套主题里都是 amber-500 #f59e0b
(白底对比度实测 2.15:1,「橙色告警几乎看不见」就是这么来的)。所以卡片改用自己的色板,
数值都是实测计算的 WCAG 对比度:
浅色(白底) | 深色(卡片 | |
正文 / 次要 / 第三 | 18.5 / 9.7 / 6.4 : 1 | 16.6 / 11.9 / 8.2 : 1 |
强调 / 成功 | 6.6 / 7.6 : 1 | 8.9 / 10.4 : 1 |
告警(还是橙的) | 7.0 : 1 | 11.7 : 1 |
错误 | 7.8 : 1 | 9.0 : 1 |
告警也不再只靠颜色:提示条带底色 + 左边条,详情页里超时/久未增长的整行会整行染色
(data-tone="err"/"warn")。
⚠️ 附带的一处全局副作用(不想要可以删):同一个
state-warn-primary也是宿主自己 所有"橙色告警文字/圆点"用色,所以卡片在浅色主题下顺手把这个 token 压深成#8a4700(body:not([data-ds-dark-theme]) { --dsw-alias-state-warn-primary: #8a4700 }), 让整个 GUI 的橙色告警文字都达到 7.0:1。它只改-primary(文字与圆点), 卡片边框用的-secondary、条底色用的-tertiary都没动;深色主题完全不受影响。 删掉gui/lib/client.js里那一条规则即可恢复原样。
缩放为什么用 zoom 而不是 transform: scale():transform 只改视觉、不改布局,放大后会留下一个
透明的空盒子挡住下面的点击;zoom 让整块布局一起缩放,−/+ 之外不会多出任何可点区域。
缩放只作用于卡片内容(外层 .sap-zoom),所以拖动坐标、点击命中都不受缩放影响。
标题为什么不会被截断:头部一排控件挤在一起时,flex 收缩会让 SUBAGENT 变成 SUBAGENT…。
现在标题 flex: none(永不收缩、white-space: nowrap)并且头部允许换行(flex-wrap: wrap)——
挤不下时控件换行,而不是把标题裁掉。踩过两次:第一次只加 flex: none,而卡片宽度是写死的 300px,
标题仍然显示不全(从"省略号"变成"被卡片裁掉");第二次才定位到根因是头部根本放不下,
于是既让它换行、又把卡片做成可拉宽(拉宽后这一行自然放得下)。
改尺寸为什么不是 zoom:字号缩放(−/+,60%~200%)和窗口尺寸是两件事,分开记。
窗口尺寸直接写 CSS 变量(width: var(--sap-w, 300px) / height: var(--sap-h, auto)),
拖动位移除以当前 zoom 换成 CSS 像素;网格用 auto-fill 跟着宽度重新排列 ——
这才是"拉宽时每行多几个卡片",等比例缩放做不到这件事。
原理:卡片不在侧边栏里塞东西,而是注册进 shell.overlay —— ui-layout 声明的帧级悬浮层
(可叠加、默认点击穿透、子元素自动接管指针事件)。数据不额外开后门:观察器把每个任务的
元数据写进会话的投影值 projectionValues['dsh-subagent'],客户端列表快照本来就带投影,
卡片读它即可(所以卡片和侧边栏永远一致,不需要第二条通道)。
装了哪些文件:
~/.dsh/subagent/gui/package.json+gui/lib/index.js(宿主半边,空实现)+gui/lib/client.js(预构建的浏览器 bundle,window.__ModuleLoader__.load形态);~/.dsh/profiles/desktop/cordis.patch.yml里被>>> dsh-subagent-panel >>>包住的 insert 条目 (file://指向gui/lib/index.js;Loader 会走到最近的package.json读出dsh.client,再把exports["./client"]作为浏览器 bundle 投送)。
生效条件:新增 Loader 条目这一侧,浏览器要刷新一次页面才会拿到重组的启动图
(__DSH_BOOT__ 是页面加载时注入的);打包版的 patch 热应用不生效,所以首次仍需重启一次
DSH Desktop。两者都做过之后,以后任何调用方拉起的任务都会自动出现在卡片里。
自检:
# 卡片逻辑(离线:假 window/__ModuleLoader__ + 迷你 React,真跑组件函数)
node $env:USERPROFILE\.dsh\subagent\test\panel-selftest.mjs # 117 项
# 真实任务日志 → token 用量折叠(逐帧解 zstd;只读)
node $env:USERPROFILE\.dsh\subagent\test\usage-fold-probe.mjs --all
# 启动图(起一个一次性 web 实例,确认插件真的进了 __DSH_BOOT__ 且 bundle 能取到)
dsh --patch $env:USERPROFILE\.dsh\subagent\gui\test-overlay.yml --profile web --no-open --port 34199
node $env:USERPROFILE\.dsh\subagent\test\gui-graph-probe.mjs "http://127.0.0.1:34199/?token=<上面打印的 token>"6.6 ⚠️ 为什么"运行中的外部会话"不能直接打开
这是本机实测出来的破坏性行为,不是猜测。
宿主把"打开会话"实现成了"恢复该会话并取得它的写权":客户端只在当前选中会话上开流
(sessions.follow),而 follow 对非 live 会话必然 promote() → agents.resume() →
persistence.prepare()。当这个会话是外部进程(桥接拉起的 DSH 子代理)正在写的文件时:
prepareCore会为"尾部未闭合的 turn"合成收尾事件(带interrupted-tool-result-*、turn/end{reason:"interrupted"}),commitRepair用 truncate + append 把它们落盘;发布时又会追加
session/end-seed,并把"未发布后缀"写进同一个文件;子进程完全不知道,继续用同一个 seq 段 append ⇒ 日志里出现重复 seq / seq 回跳, 活着的 turn 中间插进假的
turn/end{interrupted}。
取证:本机 106 个会话日志里 5 个带这种注入指纹(全部是外部 harness 拉起的会话),
其中 session-5e4c139b… 的 seq 5185–5188 各出现两次。这些日志随后对任何读者都是语义损坏。
顺带解释另外两个现象:
"打开了也一直不动":
follow的尾随循环只消费本进程的session/event(dsh-api-session-controller),没有任何"按字节 tail 文件"的通道 —— 子进程后续写的内容 永远不会进入宿主的 live 会话,所以转录在打开那一刻就冻结了。这是设计使然。"一观测就卡":读路径对持续变化的文件是"等稳定 + 无限重试"设计,每轮都要整文件
readFile+ 全量 zstd 解码 + 重建会话 + 折叠投影(解码每 500ms 才让出一次事件循环), 而宿主进程同时提供 Web 服务 ⇒ 点击瞬间的卡顿。反向澄清:"GUI 把子任务卡死"在代码上不成立(没有锁、没有独占句柄、没有 owner 标记); 日志静默期基本都是子代理在等自己的长工具调用(官方也注释了 continuous external writers may delay completion)。
所以本仓库的处理:
场景 | 行为 |
任务已结束 | 悬浮卡片点击 = 普通打开,完全一致(此时没有并发写者,resume 是正常路径) |
任务运行中 | 默认不打开,进只读详情页;详情页里的「打开会话」是禁用状态,旁边留了一个需要二次确认的「仍要打开」(标红,写明有损) |
想实时看内容 | 走详情页的实时输出行(读 |
想彻底消除风险 | 在宿主侧把 |
6.7 本机 subagent 与外部 dsh_task 是两扇门(为什么两个都要)
经常会被问:"DSH 自己就有 subagent,为什么还搞一个 dsh_task?" —— 因为调用方在两个不同的世界里:
本机 | 外部 | |
谁能调 | 只有 DSH 内部的模型(工具表里的一个工具) | 任何 MCP 客户端:Cursor / Claude Code / Codex / 其它 harness |
子代理在哪 | 同一个宿主进程内( | 独立进程 |
递归上限 |
| 无(由调用方自己决定要不要再派) |
控制通道 |
|
|
在卡片里 | 「本机 DSH 子代理 ✦」组(L1/L2/L3 + ⊞) | 按调用方分组的那些格 |
关键点:进程外的东西不可能调用 DSH 进程内的工具 —— Cursor 的进程里没有 DSH 的工具表,
它唯一能用的门就是 MCP。所以 dsh_task 不是"另起炉灶",而是给外部调用方开的那扇门;
DSH 自己内部派活时用的仍然是它自己的 subagent(这也是为什么你在 GUI 里能看到
"我的子代理又有子代理"的嵌套)。
两扇门都要能看见。过去卡片只认 dsh_task,于是"我自己的子代理还在跑"这件事落在视野外;
现在卡片把宿主的子代理目录(subagentsByParent)也读进来,按 activity 标运行中、
按父会话关系算出 L1/L2/L3、⊞ 表示"它自己也叫了子代理",点开就进那个会话。
另外卡片对"报告还有下一层"的子代理会主动拉一次目录(ctx.sessions.refreshSubagents),
所以递归链不用你先点开宿主的子代理面板才会显形。
注意:本机子代理是同进程的,宿主自己的入口就是直接打开,所以卡片对它们不做§6.6 的 「运行中勿点开」限制 —— 那条限制只针对进程外的
dsh_task会话。
6.8 台账里的"幽灵记录":状态一律按 pid 判活
state/tasks 是跨桥接进程共享的目录,而"写终态"这件事只有起它的那个桥接进程会做。
桥接进程被杀 / 退出时,它正在跑的任务永远不会有人去改 status —— 于是台账里留下
status:"running" 但 pid 早已消失的幽灵记录。实测踩到:108 条记录里 7 条号称在跑,
其中 4 条是探针留下的幽灵。
以前的坑:taskState() 只在单个任务查询时用 pid 兜底,而 listTasks() 直接返回 task.json 的
原始 status —— CLI --list 和 dsh_task_kill {caller} 都吃它,于是:
--list把幽灵报成"在跑";dsh_task_kill {caller}以为自己杀了 N 个,其实里面混着幽灵,而调用方看到"已强杀"以为都清了。
现在三条路径统一口径(taskState / listTasks / killByCaller),判定顺序:
本进程内还有句柄 → 一定在跑;
meta.json已写明stopReason→ 按它推导成终态(ok/error),不算幽灵;否则看 pid 存活:活着 →
running;死了或根本没记 pid →lost。
并且 listTasks() 的每条记录都会带上:
字段 | 含义 |
| 已判活之后的状态(幽灵是 |
|
|
|
|
| 状态是从 |
dsh_task_kill {caller} 只杀 pidAlive === true 的,并把幽灵放进返回值的 stale 数组,
同时输出一行 另清理了 N 条**幽灵记录**…,避免调用方以为自己还挂着一堆任务。
强杀必须"验证过才报成功"(本轮修的一个真问题):不属于本进程的任务只能按 pid 杀,
而旧实现用 spawn('taskkill', …) 发完就不管、也不看结果 —— 强杀失败(权限不足 / pid 已变 /
taskkill 起不来)照样回报 killed: true,调用方以为卡死的任务停了,进程树还在后台烧 CPU。
现在这条路改走 execFileSync(killTreeSync):taskkill 只在确实终止了进程时才返回 0,
所以"报成功"本身是被验证过的;验证不了就如实回报
killed: false, note: "强杀失败:taskkill 未确认终止(退出码 128);进程可能仍在运行,记录保持 running"。
本进程自己拉起的任务仍走原来的 killTree(它们的终态由运行器自己落盘,不需要在这里验证)。
自检(临时 DSH_HOME 造假台账,不碰真 %USERPROFILE%\.dsh):
node $env:USERPROFILE\.dsh\subagent\test\ledger-liveness-probe.mjs # 20 项:幽灵/在跑/meta 推导/真的杀掉/强杀失败如实回报判活那条断言必须轮询:taskkill 是"请求终止"、进程退出是异步的,固定等 1.2 秒在机器忙时会假红 (实测同一条断言 3 次里红 1 次,而被杀的进程其实已经没了)。现在是 200→2000ms 递进轮询。
7. 环境变量一览
变量 | 默认值 | 作用 |
|
|
|
|
| 硬截止宽限系数: |
|
| 外层绝对墙钟上限(比硬截止更宽松) |
|
| 停滞探测间隔(秒);每轮采一次多信号快照 |
|
| 静默窗口内连续多少次"所有信号零变化"才判停滞 |
|
| 判定停滞前的最小静默秒数(防"慢模型响应"被误杀) |
|
| 树累计 CPU 增长多少毫秒才算"有进展"(小增量会累加) |
|
| 窗口内树 CPU 累计增长低于此值时不算"在干活":把 IO/定时器空转噪声与真正的工作区分开(见 §6.2) |
| 未设置 | 设为 |
|
| 单次进程快照的 |
| 系统 PowerShell | 覆盖进程快照所用的 powershell.exe 路径 |
|
| 单个 MCP server 进程的并发上限 |
| 未设置(开) | 设为 |
|
| 两次自动拉起之间的最小间隔,防抖(也用作进程表探测的缓存窗口) |
|
| 心跳多旧算"没有宿主在跑" |
| 未设置(开) | 设为 |
|
| 枚举/分类时要看的进程映像名 |
| 未设置 | 设 |
| 未设置 | 覆盖要拉起的可执行文件(可带参数;测试用假 exe 就靠它) |
| 未设置 | 追加参数(JSON 数组,或按空格拆分的字符串) |
|
| 宿主进程枚举( |
|
| 子代理默认权限档 |
|
| 回传给 harness 的答复字数上限 |
|
| 活动流( |
|
| 同一会话的日志最多多久重新折叠一次(折叠一次 ~33ms) |
|
| 进度字节/用量变化触发提前重播的最小间隔 |
|
| 算吞吐( |
|
| 吞吐取最近几次采样的平均(≈20~40 秒窗口) |
CLI 另有两个参数:--expected-seconds <n>(默认取外层上限的一半)、--acceptance <text>、
--caller <name>(默认 cli)。
8. 常见问题
Q:传了 permission: "workspace-write",任务 2 秒就失败(退出码 1),堆栈里是
permission: composed sandbox and approval defaults match no preset?
已经修好了(2026-09-11),原因与修法见 §5 第 1 条。要点:DSH 自带预设表把
workspace-write/read-only 配成 approval: ask,而无人值守的子代理固定 never,
组合不出表项 → 预设服务在构造期抛错 → 插件树加载失败 → agent 还没起就退出。
现在 profile 显式声明了"三种模式 × never"的表,三个档位都能跑。
(注意:修复前 read-only 同样是坏的,只是没人试过。)
Q:--permission workspace-write 能跑,但子代理照样写到了工作区外面?
也是已修的坑(§5 第 2 条):权限预设值存在全局 $DSH_HOME/settings.yaml(GUI 里选的
档位),会盖掉 profile 的 config.defaultPreset;而工具层是按会话事件解析沙箱策略的。
现在 runner 会在发提示词前把档位写进本次会话事件,并用
node test/session-perm-probe.mjs <sessionId> 可以验证会话里到底落的是哪一档。
Q:harness 里看不到 dsh 工具?
重启该 harness(Cursor/Claude Code/Codex 只在启动时读 MCP 配置)。Claude Code 可用
claude mcp list 自检,应出现 dsh: … √ Connected;Codex 用 codex mcp list。
Q:dsh_task 返回 status: running,然后呢?
在同一轮里继续调用 dsh_task_status(job_id, wait_seconds=25),大约每 20~30s 一次,直到
ok/error/deadline/stalled/cancelled/killed。状态里会带 recent_activity(子代理此刻
在干什么)与 progress_bytes;任务的 prompt.md、stderr.log 也实时落盘,想看原始进度直接看文件。
Q:Cursor / Claude Code 里这个 MCP server 显示一个 warning?
harness 会把 MCP 子进程的 stderr 一律渲染成 warning/error,所以哪怕我们只打一行
"启动成功"的信息,你在 Cursor 里也会看到告警。实测 Cursor 的 mcpprocess.log:
[warning] [McpProcess stderr] ERR dsh-subagent: MCP stdio server ready (bridge v…)因此现在的约定是:正常路径下 stderr 一个字都不写,stderr 只留给"真的出问题"
(例如进程树探测不可用的降级告警)。要排查就把 DSH_SUBAGENT_DEBUG 设成 1,
启动横幅与调试行会重新出现。自检里钉了这条(正常启动不往 stderr 写任何东西),
以后不会退化。
Q:结果为空?
看 meta.json 的 stopReason 与 error,以及 stderr.log 末尾。DSH 进程退出码 N
一般是模型/凭据问题。
Q:子代理回答"文件是二进制/乱码"? 先确认那个文件是谁写的、能不能被别的进程按原文读到(node/npm 现场生成的中间文件在某些 安全软件环境下可能被改写),再怀疑模型。别把这类现象当成模型幻觉。
Q:任务太长被掐断?
先看是哪种掐断:status: deadline 说明 expected_seconds 估小了(或宽限系数太小),
估准了重派、或把任务拆小;status: timeout 才是外层 timeout_seconds(默认 1800)到了;
status: stalled 是看门狗判定"连续无产出",见 §6.2。宿主 harness 自己的 MCP 工具超时
(Claude Code 的 MCP_TOOL_TIMEOUT 等)也要相应放大。
Q:怎么换模型?
按次:dsh_task(model: "deepseek-v4-pro") 或 dsh-subagent -m deepseek-v4-pro;
全局:改 DSH 设置里的默认模型(设置 → 模型),子代理默认跟随。
Q:外部任务能不能自己再派子代理?
不能(叶子闸门,§4.1)。由其它 harness 经 dsh_task 调进来的 DSH 实例,工具表里没有
subagent / subagent_fork / workflow / ralph / 子代理控制通道;DSH 自己内部派活走的原生
subagent 不受影响(默认 3 层)。想恢复:删 profile/cordis.patch.yml 第 7 条并重跑
node install.mjs --only profile。
Q:我的子代理还没跑完,但这一轮已经答完了,它们去哪了?
DSH 父会话回答完不会杀掉子代理(杀了等于丢工作),它们会继续跑完并写回结果。所以看的地方是
悬浮卡片(§6.5):「本机 DSH 子代理 ✦」那一组按 L1/L2/L3 列出宿主自己的子代理树,
⊞ 表示"它自己也叫了子代理",运行中的会亮着 —— 不用再靠"翻侧边栏找会话"。
9. 目录速查
~/.dsh/subagent/
├── README.md 本文档
├── install.mjs 幂等装配器(--dry-run / --only=…)
├── uninstall.mjs 摘除所有 harness 里的 dsh 注册
├── profile/ DSH profile 源文件(install 会同步到 $DSH_HOME/profiles/subagent)
├── lib/
│ ├── launcher.mjs 定位并解析本机 dsh 启动器(直接 spawn exe,绕开 cmd 转义)
│ ├── tasks.mjs 任务生命周期:启动 / 等待 / 查询 / 取消 / 强杀 / 硬截止 / 停滞看门狗 / 并发闸门
│ ├── mcp.mjs MCP stdio server 与五个工具的实现在此(工具定义里的委派手册也在这)
│ ├── monitor-host.mjs 监控窗口自动拉起(三步判活:心跳+pid 存活 / 残留不采信 / 命令行分类拦第二个 GUI)
│ └── util.mjs 路径、JSON、裁剪、进程存活等小工具
├── bin/
│ ├── dsh-subagent.mjs CLI:任何 harness 都能 shell 调用
│ └── dsh-subagent-mcp.mjs MCP server 入口
├── test/
│ ├── selftest.mjs 协议级端到端自检(54 项)
│ ├── monitor-autostart-probe.mjs 监控窗口自动拉起自检(27 项,假 exe + 临时 DSH_HOME)
│ ├── ledger-liveness-probe.mjs 台账幽灵记录自检(21 项,临时 DSH_HOME 造假台账)
│ ├── e2e-harness.mjs 验收脚本:让每个 harness 自己委托一次并核对产物
│ ├── concurrency-probe.mjs 并发(N 路同时委托)+ 取消验证
│ ├── tasks-probe.mjs 只验任务层的小烟测
│ ├── session-perm-probe.mjs 解开某个会话日志,打印它**实际生效**的权限事实
│ ├── panel-selftest.mjs 悬浮卡片逻辑自检(离线 117 项)
│ ├── observer-selftest.mjs GUI 观察器自检(34 项,含心跳+口径版本、幽灵收尾、pid 宽限期、token 折叠、吞吐窗口)
│ ├── live-audit.mjs 活跃审计:真在跑/幽灵/没记 pid/宿主状态/最近任务耗时(--fix 订正幽灵)
│ ├── monitor-live-probe.mjs 真心跳 + 真 dsh_task:验 already-running 分支,并确认不重复拉起 GUI
│ ├── usage-fold-probe.mjs 真实任务日志 → token 用量折叠(逐帧解 zstd,验底部状态条的数据源)
│ ├── leaf-only-probe.mjs 叶子闸门探针(14 项:配置级 7 条闸门 + 会话日志里的真实工具表)
│ ├── gui-graph-probe.mjs 客户端插件启动图探针(真起一个 web 实例)
│ ├── live-probe.mjs 两采样进度探针(判断任务是否真的在动)
│ └── dump-session.mjs 解压查看某个 DSH 会话事件时间线
└── state/
├── tasks/<job-id>/ 每次委托的完整现场
└── selftest-report.json 最近一次自检报告10. 验收记录(本机实测)
一键复跑: node test/e2e-harness.mjs <工作空间>(会依次驱动 Claude Code / Codex /
Cursor 各委托一次,并核对 DSH 是否真的按内容要求写出了文件)。
已完成的实测(工作空间 D:\dsh-subagent-selftest):
链路 | 调用方式 | 结果 |
Claude Code → |
| ✅ 6.1s,产物 |
Claude Code 子代理 → DSH |
| ✅ 产物 |
Codex → |
| ✅ 12.5s,产物 |
Cursor → |
| ✅ 6.0s,产物 |
命令行 |
| ✅ 5.5s,stdout 就是 DSH 答复 |
直接调用 DSH profile |
| ✅ 1.2s,退出码 0 |
协议级自检 |
| ✅ 54/54(含 caller / |
并发 |
| ✅ 3 路并行 14.6s 全成功;取消 ✅ |
硬截止 |
| ✅ |
停滞看门狗(真挂起) | 进程树完全静止(根进程阻塞在 | ✅ 判 |
停滞看门狗(真挂起·六进程树) |
| ✅ 判 |
停滞看门狗(对照:关掉 CPU 强度下限) | 同一棵树, | ❌ 漏判 —— |
停滞看门狗(反例不误杀) | 长时间不出字但在真干活(连续 30 次 | ✅ 未被杀, |
监控窗口自动拉起 |
| ✅ 27/27:心跳新鲜且 pid 存活→ |
监控窗口自动拉起(全链路) | 真 MCP server + 真 | ✅ 结果里 |
监控窗口自动拉起(本机真实环境) | 真 | ✅ 心跳文件不存在(宿主加载的还是旧版观察器),命令行分类得到 |
台账幽灵记录 |
| ✅ 21/21:幽灵(pid 已死 / 没记 pid)→ |
卡片底部 token 状态条 |
| ✅ 紧凑口径 |
token 折叠(真实日志) |
| ✅ 真实多帧 zstd 日志(1282 帧 / 1727 行): |
吞吐口径(用户报的"每秒几千 token") |
| ✅ |
自由改尺寸 / 拉宽多列 / 标题完整 / 空态居中 |
| ✅ 三个把手 |
启动图与 bundle 缓存 |
| ✅ 10/10(插件进了 |
台账幽灵记录(本机真实数据) |
| ✅ 134 条记录: |
强制终止 |
| ✅ 一次杀掉该 caller 的 2 个运行中任务;已结束的重杀报"无需终止" |
注册可见性 |
| ✅ |
GUI 可见性 | 会话落在一个按工作区路径编码出来的目录里(如 §6.3 那种 | ✅ 执行中文件持续增长(38KB→89KB/25s);GUI 列表可见,但无「执行中」徽标 |
DSH 升级到 0.1.5-rc.1 后回归 | 全套 7 个探针 | ⚠️ 升级当场打坏: |
同上,修复后 | 全套 7 个探针 | ✅ 275/275:panel 117、selftest 54、observer 34、autostart 27、ledger 21、leaf 14、monitor-live 8 |
新版真跑一轮(MCP 桥接层) |
| ✅ |
新版直接调 profile |
| ✅ |
新版 row id 兼容审计 | 对 | ✅ 12/12 仍存在;新发现 |
新版客户端接入点审计 | 临时 web 实例上取组合 bundle(11.2MB) | ✅ 6/6 仍在: |
上游 lsp 缺陷影响面 |
| ✅ 桌面宿主不受影响(没有 |
MCP 连接不再产生 warning | 手工握手 + 读 Cursor | ✅ server 正常启动 stderr 0 字节;原先 |
This server cannot be deployed
Maintenance
Related MCP Connectors
Remote MCP server for supportsheep: run AI interviews and manage support content for your blog.
Hosted MCP server for task-first delegation to remote workstations and workers.
Nifty's MCP server — exposes tasks, projects, messages, and files as tools for AI agents.
Personal assistant MCP server with search, execute, packages, jobs, secrets, and integrations.
Related MCP Servers
- AlicenseNot gradedqualityAmaintenanceExposes DeepSeek Harness agent capabilities as an MCP server, letting any MCP client drive Harness to execute real coding tasks with structured results, context isolation, and parallel execution.74 npm12MIT
- AlicenseNot gradedqualityBmaintenanceMCP server bridging ChatGPT to DeepSeek Harness, exposing 13 tools for task submission, status tracking, result retrieval, project/session management, and human-in-the-loop approvals.1MIT
- AlicenseNot gradedqualityCmaintenanceExposes DeepSeek Harness's coding agent as a model backend via MCP, with user-confirmed task execution and self-inspection/config-patch tools.MIT
- AlicenseNot gradedqualityAmaintenanceEnables external MCP clients to drive DeepSeek Harness agents for real coding tasks, providing tools for task execution and queueing, session management, sandboxed file access, preset switching, and usage statistics.357 npm2GPL 3.0