feat(coding-agent): Less Computer 接入 dsh(DeepSeek Harness),自带事件插件补齐流式与工具展示 (#975)
* fix(coding-agent): 版本号解析认预发布号
dsh 的真实输出是 `0.1.0-rc.6`,而解析器要求严格的三段全数字:它被切成
["0","1","0-rc","6"] 四段,判定失败。用这个判「装没装」的地方就会说它没装。
改成认 `-rc.6` / `+build.7` 这类预发布与构建后缀,并原样保留(用户看到的版本号才是
真的)。同时把排版噪声挡在外面:`(1.2.3)` 取到的是 1.2.3,不是 `1.2.3)`。
单测里的样例串是手抄的,抄错了测试照样绿。所以另加一条 #[ignore] 的 live 测试,直接问
本机四个 CLI 要 --version 输出再喂给解析器——那才是「设置页会不会误报没装」的可信证据。
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(coding-agent): 「装没装」改看命令能否跑通,不再依赖版本号解析
上一条修的是解析器,那只是补了一个洞。根子在于判据选错了:
installed: version.is_some() // 解析不出版本号 = 判定没装
版本字符串是这些 CLI 里最容易变的东西——改排版、加后缀、换前缀,对它们都不算 breaking
change,但对「拿版本号当判据」的我们就是。
改成:命令跑通(进程起得来且 exit 0)= 装了;版本号只用来显示,解析不出来最多让界面少
显示一个号码,不会把能用的后端说成没装。三个后端共用一个 probe_cli,顺手删掉了三份
几乎一样的 detect_* 。
单测拿 `echo` 当替身——`echo --version` 会原样打印并 exit 0,正是「跑得通但读不出版本号」
这个形状,且不依赖本机装了哪些 agent CLI。
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* feat(dsh): 无头 dsh 后端,自带事件 tap 插件
dsh(DeepSeek Harness)的 headless 只在结束时往 stdout 打一行最终文本——没有 JSON、
没有逐字流、没有工具事件。但这不是它缺数据:它内部的会话事件总线上 assistant/chunk、
tool/call、turn/end 一应俱全,只是 headless 汇总完就丢了。
dsh 是插件栈架构,`--patch` 是官方留给外部叠自己那层的入口。这里挂上一个订阅
session/event 的小插件,把需要的事件规范化成 NDJSON 打到 stderr,stdout 的「只打最终
文本」契约原样不动。挂载不侵入用户的 profile,插件随运行落到临时目录、跑完删掉。
上游耦合被刻意压缩在那个 30 行的 JS 里:它把 dsh 的内部事件翻译成我们自己定义的稳定
形状,上游改字段只需要改 JS,Rust 的解析和单测都不用动。tap 是纯增益——挂不上也只是
少了逐字流和工具展示,最终结果照样从 stdout 拿得到。
prompt 不进 argv:dsh 的启动器不认 `--` 作为选项终止符(实测 `dsh --profile headless
"--version"` 会真的去执行 --version),所以以 `-` 开头的转写会被劫持成 flag。既然本来
就要写 patch 文件,索性用它直接覆盖 runner 的 task 配置,prompt 连进程列表都不会出现
——这点比另外三家都严。
护栏走 DSH_PERMISSION_MODE 三档沙箱,沙箱根 = 子进程工作目录。和 Codex 一样没有逐命令
deny 清单,所以审批卡对它同样不生效,撞墙时如实报错。
模型解析永远返回 None:headless profile 没有 --model,模型由 dsh 自己的 profile 决定,
返回 Some 只会让调用方以为选得动。
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* feat(settings): Less Computer 后端下拉加入 dsh
第四项。它的能力面和另外三家不一样,设置页如实反映,不给假开关:
- 模型那一行**整行不显示**,换成一句说明:dsh 的 headless 没有模型开关,模型由它自己的
profile 决定,在这里改不了。给个选不动的下拉比不给更糟。
- 复用 Codex 那条沙箱护栏说明(两家都只有粗粒度档位、都不弹审批卡)。
- 装没装当场检测;可执行文件 placeholder 跟着后端走。
文案 5 语言齐全。
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* refactor(dsh): tap 插件抽成独立项目 dsh-jsonl,OpenLess 改消费它的公开 schema
原来这个插件是内嵌在 dsh.rs 里的一段 Rust 字符串,只发我们自己要的 4 类事件、用我们
自己的私有形状。它要开源出去(dsh 缺结构化输出,而上游不收 PR,第三方插件是这个能力
唯一可能存在的形式),于是有了「两份会各自跑偏的代码」这个问题。
解法是保证世界上只有一份这个 JS:
- 源头在 github.com/bigsongeth/dsh-jsonl,发完整的、有文档、有版本的事件词表。
- OpenLess 用 include_str! 内嵌一份逐字副本(vendor/dsh-jsonl.js),带版本标记。
内嵌而不是依赖 npm 包:用户装机即用,不需要网络、不需要 npm i,也不碰用户自己的
dsh profile 配置。
- Rust 侧改成解析那个公开 schema(text.delta / tool.call / turn.end),不再有私有形状。
用不上的事件按 schema 的兼容性约定忽略——上游加新事件不会把我们打挂。
**跨仓库没有任何自动同步**,这点没被消除,只是变便宜了:
- scripts/vendor-dsh-jsonl.sh 把同步变成一条命令
- 单测盯着 vendor 文件的版本标记与 VENDORED_DSH_JSONL_VERSION 一致,防止有人直接手改副本
- 插件报的 schema 大版本与我们支持的不一致时留警告日志(不中止:最终结果始终从 stdout
拿得到,流式展示只是锦上添花)
- 真的对不上时,live::tap_plugin_delivers_streaming_and_tool_events 会红
顺带把「禁止任何 import」的断言放宽成「只准 node: 内置」——公开插件要写文件,用了
node:fs,而 node: 内置不需要 node_modules,不影响从临时目录绝对路径加载。
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* test(dsh): 沙箱验收的靶子选错了,它一直是靠运气过的
那条「沙箱挡得住工作目录之外的写入」把靶子放在 $TMPDIR。但 dsh 的 workspace-write 把
可写根写死成 [工作目录, "/tmp", $TMPDIR](见 @deepseek-ai/dsh-sandbox 的 writableRoots)
——往 $TMPDIR 写**本来就是允许的**。
它之前能过,只是因为模型碰巧挑了受限更严的 `write` 工具;这次挑了 `bash`,文件就真的
落下去了。也就是说这条测试的成败取决于模型当次的选择,不取决于沙箱是否失效。
改成打真正的边界(家目录),实测会被拒:
bash: /Users/…: Operation not permitted [sandbox: file access denied under workspace-write mode]
另加一条把 dsh 的既有行为钉成契约:$TMPDIR 可写,且**没有配置项能收掉**——Codex 那边
有 -c sandbox_workspace_write.exclude_* 可以收紧,dsh 没有对应物。哪天 dsh 收紧了这条
会红,那是好消息,届时删掉它并更新文档。
所以对用户的措辞只能是「撞到限制会如实报错」,不能说成「写入限制在工作目录内」。
模块文档里把这个边界写清楚了。
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(dsh): 收紧运行协议与权限边界
* fix(dsh): 修复权限与连续对话审查项
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Chris233 <h-chris233@outlook.com>