DSH 独有的工具面:terminal / lsp / jobs
别家没有的几个工具各解决什么问题。核心源码:packages/terminal/、packages/lsp/ 与 packages/jobs/,工具描述全文见 docs/tool-catalog.zh.md。
同一个任务:起一个 Python REPL,分三步调试一段代码。情景 A 左边只给 bash 单工具,右边给 terminal 六件套,直接看轮数和重复劳动的差距。情景 B 演示 jobs 面板:三种完全不同的后台任务,怎么被同一张列表管起来。
docs/tool-catalog.zh.md 中 bash 与 terminal_* 的真实 schema;情景 B 的任务形态对应 @deepseek-ai/dsh-tool-jobs 的三个工具。SEND_ACTIVE 报错行为对应 packages/terminal/terminal/src/index.ts 第 246 行。- 六个工具:
terminal_open / send / read / signal / close / list,背后是持久 PTY 会话。 - REPL、gdb、ssh 这类有状态程序,跨调用活着。
- 每个会话归属确切的 Agent 实例,别的 agent 拿到 id 也操作不了。
- 恰好四类查询:跳定义、找引用、跳实现、hover,闭合联合,加一类就是编译期大改。
- 故意不开通用 JSON-RPC 逃生口,模型玩不出协议花活。
- 没有 provider 时 schema 不变,返回结构化
LSP_UNAVAILABLE。
- 后台 bash、后台 PTY 发送、后台 subagent,全部注册成
<kind>-N任务。 - 统一
job_list / job_output / job_kill三个工具管一切。 - 授权看所有者会话,id 可预测也无所谓;每个 owner 默认最多 10 个并发。
先看最容易被低估的 terminal。一次性 bash 的问题演示里已经看到了:REPL 的变量活不过一次调用,模型只能把旧代码全部重发一遍,轮数和 token 双倍烧。DSH 的解法是把 PTY 会话做成一等资源:terminal_open 建会话拿 id,之后的 send、read、signal、close 全按 id 操作,会话在后端和工具插件热重载期间照样活着(docs/subsystems/terminal.zh.md「归属与持久性」一节)。
工具描述本身就是提示词工程的范本,1878 行的工具目录里 terminal_send 这条把等待语义一句话说清:
「向持久终端发送文本。默认会提交 Enter,并等待提示符、stdin 等待、输出静默、超时或会话退出。后台模式会返回供 job_output/job_kill 使用的 job id。」
然后是并发纪律:一个 PTY 会话同一时刻只接受一个活动 send。两个调用抢同一个终端,第二个直接吃结构化报错,谁都别想把字符插进别人的命令中间。
看 startSend() 的把关顺序,三道检查一道都省不掉。进门第一件事是 expectOwned(owner, id):授权比较的是拥有会话的确切 Agent 实例,id 不是秘密,边界靠归属,别的 agent 就算猜中了 id 也过不了这一关。第二道看会话是否正在关闭,关到一半的会话拒收任何新输入。第三道看 record.active:上一条 send 还没结算,新来的这条直接抛带 SEND_ACTIVE 错误码的 TerminalError,模型看到的是一条结构化失败,等上一条结算完再来就行。检查全过之后,本次操作被登记为这个会话唯一的活动 send,等它的 done 承诺结算(成功失败都算),登记才清空,下一条 send 才有资格进门。归属即边界这条纪律,待会在 jobs 里还会再见一次。
出处:packages/terminal/terminal/src/index.ts 第 243 至 254 行(startSend()),核对日期 2026-08-13。
lsp 工具把语言服务器的能力收窄到四类语义查询。为什么不直接把 LSP 协议整个透出去?因为那样模型面对的是一个无底洞 schema,provider 换一家行为就漂一次。DSH 的做法是闭合:seam、提供方、工具三层共享同一个四操作联合,加第五个操作会让编译失败,直到三层都改完。选路也简单粗暴,按文件扩展名找 provider,一个扩展名只属于一家:
async query(request: LspQueryRequest, signal?: AbortSignal): Promise<LspQueryResult> {
const route = this.routes.get(finalExtension(request.filePath))
if (route === undefined) {
throw new LspError(`no LSP provider handles "${request.filePath}"`, 'LSP_UNAVAILABLE')
}
return route.provider.query({ ...request, languageId: route.languageId }, signal)
}
packages/lsp/lsp/src/index.ts,核对日期 2026-08-13。代码块保留源码原文。重点在没有 provider 的时候:lsp 工具照常挂在目录里,schema 一个字不变,调用返回带 LSP_UNAVAILABLE 错误码的结构化失败。模型学到的是这个项目没配语言服务器,不用去猜工具怎么消失了。降级要结构化、词汇表要稳定,这个思路和 bash 报 [sandbox: …] 一脉相承。
最后是 jobs。后台 bash 命令、terminal_send 的后台模式、后台 subagent,三种生产方形态完全不同,DSH 让它们全部注册进同一个 ctx.jobs 注册表,领到 bash-1、subagent-2 这种按种类加序号发的 id,然后模型用同一组 job_list / job_output / job_kill 通吃。生产方拥有执行资源,注册表拥有身份、访问权限和生命周期状态(docs/subsystems/jobs.zh.md)。
授权不靠 id 保密id 按 <kind>-N 顺序发放,完全可预测。防线是所有者授权:读、杀、等,全都校验调用方的会话与任务 owner 是否一致,别人的任务连 label 都看不到。
done 等的是资源释放生产方的 done 承诺在资源释放后才 resolve,工作干完了还不算数。owner 被销毁时注册表取消并等待任务,不留孤儿进程。
完成通知不重复打扰某个接口已经交付过终止状态时,reported 标记会抑制重复的完成通知,避免一次任务结束让模型收两遍消息、开两个 turn 白烧请求。
Grok Build 在 PTY 这件事上和 DSH 想到了一块:仓库里有独立的 ptyctl crate(crates/codegen/ptyctl/,内含 pty、session、server、term、wait 等模块,另配 ptyctl-cli),把终端控制做成了可复用的基础设施。两家都认为有状态终端值得一等公民待遇,差异在组合方式:Grok 是编译期链接的 Rust crate,DSH 是运行时挂载的插件加六个面向模型的工具。
Claude Code 的还原源码工具清单里(书稿第 2 章)没有一等的 PTY 会话工具,也没有 LSP 工具,这一条基于已公开的还原源码证据。后台能力它有:bash 带 run_in_background,agent 任务还能按耗时自动转后台(tengu_auto_background_agents 开关,书稿第 2 章第 448 至 458 行引文),但那是围绕 bash 和 subagent 各自做的,没有跨种类的统一任务注册表。这个差距不是谁偷懒:CC 是产品,交互式调试有 IDE 兜底,语义导航有编辑器兜底;DSH 是运行时,要在无头环境里独自把这些能力供给模型。产品可以不做的事,运行时值得做。顺带点破一句:这三套能力都不在 agent loop 主干上,全是可选插件,minimal 预设一个都不挂,照样是完整的 coding agent。
推演两个边界场景
场景一:agent 在同一个 terminal 会话上先发了一条 run_in_background: true 的 send,紧接着又发一条前台 send。第二条的下场是什么,错误码是哪个?job 面板里此时能看到什么?场景二:项目没配任何语言服务器,模型调了一次 lsp 工具查 a.py 的定义。写出模型收到的结果形态,并说明为什么这比直接把 lsp 工具从目录里摘掉对模型更友好。