DeepSeek Harness 的用户命令系统:斜杠命令从注册到执行的一条线
先说清楚前提:DeepSeek Harness 的仓库 README 里有一节标题就叫 Developer preview,正文写明该项目处于开发者预览阶段、正在快速迭代,并用大写强调「THERE WILL BE COMPATIBILITY-BREAKING CHANGES」。所以下面出现的每一个字段名、正则、默认值,都对应我们核对的那一份仓库快照,随时可能变。别把它当成稳定 API 抄进你的生产代码。
从一个问题开始:敲完 /compact 回车,模型知道你敲过吗
答案是不知道。这不是猜的,packages/interaction/commands/README.md 的 Model Experience 一节自述得很直白:注册表本身什么都不提交,已知的斜杠命令在 UI 命令平面里执行,它的 CommandResult 文本不会作为一条 user message 提交;命令的发现、执行与输出「add no model tokens」。
这句话决定了整个子系统的形状。它不是一个「把命令翻译成 prompt」的语法糖层,而是一条与模型对话完全平行的通道。理解这条通道,最省事的办法是跟着一行文本走一遍。
第一站:客户端的 / 触发源
Web 客户端这一侧在 packages/client/ui-commands/src/client/service.ts 里,CommandUiRuntime 的构造函数向 inputTriggers 注册了一个 trigger: '/'、name: 'command' 的候选源。这个类的 static inject 写的是 ['inputTriggers', 'sessions', 'remote', 'remote.commands'],四项依赖一个不少;构造函数里还额外写了一道显式守卫,ctx.get('inputTriggers') 取不到就抛 ui-commands: slash service unavailable。
真正的提交动作在私有方法 execute() 里,正文只有四步:await this.ctx.remote.commands.execute(session.sessionId, line);传输层失败(!result.ok)直接抛错;result.value === undefined 时返回 { kind: 'error', text: \unknown or malformed command: ${line}` };否则发一条本地确认再返回 { kind: ‘success’ }`。第三步这个细节值得记住:命令没被认出来时,客户端给的是一句错误提示,而不是把这行文本改道发给模型。README 里对应的说法是,未知的斜杠命令输入会被随包发布的适配器拒绝,而不是变成一条模型 prompt。
顺带说一句第四步的口径:源码注释自述,一条被接纳的命令无论 handler 结果是成功还是失败,这里都报 plain success——因为真正的结果已经由宿主执行器落进了 command/run / command/done,由持久的流节点去渲染,输入框不再回显一遍。
顺带一个容易被忽略的分支:候选列表的刷新有两条路。一条是 ctx.remote.$on('commands/change', ...) 触发 directory.invalidateAll();另一条是 ctx.remote.$on('agent-preset/selected', ...) 只刷新那一个 session 的 key。源码注释自述了原因——切换 preset 改变的是某个 session 的 agent 能解析到哪些命令,它不注册任何全局东西,所以全局信号根本不会为它触发。
第二站:parseCommand() 的边界比你以为的严
服务端入口是 packages/interaction/commands/src/index.ts。第一件事是 parseCommand(line),它的正则原样是:
/^\/([a-z][a-z0-9_-]*)(?=$|[\t\n\r ])/u
拆开看四个约束:斜杠必须在行首第 0 个字节;名字必须以小写字母开头,后续只能是小写字母、数字、_、-;名字之后必须是行尾或者一个 \t、\n、\r、空格四者之一;匹配段之后的全部字节原样成为 rawInput。
包里的 tests/commands.spec.ts 用两张表把边界钉死了。接受的一侧:/goal 得到 rawInput: '';/goal create the thing 得到 rawInput: ' create the thing'——注意那个前导空格是留着的;/goal\ncreate the thing 的 rawInput 以 \n 开头;/goal_name-2\t x 也能过,尾部空格照留。拒绝的一侧列了六个:goal(没有斜杠)、 /goal(斜杠前有空格)、/、/Goal(大写)、/goal/path(名字后面跟的不是空白)、/goal🔥。
这是第一处会咬人的地方。rawInput 连分隔空白都原样交给你,所以每个 handler 自己都得先 trim()。仓库里的实现全是这么写的:/compact 判断 invocation.rawInput.trim().length > 0 就直接返回 usage 错误,/feedback 判断 trim().length === 0 就返回一条以 Feedback text is required. 开头、后面接上模块里那份 USAGE 的错误文本,/plan 取 rawInput.trim() 再和 'off' 比。README 对此的措辞是消费者自己拥有命令专属的语法,只能规范化各自语法允许的部分。
第三站:语法合法不等于命令存在
execute() 的前四行是两道独立的关卡:
const parsed = parseCommand(line)
if (parsed === undefined) return undefined
const command = this.view(agent).get(parsed.name)
if (command === undefined) return undefined
两次落空返回的都是同一个 undefined,客户端也就分不出「语法不对」和「名字不存在」,两者合并渲染成同一句提示。文档里把这两种情况统称为 admission miss,并写明它们不写任何日志——因为它们从来没进过 handler。
view(agent) 背后是 packages/core/scope/src/store.ts 里 ScopedLayers.merge(),实现只有五行:先把全局层的条目整个拷进一个 Map,再顺着这个 scope 的链式层逐个 merged.set(name, value) 覆盖上去。所以「agent 作用域的定义遮蔽同名全局定义」不是什么特殊逻辑,就是后写覆盖先写。同一层内重名则在注册阶段就炸——CommandLayer 的构造函数给 NamedEntries 传了两句不同的错误文案,全局层那句还顺手写了替代方案:for a per-agent variant, mount a command-injected plugin under that agent's \agent.ctx“。
第四站:注册时到底校验了什么
register() 只做两件事:normalizeDefinition(),然后把插入动作挂成 Cordis effect,返回的是那个 effect 的 disposer。校验清单在 normalizeDefinition() 里,逐条是:name 必须匹配模块顶部的 COMMAND_NAME = /^[a-z][a-z0-9_-]*$/u;description 必须是 string 且 trim() 后非空;handler 必须是 function;如果给了 input,它必须是个带 string 类型 hint 的对象,且 hint 去空白后非空。任何一条不满足都抛 TypeError,消息里带上命令名。
通过之后,定义和它的 handler-free 视图 descriptor 都走 Object.freeze。给 UI 的 list(agent) 返回的是冻结后的 descriptor 数组,按名字排序,代码里那句排序注释自述「Names are unique in the effective view, so equality is impossible」,所以比较函数没有返回 0 的分支。
仓库里真正调用注册的业务代码有六处(packages/ 下排除 tests 目录后,commands.register( 的命中还有第七处,那是注册表自己 effect 的 label 字符串)。这六处及其 description 原文分别是:
| 命令 | description 原文 | 注册文件 |
|---|---|---|
compact | Compact older conversation history | packages/compaction/command-compact/src/index.ts |
feedback | record feedback about this session | packages/feedback/command-feedback/src/index.ts |
goal | set or view the goal for a long-running task | packages/goal/command-goal/src/index.ts |
permission | Switch the permission preset (sandbox mode + approval policy) | packages/interaction/permission-presets/src/index.ts |
plan | Enter or leave plan mode | packages/plan/plan-mode/src/index.ts |
export | Download this Session log as a ZIP archive | packages/session-query/session-log-export/src/index.ts |
前三个和最后一个是直接在 ctx 上注册的;permission 和 plan 包在 ctx.inject(['commands'], (commandCtx) => ...) 里,源码注释自述这个子上下文只在组合里确实装了命令注册表时才激活。换句话说,一个没挂 UI 命令面的组合(README 里点名 UI-less demo spine 与 ACP 自动化不提供命令适配器)不会因为缺少注册表而崩,那段注册干脆不激活。
permission 这条要如实说清楚:它的 description 原文就写着切换的是 sandbox mode 与 approval policy。handler 先把 rawInput.trim() 拿到手,空串时返回当前 preset 与可选清单,不在 this.names 里就返回 unknown preset "..." 的错误;命中之后走 this.apply(agent.session, name, (policy) => { this.ctx.approval.setPolicy(agent, policy) }),也就是在回调里把新的 policy 交给 ctx.approval。这是一条会改变本机执行边界的命令,不是一个显示偏好开关——dsh 会在你本机跑工具、起子进程,改这条就是在改那条边界该卡多严,用之前请自己评估环境。
第五站:command/run 与 command/done 这对日志
命中定义之后,execute() 先检查 signal.aborted,然后 mintCommandId()。这个 id 的生成逻辑很短:实例上有个从 0 开始自增的 commandSeq,还有一个 instanceToken = crypto.randomUUID().slice(0, 8),拼出来的形状是 cmd-${instanceToken}-${commandSeq}。注释自述这个实例 token 的作用是让同一份被恢复的日志跨进程重启也不会撞 id。
接着 appendLifecycle(agent.session, 'command/run', ...) 写第一条,payload 是 { commandId, name, args?, source }。args 那一项写成 ...command.definition.recordInput === false ? {} : { args: parsed.rawInput }——默认写、显式设 false 才不写。类型声明在 src/types.ts 里,注释说明 name 和 args 就是 parseCommand 自己的切分结果(含分隔空白),消费者不必再解析一遍原始行。source 目前只有 { kind: 'user' } 一个变体,CommandSourceMap 的注释自述今天所有执行调用方都是面向人的 UI 面。
recordInput: false 在仓库里只有一个用例:/feedback。把两处放在一起看就明白了——command-feedback 自己声明了 feedback/record 事件,handler 里通过 recordFeedback() 把 trim() 后的文本写进这个事件,所以 command/run 再存一份 args 就是重复。README 对这个字段的说法正是「一条命令若由它自己的权威领域事件持有 payload,就把它设为 false」。
顺手提醒一句实务上的影响:这个字段默认是 true,意味着你在命令行里跟着命令敲的整行内容,默认会原样落进会话日志。写自己的命令时要自己判断这一行该不该留痕。
handler 结算后写 command/done,payload 是 { commandId, kind, text?, sourceEventSeq? }。两条都通过私有的 appendLifecycle() 走 session.append 的两参数形态,注释自述它们是 log-only 的直接追加:没有 turn 包住它们,也不强制 flush,持久化在普通检查点和拆卸时排空。
第六站:失败路径是不对称的
这是我觉得最值得单独拎出来的一段。command/run 的追加没有任何 try 包裹,失败就直接把整个执行炸掉;而 handler 抛错之后写 command/done 的那次追加是包在 try 里的,追加失败只走 this.ctx.logger.warn(...),然后原样 throw error 把 handler 自己的错误抛出去。文档里对这个不对称给了自述理由:让 handler 自己的错误保持为被上报的那个失败。
还有一处容易漏看:normalizeResult() 是在 try 内部调用的。也就是说,handler 返回了一个不合法的 CommandResult——比如 kind: 'error' 却给了空字符串 text,或者返回了未知的 kind——抛出的 TypeError 走的也是这条失败路径:先记一条 kind: 'error' 的 command/done,再把 TypeError 抛给调用方。校验规则本身是:success 的 text 可选但给了必须是 string,sourceEventSeq 给了必须是非负安全整数;error 的 text 必须是非空字符串。
第七站:取消只到「不再等」为止
withAbort() 的写法是:signal 已经 abort 就直接 reject;否则把 handler 的 promise 和 abort 事件做成一场竞速。请注意它做的仅仅是停止 await。README 的 Known Limitations 一节把这一点写成了明确的已知限制,标题是 Cooperative side-effect cancellation:派发在 abort 时停止等待,handler 必须自己响应信号,才能停下那些已经逃逸到外部系统的工作。
所以「按了取消」和「那件事停了」是两回事。这类命令里 /compact 是个典型——它的 handler 把 invocation.signal 一路传进 ctx.compaction.compactNow(agent, signal, commandId),连 commandId 都传了下去。传不传得下去,决定了取消是否真的能生效。
第八站:sourceEventSeq 这个小接口和它的守卫
CommandResult 的 success 分支可以带一个 sourceEventSeq,含义是「更丰富的呈现由更早的那条领域事件持有」。仓库里的用例还是 /compact:成功时返回的 text 形如 Compacted N history items (~M tokens).,同时把 sourceEventSeq: result.summarySeq 一并带上。客户端在 packages/client/ui-conversation/src/client/conversation-nodes/command.ts 里对这个值做了自己的一遍防御性检查才使用。
真正把规则钉死的是包内的 src/invariant.ts。它注册了一个 invariant 伴随插件,对每个 session 的日志做校验:command/run 的 commandId 不能重复;command/done 必须能在同一份日志里配到一条先前的 command/run;带了 sourceEventSeq 时,必须 kind === 'success'、必须是非负安全整数、必须严格小于本事件的 seq、session.events[source].seq 必须等于 source,并且被指向的那条事件的 type 不能是 command/run 或 command/done。最后一条的意思很明确:命令记录不许指向另一条命令记录。
这条线告诉你的几件事
第一,命令的输出天然不进模型上下文。想让模型看见,必须显式安排。plan-mode 就是范例:/plan 后面跟的消息不为空时,handler 里调 agent.steer(createUserMessage({ ... })) 把它显式送进去——README 说得很清楚,注册表从不隐式提交 rawInput,一旦命令生产者显式使用 agent,那条消息的契约就归生产者自己管。
第二,注册表的通知是弱的。notifyChange() 逐个 catch 每个 commands/change 监听器,抛异常只 warn,返回的 promise 挂了也只 warn,注释自述这是因为注册表通知不具否决权,不能让某个 UI 刷新变成关键路径。所以别把业务副作用挂在这个事件上。
第三,这个包目前明确不做的事写在 README 的已知限制里:只支持非结构化文本输入,表单、补全 schema、带类型的参数仍然是各命令自己的解析问题。你要做一个带参数校验的命令,校验代码得写在你自己的 handler 里。
最后重复开头那句限定:以上全部来自开发者预览阶段的一份仓库快照,README 自己写明会有破坏兼容性的变更。要用之前请回仓库对一遍你手上那个版本的 packages/interaction/commands/src/index.ts。
本文依据 DeepSeek Harness 官方仓库(github.com/deepseek-ai/deepseek-harness)的 README、docs/ 下的
架构与子系统文档、以及 packages/ 下的源码整理,核对日 2026-08-17,对应仓库快照 47f9438(版本 0.1.0-rc.5)。
本文内容为仓库源码与文档口径,我们没有安装、也没有运行过这个项目,
因此不涉及界面外观、操作手感与运行速度的任何描述。
该仓库 README 自述处于开发者预览阶段并明确说明未来会有破坏兼容性的变更,
文中出现的命令、配置与默认值随时可能变动,请以仓库最新内容为准。
安全与合规相关做法请结合自身环境评估,本文不构成安全方案建议。