学 AI 产品 · 专业 AI 产品经理播客第 4 章 · T4 解剖 DeepSeek Harness:一切皆插件的 Agent 底座 · EP 10
第 4 章 · EP 10

DSH 独有的工具面:terminal / lsp / jobs 等 2 节

时长 15:38音色 云健 · 男声

同步字幕

章节导航(点击跳转)

0:00开场 · 两件被折叠起来的事1:50
1:50terminal 治的是状态失忆2:03
3:53归属即边界1:22
5:16lsp 只给四个操作1:53
7:09jobs 把后台任务收进一张表1:32
8:41两个旋钮各管什么1:38
10:20预设只是给组合起名字2:04
12:24审批全链路都是不拿到答案就拒绝1:38
14:02可带走的原则1:35
解读全文

dsh10 · DSH 独有的工具面:terminal / lsp / jobs · 审批与权限:两个旋钮,一个下拉框

  • 模块:T4 解剖 DeepSeek Harness:一切皆插件的 Agent 底座
  • 集页:https://xueai-podcast.pages.dev/t/dsh10/
  • 取材课节:DSH 独有的工具面:terminal / lsp / jobs、审批与权限:两个旋钮,一个下拉框(2 节,素材 9237 字)
  • 来源:xueai.miyang.cn(小山学堂 · 洛小山《学 AI 产品,从入门到精通》)
本页文字稿为音频的配套解读,内容取自小山学堂课程素材,属二次演绎版本。
行号与源码核对日期为 2026-08-13,随版本演进可能变化。

一、本集要解决什么

两件事:既然 bash + read/write 已经能干活,为什么还要再配三套一等公民工具;以及"权限"这个大词在底层是怎么被拆开的。

贯穿两件事的,是同一种设计习惯——把复杂概念拆成正交的小变量,再让产品概念降级成"给变量组合起的名字"。

部分核心问题答案
工具面三套工具各治什么病状态失忆 / 文本搜索无语义 / 后台任务没人收尸
权限权限如何拆分sandbox/mode × approval/policy 两个正交旋钮

二、能力地图 · 三套工具

工具面治什么形态源码位置
terminal状态失忆6 个工具(terminal_open / send / read / signal / close / list)+ 持久 PTYpackages/terminal/
lsp文本搜索无语义恰好 4 类语义查询packages/lsp/
jobs后台任务没人收尸统一 job_list / job_output / job_killpackages/jobs/

三者共同的纪律:闭合词汇、结构化降级、归属即边界。且全是可选插件——极简预设一个都不挂,照样是完整的 coding agent。

产品可以不做的事,运行时值得做。CC 是产品,交互式调试有 IDE 兜底、语义导航有编辑器兜底;DSH 是运行时,要在无头环境里独自把这些能力供给模型。

三、terminal · 让状态活过一次调用

一次性 bash 的问题:REPL 的变量活不过一次调用,模型只能把旧代码全部重发一遍,轮数与 token 双倍烧。

解法是把 PTY 会话做成一等资源:terminal_open 建会话拿 id,之后 send / read / signal / close 全按 id 操作。会话在后端和工具插件热重载期间照样活着。

持久会话带来的不只是省钱

它改变的是模型的工作方式:

  • 无持久会话 → 每一步都得写成一次性完整脚本(批量提交);
  • 有持久会话 → 可以像人一样先起环境、逐步试探、边看输出边调整(交互式迭代)。

两种模式的产出质量差得很远。

并发纪律 · 三道把关(startSend())

出处:packages/terminal/terminal/src/index.ts 第 243–254 行。

顺序检查失败行为
1expectOwned(owner, id) 校验归属授权比较的是拥有会话的确切 Agent 实例,id 不是秘密
2会话是否正在关闭关到一半的会话拒收任何新输入
3record.active 上一条 send 是否结算抛带 SEND_ACTIVE 的 TerminalError(第 246 行)

检查全过 → 登记为该会话唯一的活动 send,等其 done 承诺结算(成功失败都算)后登记才清空。

归属即边界:不靠保密,靠校验。顺序发放的 id 也很安全——这条纪律在 jobs 里会再出现一次。

四、lsp · 四个操作,一个不多

恰好四类语义查询:跳定义、找引用、跳实现、hover。

为什么不把整套 LSP 协议透出去:那样模型面对的是一个无底洞 schema,provider 换一家行为就漂一次。

闭合词汇的实现

seam、提供方、工具三层共享同一个四操作联合——加第五个操作会让编译失败,直到三层都改完。用编译期错误挡住词汇表膨胀(与上一集用 never 类型挡住非法组合是同一类思路)。

选路按文件扩展名,一个扩展名只属于一家(packages/lsp/lsp/src/index.ts 第 143–149 行)。

没有 provider 时:结构化降级

lsp 工具照常挂在目录里,schema 一字不变,调用返回带 LSP_UNAVAILABLE 的结构化失败。

做法模型学到什么
让工具消失不知道工具去哪了 → 反复试探一个不存在的东西
结构化降级"这个项目没配语言服务器" → 一次讲清
接口的消失比接口的失败昂贵得多。 凡是模型能看见的接口,稳定性都比丰富度重要。

五、jobs · 三种后台任务,一张列表

三种生产方形态完全不同,却全部注册进同一个 ctx.jobs 注册表:

生产方形态统一 id
后台 bash 命令进程bash-N
terminal_send 后台模式PTYterminal-N
后台 subagent子智能体subagent-N

分工:生产方拥有执行资源;注册表拥有身份、访问权限与生命周期状态。

授权不靠 id 保密

id 按 <kind>-N 顺序发放,完全可预测。防线是所有者授权:读、杀、等全部校验调用方会话与任务 owner 是否一致,别人的任务连 label 都看不到。每个 owner 默认最多 10 个并发。

两个易漏细节

  1. done 等的是资源释放:生产方的 done 承诺在资源释放后才 resolve——工作干完了还不算数;owner 被销毁时注册表取消并等待任务,不留孤儿进程。
  2. 完成通知不重复打扰:某接口已交付过终止状态时,reported 标记抑制重复完成通知,避免模型收两遍消息、白白多开一个 turn。

六、权限 · 两个正交旋钮

旋钮回答什么取值管辖范围
sandbox/mode这条命令的文件效果允许到什么程度read-only / workspace-write / danger-full-access只管文件系统;网络与进程可见性不在其词汇表
approval/policy碰到需要人拍板的事问不问ask / never只管问不问人

正交的实际含义:两个旋钮在日志里是两种独立事件;执行、提示词、回放全都只读这两种事件的折叠结果。拧任何一个,另一个纹丝不动。

三档沙箱

档位行为
read-only后端必须拒绝写入,只保留 /dev/null 这类 shell 必需接收器
workspace-write工作区根目录 + 后端承诺的临时区可写,界外一律拦
danger-full-access绕过隔离,消费方直接 spawn 原始命令,根本不调用 ctx.sandbox

两档审批

  • ask(默认):交给组合的应答者链。一个应答者都没有 → 链走到头返回 unavailable,照样按拒绝处理。
  • never:确定性返回 rejected,不分发任何应答者。CI 与无人值守的标准姿势。
"ask" 不是"问了就一定答",而是"启动一条链去要答案"——要不到就当拒绝。把"要不到答案"与"明确拒绝"在执行方眼里统一成同一个结果,是这套设计里最省心的决定之一。

七、预设 · 只是给组合起名字

ctx.permissionPresets 维护一张"名字 → 旋钮组合"的表。默认表就两行(permission-presets/src/index.ts 第 167–176 行):

预设名sandbox/modeapproval/policy
workspace-writeworkspace-writeask
danger-full-accessdanger-full-accessnever

它是可选能力,不在 agent loop 主干上,也不拥有任何强制执行。

切预设时发生什么

三条事件,顺序固定(apply(),第 379–392 行):

  1. 追加一条只记日志的 permission/preset,记下你的意图;
  2. 通过两个旋钮各自的规范 setter 写 sandbox/mode 与 approval/policy;
  3. 只写生效值真的变了的那个——重选当前预设,什么都不追加。
切预设没有第三个执行路径,也没有隐藏状态:日志里追加了什么,执行层就折叠出什么。
回放时执行层只认旋钮事件;preset 事件的唯一作用是当两个预设共享同一组合时,记住你当初选的是哪个名字。

custom · 只出不进

custom 是保留字,表里不许有叫这个名字的条目(插件加载时抛异常)。只有手动把旋钮拧到表里没有的组合,current() 才派生出 custom 给客户端显示。

  • 可以是当前状态,永远成不了切换目标,也绝不出现在事件 payload 里。
  • 理由:它是派生值不是事实。日志里的事实只有两条旋钮事件;若允许把 custom 当目标写进事件,日志会多出无法解释执行层行为的条目,回放也会分叉。

对称性:查表(名字 → 组合)与派生(组合 → 名字)两个方向都写死,各走各的路。


八、fail-closed 贯穿始终

唯一的放行值是 allowed-once,且只授权所询问的那一个操作。 rejected / cancelled / unavailable 三种结果调用方全部执行拒绝。

归一化为拒绝的情形:没有应答者、应答者抛异常、返回词汇表外的怪值、弹窗期间用户关掉界面。

审批不进模型上下文

approval/asked 与 approval/decided 成对写入会话日志,只做审计。模型看到的是工具结果与运行时上下文快照。ApprovalRequest 故意不带工具参数,用 callId 指向已流式输出的那次调用,避免渲染一份会漂移的副本。

提权是一次性的

写操作被沙箱拦下后,模型可带 sandbox_permissions 请求提权重试;通过给 allowed-once,这次重试以显式更宽的模式解析一次策略,用完即弃,会话的旋钮设置不因此改变。

never 在瀑布之前就拦死

边界问题:若插件在审批服务挂载后用 prepend 往应答者链最前面塞一个全部放行的应答者,能否绕过 never?不能——因为 never 根本不在链里执行。

出处:packages/interaction/user-approval/src/index.ts 第 304–312 行 decide()。注释把设计意图讲完了:

The 'never' policy is decided HERE, before any dispatch: a listener registered with prepend: true after this service mounts would sit ahead of any gate LISTENER, so a listener-shaped gate cannot keep the documented promise that 'never' rejects deterministically regardless of registration order — only the service's own request path can.

这与上一集 Guard 的单调性是同一类思路:能用结构保证的属性,不要留给运行时顺序去碰运气。


九、横向对比 · 单一模式轴 vs 双旋钮

产品模型特点
DeepSeek Harness双旋钮正交能表达 read-only + ask(沙箱只读兜底,确需写入时弹窗问一次)
Claude Code单一 permission mode 轴(default / plan / acceptEdits / bypassPermissions / dontAsk)+ 规则表五档在一根轴上滑动,沙箱与问人被绑在一起卖;但规则表可做命令前缀级细粒度控制
Grok Build授权链逐层放行从工具请求到受限执行逐层放行
Codex两层叠加execpolicy 命令级精确前缀 + 反例;Guardian 用一次模型审查把弹窗从主路径拿掉

各自的取舍:CC 的规则表是工具/命令前缀粒度,DSH 的两个旋钮都是会话级全局值,工具粒度门禁要靠 hook 层做。两边把复杂度放在了不同地方。


十、审查清单

设计面向模型的工具面时:

  1. 有状态的程序,状态能否跨调用存活?
  2. 词汇表是否闭合?加一类是否需要付出编译期代价?
  3. 能力不可用时是结构化降级,还是让工具消失?
  4. 授权靠 id 保密还是归属校验?每个入口都校验了吗?
  5. 后台任务是否统一注册、统一收口、owner 销毁时不留孤儿?

设计权限系统时:

  1. "文件效果"与"问不问人"是否被拆成两个正交变量?
  2. 预设是否只是名字映射,而不拥有强制执行?
  3. 派生状态(如 custom)是否只出不进?
  4. 唯一的放行值是否只授权单个操作?
  5. 拿不到答案时,是否一律按拒绝处理?

十一、约束说明

  • 行号口径:基于 2026-08-13 对 deepseek-harness-master 本地仓库核对。
  • 演示口径:课程交互演示中的输出与任务形态为教学化模拟;工具名、schema、判定逻辑对应真实源码。
  • 证据边界:关于 Claude Code 无一等 PTY 与 LSP 工具,基于已公开还原源码证据;三套能力在 DSH 中均为可选插件。
  • 本页用途:文字稿仅供阅读,音频以集页播放器为准;题目页内容不进入音频。

十二、实践提示

提示一 · 让状态活过一次调用

凡模型需要与有状态程序反复对话,先问:这个状态能不能跨调用保留?重发一遍旧代码的代价,远大于多写几个工具。

提示二 · 闭合词汇表,拒绝逃生口

给模型的接口宁可窄而稳定,也不要宽而漂移。"加一类操作要付出编译期代价"正是词汇表不膨胀的保证金。

提示三 · 降级要结构化

能力不可用时返回带错误码的失败,而不是让工具消失。前者教会模型一件事,后者只会让它反复试探。

提示四 · 归属即边界

不要靠 id 保密做授权——id 迟早被猜到或泄露。把归属校验放在每个入口上,顺序发放的 id 同样安全。

提示五 · 把大概念拆成正交变量

沙箱管文件效果,审批管问不问人,产品概念降级成给组合起的名字。拆对了,你能表达出单轴上根本不存在的组合。派生状态要只出不进。


十三、课堂练习(附推导)

场景一:agent 在同一 terminal 会话上先发一条 run_in_background: true 的 send,紧接着又发一条前台 send。第二条的下场是什么,错误码是哪个?job 面板里此时能看到什么?
场景二:项目没配任何语言服务器,模型调了一次 lsp 工具查 a.py 的定义。写出模型收到的结果形态。

场景一:第二条前台 send 会因第三条检查(上一条 send 尚未结算)抛 SEND_ACTIVE 错误码的结构化失败。注意——后台 send 也是一次 send,只要有活跃 send 未结算就拦。job 面板里此时能看到后台任务 terminal-N,可用 job_output 取输出。

场景二:模型收到带 LSP_UNAVAILABLE 错误码的结构化失败。这比把 lsp 工具从目录里摘掉更友好——模型学到的是"这个项目没配语言服务器",而不是"工具怎么突然消失了",不会反复试探。

场景三:审批弹窗弹出后用户直接关掉浏览器,UI 应答者随 dispose 被移除。这次请求最终以什么结果结算?
答:unavailable(或 cancelled),调用方一律当拒绝——不放行。

十四、Takeaway

  • terminal 让状态活过调用,lsp 给四类语义查询并拒绝协议逃生口,jobs 把三种后台任务收进按 owner 授权的列表;
  • 三者共同纪律:闭合词汇、结构化降级、归属即边界,且全是可选插件,主干不背包袱;
  • 权限底层只有两个正交变量:sandbox/mode 管文件效果,approval/policy 管问不问人;
  • 预设是给组合起名字的表键,custom 只出不进;
  • 审批全链路 fail-closed:唯一放行值是 allowed-once 且只管那一个操作;never 在服务内部、瀑布分发之前就拦死。

来源:xueai.miyang.cn(小山学堂 · 洛小山《学 AI 产品,从入门到精通》)

本内容改编自小山学堂课程素材,为二次演绎版本。