← Back to home@xDJTomato

deepseek-harnessed

Turn a DeepSeek Harness (DSH) instance into a subagent any harness (Cursor / Claude Code / Codex / Gemini CLI ...) can call over MCP - plus a live monitor panel inside DSH Desktop.

Stars
0
Language
JavaScript
Created
Sep 11, 2026
Updated
Sep 18, 2026

Introduction

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 桥接层bin/ + lib/一个 stdio MCP server,暴露 dsh_task 等五个工具;每个任务拉起一个独立的 DSH 进程
DSH subagent profileprofile/让这个 DSH 进程成为"一次会话、无人值守、只做叶子"的执行体
GUI 宿主插件monitor/ + gui/观察器把外部任务的实时状态灌进会话投影;悬浮卡片按调用方分组显示

先看这三页

文档内容
docs/install.md前置条件、一键安装、五分钟验证、卸载、常见坑
docs/configuration.md全部环境变量(逐条来自源码)、profile 逐行解释、权限与叶子闸门、卡片偏好
docs/clients.md每个客户端的注册位置与 JSON 形状、手工接入、让内置 subagent 转调 DSH

本文件往下是完整设计与实测记录(§1~§10),含每一条踩过的坑与真实测量数据。


支持矩阵

客户端注册方式一键
Cursor(IDE + cursor-agent)~/.cursor/mcp.json → mcpServers.dsh✅
Claude Code~/.claude.json → mcpServers.dsh✅
Claude Desktop%APPDATA%\Claude\claude_desktop_config.json✅
Codex CLI / Desktop~/.codex/config.toml → [mcp_servers.dsh]✅
Gemini CLI~/.gemini/settings.json✅
Antigravity~/.gemini/antigravity/mcp_config.json✅
Kiro~/.kiro/settings/mcp.json✅
Qoder~/.qoder/mcp.json✅
VS Code / Copilot%APPDATA%\Code\User\mcp.json → servers.dsh✅
opencode~/.config/opencode/opencode.json → mcp.dsh✅
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 的三处破坏性变化(以及我们怎么处理)

变化症状处理
@deepseek-ai/dsh-llm 不再导出 assertNever(搬到了 dsh-util-values)最凶的一个:profile 在插件树加载阶段就 SyntaxError,dsh_task 1.5 秒即失败、不写文件、答复为空 —— 看起来像"任务跑完了但什么都没发生"去掉该 import,改成 runner 内的 warnUnknownChunk():未知 chunk 告警并忽略,不再让一个未知事件类型打断整轮
permissionPresets.current() 改收 Session 对象(原先收事件数组)传错形状不报"参数不对",而是从 DSH 内部炸出 Cannot read properties of undefined (reading 'header'),栈里全是 DSH 内部帧,极难定位新增 currentPreset():先按新版调(session),失败再按旧版调(events),两条路都不通才抛原错
session.events → session.log同上,取事件流会拿到 undefined新增 eventsOf(session),兼容两个名字

同时 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 完全起不来(实测,与是否装了本插件无关)。

  • 桌面宿主不受影响:desktop profile 的插件树里根本没有这两行(实测 --dump-config, 宿主日志里也没有 assertNever)。

  • 万一你需要 web profile,临时绕开办法就是关掉这两行:

    - id: lsp-stdio
      disabled: true
    - id: tool-lsp
      disabled: true
    

    (--patch 传一个只含这两行的 overlay 即可,已验证能起来。)

升级后怎么快速自证没坏

dsh --version                                  # 先看版本
node test/selftest.mjs                         # 协议级端到端:真拉 MCP + 真跑一轮(最能说明问题)
node test/launcher-heal-probe.mjs              # 垫片里的入口文件还在不在(Desktop 换打包形态时第一个红)
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

# 等待口径:默认短超时自己等、只有 running 才轮询(会真的派两个任务)
node $env:USERPROFILE\.dsh\subagent\test\wait-policy-probe.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 --all

2. 对外暴露的五个 MCP 工具

服务器名统一叫 dsh,因此在各 harness 里工具名形如 mcp__dsh__dsh_task(Claude Code)或 dsh.dsh_task(Codex/Cursor)。

工具作用
dsh_task委托一项自包含任务。必须给 expected_seconds(见下);默认自己先等 45s(短超时):任务在这段里结束就把结果直接带回来(不需要轮询);到点还没结束才返回 status: running 和 job_id(见 §2.3)
dsh_task_status查询/继续等待某个 job;带上 expected_seconds / deadlineAt / remainingSeconds、process_tree(实时后代进程数 + 整树 CPU)与 recent_activity(子代理此刻在干什么);ok 时一并返回 DSH 的最终答复全文
dsh_task_cancel优雅取消:标记取消并请求停下,让任务自己收尾
dsh_task_kill强制终止:按 job_id 或按 caller(停掉"我起的全部任务")立刻杀掉整个 DSH 进程树;杀掉是验证过的(不属于本进程的任务走 taskkill 并检查退出码,失败如实报 killed:false 而不是谎报成功);回报杀了哪些、哪些已经结束
dsh_health探活:回报解析到的 dsh 启动器、DSH_HOME、默认工作空间、默认模型、每个调用方的并发数、每个实时任务的截止/进度、最近任务

dsh_task 的参数:

参数说明
prompt(必填)必须自包含:DSH 看不到宿主会话历史、看不到宿主打开的文件,也不会追问
expected_seconds(必填)调用方自己预估这次委派要多少秒(正整数)。缺失/非正数会被直接拒绝(不做默认值兜底),而且是以 isError: true 的工具结果返回——调用方必须把它当成"这次调用不成立",不能当成正常结果继续往下走。它同时是硬截止:deadlineAt = startedAt + expected_seconds × DSH_SUBAGENT_DEADLINE_GRACE(默认 ×2),到点杀进程树并记 status: "deadline"
acceptance可选的一句验收标准("做完 = ……"),落进 task.json 并在结果里回显,把验收契约写明确;超过 2000 字符同样以 isError: true 拒绝
workspace目标工作空间绝对路径;缺省取调用方工作空间(MCP roots),再缺省取 server cwd
wait_seconds本次调用最多阻塞多久,默认 45(短超时,推荐直接省略);0 = 立刻返回 job_id(不推荐:等于把一次调用拆成多次轮询)。不要传 >60 —— 多数 harness 的单次工具超时就 60s(见 §2.3)
timeout_seconds外层绝对墙钟上限,默认 1800;比 expected_seconds 的硬截止更宽松,两道闸门都生效
model / provider单次调用换模型/服务商,取值必须是本机 DSH 里已配置的:模型见 $DSH_HOME/settings.yaml 的 llm-pi-ai.providers.<provider>.models[].id(或 llm-deepseek.models[]),provider 见同一份文件里的 providers 键;本机当前是 rigol / deepseek-v4.1-flash。默认沿用 DSH 设置里的模型。写错不会静默回退(UNKNOWN_MODEL / NO_ADAPTER)
reasoning_effort单次调用改推理强度,取值以该 provider/model 在 DSH 里声明的档位为准(settings.yaml 的 models[].reasoningEfforts,常见档位 off/minimal/low/medium/high/xhigh/max)。模型没声明的档位不会静默降级,DSH 直接以 UNSUPPORTED_REASONING_EFFORT 快速失败(约 0.5 秒、不调模型)。省略则沿用 DSH 设置里的默认档位。结果头部的 model: 与 reasoning_effort: 会回显本次真实生效的取值,便于复核
permissionread-only / workspace-write / danger-full-access,默认 danger-full-access
raw_prompttrue 时不加「委托说明」前言,原样传递

调用方身份:每个 MCP 连接在 initialize 里带的 clientInfo.name 会被记到该连接发起的 每个任务上(task.json 的 caller / callerVersion,以及工具结果里的 caller: 行)。 缺省为 "unknown";命令行入口记 "cli"。GUI 与 dsh_health 都按它分组。

等待口径(先等到结果,超时才轮询):dsh_task 默认会自己等 45s。 任务在这段里结束,结果由这一次调用直接带回 —— 你不用轮询。只有返回 status: running (45s 到点还没完)才转成轮询:在同一轮里调 dsh_task_status(job_id, wait_seconds=30), 一次等 30s,一轮一轮推到 ok / error / deadline / stalled / cancelled / killed。 不要传 wait_seconds=0:那是"立刻拿 job_id 然后自己轮询十几次",而每一轮都是调用方 一次完整的推理 —— 又慢又贵。为什么是 45s、以及"想让长任务也一次返回"该改哪里见 §2.3。

桥接层默认在提示词前加一段简短的「委托说明」:一次性会话、没人会回答追问、 结束时汇报做了什么/改了哪些文件/结论。这样 DSH 不会提问后卡死。

2.1 「工具调用建议」已经写进工具定义本身

以前这些约定只活在 README 里,harness 的模型看不到。现在它们搬进了 MCP 层的工具定义, 任何 harness 的模型只读 schema 就知道怎么用:

位置内容
initialize.instructions委派操作手册浓缩版(≤1500 字符):先估时长 → 写可机检验收 → 先让 dsh_task 自己等(短超时),超时才轮询 → 状态语义 → 如何止损 → 失败排查 → 禁止项(MCP 规范支持,harness 会把它当系统提示)
dsh_task.description完整手册(约 1800 字符):硬承诺与经验值区间(单文件小改 60180s / 多文件小特性 300900s / 大重构 900~1800s 且应拆分)、deadlineAt = 开始时刻 + expected_seconds × grace、七种状态各自的语义与补救动作、失败时按 recent_activity → progress_bytes/last_progress_at → 任务现场三处排查、以及两条禁止项(>20 分钟的任务必须拆、permission 收窄时不要让子代理用 pwsh 写文件)
各字段 descriptionexpected_seconds 写明"硬承诺 + 到点杀进程树 + 经验值区间";acceptance 写明"一句话、可判定真伪"并给出正例;wait_seconds 写明"默认 45 短超时、推荐省略、到点没结束才用 dsh_task_status(wait_seconds=30) 轮询、别传 0 也别传 >60";permission 写明受限档位下请用 write/edit 文件工具;timeout_seconds/model/provider/reasoning_effort/raw_prompt/label 各自说明取舍(model/provider/reasoning_effort 写明取值必须来自本机已配置的模型 / 已注册的 provider / 该模型已声明的档位,写错即 UNKNOWN_MODEL / NO_ADAPTER / UNSUPPORTED_REASONING_EFFORT)
dsh_task_status.description逐字段解释 progress_bytes / last_progress / recent_activity / process_tree / silent_seconds,并说明"静默但在干活不算停滞"
dsh_task_kill / dsh_task_cancel.description两种模式(按 job_id / 按 caller)与"什么时候该止损(任务跑偏、deadline 将近仍无进展)"
dsh_health.descriptionliveTasks / activeByCaller / 看门狗阈值 / monitorHost 各字段含义与三种使用场景

自检会核对这套文案真的出现在 schema 里(见 §10 的 initialize 带委派操作手册、 dsh_task 描述含完整委派约定 等条目),避免以后被人无声改掉。


2.2 停止语义:调用方停这一轮,子代理这边也停

在 Cursor / Claude Code 里按"停止本轮",harness 会发 MCP 的 notifications/cancelled。 桥接层真的会停对应任务。旧实现是直接忽略这个通知(注释写着"留给任务自己按 timeout 收尾"), 后果是:调用方那边已经停了,这边的 dsh 实例还在后台继续跑、继续烧 token,还往工作区里 写文件 —— 看起来像"幽灵改动"。

调用方做了什么桥接层的动作
取消正在等待的 dsh_task / dsh_task_status 请求停掉这次请求对应的那个任务(精确,不碰别的任务)
发来取消但对不上具体请求(取消晚到了)只在本连接恰好一个任务在跑时停它(本连接 ≈ 这个 harness 窗口,别的窗口不受影响)
关掉 MCP 连接 / 关掉 harness停掉本连接名下所有在跑任务,然后退出
直接杀掉 MCP 进程(SIGTERM/SIGINT)先停掉本进程名下所有任务,再退出

刻意没做的事:不按"多久没轮询"来猜你还在不在。轮询间隔里调用方可能只是在思考, 拿它当依据会误杀真正在跑的活。所以覆盖的是上面这四种明确的停止信号; 如果客户端既不取消也不关连接,任务会一直跑到自己的 deadline —— 这种情况请用 dsh_task_cancel / dsh_task_kill 主动止损。

说明:Windows 上 taskkill /F(以及 host 用 TerminateProcess)不会给进程任何执行收尾代码的 机会,所以"被强杀"那条兜不住;真正兜住的是前三种。验收记录里有实测:发取消通知后子进程确实消失。

2.3 等待口径:先等到结果,超时才轮询(45s / 30s 是评估出来的)

症状:长任务被拆成一长串轮询。调用方按老文案传 wait_seconds=0,拿到 job_id 后每 20~30s 调一次 dsh_task_status;一个 5 分钟的任务要 10 次以上往返,而每一轮都是调用方一次 完整的推理(前缀全量重算 + 思考),贵且慢。

改法:dsh_task 默认自己等 45s。这一段里任务结束 → 结果由这次调用直接带回, 零轮询;只有到点还没结束才返回 status: running,那时才用 dsh_task_status (默认等 30s)一轮一轮推到终态。也就是说:默认路径不轮询,轮询是兜底。

两个默认值的来源(实测,不是拍脑袋):

调用方单次工具超时结论
Codex(codex-mcp-client,本机最常跑的调用方)60s —— mcp_servers.<id>.tool_timeout_sec 默认 60(官方 config reference)最紧的约束
Claude Codestdio 服务器空闲窗默认 30 分钟、单次墙钟上限约 28 小时;主会话里超过 2 分钟的调用会自动转后台任务,结果以任务通知回来远宽于 45s
Cursor(cursor-vscode)官方 MCP 文档未公开单次工具超时按 60s 保守处理

取最紧的 60s 留 25% 余量 ⇒ 45s;轮询那一段更保守(30s),保证永远打不到超时。 实测数据:同一个"只回答两个字"的任务,DHS 侧整轮耗时 1.4~1.7s,dsh_task 默认调用 3.5s 就带着终态返回(§10 test/wait-policy-probe.mjs 的 11 项断言)。

为什么不干脆等 5 分钟一次拿完:超过调用方的工具超时,harness 会先掐断这次调用; 而掐断时它可能发 notifications/cancelled —— 按 §2.2 的语义,那会被当成"调用方停了这一轮" 而把还在跑的任务杀掉。所以 45s 是"尽量少轮询"和"绝不越过调用方超时"之间的取值。

想让长任务也一次返回,要两步一起走:

# ① 先把调用方的单次工具超时调大(Codex 为例;command/args 用安装器写好的那两行)
[mcp_servers.dsh]
command = "C:\\Program Files\\nodejs\\node.exe"
args = ["C:\\Users\\<you>\\.dsh\\subagent\\bin\\dsh-subagent-mcp.mjs"]
tool_timeout_sec = 600
# ② 再同步把桥接层的等待调大(两个都可单独调)
DSH_SUBAGENT_WAIT_SECONDS=540          # dsh_task 的短超时
DSH_SUBAGENT_STATUS_WAIT_SECONDS=540   # dsh_task_status 的默认等待

两个值都必须小于调用方的工具超时,否则会走到上面那个"被掐断 + 误杀"的分支。 dsh_health 会回报当前生效的 defaultWaitSeconds / statusWaitSeconds / waitModel, 换过环境变量后先用它确认一遍。

改完要重启调用方:MCP server 是每个 harness 在自己启动时拉起的常驻进程,代码与 环境变量都在那一刻定型 —— 换过 DSH_SUBAGENT_WAIT_SECONDS 这类环境变量、或升级过 桥接层本体之后,要重启对应 harness(Cursor / Codex / Claude Code)才会生效。

3. 本机已写入的配置

Harness配置文件写入内容
Cursor(IDE + cursor-agent CLI)~/.cursor/mcp.jsonmcpServers.dsh
Cursor 用户级规则(尽力而为)~/.cursor/rules/dsh-subagent.mdc「委派子任务优先用 DSH」
Claude Code~/.claude.json用户级 mcpServers.dsh
Claude Code 子代理~/.claude/agents/dsh.mdTask(subagent_type: "dsh") = 转发给 DSH
Claude Code 全局记忆~/.claude/CLAUDE.md「子代理委派:统一走 DSH」
Claude Code 权限~/.claude/settings.jsonpermissions.allow += mcp__dsh
Claude Desktop(若安装)%APPDATA%\Claude\claude_desktop_config.jsonmcpServers.dsh
Codex CLI / Desktop~/.codex/config.toml[mcp_servers.dsh](含 startup_timeout_sec = 60 / tool_timeout_sec = 600,与 §2 同口径)
Codex 全局记忆~/.codex/AGENTS.md「子代理委派:统一走 DSH」
Gemini CLI~/.gemini/settings.jsonmcpServers.dsh
Antigravity~/.gemini/antigravity/mcp_config.jsonmcpServers.dsh
Kiro~/.kiro/settings/mcp.jsonmcpServers.dsh
Qoder~/.qoder/mcp.jsonmcpServers.dsh
VS Code / Copilot%APPDATA%\Code\User\mcp.jsonservers.dsh
Copilot CLI 回退路径~/.vscode/mcp.jsonservers.dsh
opencode~/.config/opencode/opencode.jsonmcp.dsh
所有 harness 的 shell 路径~/.local/bin/dsh-subagent.cmd、dsh-subagent-mcp.cmd命令行入口
DSH 本体$DSH_HOME/profiles/subagent/一次性 subagent profile
DSH Desktop(GUI 宿主)$DSH_HOME/profiles/desktop/cordis.patch.yml两块受管 insert:>>> dsh-subagent-observer >>>(会话列表实时监控)与 >>> dsh-subagent-panel >>>(右上角悬浮卡片)
DSH Desktop(GUI 宿主)$DSH_HOME/subagent/monitor/observer.mjs、$DSH_HOME/subagent/gui/**上面两块 insert 指向的插件本体(§6.4 / §6.5)

所有写入都是幂等的,并且会先备份为 <原文件>.bak-dshsubagent-<时间戳>; JSON 配置若解析失败则跳过不写,绝不覆盖用户配置。

3.1 把 RIGOL 的 dv41 推理强度固定成 high

问题:本机 llm-pi-ai 里的 rigol 路由原先没有声明任何推理能力 —— 模型条目只有 id: deepseek-v4.1-flash,既没有 reasoningEfforts,路由也没有 reasoning。于是 DSH 发请求时 完全不带 reasoning 参数,思考强度交给网关自己定。实测同一个"只回答两个字"的请求: 不带参数时 reasoning_tokens = 103,显式 effort: "high" 时 reasoning_tokens = 35 —— 也就是说默认比 high 更啰嗦,这正是"长任务里思考强度太夸张"的来源。

~/.dsh/settings.yaml 现在这样写(本机已写入):

llm-pi-ai:
  providers:
    rigol:
      apiKeyEnv: RIGOL_API_KEY
      api: openai-responses
      baseURL: https://airouter.rigol.com/v1
      reasoning: high            # 路由默认推理强度:没有显式 --reasoning-effort 时就用它
      models:
        - id: deepseek-v4.1-flash
          reasoningEfforts:      # 声明"这个模型支持哪些档位",并给出各档位落到协议上的写法
            off: none            # 网关不接受空值,off 必须显式写成 none
            minimal: minimal
            low: low
            medium: medium
            high: high
            xhigh: xhigh
            max: max

要点:

  • 没有 75 这个取值。 网关只认 none|minimal|low|medium|high|xhigh|max,传 75 会被拒 (unknown variant \75`,HTTP 400)。"75" 是 harness 侧的显示刻度,落到这个网关就是 high(与 Codex 的model_reasoning_effort = "high"` 同档)。
  • 两个字段缺一不可:reasoningEfforts 决定"模型支不支持 high"(不声明 → DSH 认为它不支持, 显式传 --reasoning-effort high 会直接报 UNSUPPORTED_REASONING_EFFORT); reasoning 决定"没人指定时默认发什么"。路由级 reasoning 就是本次要的"固定为 high"。
  • settings 是每次操作重新读取的:改完立刻生效,不用重启 DSH Desktop。
  • 想让某一轮更省或更狠:单次调用用 dsh_task 的 model 换模型,或 dsh --profile subagent --reasoning-effort low --prompt ...,都在同一份 reasoningEfforts 声明的范围内(唯一例外是 off 也必须先声明 —— 否则被当成"不支持")。
  • 本机的 vision-toolkit、以后新增的 provider 都可以照这套写。

自证(本机实测):

# 改之前:失败 —— 说明这个模型压根没声明推理能力(连 high 都不支持)
dsh --profile subagent --prompt "只回答两个字:好的" --reasoning-effort high
# → exit 1, {"code":"UNSUPPORTED_REASONING_EFFORT","message":"... does not support reasoning effort \"high\""}

# 改之后:成功
dsh --profile subagent --prompt "只回答两个字:好的" --reasoning-effort high
# → exit 0, meta.json 里 {"reasoningEffort":"high","stopReason":"completed"}

反过来,如果这份 settings 写坏了(比如给 reasoningEfforts 塞了网关不认的值), dsh-settings-file 会整段拒绝加载并让整个 provider 消失(NO_ADAPTER: no adapter registered for provider "rigol")—— 出错是"响亮"的,不会静默退回旧行为。

重新装配 / 全部撤回:

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 的差异:

  1. 换掉只认 argv 的启动器,支持 --prompt-stdin(无长度上限)与 --result-file / --metadata-file / --model;
  2. 换掉 runner:能写出最终答复文件与 {sessionId, model, stopReason, durationMs} 元数据,能在单次调用里换模型;
  3. 关掉 LLM 起标题(省一次模型调用);
  4. 审批策略固定 never —— 子代理是非交互进程,没有人能点「同意」;
  5. 沙箱模式由 DSH_SUBAGENT_PERMISSION 控制,默认 danger-full-access;
  6. 显式声明「无人值守」权限预设表(否则 --permission workspace-write 会崩,见 §5);
  7. 叶子闸门:外部任务不允许再分叉(见下)。

4.1 叶子闸门(外部任务不许派子代理)

策略:由其它来源(Cursor / Claude Code / Codex …)经 dsh_task 调进来的任务,不能再造出更多 agent; DSH 自己内部派活走的原生 subagent 不受影响。 实现方式是在 subagent profile 里关掉那几行 "能造 agent"的插件(只关工具,不关服务 —— 关服务会让 inject 它们的插件加载失败):

关掉的行拿掉的工具
tool-subagentsubagent
tool-subagent-forksubagent_fork
tool-workflow + workflow-worker-threadworkflow(脚本里 agent() 扇出)
tool-ralphralph(每轮开一个新子代理)
tool-subagent-control + tool-subagent-list-agentslist_agents / send_message / interrupt_agent

实测前后对比(解出子代理会话的 jsonl.zstd,看下发给模型的工具表):

工具数分叉工具
闸门前25subagent, subagent_fork, workflow, ralph, list_agents, send_message, interrupt_agent
闸门后18上面 7 个全部消失;pwsh / read / write / edit / grep / web_search / todo_write 等干活能力保留

自检(配置级 + 会话级两路证据):

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-stdin

5. 权限与安全

关注点现状
默认权限danger-full-access(与宿主 harness 本身的权限一致,且 DSH 自身会话也是这个档位)
收紧方式工具参数 permission: "workspace-write" / "read-only",或环境变量 DSH_SUBAGENT_PERMISSION
审批子代理进程内一律 never:没有人类可点确认,越权由沙箱直接拒绝(而不是挂起等审批)
并发每个 MCP server 进程默认最多 4 个并行 DSH 实例;超出者排队。详见 §6.1
审计每次委托落盘:$DSH_HOME/subagent/state/tasks/<job-id>/{prompt.md,result.txt,meta.json,stderr.log,task.json};会话本身也按 DSH 常规落盘,可在 DSH GUI 对应工作空间下复查
遥测沿用 DSH 自身设置;如需关闭,给调用方环境加 DSH_TELEMETRY_DISABLED=1

权限档位怎么"真的生效"(这一块踩过两个坑,2026-09-11 修复,见 §8 第 1、2 条):

  1. 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 秒,什么都没干)。
  2. 光有表还不够:权限预设值还存在全局 $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。


5.1 ⚠️ 沙箱档下 shell 会"空转成功"(命令没跑,工具却报成功)

现象:权限档设成 workspace-write 或 read-only 时,DSH 的 shell 工具对任何命令都 返回空 —— 没有 stdout、没有 [exit code: N]、没有任何副作用,而且 isError: false。

同一条 Write-Output SPAWN-PING,只换权限档:

权限档shell 工具实际返回
danger-full-accessSPAWN-PING\r\nELAPSED_MS=59\r\n ✅ 真的执行了
workspace-write / read-only"\r\n",isError: false ✗ 进程从未启动

排查用的对照实验(这些排除了"只是输出丢失"这类解释):exit 3 不返回 [exit code: 3]; Start-Sleep -Seconds 5 根本不睡;Set-Content / cmd /c echo > file 不生成文件;而同一会话里 的文件读写工具一切正常 ⇒ 故障点在创建进程那一层:沙箱把 spawn 吞掉了。

不是版本问题:升级前后的真实会话日志各取一份,tool/result 里都是同样的 "\r\n" + isError: false(见 §10 验收记录)。也不是本插件配置问题:subagent 与 desktop 两个 profile 的沙箱相关行集合一致(sandbox-local / sandbox-policy / pwsh-sandbox / fs-sandbox,pwsh-sandbox 两边都没有额外配置)。

桥接层会替你说出来:任务收尾时桥接层解会话日志,把"空内容 + isError:false"的 shell 调用数出来,在答复末尾附一段告警(调用次数、命令样例、当前权限档、出路),并落进任务记录的 shellHollowCalls / shellHollowSamples —— 不会再把"完成但什么都没做"的结果悄悄交给调用方。

怎么办:要跑命令(构建 / 测试 / CLI 调用)就显式给 permission: "danger-full-access"; 只做文件读写的任务不受影响,照常派即可。验证办法:派一条 dsh_task 让它 echo 一个标记, 看答复末尾有没有那段告警(有 ⇒ 执行面是空的)。


6. 并发(多开)与在 GUI 里查看

6.1 能不能多开

能。同一时刻可以有多个 DSH 实例在跑,机制是:

维度行为
一次 harness 回合里多次调用可以并行。MCP 层对每个请求异步处理(lib/mcp.mjs 的 onFrame),一个 dsh_task 没返回也不会挡住下一个;同一轮里连发 3 个 dsh_task,3 个 DSH 实例同时开跑
单个 MCP server 进程的并发上限默认 4(DSH_SUBAGENT_MAX_CONCURRENCY)。第 5 个不会报错,而是排队等名额,拿到名额才启动
跨 harness每个 harness(Cursor / Claude / Codex / …)各有一个独立的 MCP server 进程,各自 4 个名额;CLI(dsh-subagent)同样自带一份
怎么放大在对应 harness 的 MCP 配置里给 env 加 "DSH_SUBAGENT_MAX_CONCURRENCY": "8",或给 dsh-subagent-mcp.cmd 设环境变量;改完重启该 harness
查当前状态dsh_health 会回报 maxConcurrency、activeTasks、activeByCaller(如 cursor-vscode: 2 running)、liveTasks(每个实时任务的 job_id/caller/workspace/status/pid/deadlineAt/remainingSeconds/lastProgressAt/progressBytes/queuedSeconds)和最近 5 个任务
代价每个实例都是一次独立的模型调用(自己的上下文与 token),并且都打同一个网关;开到 8~16 以上时瓶颈通常变成 API 吞吐/限流,而不是本机 CPU

实测(node test/concurrency-probe.mjs <工作空间> 3):3 路同时发起,总墙钟 9.2s,三个任务各自 ~8-9s 全部成功——是并行而非串行(串行应≈25s+)。

推荐的用法:默认让 dsh_task 自己等(45s 短超时)—— 短任务一次调用直接拿结果;只有它 返回 status: running 才用 dsh_task_status(默认每次等 30s)接着推。真要"派完就干别的", 再显式传 wait_seconds: 0 拿 job_id,并自己承担后续轮询(见 §2.3)。

停止:

  • dsh_task_cancel = 优雅取消:对"正在跑"和"还在排队等名额"的任务都有效(排队中的直接标记 cancelled 且不会再启动),由任务的退出路径收尾。
  • dsh_task_kill = 强制终止:job_id 杀一个;caller 一次停掉该调用方本进程内起的全部 运行中任务("停掉我起的全部东西")。排队中的任务会被立即落终态,跑着的直接杀整个进程树。 注意 caller 模式的作用域是当前 MCP server 进程——每个 harness 一个进程,跨进程请分别调用。

6.2 硬截止与停滞看门狗(防止无限挂住)

机制触发条件结果
外层上限timeout_seconds(默认 1800)status: "timeout"
硬截止startedAt + expected_seconds × grace(grace 默认 2)status: "deadline",error: 超出预估时间 Ns × grace G 仍未完成,已终止
停滞看门狗每 DSH_SUBAGENT_WATCHDOG_INTERVAL(默认 30s)采一轮多信号快照(见下),所有信号零变化 + 静默已达 DSH_SUBAGENT_STALL_MIN_SECONDS(默认 180s)+ 窗口内连续 DSH_SUBAGENT_STALL_PROBES(默认 2)次零变化杀进程树,status: "stalled",并落 signalState / treePids / treeCpuMs / cpuDeltaMs / silenceSeconds 取证

停滞判定的信号模型(任一信号推进即算"有进展")

只看日志字节数是不够的,而且会误杀正常任务。本机实测反例:一个任务在 6 分钟里 stderr.log(18474B)与 session.jsonl.zstd(191279B)字节数一个字节都没变,但它当时正在 跑一个 133 步的循环脚本——后代进程 powershell.exe → bash.exe → bash.exe 都活着, 根进程 CPU 从 8.42s 涨到 8.50s。长工具调用期间 DSH 不写任何输出是设计使然。

因此每轮探测采集下列信号,任一推进即算有进展:

类别信号说明
日志stderr.log / stdout.log / result.txt / 会话日志 session.jsonl.zstd 的字节数只用字节数,绝不使用 mtime —— mtime 在部分环境里不可靠(安全软件的文件过滤会让它停在创建时刻,实测 18KB 的 stderr.log mtime 没变过),而字节数变化是可靠的
进程树① 根进程是否存活 ② 后代进程 pid 集合变化 ③ 整棵树累计 CPU(KernelModeTime + UserModeTime)增长 ≥ DSH_SUBAGENT_STALL_CPU_MS(默认 200ms)每轮只跑一次 Get-CimInstance Win32_Process 取全表,在内存里做父子遍历;绝不"每个 pid 起一次进程"。未达阈值的小增量会累加,累计越阈值同样算进展(慢活每轮只烧几十毫秒也认得出来)
调用方轮询时看到的新增 progressBytes调用方每次 dsh_task_status 都是一次真实观测

快照取不到时按"未知"处理:进程树类信号一律不参与判定,由日志信号 + 静默窗口兜底。 测不到 ≠ 没有进展,绝不允许探测失败或测量缺口导致误杀。同理,快照里查不到某个 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.json

job_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 秒内该会话自动出现在侧边栏(归到它的工作空间那一组),带"运行中"标记
运行中会话被持续顶到列表最前;标题是 ⚡ <调用方> · <标题> ⚠运行中·勿点开(最后一句是刻意的护栏,原因见 §6.6),并带上调用方/任务号/截止时间等投影(§6.5 的卡片就用这些)
任务结束运行中标记自动消失,会话留在列表里,这时点开内容完整可复查

观察器本身是安全的:标 running 与 activity 都只影响客户端列表(running 不会让宿主 attach,activity 只重排列表),不读也不写会话文件。危险的是"点开"(§6.6)。

原理(都是宿主插件,不改前端、不改 DSH 源码):

  1. 桥接层在任务一开始就把 sessionId 写进任务现场的 meta.json(runner 一建立会话就写, 不等这一轮结束),task.json 里有 pid / status / workspace;
  2. 观察器每 2 秒扫一遍 $DSH_HOME/subagent/state/tasks/*/,用 pid 存活性确认"真在跑" (避免留下永远"执行中"的僵尸会话),然后发三个官方声明转发的远程事件: api-session/added(入列)/ api-session/status(运行中徽标)/ api-session/activity(活动时间);
  3. 客户端侧(dsh-api-session-controller 的 client 层)对这些事件的处理就是 mergeSummary / session.handleRunning(running),所以侧边栏立刻生效;
  4. 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心跳新鲜(Date.now() - epochMs < 20000) 且心跳里的 pid 还活着already-running:什么都不做
2心跳存在但 pid 已消失 / 时间过期判为残留文件:既不当作"宿主在",也不当作"没宿主",继续往下判
3总开关 DSH_SUBAGENT_AUTOSTART_MONITOR=offskipped
4非 Windows 平台skipped
5枚举 DSH Desktop.exe 并按命令行分类后有"真正的 GUI 宿主"gui-open-old-observer:绝不拉起第二个(GUI 开着,只是它跑的是旧观察器、写不出心跳)
6进程表查不到(探测失败两次)probe-failed:未知 ≠ 没有宿主,不启动,下次调用再试
7距上次尝试 < DSH_SUBAGENT_MONITOR_COOLDOWN_MS(默认 60000)skipped(冷却防抖)
8以上都不成立launched:spawn(exe, args, {detached:true, stdio:'ignore', windowsHide:false}).unref(),不 await GUI 起来
—找不到可执行文件unavailable
—本进程还没做过判断unknown(只可能出现在 dsh_task_status:那个接口只读)

第 5 步的命令行分类(与 test/live-audit.mjs 同一口径):

命令行含判定要不要阻止拉起
--type=Chromium 渲染/GPU 子进程不阻止(噪声)
--expose-internals以 CLI 方式跑的 dsh —— 我们自己的子代理任务不阻止
两者都没有真正的 GUI 宿主(有窗口)阻止(gui-open-old-observer)

命令行是用 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   # 35 项,含投影、僵尸/半成品判活、心跳+口径版本、token 折叠、吞吐窗口、0.1.5 新日志名
node $env:USERPROFILE\.dsh\subagent\test\live-audit.mjs          # 现场审计:真的在跑几个 / 幽灵几个 / 宿主状态

6.5 悬浮卡片 Subagent(独立的实时视图,已装好)

侧边栏里外部任务和普通会话混在一起,不适合当"看板"。所以另装了一个独立的客户端插件 dsh-subagent-panel,在窗口右上角渲染一张悬浮卡片,和其他会话在观感上分开:

能力说明
按调用方分组Cursor / Claude Code / Codex / 命令行 / 自检 / 未知来源各成一组,组标是该调用方的字形(⌖ ✳ ⬢ >_ ◎ ◈)
本机 DSH 子代理也在里面DSH 自己的 subagent(同进程子代理,subagentsByParent 目录)自成一格「本机 DSH 子代理 ✦」,与外部 dsh_task 任务并列:层级徽标 L1 ⊞(⊞ = 它自己也叫了子代理)、运行/结束状态、所属父会话、多久前有动作;点开与宿主自己的子代理入口完全一致
手机式展开点组标题像点手机上的应用文件夹一样展开成磁贴网格;有活跃任务的组默认就是展开的
默认只看活跃卡片头部默认只显示正在跑的会话(外部 dsh_task + 本机子代理);点「全部」才连最近结束的一起显示
看得清自带配色(不依赖宿主 token):浅色/深色都按 AA 以上对比度取值,跟随宿主的 body[data-ds-dark-theme] 切换
可缩放(字号)头部 − / 120% / +,范围 60%~200%,按钮步进 10%,点百分比复位 100%,选择记在 localStorage
自由改尺寸(像真窗口)三个把手:右边 = 只改宽、下边 = 只改高、右下角 = 宽高一起改(位移除以 zoom,放大到 150% 时不会一格跑两格);尺寸单独记在 localStorage,双击任意把手复位默认;宽高不是等比例——拉宽只加列,不动字号
拉宽就多放几列网格是 repeat(auto-fill, minmax(132px, 1fr)),列数跟着卡片实际宽度走:默认 300px 是 2 列,拉到 700px 就是 4~5 列(实测列数按 132px 最小宽自动排);拉高后内容区滚动、头部与底部状态条固定
底部状态条250 tok/s | 缓存命中 99% | 输入 111M tok · 输出 636K tok:当前显示的会话合计用量,tok/s 是在跑会话的吞吐之和(待机显示 —);鼠标悬停看 prompt 侧总量、缓存读,以及"这个速率是观察器按真实采样时间窗算的"
空态无活跃子代理时居中显示雷达图 + 一行「无活跃子代理」
一眼看出区别HUD 四角、扫描线、脉动的运行指示灯、运行中磁贴的流光与进度条、等宽字体的倒计时;磁贴第二行还带 ↑输入 ↓输出 与 N tok/s
运行中 → 实时详情点运行中的磁贴进只读详情页:调用方 / 任务号 / 工作区 / 已耗时 / 预计剩余 / 输出字节与"上次增长多久前" / 验收条件 / tokens 明细(输入·输出·缓存命中,以及这份数字的来源) / 子代理最近几行推理输出(来自任务现场的 stderr.log);过期或久未增长的整行会标红标橙
已结束 → 普通打开点已结束的磁贴 = ctx.sessions.open(id),和点侧边栏里的会话完全一样
可拖可收头部可拖动(位置记在 localStorage),「收起」后缩成一条状态条

头部为什么允许换行:卡片默认 300px,而头部有标题、活跃计数、全部、− 120% +、收起 六个控件 —— 挤不下时换行(flex-wrap: wrap),而不是把 Subagent 裁成 SUBAGENT…。 踩过两次:只写 flex: none 而卡片宽度固定时,标题只是从"省略号"变成了"被卡片裁掉",都没修好; 真正的修法是让它换行,再把卡片做成可拉宽(拉宽后一行放得下)。

7.5.1 底部状态条的 token 数字是从哪来的

和宿主自己的对话统计同口径,但两条数据源:

会话类型数据源为什么
本机子代理 / 宿主自己的会话宿主的 projectionValues.tokenUsage 投影token-meter 是按会话投影的,这就是宿主 UI 里那份数
外部 dsh_task 任务观察器自己折叠子代理的会话日志那些会话宿主从没加载过 ⇒ 标准投影不存在,只能自己算

观察器的折叠口径抄宿主 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 对比度:

浅色(白底)深色(卡片 #14171d)
正文 / 次要 / 第三18.5 / 9.7 / 6.4 : 116.6 / 11.9 / 8.2 : 1
强调 / 成功6.6 / 7.6 : 18.9 / 10.4 : 1
告警(还是橙的)7.0 : 111.7 : 1
错误7.8 : 19.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 子代理)正在写的文件时:

  1. prepareCore 会为"尾部未闭合的 turn"合成收尾事件(带 interrupted-tool-result-*、turn/end{reason:"interrupted"}),commitRepair 用 truncate + append 把它们落盘;
  2. 发布时又会追加 session/end-seed,并把"未发布后缀"写进同一个文件;
  3. 子进程完全不知道,继续用同一个 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 是正常路径)
任务运行中默认不打开,进只读详情页;详情页里的「打开会话」是禁用状态,旁边留了一个需要二次确认的「仍要打开」(标红,写明有损)
想实时看内容走详情页的实时输出行(读 stderr.log 尾部),不碰会话日志
想彻底消除风险在宿主侧把 follow 的 promote 改成"冷会话只读跟随"(需要改 DSH 核心:SessionHistoryController.follow + jsonl 后端的按偏移读取) —— 属于上游改动,本仓库不做

6.7 本机 subagent 与外部 dsh_task 是两扇门(为什么两个都要)

经常会被问:"DSH 自己就有 subagent,为什么还搞一个 dsh_task?" —— 因为调用方在两个不同的世界里:

本机 subagent 工具外部 dsh_task(MCP)
谁能调只有 DSH 内部的模型(工具表里的一个工具)任何 MCP 客户端:Cursor / Claude Code / Codex / 其它 harness
子代理在哪同一个宿主进程内(dsh-subagent-spawn-in-process 驱动的子会话)独立进程 dsh --profile subagent(独立权限档、独立日志、崩了不牵连宿主)
递归上限tool-subagent.maxDepth,默认 3 层无(由调用方自己决定要不要再派)
控制通道list_agents / send_message / interrupt_agent(DSH 原生)dsh_task_status / dsh_health / dsh_task_kill
在卡片里「本机 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),判定顺序:

  1. 本进程内还有句柄 → 一定在跑;
  2. meta.json 已写明 stopReason → 按它推导成终态(ok/error),不算幽灵;
  3. 否则看 pid 存活:活着 → running;死了或根本没记 pid → lost。

并且 listTasks() 的每条记录都会带上:

字段含义
status已判活之后的状态(幽灵是 lost,不再是 running)
staletrue = 记录说在跑但进程已不在(幽灵);false = 可信
pidAlivetrue 活着 / false 已消失 / null 不适用(终态记录)
derived状态是从 meta.json/pid 推导出来的,不是 task.json 里原有的

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. 环境变量一览

变量默认值作用
DSH_SUBAGENT_WAIT_SECONDS30dsh_task 的默认阻塞窗口(原来 120)
DSH_SUBAGENT_DEADLINE_GRACE2硬截止宽限系数:deadlineAt = startedAt + expected_seconds × grace
DSH_SUBAGENT_TIMEOUT_SECONDS(或别名 DSH_SUBAGENT_TASK_TIMEOUT)1800外层绝对墙钟上限(比硬截止更宽松)
DSH_SUBAGENT_WATCHDOG_INTERVAL30停滞探测间隔(秒);每轮采一次多信号快照
DSH_SUBAGENT_STALL_PROBES2静默窗口内连续多少次"所有信号零变化"才判停滞
DSH_SUBAGENT_STALL_MIN_SECONDS180判定停滞前的最小静默秒数(防"慢模型响应"被误杀)
DSH_SUBAGENT_STALL_CPU_MS200树累计 CPU 增长多少毫秒才算"有进展"(小增量会累加)
DSH_SUBAGENT_STALL_CPU_WORK_FLOOR_MS1500窗口内树 CPU 累计增长低于此值时不算"在干活":把 IO/定时器空转噪声与真正的工作区分开(见 §6.2)
DSH_SUBAGENT_WATCHDOG未设置设为 off 可整体关闭停滞看门狗
DSH_SUBAGENT_TREE_PROBE_TIMEOUT_MS15000单次进程快照的 Get-CimInstance 超时
DSH_SUBAGENT_PS系统 PowerShell覆盖进程快照所用的 powershell.exe 路径
DSH_SUBAGENT_MAX_CONCURRENCY4单个 MCP server 进程的并发上限
DSH_SUBAGENT_AUTOSTART_MONITOR未设置(开)设为 off/0/false 关闭"调 dsh_task 时自动拉起监控窗口"(见 §6.4)
DSH_SUBAGENT_MONITOR_COOLDOWN_MS60000两次自动拉起之间的最小间隔,防抖(也用作进程表探测的缓存窗口)
DSH_SUBAGENT_MONITOR_HEARTBEAT_MS20000心跳多旧算"没有宿主在跑"
DSH_SUBAGENT_MONITOR_HOST_CHECK未设置(开)设为 off 关掉"已有 GUI 宿主进程就跳过"的闸门(测试逃生门)
DSH_SUBAGENT_MONITOR_HOST_PROCESSDSH Desktop.exe枚举/分类时要看的进程映像名
DSH_SUBAGENT_MONITOR_FORCE未设置设 1 强制忽略宿主闸门(手动逃生门)
DSH_SUBAGENT_MONITOR_CMD未设置覆盖要拉起的可执行文件(可带参数;测试用假 exe 就靠它)
DSH_SUBAGENT_MONITOR_ARGS未设置追加参数(JSON 数组,或按空格拆分的字符串)
DSH_SUBAGENT_HOST_PROBE_TIMEOUT_MS15000宿主进程枚举(Get-CimInstance)单次超时;失败会自动重试一次(实测单次 1.3~1.8 秒)
DSH_SUBAGENT_PERMISSIONdanger-full-access子代理默认权限档
DSH_SUBAGENT_RESULT_CLIP60000回传给 harness 的答复字数上限
DSH_SUBAGENT_ACTIVITY_CLIP1800活动流(recent_activity)回传上限
DSH_SUBAGENT_OBSERVER_USAGE_REFRESH_MS5000同一会话的日志最多多久重新折叠一次(折叠一次 ~33ms)
DSH_SUBAGENT_OBSERVER_ACTIVITY_MS5000进度字节/用量变化触发提前重播的最小间隔
DSH_SUBAGENT_OBSERVER_RATE_MIN_MS3000算吞吐(tok/s)的最小采样跨度;低于它的窗口不算(见 §6.5.2)
DSH_SUBAGENT_OBSERVER_RATE_SAMPLES4吞吐取最近几次采样的平均(≈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=30)(默认就是 30, 可以直接省略 wait_seconds),每次等 30s,直到 ok/error/deadline/stalled/cancelled/killed。 能走到这一步,说明 45s 的短超时已经用完了(§2.3)。状态里会带 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:子代理说"命令执行了"但没有任何输出/副作用?

先看任务答复末尾有没有 ⚠️ 执行面告警。有 ⇒ 是沙箱档把 shell 吞了(见 §5.1),让调用方改用 permission: "danger-full-access" 再派一次。没有那段告警但确实没输出 ⇒ 才是命令本身或模型的问题。

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.1-flash", provider: "rigol", reasoning_effort: "low") 或 dsh-subagent -m <模型 id>;取值必须是本机 DSH 里已配置的模型/已注册的 provider/该模型已声明的档位 (见 §2 的参数表),写错是响亮失败(UNKNOWN_MODEL / NO_ADAPTER / UNSUPPORTED_REASONING_EFFORT)。 全局:改 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          协议级端到端自检(53 项)
│   ├── monitor-autostart-probe.mjs 监控窗口自动拉起自检(26 项,假 exe + 临时 DSH_HOME)
│   ├── ledger-liveness-probe.mjs   台账幽灵记录自检(20 项,临时 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 观察器自检(35 项,含心跳+口径版本、幽灵收尾、pid 宽限期、token 折叠、吞吐窗口、0.1.5 新日志名)
│   ├── exec-surface-probe.mjs 执行面探针(11 项:会话日志改名兼容 + 沙箱"空转成功"的判定与零误报)
│   ├── 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 条闸门 + 会话日志里的真实工具表)
│   ├── launcher-heal-probe.mjs 垫片入口自愈探针(13 项:入口失效自愈 / 回退到下一个候选 / 全失效时聚合报错 / app.asar↔app 互换)
│   ├── 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 → mcp__dsh__dsh_taskclaude -p … --allowedTools mcp__dsh…✅ 6.1s,产物 e2e-claude-*.txt
Claude Code 子代理 → DSHTask(subagent_type: "dsh")✅ 产物 subagent-claude-*.txt
Codex → mcp__dsh__dsh_taskcodex exec …✅ 12.5s,产物 e2e-codex-*.txt
Cursor → mcp__dsh__dsh_taskcursor-agent -p --force --approve-mcps✅ 6.0s,产物 e2e-cursor-*.txt
命令行dsh-subagent -w <dir> "…" / --json✅ 5.5s,stdout 就是 DSH 答复
直接调用 DSH profiledsh --profile subagent --prompt-stdin✅ 1.2s,退出码 0
协议级自检node test/selftest.mjs✅ 54/54(含 caller / expected_seconds 必填且 isError:true / 强杀 / 幽灵 stale 回报 / initialize.instructions / 工具定义文案 / monitor_host 接线 / stderr 必须安静 / 版本号与 package.json 一致 / DSH_SUBAGENT_DEBUG 才出横幅)
并发node test/concurrency-probe.mjs <ws> 3✅ 3 路并行 14.6s 全成功;取消 ✅
硬截止expected_seconds=10,任务是静默 300s✅ status: "deadline",error: 超出预估时间 10s × grace 1.5 仍未完成,已终止,pid 已消失
停滞看门狗(真挂起)进程树完全静止(根进程阻塞在 waitpid):外部单独杀掉 sleep 子进程 → 字节不涨、后代不变、树 CPU 不涨✅ 判 status: "stalled",落 treePids / treeCpuMs / signalState 取证
停滞看门狗(真挂起·六进程树)powershell → bash → bash → node hang.mjs → node(600s sleep),日志 4823B 冻结 122 秒、树 CPU 每轮仍有 15~220ms 空转噪声✅ 判 status: "stalled"(silentProbes: 2、silenceSeconds: 73、treeCpuMs: 1078),靠"窗口内 CPU 强度下限"把空转噪声与真干活区分开
停滞看门狗(对照:关掉 CPU 强度下限)同一棵树,stderr 自 +24s 起冻在 1439B 连续约 590 秒零增长(整轮 612s),cpuDeltaMs 每轮 0~188ms❌ 漏判 —— progressed 每 3 轮被"树 CPU 累计 +2xx ms"翻成 true、stalledProbes 在 0~4 之间反复归零(一次都没到阈值),最终 status:"ok"(退出码 1)。这就是加上下限的原因;详见 §6.2
停滞看门狗(反例不误杀)长时间不出字但在真干活(连续 30 次 Start-Sleep 2,后代进程活着、树 CPU 持续增长),间隔 10s / 2 次✅ 未被杀,stalledProbes 反复归零,最终 status: ok,CPU 信号是保住它的原因
监控窗口自动拉起node test/monitor-autostart-probe.mjs✅ 27/27:心跳新鲜且 pid 存活→already-running 不 spawn;心跳新鲜但 pid 已死(残留)→不判已有宿主、继续判并拉起;心跳缺失→launched 且标记文件出现;总开关 off→skipped;"GUI 宿主进程在但跑旧观察器"→gui-open-old-observer 不 spawn;真实 --expose-internals 进程→分类 dsh-cli 不算宿主;FORCE=1→launched;分类规则 4 条纯函数单测
监控窗口自动拉起(全链路)真 MCP server + 真 dsh_task(假 exe + HOST_CHECK=off)✅ 结果里 monitor_host: launched(心跳缺失/过期)、标记文件出现、monitor-host.log 记 reason=dsh_task <job_id>、任务本身 status: ok
监控窗口自动拉起(本机真实环境)真 DSH_HOME + 默认开关✅ 心跳文件不存在(宿主加载的还是旧版观察器),命令行分类得到 guiHosts=[45148](单个真 GUI 宿主,Chromium 子进程被忽略),state=host-older-observer → gui-open-old-observer,没有拉起第二个 GUI
台账幽灵记录node test/ledger-liveness-probe.mjs✅ 21/21:幽灵(pid 已死 / 没记 pid)→status:lost + stale:true;真在跑的 → running + pidAlive:true;meta 已写明结束 → 推导成 ok 且不算幽灵;killByCaller 只杀真的活着那个(实测进程确实消失)、幽灵进 stale 数组、再杀一次 notFound:true 但仍回报幽灵;killTreeSync 对已死/无效 pid 如实报失败(不谎报"已强杀")
卡片底部 token 状态条node test/panel-selftest.mjs(117 项)✅ 紧凑口径 517/12.2K/517K/1.2M;部分命中绝不显示 100%(99.7% 不四舍五入成 100);两条数据源都能算(宿主 tokenUsage 投影 + 观察器 meta.usage);吞吐只认观察器给的 tokensPerSecond(客户端不再自己采样,没有该字段就报 —);跑完的会话不显示速率;合计只累计"在跑且真有速率"的会话;底部按 tok/s | 缓存命中 % | 输入 N tok · 输出 M tok 渲染
token 折叠(真实日志)node test/usage-fold-probe.mjs --all✅ 真实多帧 zstd 日志(1282 帧 / 1727 行):chunk.type=usage 与 data.usage 各 36 次 ⇒ 同轮替换必须生效(不替换就翻倍);折出 输入 92103 · 输出 50207 · 缓存读 1949440 · 命中率 95.5%
吞吐口径(用户报的"每秒几千 token")node test/observer-selftest.mjs(34 项)✅ 2 秒里跳 6000 token 不给速率(旧算法会算成 3000 tok/s);跨 29 秒的 6000 token → ~207 tok/s;输出没长不给速率;最近 4 次采样取平均;真实日志在长 → 投影带出 tokensPerSecond 且 < 2000;心跳带 usageRate 口径版本(用来分辨宿主里跑的是哪一版观察器)
自由改尺寸 / 拉宽多列 / 标题完整 / 空态居中node test/panel-selftest.mjs✅ 三个把手 data-axis=x,y,xy(右=宽、下=高、右下角=宽高);cursor: ew-resize/ns-resize;卡片 width: var(--sap-w, 300px) + height: var(--sap-h, auto);网格 repeat(auto-fill, minmax(132px, 1fr))(拉宽自动多列);.sap-root[data-sized="true"] .sap-body { max-height: none }(拉高后内容滚动、头尾固定);头部 flex-wrap: wrap + 标题 flex: none;空态 justify-content: center 且文案就是「无活跃子代理」
启动图与 bundle 缓存node test/gui-graph-probe.mjs <临时实例 URL>✅ 10/10(插件进了 __DSH_BOOT__、组合 URL 200、bundle 37.4MB 含新代码:sap-grip / auto-fill / flex-wrap / 自由缩放 / 无活跃子代理 全为 true,旧文案 雷达静默 与旧算法 throughputOf 均为 false);响应头实测 cache-control: public, max-age=31536000, immutable
台账幽灵记录(本机真实数据)dsh-subagent --list✅ 134 条记录:--list 每条都带 stale/pidAlive/derived,不再有"号称在跑"的幽灵(对方已用 live-audit --fix 把 5 条纠正为 lost)
强制终止dsh_task_kill {caller}✅ 一次杀掉该 caller 的 2 个运行中任务;已结束的重杀报"无需终止"
注册可见性claude mcp list / codex mcp list✅ dsh 均显示 Connected / enabled
GUI 可见性会话落在一个按工作区路径编码出来的目录里(如 §6.3 那种 --D-work-demo--)✅ 执行中文件持续增长(38KB→89KB/25s);GUI 列表可见,但无「执行中」徽标
DSH 升级到 0.1.5-rc.1 后回归全套 7 个探针⚠️ 升级当场打坏:selftest 43/51、leaf-only-probe 14/15、monitor-live-probe 7/8(详见「DSH 版本兼容性」)
同上,修复后全套 9 个探针✅ 296/296:panel 117、selftest 54、observer 35、autostart 26、ledger 20、leaf 14、exec-surface 11、wait-policy 11、monitor-live 8(数字取各探针自报值;早先写的 275/287 是把每个探针的收尾行"…通过 ✅"也数了进去,已订正。0.1.4 时是 284/8 个探针,0.1.5 起把等待口径的 11 项并进 test:all)
垫片入口失效自愈(0.1.6)node test/launcher-heal-probe.mjs + 全套 10 个探针✅ 309/309:新增 launcher 13 项(垫片指向已消失的 app.asar 入口 → 自愈改指 resources\app\lib\desktop-cli.js 且其余参数顺序不变;候选 1 是失效残留、候选 2 可用 → 回退到候选 2 并成功;所有候选都失效 → 聚合报错并逐条列出每个垫片的原因与处置办法;exe 不在安装根时靠 app.asar↔app 互换命中;本机真实垫片入口真实存在),其余 296 项不变。真实故障:Desktop 更新后垫片仍指旧入口 → 每次调 dsh 都 Cannot find module + exit 1(表现为"派活静默失败")
dsh_task 可按次指定模型/服务商/推理强度(0.1.7)真跑 MCP server 四例(reasoning_effort=low / 写错模型 / 写错档位 / 类型不对)+ npm run test:all✅ 315/315 全绿(改前 309;selftest.mjs 由 54 → 60,新增 6 条断言)。四例真跑:reasoning_effort=low + model=deepseek-v4.1-flash → status=ok 且 task.json 里 reasoningEffort:"low"(档位真到达 DSH);写错模型 → UNKNOWN_MODEL(DSH 侧 474ms 就返回,result.txt 0 字节);写错档位 → UNSUPPORTED_REASONING_EFFORT(557ms);model/provider/reasoning_effort 传非字符串 → isError:true 点名参数名且不启任务(旧行为是静默忽略,调用方以为指定生效了)。工具结果头部在有值时回显 reasoning_effort: <值>,调用方自己可核对
推理强度固定成 high(§3.1)真 dsh --profile subagent --reasoning-effort high + 网关直连对比✅ 改前:UNSUPPORTED_REASONING_EFFORT(模型没声明推理能力,DSH 完全不带 reasoning 参数);改后:exit 0、reasoningEffort: "high"。网关侧同题实测:不带参数 reasoning_tokens=103,显式 high reasoning_tokens=35 ⇒ 默认比 high 更啰嗦,这才是"思考强度夸张"的根因。另验证配置写坏时是响亮失败(整段 settings 被拒、provider 变 NO_ADAPTER),不会静默退回旧行为
等待口径(§2.3)node test/wait-policy-probe.mjs✅ 11/11:默认 defaultWaitSeconds=45 / statusWaitSeconds=30 且都 <60;不传 wait_seconds 时一次调用3.5s 就带着 status: ok 返回(不再需要轮询),且回答里没有让调用方去轮询;DSH_SUBAGENT_WAIT_SECONDS=5 时同一次调用 5.7s 返回 running 并给出 dsh_task_status(job_id="…", wait_seconds=30);dsh_task_status 不传 wait_seconds 时自己等满 20.7s 直到终态
沙箱档下 shell 空转(§5.1)三种权限档各派一条真任务 + 读真实会话日志的 tool/result✅ 复现:danger-full-access 返回 SPAWN-PING\r\nELAPSED_MS=59\r\n;workspace-write / read-only 返回 "\r\n" 且 isError:false(命令从未启动)。升级前后各取一份日志,行为一致 ⇒ 与 DSH 版本无关
空转检测的准确率真实会话日志跑 detectHollowShellCalls✅ 沙箱那次数出 11 次空转调用(带命令原文),danger-full-access 那次 0 次 ⇒ 零误报
会话日志改名兼容node test/exec-surface-probe.mjs + observer-selftest✅ 按 session*.jsonl.zstd 找、取最大;新旧同名时选中新格式;观察器能从 session.v3.jsonl.zstd 折出用量(改名前这条会失败)
调用方停这一轮后子代理是否真停真起 MCP server + 真派任务,在轮询在途时发 notifications/cancelled✅ 7/7:任务终态 cancelled、在途请求立刻返回、子进程用 process.kill(pid,0) 探到确实消失、连接断开后 server 自行退出
新版真跑一轮(MCP 桥接层)node test/monitor-live-probe.mjs 内的真 dsh_task✅ status=ok 4.9s,产物落盘,观察器记到 running=true job=20260911-124736-5c6cbc3c
新版直接调 profiledsh --profile subagent --prompt "…"✅ stopReason: completed,答复「好的」,1.2s
新版 row id 兼容审计对 app.asar 逐个查 12 个被 patch 的行 id✅ 12/12 仍存在;新发现 tool-subagent-report 不再是 loader 行(变成协议消息 kind),探针已同步
新版客户端接入点审计临时 web 实例上取组合 bundle(11.2MB)✅ 6/6 仍在:shell.overlay / subagentsByParent / projectionValues / sessions.open / useSessions / __ModuleLoader__;启动图 10/10,卡片 rev 36bac599d008e66e-45
上游 lsp 缺陷影响面desktop profile --dump-config + 宿主日志✅ 桌面宿主不受影响(没有 lsp-stdio/tool-lsp 这两行,日志无 assertNever);只有 web 这类 profile 起不来,已给出两行 overlay 的绕开办法
MCP 连接不再产生 warning手工握手 + 读 Cursor mcpprocess.log✅ server 正常启动 stderr 0 字节;原先 [warning] [McpProcess stderr] ERR dsh-subagent: MCP stdio server ready … 不再出现;DSH_SUBAGENT_DEBUG=1 时横幅与正确版本号(来自 package.json)才出现