dsh-acp-enhanced 0.3.0 → 0.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README-zh.md ADDED
@@ -0,0 +1,211 @@
1
+ **[English](README.md) | 中文**
2
+
3
+ # dsh-acp-enhanced
4
+
5
+ 面向 [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness)(dsh)的增强版
6
+ [Agent Client Protocol](https://agentclientprotocol.com)(ACP)服务器,为 **Zed** 等 ACP
7
+ 编辑器设计。它是官方 `@deepseek-ai/dsh-acp` 桥接器的即插即用替代品:官方桥只做纯文本
8
+ 输出,本桥把 Web GUI 的能力(流式、遥测、模型/权限控制、会话管理、MCP)全部暴露到
9
+ ACP 线上。
10
+
11
+ ## 特性
12
+
13
+ ### 输出与遥测
14
+
15
+ - **块级流式 + 推理流式**:文本块与思考过程实时到达(`agent_message_chunk` /
16
+ `agent_thought_chunk`),取消/重试不留半截输出
17
+ - **完整遥测**:上下文用量环 + 缓存命中率 / TPS / 输入-输出-推理 token / 工具耗时 /
18
+ 轮次计数(`usage_update._meta` 携带全量明细)
19
+
20
+ ### 模型与权限
21
+
22
+ - **模型切换**:实时 `provider/model` 目录下拉(按 ACP 规范分组线格式)
23
+ - **推理强度**:`reasoning_effort` 下拉——仅当当前路由暴露可选 efforts 时出现
24
+ - **权限预设**:read-only / workspace-write / full-access 三种会话模式
25
+ - **审批**:工具调用弹出原生 allow-once / reject-once 审批
26
+
27
+ ### Zed 深度集成
28
+
29
+ - **工具卡片**:折叠态即显示一行摘要——`Read <路径>`、执行的命令、`Search: <模式>`、
30
+ `Fetch: <URL>` 等;展开可见每次调用的完整参数与结果预览(`rawInput` / `rawOutput`),
31
+ 按工具类型渲染图标
32
+ - **Zed 文件与终端**:`zed_read_text_file` / `zed_write_text_file` / `zed_terminal` 把
33
+ 文件编辑放进 Zed 的"编辑文件"区(diff + 接受/拒绝)、命令跑在 Zed 真实终端
34
+ - **原生表单提问**:`ask_user_question` → `elicitation/create` 表单,选项即点即答
35
+ - **Plan 面板**:plan mode 开关 → Zed 底部"规划中"状态条
36
+
37
+ ### 会话
38
+
39
+ - **恢复与归档**:`session/load` 恢复历史线程(完整回放);`session/list` /
40
+ `session/delete` 管理线程归档(带标题、按更新时间排序);标题实时推送
41
+
42
+ ### 命令
43
+
44
+ - **Slash 命令**:输入 `/` 即可见命令列表(`available_commands_update`):`/status`
45
+ 查看路由与遥测、`/model` 列出或切换模型(列表以等宽代码块排版,一眼全见),其余
46
+ (`/compact` `/goal` `/permission` `/plan`…)直通 harness 命令注册表,全部**不经过
47
+ 模型 turn** 即时执行;未解析的 slash 放行给模型(`/skill-name` 技能手势)
48
+
49
+ ### MCP
50
+
51
+ - **MCP servers**:`session/new` 的 `mcpServers` 挂载任意 MCP server(stdio +
52
+ streamable HTTP),工具以 `mcp__<server>__<tool>` 注入;失败的 server 不会拖垮会话
53
+
54
+ ## 效果预览
55
+
56
+ 在 Zed 的 AI Agent 面板中选择 **dsh-acp-enhanced** 后:
57
+
58
+ <img src="assets/screenshots/approval-config-context.png" width="560">
59
+
60
+ <img src="assets/screenshots/tool-cards-elicitation.png" width="560">
61
+
62
+ ## 快速开始
63
+
64
+ 本包遵循 dsh 官方插件规范(声明了 `dsh.bundle`),安装与官方组合包一致:**一条命令**
65
+ 完成,自动初始化 profile、安装包、追加 bundle 层,全程无需手写 profile YAML。
66
+
67
+ ### 安装(2 步)
68
+
69
+ **第 1 步:安装**(从 npm registry,无需下载源码)
70
+
71
+ ```sh
72
+ dsh plugin --profile acp-enhanced add dsh-acp-enhanced
73
+ ```
74
+
75
+ > 开发/改源码时用 `link:` 指向本地 checkout(改动实时生效):
76
+ > `dsh plugin --profile acp-enhanced add "link:/absolute/path/to/dsh-acp-enhanced"`
77
+
78
+ **第 2 步:注册进 Zed**(在 `~/.config/zed/settings.json` 的 `agent_servers` 里注册;
79
+ Zed 会用极简 PATH 拉起 agent,因此用随附启动器 `scripts/dsh-acp-zed.sh` 定位
80
+ `node`/`dsh`)
81
+
82
+ > **启动器随包发布**,绝对路径取决于第 1 步的安装方式:
83
+ > - **npm 安装(默认)**:`$HOME/.dsh/profiles/acp-enhanced/node_modules/dsh-acp-enhanced/scripts/dsh-acp-zed.sh`。Zed 不会展开 `~` 或环境变量,请把 `$HOME` 换成你的用户目录(如 `/Users/you`)后写全绝对路径。
84
+ > - **`link:` 开发安装**:`<你的 checkout 路径>/scripts/dsh-acp-zed.sh`。
85
+
86
+ #### 最常见:DeepSeek 官方 API(默认路由)
87
+
88
+ ```jsonc
89
+ {
90
+ // ...你已有的设置...
91
+ "agent_servers": {
92
+ "dsh-acp-enhanced": {
93
+ "type": "custom",
94
+ "command": "/bin/bash",
95
+ "args": ["/absolute/path/to/dsh-acp-enhanced/scripts/dsh-acp-zed.sh"],
96
+ "env": {
97
+ "DSH_ACP_PROVIDER": "deepseek-official", // 官方 provider id
98
+ "DSH_ACP_MODEL": "deepseek-v4-flash" // 官方模型 id
99
+ }
100
+ }
101
+ }
102
+ }
103
+ ```
104
+
105
+ > 这两项 env 与包自带 patch 的缺省值一致,**省略也能工作**——显式写上只是让路由意图
106
+ > 一目了然。API key 不必写进 Zed:存入 `~/.dsh/.credentials.yaml`
107
+ > (`DEEPSEEK_API_KEY`)由 dsh 凭据服务解析即可;启动脚本还会兜底继承正在运行的
108
+ > `dsh web` 进程的 key。
109
+
110
+ 可选:固定面板默认项(都可随时在面板里改):
111
+
112
+ ```jsonc
113
+ "dsh-acp-enhanced": {
114
+ // ...上面的 type/command/args/env...
115
+ "default_config_options": {
116
+ "model": "deepseek-official/deepseek-v4-flash",
117
+ "plan_mode": false,
118
+ "reasoning_effort": "high"
119
+ },
120
+ "favorite_config_option_values": {
121
+ "model": ["deepseek-official/deepseek-v4-flash", "deepseek-official/deepseek-v4-pro"]
122
+ }
123
+ }
124
+ ```
125
+
126
+ #### 扩展:走 OpenAI-Responses 网关(如公司内部模型网关)
127
+
128
+ 同一安装路径,只是 env 换成网关暴露的 provider/model 与它要求的 key 环境变量名:
129
+
130
+ ```jsonc
131
+ "dsh-acp-enhanced": {
132
+ "type": "custom",
133
+ "command": "/bin/bash",
134
+ "args": ["/absolute/path/to/dsh-acp-enhanced/scripts/dsh-acp-zed.sh"],
135
+ "env": {
136
+ "DSH_ACP_PROVIDER": "<gateway-provider-id>", // 网关暴露的 provider id
137
+ "DSH_ACP_MODEL": "<gateway-model-id>", // 网关暴露的 model id
138
+ "<KEY_ENV_NAME>": "<key>" // 网关声明读取的 key 环境变量名
139
+ }
140
+ }
141
+ ```
142
+
143
+ > `<KEY_ENV_NAME>` 也可以省掉,把 key 存进 `~/.dsh/.credentials.yaml` 统一管理。
144
+
145
+ Zed 会热重载设置。打开 **AI Agent 面板**(`Cmd+Shift+A`)→ agent 选择器选
146
+ **dsh-acp-enhanced** → 输入第一条消息即可:回复实时流式返回,状态栏显示上下文用量,
147
+ 面板顶部有 Model / Permission preset / Plan mode 配置项与三种模式,线程归档可恢复
148
+ 历史会话。
149
+
150
+ 本地验证(无需 Zed):
151
+
152
+ ```sh
153
+ node scripts/acp-client.mjs # 官方默认路由,无需 env;期望 ALL CHECKS PASSED
154
+ DSH_ACP_PROVIDER=... DSH_ACP_MODEL=... node scripts/acp-client.mjs # 自定义路由时再传
155
+ ```
156
+
157
+ ### 可选:web_search 走同一个网关
158
+
159
+ 若网关实现 OpenAI Responses 的 `web_search` 服务端工具,可把搜索也路由到网关(复用
160
+ 同一凭据)。装子包并给 profile 的 `cordis.patch.yml` 追加两段:
161
+
162
+ ```sh
163
+ dsh plugin --profile acp-enhanced add dsh-web-search-openrouter
164
+ ```
165
+
166
+ ```yaml
167
+ - id: web
168
+ config:
169
+ searchProvider: openai-responses # 子包注册在 ctx.web 上的搜索 provider id(固定值)
170
+
171
+ - insert:
172
+ - id: web-search-openrouter
173
+ name: 'dsh-web-search-openrouter'
174
+ config:
175
+ enabled: true
176
+ baseURL: http://<gateway-host>:<port>/v1
177
+ model: <your-model-id>
178
+ apiKeyEnv: <KEY_ENV_NAME>
179
+ ```
180
+
181
+ > ⚠️ `searchProvider` 必须**精确等于** `openai-responses`——这是
182
+ > `dsh-web-search-openrouter` 注册在 `ctx.web` 上的搜索 provider id,**不是**网关的
183
+ > LLM provider id(即上面 `DSH_ACP_PROVIDER` 填的那个)。web 插件按 id 精确匹配,
184
+ > 填错时配置期不会报错,直到首次搜索才抛 `WEB_PROVIDER_CONFIGURED_MISSING`。
185
+
186
+ ## 故障排查
187
+
188
+ | 症状 | 处理 |
189
+ |---|---|
190
+ | `exec: dsh: not found`(status 127) | 用随附 `dsh-acp-zed.sh` 启动器(自定位 node/dsh) |
191
+ | `no API key for provider route "xxx"` | 写入 `~/.dsh/.credentials.yaml`,或在 agent_servers 里设 `env.DEEPSEEK_API_KEY` |
192
+ | 无法切换模型 / 上下文用量不显示 | 选到了不可路由的"幽灵 provider";本桥默认过滤(只广播 `config.provider` 的模型),确认 profile 的 provider 指向真实路由 |
193
+ | 需要详细诊断 | `ACP_DEBUG=1 dsh --profile acp-enhanced`(stderr 生命周期 trace) |
194
+
195
+ ## 开发
196
+
197
+ ```sh
198
+ node scripts/acp-client.mjs # 端到端冒烟(需要 API key)
199
+ node scripts/acp-client-tools.mjs # 客户端工具测试(模拟 Zed 的 fs/terminal/elicitation/plan)
200
+ node scripts/acp-mcp-test.mjs # MCP 挂载测试(无模型调用)
201
+ node scripts/acp-smoke-keyless.mjs # keyless 冒烟(CI 用)
202
+ node scripts/acp-resume-test.mjs # 会话恢复测试
203
+ ```
204
+
205
+ ## 已知限制
206
+
207
+ 仅 baseline prompt(无图片/音频附件)、不支持 `additionalDirectories`、文本按块粒度
208
+ 流式、每会话同时一个 in-flight prompt。MCP 支持 stdio 与 streamable HTTP(不声明
209
+ legacy SSE / `acp` 传输)。`session/close` / `session/fork` / `session/resume` 未实现
210
+ (不声明能力,合规客户端不会调用);`session/delete` 因 dsh 持久化无官方删除 API,
211
+ 采用直接删除后端目录的方式。
package/README.md CHANGED
@@ -1,118 +1,129 @@
1
- **[English](README-en.md) | 中文**
1
+ **[中文](README-zh.md) | English**
2
2
 
3
3
  # dsh-acp-enhanced
4
4
 
5
- 面向 [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness)(dsh)的增强版
6
- [Agent Client Protocol](https://agentclientprotocol.com)(ACP)服务器,为 **Zed** 等 ACP
7
- 编辑器设计。它是官方 `@deepseek-ai/dsh-acp` 桥接器的即插即用替代品:官方桥只做纯文本
8
- 输出,本桥把 Web GUI 的能力(流式、遥测、模型/权限控制、会话管理、MCP)全部暴露到
9
- ACP 线上。
5
+ An enhanced [Agent Client Protocol](https://agentclientprotocol.com) (ACP) server for
6
+ [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) (dsh), built for ACP
7
+ editors like **Zed**. It is a drop-in replacement for the official `@deepseek-ai/dsh-acp`
8
+ bridge: the official bridge only streams plain text, this one exposes the Web GUI's
9
+ capabilities — streaming, telemetry, model/permission control, session management, MCP —
10
+ over the ACP wire.
10
11
 
11
- ## 特性
12
+ ## Features
12
13
 
13
- ### 输出与遥测
14
+ ### Output & telemetry
14
15
 
15
- - **块级流式 + 推理流式**:文本块与思考过程实时到达(`agent_message_chunk` /
16
- `agent_thought_chunk`),取消/重试不留半截输出
17
- - **完整遥测**:上下文用量环 + 缓存命中率 / TPS / 输入-输出-推理 token / 工具耗时 /
18
- 轮次计数(`usage_update._meta` 携带全量明细)
16
+ - **Block + reasoning streaming**: text blocks and the model's thinking arrive live
17
+ (`agent_message_chunk` / `agent_thought_chunk`); cancelled/retried attempts never leak
18
+ torn output
19
+ - **Full telemetry**: context usage ring plus cache hit rate / TPS / input-output-reasoning
20
+ tokens / tool timing / turn counts (`usage_update._meta` carries the full breakdown)
19
21
 
20
- ### 模型与权限
22
+ ### Model & permissions
21
23
 
22
- - **模型切换**:实时 `provider/model` 目录下拉(按 ACP 规范分组线格式)
23
- - **推理强度**:`reasoning_effort` 下拉——仅当当前路由暴露可选 efforts 时出现
24
- - **权限预设**:read-only / workspace-write / full-access 三种会话模式
25
- - **审批**:工具调用弹出原生 allow-once / reject-once 审批
24
+ - **Model switching**: live `provider/model` catalog dropdown (ACP grouped-select wire shape)
25
+ - **Reasoning effort**: `reasoning_effort` dropdown — only when the routed model exposes
26
+ selectable efforts
27
+ - **Permission presets**: read-only / workspace-write / full-access session modes
28
+ - **Approval**: native allow-once / reject-once prompts per tool call
26
29
 
27
- ### Zed 深度集成
30
+ ### Zed deep integration
28
31
 
29
- - **工具卡片**:展开可见每次调用的完整参数与结果预览(`rawInput` / `rawOutput`),
30
- 按工具类型渲染图标
31
- - **Zed 文件与终端**:`zed_read_text_file` / `zed_write_text_file` / `zed_terminal` 把
32
- 文件编辑放进 Zed 的"编辑文件"区(diff + 接受/拒绝)、命令跑在 Zed 真实终端
33
- - **原生表单提问**:`ask_user_question` → `elicitation/create` 表单,选项即点即答
34
- - **Plan 面板**:plan mode 开关 → Zed 底部"规划中"状态条
32
+ - **Tool cards**: one-line summary in the collapsed header — `Read <path>`, the
33
+ executed command, `Search: <pattern>`, `Fetch: <url>`, etc. — with the full
34
+ arguments and result preview (`rawInput` / `rawOutput`) one click away, plus
35
+ per-kind icons
36
+ - **Zed files & terminal**: `zed_read_text_file` / `zed_write_text_file` / `zed_terminal`
37
+ put file edits into Zed's "edited files" area (diff + accept/reject) and commands into a
38
+ real Zed terminal
39
+ - **Native form questions**: `ask_user_question` → `elicitation/create` form, click an
40
+ option, no typing
41
+ - **Plan panel**: plan mode toggle → "planning" status bar in Zed
35
42
 
36
- ### 会话
43
+ ### Sessions
37
44
 
38
- - **恢复与归档**:`session/load` 恢复历史线程(完整回放);`session/list` /
39
- `session/delete` 管理线程归档(带标题、按更新时间排序);标题实时推送
45
+ - **Resume & archive**: `session/load` restores past threads (full replay);
46
+ `session/list` / `session/delete` manage the thread archive (titled, sorted by last
47
+ activity); live title updates
40
48
 
41
- ### 命令
49
+ ### Commands
42
50
 
43
- - **Slash 命令**:输入 `/` 即可见命令列表(`available_commands_update`):`/status`
44
- 查看路由与遥测、`/model` 列出或切换模型,其余(`/compact` `/goal` `/permission`
45
- `/plan`…)直通 harness 命令注册表,全部**不经过模型 turn** 即时执行;未解析的
46
- slash 放行给模型(`/skill-name` 技能手势)
51
+ - **Slash commands**: typing `/` reveals the command list (`available_commands_update`):
52
+ `/status` shows the route and telemetry, `/model` lists or switches the model (listings
53
+ render as monospace code blocks — readable at a glance), everything
54
+ else (`/compact` `/goal` `/permission` `/plan`…) runs straight through the harness
55
+ command registry — all executed **without a model turn**; unresolved slashes fall
56
+ through to the model (the `/skill-name` skill gesture)
47
57
 
48
58
  ### MCP
49
59
 
50
- - **MCP servers**:`session/new` 的 `mcpServers` 挂载任意 MCP server(stdio +
51
- streamable HTTP),工具以 `mcp__<server>__<tool>` 注入;失败的 server 不会拖垮会话
60
+ - **MCP servers**: `session/new` `mcpServers` mount any MCP server (stdio + streamable
61
+ HTTP); tools join as `mcp__<server>__<tool>`; a failing server never takes the session
62
+ down
52
63
 
53
- ## 效果预览
64
+ ## Preview
54
65
 
55
- 在 Zed 的 AI Agent 面板中选择 **dsh-acp-enhanced** 后:
66
+ After picking **dsh-acp-enhanced** in Zed's AI Agent panel:
56
67
 
57
- <img src="assets/screenshots/approval-config-context.png" alt="审批弹窗与模型/推理强度切换、上下文环" width="560">
68
+ <img src="assets/screenshots/approval-config-context.png" width="560">
58
69
 
59
- - 工具调用需要许可时弹出**原生审批弹窗**;输入框下方是模型、推理强度、权限预设、
60
- Plan mode 配置项与上下文用量环。
70
+ <img src="assets/screenshots/tool-cards-elicitation.png" width="560">
61
71
 
62
- <img src="assets/screenshots/tool-cards-elicitation.png" alt="工具调用入参与输出、Zed 原生提问表单" width="320">
72
+ ## Quick start
63
73
 
64
- - **工具卡片**可展开查看完整入参与结果预览;DSH 需要确认/选择时以 **Zed 原生表单**
65
- 弹出,选项即点即答。
74
+ This package follows the official dsh plugin conventions (it declares `dsh.bundle`), so
75
+ installation matches any official bundle: **one command** — auto-initializes the profile,
76
+ installs the package, appends the bundle layer; no profile YAML to write.
66
77
 
67
- ## 快速开始
78
+ ### Install (2 steps)
68
79
 
69
- 本包遵循 dsh 官方插件规范(声明了 `dsh.bundle`),安装与官方组合包一致:**一条命令**
70
- 完成,自动初始化 profile、安装包、追加 bundle 层,全程无需手写 profile YAML。
71
-
72
- ### 安装(2 步)
73
-
74
- **第 1 步:安装**(从 npm registry,无需下载源码)
80
+ **Step 1 — install** (from the npm registry; no source checkout needed):
75
81
 
76
82
  ```sh
77
83
  dsh plugin --profile acp-enhanced add dsh-acp-enhanced
78
84
  ```
79
85
 
80
- > 开发/改源码时用 `link:` 指向本地 checkout(改动实时生效):
86
+ > When hacking on the code, use `link:` to a local checkout instead (live edits):
81
87
  > `dsh plugin --profile acp-enhanced add "link:/absolute/path/to/dsh-acp-enhanced"`
82
88
 
83
- **第 2 步:注册进 Zed**(在 `~/.config/zed/settings.json` 的 `agent_servers` 里注册;
84
- Zed 会用极简 PATH 拉起 agent,因此用随附启动器 `scripts/dsh-acp-zed.sh` 定位
85
- `node`/`dsh`)
89
+ **Step 2 — register in Zed** (under `agent_servers` in `~/.config/zed/settings.json`;
90
+ Zed spawns agents with a minimal PATH, so use the shipped launcher
91
+ `scripts/dsh-acp-zed.sh`, which locates `node`/`dsh` itself)
86
92
 
87
- #### 最常见:DeepSeek 官方 API(默认路由)
93
+ > **The launcher ships with the package.** Its absolute path depends on how you
94
+ > installed in Step 1:
95
+ > - **npm install (default)**: `$HOME/.dsh/profiles/acp-enhanced/node_modules/dsh-acp-enhanced/scripts/dsh-acp-zed.sh` — replace `$HOME` with your home directory (e.g. `/Users/you`); Zed does not expand `~` or env vars, so write the full literal path.
96
+ > - **`link:` dev install**: `<your checkout>/scripts/dsh-acp-zed.sh`.
97
+
98
+ #### Most common: DeepSeek official API (the default route)
88
99
 
89
100
  ```jsonc
90
101
  {
91
- // ...你已有的设置...
102
+ // ...your existing settings...
92
103
  "agent_servers": {
93
104
  "dsh-acp-enhanced": {
94
105
  "type": "custom",
95
106
  "command": "/bin/bash",
96
107
  "args": ["/absolute/path/to/dsh-acp-enhanced/scripts/dsh-acp-zed.sh"],
97
108
  "env": {
98
- "DSH_ACP_PROVIDER": "deepseek-official", // 官方 provider id
99
- "DSH_ACP_MODEL": "deepseek-v4-flash" // 官方模型 id
109
+ "DSH_ACP_PROVIDER": "deepseek-official", // the official provider id
110
+ "DSH_ACP_MODEL": "deepseek-v4-flash" // the official model id
100
111
  }
101
112
  }
102
113
  }
103
114
  }
104
115
  ```
105
116
 
106
- > 这两项 env 与包自带 patch 的缺省值一致,**省略也能工作**——显式写上只是让路由意图
107
- > 一目了然。API key 不必写进 Zed:存入 `~/.dsh/.credentials.yaml`
108
- > (`DEEPSEEK_API_KEY`)由 dsh 凭据服务解析即可;启动脚本还会兜底继承正在运行的
109
- > `dsh web` 进程的 key。
117
+ > Both env vars match the shipped patch's defaults, so **they can be omitted entirely** —
118
+ > writing them out just makes the route explicit. The API key does not have to live in Zed:
119
+ > store it in `~/.dsh/.credentials.yaml` (`DEEPSEEK_API_KEY`) and the dsh credentials
120
+ > service resolves it; the launcher also falls back to a running `dsh web` process's key.
110
121
 
111
- 可选:固定面板默认项(都可随时在面板里改):
122
+ Optional: pin the panel's default config options (all still changeable in the panel):
112
123
 
113
124
  ```jsonc
114
125
  "dsh-acp-enhanced": {
115
- // ...上面的 type/command/args/env...
126
+ // ...the type/command/args/env above...
116
127
  "default_config_options": {
117
128
  "model": "deepseek-official/deepseek-v4-flash",
118
129
  "plan_mode": false,
@@ -124,9 +135,10 @@ Zed 会用极简 PATH 拉起 agent,因此用随附启动器 `scripts/dsh-acp-z
124
135
  }
125
136
  ```
126
137
 
127
- #### 扩展:走 OpenAI-Responses 网关(如公司内部模型网关)
138
+ #### Extended: route through an OpenAI-Responses gateway (e.g. a company model gateway)
128
139
 
129
- 同一安装路径,只是 env 换成网关暴露的 provider/model 与它要求的 key 环境变量名:
140
+ Same install path; only the env values change to the provider/model the gateway exposes
141
+ plus the key env var it requires:
130
142
 
131
143
  ```jsonc
132
144
  "dsh-acp-enhanced": {
@@ -134,31 +146,34 @@ Zed 会用极简 PATH 拉起 agent,因此用随附启动器 `scripts/dsh-acp-z
134
146
  "command": "/bin/bash",
135
147
  "args": ["/absolute/path/to/dsh-acp-enhanced/scripts/dsh-acp-zed.sh"],
136
148
  "env": {
137
- "DSH_ACP_PROVIDER": "<gateway-provider-id>", // 网关暴露的 provider id
138
- "DSH_ACP_MODEL": "<gateway-model-id>", // 网关暴露的 model id
139
- "<KEY_ENV_NAME>": "<key>" // 网关声明读取的 key 环境变量名
149
+ "DSH_ACP_PROVIDER": "<gateway-provider-id>", // provider id exposed by the gateway
150
+ "DSH_ACP_MODEL": "<gateway-model-id>", // model id exposed by the gateway
151
+ "<KEY_ENV_NAME>": "<key>" // the key env var the gateway reads
140
152
  }
141
153
  }
142
154
  ```
143
155
 
144
- > `<KEY_ENV_NAME>` 也可以省掉,把 key 存进 `~/.dsh/.credentials.yaml` 统一管理。
156
+ > `<KEY_ENV_NAME>` can also be omitted and the key stored in
157
+ > `~/.dsh/.credentials.yaml` instead.
145
158
 
146
- Zed 会热重载设置。打开 **AI Agent 面板**(`Cmd+Shift+A`)→ agent 选择器选
147
- **dsh-acp-enhanced** → 输入第一条消息即可:回复实时流式返回,状态栏显示上下文用量,
148
- 面板顶部有 Model / Permission preset / Plan mode 配置项与三种模式,线程归档可恢复
149
- 历史会话。
159
+ Zed hot-reloads settings. Open the **AI Agent panel** (`Cmd+Shift+A`) → pick
160
+ **dsh-acp-enhanced** in the agent selector → send your first message: replies stream in
161
+ real time, the status bar shows context usage, the panel exposes Model / Permission preset
162
+ / Plan mode options plus three modes, and the thread archive lists and resumes past
163
+ sessions.
150
164
 
151
- 本地验证(无需 Zed):
165
+ Verify locally (no Zed needed):
152
166
 
153
167
  ```sh
154
- node scripts/acp-client.mjs # 官方默认路由,无需 env;期望 ALL CHECKS PASSED
155
- DSH_ACP_PROVIDER=... DSH_ACP_MODEL=... node scripts/acp-client.mjs # 自定义路由时再传
168
+ node scripts/acp-client.mjs # official default route, no env; expect ALL CHECKS PASSED
169
+ DSH_ACP_PROVIDER=... DSH_ACP_MODEL=... node scripts/acp-client.mjs # only for a custom route
156
170
  ```
157
171
 
158
- ### 可选:web_search 走同一个网关
172
+ ### Optional: route web_search through the same gateway
159
173
 
160
- 若网关实现 OpenAI Responses 的 `web_search` 服务端工具,可把搜索也路由到网关(复用
161
- 同一凭据)。装子包并给 profile 的 `cordis.patch.yml` 追加两段:
174
+ If the gateway implements the OpenAI Responses `web_search` server tool, you can route
175
+ search through it too (reusing the same credential). Install the sub-package and append
176
+ two blocks to the profile's `cordis.patch.yml`:
162
177
 
163
178
  ```sh
164
179
  dsh plugin --profile acp-enhanced add dsh-web-search-openrouter
@@ -167,7 +182,7 @@ dsh plugin --profile acp-enhanced add dsh-web-search-openrouter
167
182
  ```yaml
168
183
  - id: web
169
184
  config:
170
- searchProvider: <provider>
185
+ searchProvider: openai-responses # the search provider id this sub-package registers on ctx.web (fixed value)
171
186
 
172
187
  - insert:
173
188
  - id: web-search-openrouter
@@ -179,29 +194,36 @@ dsh plugin --profile acp-enhanced add dsh-web-search-openrouter
179
194
  apiKeyEnv: <KEY_ENV_NAME>
180
195
  ```
181
196
 
182
- ## 故障排查
197
+ > ⚠️ `searchProvider` must be **exactly** `openai-responses` — the search provider id
198
+ > `dsh-web-search-openrouter` registers on `ctx.web`. It is **not** your gateway's LLM
199
+ > provider id (the one you put in `DSH_ACP_PROVIDER` above). The `web` plugin matches it
200
+ > exactly, so a wrong value produces no error at config time and only fails at the first
201
+ > search with `WEB_PROVIDER_CONFIGURED_MISSING`.
202
+
203
+ ## Troubleshooting
183
204
 
184
- | 症状 | 处理 |
205
+ | Symptom | Fix |
185
206
  |---|---|
186
- | `exec: dsh: not found`(status 127) | 用随附 `dsh-acp-zed.sh` 启动器(自定位 node/dsh) |
187
- | `no API key for provider route "xxx"` | 写入 `~/.dsh/.credentials.yaml`,或在 agent_servers 里设 `env.DEEPSEEK_API_KEY` |
188
- | 无法切换模型 / 上下文用量不显示 | 选到了不可路由的"幽灵 provider";本桥默认过滤(只广播 `config.provider` 的模型),确认 profile 的 provider 指向真实路由 |
189
- | 需要详细诊断 | `ACP_DEBUG=1 dsh --profile acp-enhanced`(stderr 生命周期 trace) |
207
+ | `exec: dsh: not found` (status 127) | Use the shipped `dsh-acp-zed.sh` launcher (locates node/dsh itself) |
208
+ | `no API key for provider route "xxx"` | Write `~/.dsh/.credentials.yaml`, or set `env.DEEPSEEK_API_KEY` on the agent_servers entry |
209
+ | Cannot switch models / context usage missing | A "phantom provider" route was picked; this bridge filters them by default (only `config.provider`'s models are advertised) — point the profile's provider at a real route |
210
+ | Need detailed diagnostics | `ACP_DEBUG=1 dsh --profile acp-enhanced` (stderr lifecycle trace) |
190
211
 
191
- ## 开发
212
+ ## Development
192
213
 
193
214
  ```sh
194
- node scripts/acp-client.mjs # 端到端冒烟(需要 API key)
195
- node scripts/acp-client-tools.mjs # 客户端工具测试(模拟 Zed 的 fs/terminal/elicitation/plan)
196
- node scripts/acp-mcp-test.mjs # MCP 挂载测试(无模型调用)
197
- node scripts/acp-smoke-keyless.mjs # keyless 冒烟(CI 用)
198
- node scripts/acp-resume-test.mjs # 会话恢复测试
215
+ node scripts/acp-client.mjs # end-to-end smoke (needs an API key)
216
+ node scripts/acp-client-tools.mjs # client-tool tests (mocks Zed fs/terminal/elicitation/plan)
217
+ node scripts/acp-mcp-test.mjs # MCP mount test (no model calls)
218
+ node scripts/acp-smoke-keyless.mjs # keyless boot smoke (CI)
219
+ node scripts/acp-resume-test.mjs # session resume test
199
220
  ```
200
221
 
201
- ## 已知限制
222
+ ## Known limitations
202
223
 
203
- 仅 baseline prompt(无图片/音频附件)、不支持 `additionalDirectories`、文本按块粒度
204
- 流式、每会话同时一个 in-flight prompt。MCP 支持 stdio 与 streamable HTTP(不声明
205
- legacy SSE / `acp` 传输)。`session/close` / `session/fork` / `session/resume` 未实现
206
- (不声明能力,合规客户端不会调用);`session/delete` 因 dsh 持久化无官方删除 API,
207
- 采用直接删除后端目录的方式。
224
+ Baseline prompts only (no image/audio attachments), no `additionalDirectories`, text
225
+ streams at block granularity, one in-flight prompt per session. MCP supports stdio and
226
+ streamable HTTP (legacy SSE / `acp` transports are not advertised).
227
+ `session/close` / `session/fork` / `session/resume` are not implemented (capabilities
228
+ undeclared, compliant clients will not call them); `session/delete` removes the persisted
229
+ directory directly because dsh persistence has no official delete API.
package/lib/codec.js CHANGED
@@ -57,6 +57,40 @@ export function promptHasUnsupportedContent(prompt) {
57
57
  return prompt.some((block) => block.type !== 'text' && block.type !== 'resource_link')
58
58
  }
59
59
 
60
+ /** Kramdown attribute-style inline markup (SiYuan exports), including
61
+ * truncation-damaged tails — titles are byte-budgeted upstream, so a cut
62
+ * can land mid-attribute (unterminated `"` or no closing `]`):
63
+ * `[resource_link name="..." url="..."]`, `[file_link ...]`, `[ref ...]`. */
64
+ const KRAMDOWN_ATTR_MARKUP = /\[[^\]]*="[^\]]*\]|\[[^\]]*="[^\]]*$/g
65
+
66
+ /** Tight markdown link `[label](url)` — kept as the label only. */
67
+ const MARKDOWN_LINK = /\[([^\]]+)\]\(([^)\s]+)\)/g
68
+
69
+ /** Human-facing attribute kept when stripping kramdown markup; the closing
70
+ * quote is optional so a value truncated at the byte budget still survives. */
71
+ const ATTR_NAME_OR_TEXT = /(?:name|text)="([^"]*)"?/i
72
+
73
+ /**
74
+ * Sanitize a session title for display on the ACP wire. Titles derive from
75
+ * raw first-prompt text, which often carries pasted markup — SiYuan's
76
+ * kramdown `[resource_link ...]` / `[file_link ...]` / `[ref ...]` inline
77
+ * syntax (possibly truncated mid-markup), or plain markdown links. Keeps the
78
+ * human-facing name/text (or the markdown label) and collapses whitespace
79
+ * to a single line.
80
+ * @param input - untrusted title text (raw or upstream-normalized).
81
+ * @returns the cleaned title; `''` when nothing visible remains.
82
+ */
83
+ export function sanitizeWireTitle(input) {
84
+ return String(input ?? '')
85
+ .replace(MARKDOWN_LINK, '$1')
86
+ .replace(KRAMDOWN_ATTR_MARKUP, (match) => {
87
+ const keep = ATTR_NAME_OR_TEXT.exec(match)
88
+ return keep === null ? '' : keep[1]
89
+ })
90
+ .replace(/\s+/g, ' ')
91
+ .trim()
92
+ }
93
+
60
94
  /**
61
95
  * Compute the usage telemetry snapshot for one provider-reported usage sample.
62
96
  * @param usage - TokenUsage { inputTokens, outputTokens, cacheReadTokens?, cacheWriteTokens?, reasoningTokens? }.
package/lib/index.js CHANGED
@@ -31,6 +31,7 @@
31
31
 
32
32
  import { randomUUID } from 'node:crypto'
33
33
  import { rm } from 'node:fs/promises'
34
+ import { createRequire } from 'node:module'
34
35
  import { isAbsolute, dirname } from 'node:path'
35
36
  import { Readable, Writable } from 'node:stream'
36
37
  import Schema from '@deepseek-ai/schemastery'
@@ -39,7 +40,11 @@ import { createUserMessage, errorChain, ReasoningEffortId } from '@deepseek-ai/d
39
40
  import { installModelSelection } from '@deepseek-ai/dsh-agent'
40
41
  import { defineTool } from '@deepseek-ai/dsh-tools'
41
42
  import { SessionId } from '@deepseek-ai/dsh-session'
42
- import { acpPromptToText, promptHasUnsupportedContent, turnEndToStopReason, usageTelemetry } from './codec.js'
43
+ import { acpPromptToText, promptHasUnsupportedContent, sanitizeWireTitle, turnEndToStopReason, usageTelemetry } from './codec.js'
44
+
45
+ /** Agent version advertised on the ACP wire — read from package.json so the
46
+ * handshake can never drift from the released package version. */
47
+ const AGENT_VERSION = createRequire(import.meta.url)('../package.json').version
43
48
 
44
49
  export const name = 'acp-enhanced'
45
50
  /** The bridge creates and owns agents; every other concern is carried by the composition. */
@@ -98,6 +103,138 @@ function parseToolArguments(raw) {
98
103
  }
99
104
  }
100
105
 
106
+ /** Collapse whitespace and bound a string to one display line (Zed shows the
107
+ * tool-call header from the title, truncating overflow with an ellipsis). */
108
+ function clipOneLine(text, max) {
109
+ const oneLine = String(text).replace(/\s+/g, ' ').trim()
110
+ return oneLine.length > max ? `${oneLine.slice(0, max)}…` : oneLine
111
+ }
112
+
113
+ /** Escape markdown-significant characters so a dynamic title fragment (path,
114
+ * pattern, URL, query) renders literally inside Zed's markdown label. Zed
115
+ * applies no escaping to `other`/`read`/`search`/`fetch`/`think` kinds — it
116
+ * only escapes `edit` itself — so the bridge must keep emphasis/code/link
117
+ * syntax from being interpreted. Execute-kind titles render as plain text
118
+ * and are never escaped. */
119
+ function mdEscape(text) {
120
+ return String(text).replace(/([\\*_`[\]<>])/g, '\\$1')
121
+ }
122
+
123
+ /**
124
+ * One-line human-readable summary of a tool call for the ACP `title` field.
125
+ * Zed renders this as the collapsed tool-call header, so a bare tool name
126
+ * ("read", "bash") hides what actually happened until the card is expanded.
127
+ * Mirror Zed's own native-agent titles (`Read <path>`, `Fetch <url>`,
128
+ * `Search: <pattern>`, and the raw command for terminal tools) with the
129
+ * detail escaped and bounded to one line.
130
+ */
131
+ function toolCallTitle(name, argumentsValue) {
132
+ const obj = argumentsValue !== null && typeof argumentsValue === 'object' && !Array.isArray(argumentsValue)
133
+ ? argumentsValue
134
+ : {}
135
+ const str = (key) => (typeof obj[key] === 'string' ? obj[key].trim() : undefined)
136
+ const num = (key) => {
137
+ const value = obj[key]
138
+ if (typeof value === 'number' && Number.isFinite(value)) return value
139
+ if (typeof value === 'string' && value.trim() !== '' && Number.isFinite(Number(value))) {
140
+ return Number(value)
141
+ }
142
+ return undefined
143
+ }
144
+ const stringField = (value, key) => (
145
+ typeof value === 'object' && value !== null && typeof value[key] === 'string' ? value[key].trim() : undefined
146
+ )
147
+
148
+ // Execute-kind tools (plain-text label): show the command itself.
149
+ if (name === 'zed_terminal' || /bash|shell|exec|run_code|execute|terminal/.test(name)) {
150
+ // Only zed_terminal maps to kind 'execute' (Zed renders its label as plain
151
+ // text); local executors like bash/run_code are kind 'other' and render as
152
+ // markdown, so their command must be escaped to stay literal.
153
+ const literal = (text) => (name === 'zed_terminal' ? text : mdEscape(text))
154
+ const command = str('command') ?? str('cmd') ?? str('script')
155
+ if (command) return clipOneLine(literal(command), 100)
156
+ const argv = obj.args
157
+ if (Array.isArray(argv) && argv.length > 0) return clipOneLine(literal(argv.map(String).join(' ')), 100)
158
+ const cwd = str('cwd')
159
+ if (cwd) return clipOneLine(literal(`Run in ${cwd}`), 100)
160
+ }
161
+
162
+ // File-content tools: `Read <path>` (incl. line range when present).
163
+ if (/read|cat|show|view/.test(name)) {
164
+ const path = str('path') ?? str('file_path') ?? str('filePath') ?? str('file')
165
+ if (path) {
166
+ const range = [num('line') ?? num('offset'), num('limit')].filter((value) => value !== undefined)
167
+ const suffix = range.length === 2 && Number.isFinite(range[0]) && Number.isFinite(range[1]) && range[1] > 0
168
+ ? ` (lines ${range[0]}-${range[0] + range[1] - 1})`
169
+ : range.length === 1 && Number.isFinite(range[0]) && range[0] > 1
170
+ ? ` (from line ${range[0]})`
171
+ : ''
172
+ return clipOneLine(`Read ${mdEscape(path)}${suffix}`, 120)
173
+ }
174
+ }
175
+
176
+ // File-modifying tools: `Write <path>` / `Edit <path>`.
177
+ if (/write|edit|patch|apply/.test(name)) {
178
+ const path = str('path') ?? str('file_path') ?? str('filePath') ?? str('file') ?? str('target')
179
+ if (path) return clipOneLine(`Write ${mdEscape(path)}`, 120)
180
+ }
181
+
182
+ // Content search: `Search: <pattern>`.
183
+ if (/grep|rg|content_search|search_text/.test(name)) {
184
+ const pattern = str('pattern') ?? str('regex') ?? str('query')
185
+ if (pattern) return clipOneLine(`Search: ${mdEscape(pattern)}`, 120)
186
+ }
187
+
188
+ // Path search: `Find: <pattern>`.
189
+ if (/glob|find/.test(name)) {
190
+ const pattern = str('pattern') ?? str('query') ?? str('path')
191
+ if (pattern) return clipOneLine(`Find: ${mdEscape(pattern)}`, 120)
192
+ }
193
+
194
+ // Network fetch: `Fetch: <url>`.
195
+ if (/fetch|http/.test(name)) {
196
+ const url = str('url') ?? str('uri') ?? str('endpoint')
197
+ if (url) return clipOneLine(`Fetch: ${mdEscape(url)}`, 120)
198
+ }
199
+
200
+ // Generic web/data search: `Search: <query>`.
201
+ if (/search/.test(name)) {
202
+ const query = str('query') ?? str('q') ?? str('keyword') ?? str('keywords')
203
+ if (query) return clipOneLine(`Search: ${mdEscape(query)}`, 120)
204
+ }
205
+
206
+ // User questions: `Ask: <first question>`.
207
+ if (/ask|question|elicit/.test(name) && Array.isArray(obj.questions)) {
208
+ const first = obj.questions[0]
209
+ const text = typeof first === 'object' && first !== null
210
+ ? stringField(first, 'question') ?? stringField(first, 'header') ?? ''
211
+ : String(first ?? '')
212
+ if (text.trim().length > 0) return clipOneLine(`Ask: ${mdEscape(text)}`, 120)
213
+ }
214
+
215
+ // Subagents / delegated work: show the objective.
216
+ if (/spawn_agent|subagent|spawn|delegate|task /.test(name)) {
217
+ const description = str('description') ?? str('objective') ?? str('prompt')
218
+ if (description) return clipOneLine(mdEscape(description), 100)
219
+ }
220
+
221
+ // MCP tools: inline a single string-valued field (mirrors Zed's own MCP
222
+ // primary-argument heuristic); otherwise fall back to the tool name.
223
+ if (name.startsWith('mcp__')) {
224
+ const strings = Object.entries(obj)
225
+ .filter(([key, value]) => typeof value === 'string' && value.trim().length > 0)
226
+ if (strings.length === 1) {
227
+ return clipOneLine(mdEscape(strings[0][1].trim()), 120)
228
+ }
229
+ if (strings.length > 1) {
230
+ const [key, value] = strings[0]
231
+ return clipOneLine(`${key}=${mdEscape(value)}`, 120)
232
+ }
233
+ }
234
+
235
+ return name
236
+ }
237
+
101
238
  /** Extract a bounded text preview of a dsh tool result for ACP rawOutput. */
102
239
  function resultPreview(event) {
103
240
  if (event.data.error !== undefined) {
@@ -232,14 +369,18 @@ export function apply(ctx, config) {
232
369
  case 'tool/call': {
233
370
  record.toolStats.lastCallAt = Date.now()
234
371
  record.toolStats.lastName = event.data.name
372
+ const parsedArgs = parseToolArguments(event.data.arguments)
235
373
  notify({
236
374
  sessionId: session.header.id,
237
375
  update: {
238
376
  sessionUpdate: 'tool_call',
239
377
  toolCallId: event.data.callId,
240
- title: event.data.name,
378
+ // A one-line summary (not just the tool name) so Zed's collapsed
379
+ // header shows what the call is doing — which file is read, which
380
+ // command runs, what is searched — before expanding the card.
381
+ title: toolCallTitle(event.data.name, parsedArgs),
241
382
  kind: toolKindFor(event.data.name),
242
- rawInput: parseToolArguments(event.data.arguments),
383
+ rawInput: parsedArgs,
243
384
  _meta: {
244
385
  turn: event.data.turn,
245
386
  step: event.data.step,
@@ -282,7 +423,10 @@ export function apply(ctx, config) {
282
423
  if (event.data.contextWindow !== undefined) record.contextWindow = event.data.contextWindow
283
424
  break
284
425
  case 'session/title': {
285
- const title = event.data.title
426
+ // Titles derive from raw first-prompt text; sanitize before the wire
427
+ // so pasted markup (e.g. SiYuan `[resource_link ...]`) can't leak
428
+ // into Zed's thread title.
429
+ const title = sanitizeWireTitle(event.data.title)
286
430
  if (typeof title === 'string' && title.length > 0) {
287
431
  record.title = title
288
432
  publishSessionInfo(record, {
@@ -1037,19 +1181,35 @@ export function apply(ctx, config) {
1037
1181
  })
1038
1182
  }
1039
1183
 
1184
+ /**
1185
+ * Advertise the command surface *after* the current request response is
1186
+ * written. A fresh `session/new` id is generated server-side, so the client
1187
+ * cannot route session notifications for it until the response arrives; an
1188
+ * `available_commands_update` queued before that response is dropped and
1189
+ * the slash menu stays empty ("Available commands: none").
1190
+ */
1191
+ function publishCommandsAfterResponse(record) {
1192
+ setImmediate(() => {
1193
+ publishCommands(record).catch((error) => {
1194
+ logger.warn(`acp-enhanced: command broadcast failed: ${String(error)}`)
1195
+ })
1196
+ })
1197
+ }
1198
+
1040
1199
  /** Text summary of one session: route, turns, last usage telemetry. */
1041
1200
  async function statusText(record) {
1042
1201
  const selection = record.selection.current
1043
1202
  const last = record.lastUsage
1044
1203
  const lines = [
1045
- `route: ${selection.provider ?? '?'}/${selection.model ?? '?'}`,
1046
- `turns: ${record.turnCount}`,
1204
+ `route ${selection.provider ?? '?'}/${selection.model ?? '?'}`,
1205
+ `turns ${record.turnCount}`,
1047
1206
  ...last === undefined ? [] : [
1048
- `context: ${last.used}/${last.size}`,
1049
- `last usage: in ${last.meta.inputTokens ?? '?'} / out ${last.meta.outputTokens ?? '?'} / reasoning ${last.meta.reasoningTokens ?? '?'} tokens, cache hit ${last.meta.cacheHitRate ?? '?'}%, tps ${last.meta.tps ?? '?'}`,
1207
+ `context ${last.used}/${last.size}`,
1208
+ `last usage ${last.meta.inputTokens ?? '?'}/${last.meta.outputTokens ?? '?'} in/out, ${last.meta.reasoningTokens ?? '?'} reasoning, cache hit ${last.meta.cacheHitRate ?? '?'}%, ${last.meta.tps ?? '?'} tps`,
1050
1209
  ],
1051
1210
  ]
1052
- return lines.join('\n')
1211
+ // Monospace code block: aligned columns at a glance, no interaction needed.
1212
+ return ['```', ...lines, '```'].join('\n')
1053
1213
  }
1054
1214
 
1055
1215
  /** The /model command: list the live catalog or switch by exact id/substring. */
@@ -1061,7 +1221,8 @@ export function apply(ctx, config) {
1061
1221
  const mark = `${group.id}/${model.id}` === `${current.provider}/${current.model}` ? '* ' : ' '
1062
1222
  return `${mark}${group.id}/${model.id}`
1063
1223
  }))
1064
- return lines.length > 0 ? lines.join('\n') : 'no models available'
1224
+ // Monospace code block: aligned catalog, no interaction needed.
1225
+ return lines.length > 0 ? ['```', ...lines, '```'].join('\n') : 'no models available'
1065
1226
  }
1066
1227
  const needle = query.trim().toLowerCase()
1067
1228
  const matches = catalog.flatMap((group) => group.models.map((model) => ({ provider: group.id, model: model.id })))
@@ -1155,7 +1316,9 @@ export function apply(ctx, config) {
1155
1316
  // Malformed line — skip; titles live on well-formed rows.
1156
1317
  }
1157
1318
  }
1158
- return last
1319
+ // Stored titles are the raw upstream text (the sanitizer runs on the
1320
+ // live path only); clean them here so replay shows the same wire shape.
1321
+ return last === undefined ? undefined : sanitizeWireTitle(last)
1159
1322
  } catch {
1160
1323
  return undefined
1161
1324
  }
@@ -1233,13 +1396,14 @@ export function apply(ctx, config) {
1233
1396
  }
1234
1397
  break
1235
1398
  }
1236
- case 'tool/call':
1399
+ case 'tool/call': {
1400
+ const parsedArgs = parseToolArguments(event.data.arguments)
1237
1401
  await notifyNow(record, {
1238
1402
  sessionUpdate: 'tool_call',
1239
1403
  toolCallId: event.data.callId,
1240
- title: event.data.name,
1404
+ title: toolCallTitle(event.data.name, parsedArgs),
1241
1405
  kind: toolKindFor(event.data.name),
1242
- rawInput: parseToolArguments(event.data.arguments),
1406
+ rawInput: parsedArgs,
1243
1407
  _meta: {
1244
1408
  turn: event.data.turn,
1245
1409
  step: event.data.step,
@@ -1248,6 +1412,7 @@ export function apply(ctx, config) {
1248
1412
  },
1249
1413
  })
1250
1414
  break
1415
+ }
1251
1416
  case 'tool/result': {
1252
1417
  const preview = resultPreview(event)
1253
1418
  const callId = event.data.message?.content?.[0]?.toolCallId ?? event.data.callId
@@ -1291,7 +1456,7 @@ export function apply(ctx, config) {
1291
1456
  syncClientTools()
1292
1457
  return Promise.resolve({
1293
1458
  protocolVersion: PROTOCOL_VERSION,
1294
- agentInfo: { name: 'deepseek-harness-acp-enhanced', version: '0.2.0' },
1459
+ agentInfo: { name: 'deepseek-harness-acp-enhanced', version: AGENT_VERSION },
1295
1460
  agentCapabilities: {
1296
1461
  loadSession: true,
1297
1462
  sessionCapabilities: { list: {}, delete: {} },
@@ -1327,7 +1492,7 @@ export function apply(ctx, config) {
1327
1492
  const record = makeRecord(handle)
1328
1493
  sessions.set(sessionId, record)
1329
1494
  await syncMcpServers(params.mcpServers, params.cwd)
1330
- publishCommands(record)
1495
+ publishCommandsAfterResponse(record)
1331
1496
  const permission = permissionPresets()
1332
1497
  return {
1333
1498
  sessionId,
@@ -1389,7 +1554,7 @@ export function apply(ctx, config) {
1389
1554
  // Zed inserts the thread before the load RPC completes; replay the
1390
1555
  // conversation history as notifications so the thread renders.
1391
1556
  await replayHistory(record)
1392
- publishCommands(record)
1557
+ publishCommandsAfterResponse(record)
1393
1558
  const permission = permissionPresets()
1394
1559
  return {
1395
1560
  ...permission === undefined ? {} : {
@@ -1421,6 +1586,11 @@ export function apply(ctx, config) {
1421
1586
  const text = acpPromptToText(params.prompt)
1422
1587
  if (text.trim().length === 0) throw invalidParams('empty prompt')
1423
1588
 
1589
+ // Insurance: by the first prompt the client is guaranteed to know the
1590
+ // session, so re-advertise the command surface (idempotent) — covers
1591
+ // any client that missed the post-session/new broadcast.
1592
+ publishCommands(record)
1593
+
1424
1594
  // Adapter-level slash commands never reach the model: /status and
1425
1595
  // /model are built in, any other registered slash (compact/goal/
1426
1596
  // permission/plan/…) runs through the harness command registry
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-acp-enhanced",
3
- "version": "0.3.0",
3
+ "version": "0.3.2",
4
4
  "description": "Enhanced ACP server for DeepSeek Harness: block-level streaming, usage/stat telemetry (cache hit rate, token speed, input/output tokens, context length, turns, tool timing), model & reasoning-effort switching, and permission-preset control over the ACP wire (Zed-friendly)",
5
5
  "keywords": [
6
6
  "dsh",
@@ -23,9 +23,10 @@
23
23
  "files": [
24
24
  "lib",
25
25
  "profile",
26
+ "scripts/dsh-acp-zed.sh",
26
27
  "cordis.patch.yml",
27
28
  "README.md",
28
- "README-en.md",
29
+ "README-zh.md",
29
30
  "LICENSE"
30
31
  ],
31
32
  "scripts": {
@@ -0,0 +1,64 @@
1
+ #!/bin/bash
2
+ # Zed launcher for dsh-acp-enhanced.
3
+ #
4
+ # Zed (a GUI app) spawns agent processes with a minimal PATH that usually does
5
+ # NOT include node or dsh, so this wrapper locates both itself:
6
+ # - node: PATH, /opt/homebrew/bin, /usr/local/bin, ~/.nvm/versions/node/*
7
+ # - dsh: PATH, the npx cache (~/.npm/_npx/*/node_modules/.bin), the global
8
+ # npm prefix bin dir, /opt/homebrew/bin, /usr/local/bin
9
+ # and prepends the node dir to PATH so dsh's `#!/usr/bin/env node` shebang
10
+ # resolves.
11
+ #
12
+ # The profile resolves DEEPSEEK_API_KEY through the dsh credentials service
13
+ # (~/.dsh/.credentials.yaml), so no environment plumbing is required; an
14
+ # explicit DEEPSEEK_API_KEY from Zed's agent_servers.env wins, and a running
15
+ # `dsh web` process is a final fallback source.
16
+ #
17
+ # stdout stays the ACP JSON-RPC wire; diagnostics go to stderr.
18
+ set -u
19
+
20
+ NODE_BIN="$(command -v node 2>/dev/null || true)"
21
+ if [ -z "${NODE_BIN}" ]; then
22
+ for candidate in \
23
+ /opt/homebrew/bin/node \
24
+ /usr/local/bin/node \
25
+ "$HOME"/.nvm/versions/node/*/bin/node; do
26
+ if [ -x "${candidate}" ]; then
27
+ NODE_BIN="${candidate}"
28
+ break
29
+ fi
30
+ done
31
+ fi
32
+ if [ -n "${NODE_BIN}" ]; then
33
+ export PATH="$(dirname "${NODE_BIN}"):${PATH}"
34
+ fi
35
+
36
+ DASH_BIN="$(command -v dsh 2>/dev/null || true)"
37
+ if [ -z "${DASH_BIN}" ]; then
38
+ for candidate in \
39
+ "$HOME"/.npm/_npx/*/node_modules/.bin/dsh \
40
+ "$(npm prefix -g 2>/dev/null)/bin/dsh" \
41
+ /opt/homebrew/bin/dsh \
42
+ /usr/local/bin/dsh; do
43
+ if [ -x "${candidate}" ]; then
44
+ DASH_BIN="${candidate}"
45
+ break
46
+ fi
47
+ done
48
+ fi
49
+ if [ -z "${NODE_BIN}" ] || [ -z "${DASH_BIN}" ]; then
50
+ echo "dsh-acp-zed: cannot locate node and/or dsh (node='${NODE_BIN}' dsh='${DASH_BIN}'); install them or set PATH" >&2
51
+ exit 127
52
+ fi
53
+
54
+ if [ -z "${DEEPSEEK_API_KEY:-}" ]; then
55
+ WEB_PID="$(pgrep -f 'dsh web' | head -n 1)"
56
+ if [ -n "${WEB_PID}" ]; then
57
+ KEY="$(ps eww "${WEB_PID}" 2>/dev/null | tr ' ' '\n' | grep '^DEEPSEEK_API_KEY=' | cut -d= -f2-)"
58
+ if [ -n "${KEY}" ]; then
59
+ export DEEPSEEK_API_KEY="${KEY}"
60
+ fi
61
+ fi
62
+ fi
63
+
64
+ exec "${DASH_BIN}" --profile acp-enhanced "$@"
package/README-en.md DELETED
@@ -1,223 +0,0 @@
1
- **[中文](README.md) | English**
2
-
3
- # dsh-acp-enhanced
4
-
5
- An enhanced [Agent Client Protocol](https://agentclientprotocol.com) (ACP) server for
6
- [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) (dsh), built for ACP
7
- editors like **Zed**. It is a drop-in replacement for the official `@deepseek-ai/dsh-acp`
8
- bridge: the official bridge only streams plain text, this one exposes the Web GUI's
9
- capabilities — streaming, telemetry, model/permission control, session management, MCP —
10
- over the ACP wire.
11
-
12
- ## Features
13
-
14
- ### Output & telemetry
15
-
16
- - **Block + reasoning streaming**: text blocks and the model's thinking arrive live
17
- (`agent_message_chunk` / `agent_thought_chunk`); cancelled/retried attempts never leak
18
- torn output
19
- - **Full telemetry**: context usage ring plus cache hit rate / TPS / input-output-reasoning
20
- tokens / tool timing / turn counts (`usage_update._meta` carries the full breakdown)
21
-
22
- ### Model & permissions
23
-
24
- - **Model switching**: live `provider/model` catalog dropdown (ACP grouped-select wire shape)
25
- - **Reasoning effort**: `reasoning_effort` dropdown — only when the routed model exposes
26
- selectable efforts
27
- - **Permission presets**: read-only / workspace-write / full-access session modes
28
- - **Approval**: native allow-once / reject-once prompts per tool call
29
-
30
- ### Zed deep integration
31
-
32
- - **Tool cards**: expand to see each call's full arguments and result preview
33
- (`rawInput` / `rawOutput`), with per-kind icons
34
- - **Zed files & terminal**: `zed_read_text_file` / `zed_write_text_file` / `zed_terminal`
35
- put file edits into Zed's "edited files" area (diff + accept/reject) and commands into a
36
- real Zed terminal
37
- - **Native form questions**: `ask_user_question` → `elicitation/create` form, click an
38
- option, no typing
39
- - **Plan panel**: plan mode toggle → "planning" status bar in Zed
40
-
41
- ### Sessions
42
-
43
- - **Resume & archive**: `session/load` restores past threads (full replay);
44
- `session/list` / `session/delete` manage the thread archive (titled, sorted by last
45
- activity); live title updates
46
-
47
- ### Commands
48
-
49
- - **Slash commands**: typing `/` reveals the command list (`available_commands_update`):
50
- `/status` shows the route and telemetry, `/model` lists or switches the model, everything
51
- else (`/compact` `/goal` `/permission` `/plan`…) runs straight through the harness
52
- command registry — all executed **without a model turn**; unresolved slashes fall
53
- through to the model (the `/skill-name` skill gesture)
54
-
55
- ### MCP
56
-
57
- - **MCP servers**: `session/new` `mcpServers` mount any MCP server (stdio + streamable
58
- HTTP); tools join as `mcp__<server>__<tool>`; a failing server never takes the session
59
- down
60
-
61
- ## Preview
62
-
63
- After picking **dsh-acp-enhanced** in Zed's AI Agent panel:
64
-
65
- <img src="assets/screenshots/approval-config-context.png" alt="Approval popup, model/reasoning-effort switches, context ring" width="560">
66
-
67
- - Tool calls that need permission pop a **native approval prompt**; below the input box sit
68
- the model, reasoning effort, permission preset, plan mode options and the context usage
69
- ring.
70
-
71
- <img src="assets/screenshots/tool-cards-elicitation.png" alt="Tool call inputs and outputs, native Zed question form" width="320">
72
-
73
- - **Tool cards** expand to show full arguments and result previews; when dsh needs your
74
- confirmation or a choice, the question arrives as a **native Zed form** — click an
75
- option, no typing.
76
-
77
- ## Quick start
78
-
79
- This package follows the official dsh plugin conventions (it declares `dsh.bundle`), so
80
- installation matches any official bundle: **one command** — auto-initializes the profile,
81
- installs the package, appends the bundle layer; no profile YAML to write.
82
-
83
- ### Install (2 steps)
84
-
85
- **Step 1 — install** (from the npm registry; no source checkout needed):
86
-
87
- ```sh
88
- dsh plugin --profile acp-enhanced add dsh-acp-enhanced
89
- ```
90
-
91
- > When hacking on the code, use `link:` to a local checkout instead (live edits):
92
- > `dsh plugin --profile acp-enhanced add "link:/absolute/path/to/dsh-acp-enhanced"`
93
-
94
- **Step 2 — register in Zed** (under `agent_servers` in `~/.config/zed/settings.json`;
95
- Zed spawns agents with a minimal PATH, so use the shipped launcher
96
- `scripts/dsh-acp-zed.sh`, which locates `node`/`dsh` itself)
97
-
98
- #### Most common: DeepSeek official API (the default route)
99
-
100
- ```jsonc
101
- {
102
- // ...your existing settings...
103
- "agent_servers": {
104
- "dsh-acp-enhanced": {
105
- "type": "custom",
106
- "command": "/bin/bash",
107
- "args": ["/absolute/path/to/dsh-acp-enhanced/scripts/dsh-acp-zed.sh"],
108
- "env": {
109
- "DSH_ACP_PROVIDER": "deepseek-official", // the official provider id
110
- "DSH_ACP_MODEL": "deepseek-v4-flash" // the official model id
111
- }
112
- }
113
- }
114
- }
115
- ```
116
-
117
- > Both env vars match the shipped patch's defaults, so **they can be omitted entirely** —
118
- > writing them out just makes the route explicit. The API key does not have to live in Zed:
119
- > store it in `~/.dsh/.credentials.yaml` (`DEEPSEEK_API_KEY`) and the dsh credentials
120
- > service resolves it; the launcher also falls back to a running `dsh web` process's key.
121
-
122
- Optional: pin the panel's default config options (all still changeable in the panel):
123
-
124
- ```jsonc
125
- "dsh-acp-enhanced": {
126
- // ...the type/command/args/env above...
127
- "default_config_options": {
128
- "model": "deepseek-official/deepseek-v4-flash",
129
- "plan_mode": false,
130
- "reasoning_effort": "high"
131
- },
132
- "favorite_config_option_values": {
133
- "model": ["deepseek-official/deepseek-v4-flash", "deepseek-official/deepseek-v4-pro"]
134
- }
135
- }
136
- ```
137
-
138
- #### Extended: route through an OpenAI-Responses gateway (e.g. a company model gateway)
139
-
140
- Same install path; only the env values change to the provider/model the gateway exposes
141
- plus the key env var it requires:
142
-
143
- ```jsonc
144
- "dsh-acp-enhanced": {
145
- "type": "custom",
146
- "command": "/bin/bash",
147
- "args": ["/absolute/path/to/dsh-acp-enhanced/scripts/dsh-acp-zed.sh"],
148
- "env": {
149
- "DSH_ACP_PROVIDER": "<gateway-provider-id>", // provider id exposed by the gateway
150
- "DSH_ACP_MODEL": "<gateway-model-id>", // model id exposed by the gateway
151
- "<KEY_ENV_NAME>": "<key>" // the key env var the gateway reads
152
- }
153
- }
154
- ```
155
-
156
- > `<KEY_ENV_NAME>` can also be omitted and the key stored in
157
- > `~/.dsh/.credentials.yaml` instead.
158
-
159
- Zed hot-reloads settings. Open the **AI Agent panel** (`Cmd+Shift+A`) → pick
160
- **dsh-acp-enhanced** in the agent selector → send your first message: replies stream in
161
- real time, the status bar shows context usage, the panel exposes Model / Permission preset
162
- / Plan mode options plus three modes, and the thread archive lists and resumes past
163
- sessions.
164
-
165
- Verify locally (no Zed needed):
166
-
167
- ```sh
168
- node scripts/acp-client.mjs # official default route, no env; expect ALL CHECKS PASSED
169
- DSH_ACP_PROVIDER=... DSH_ACP_MODEL=... node scripts/acp-client.mjs # only for a custom route
170
- ```
171
-
172
- ### Optional: route web_search through the same gateway
173
-
174
- If the gateway implements the OpenAI Responses `web_search` server tool, you can route
175
- search through it too (reusing the same credential). Install the sub-package and append
176
- two blocks to the profile's `cordis.patch.yml`:
177
-
178
- ```sh
179
- dsh plugin --profile acp-enhanced add dsh-web-search-openrouter
180
- ```
181
-
182
- ```yaml
183
- - id: web
184
- config:
185
- searchProvider: <provider>
186
-
187
- - insert:
188
- - id: web-search-openrouter
189
- name: 'dsh-web-search-openrouter'
190
- config:
191
- enabled: true
192
- baseURL: http://<gateway-host>:<port>/v1
193
- model: <your-model-id>
194
- apiKeyEnv: <KEY_ENV_NAME>
195
- ```
196
-
197
- ## Troubleshooting
198
-
199
- | Symptom | Fix |
200
- |---|---|
201
- | `exec: dsh: not found` (status 127) | Use the shipped `dsh-acp-zed.sh` launcher (locates node/dsh itself) |
202
- | `no API key for provider route "xxx"` | Write `~/.dsh/.credentials.yaml`, or set `env.DEEPSEEK_API_KEY` on the agent_servers entry |
203
- | Cannot switch models / context usage missing | A "phantom provider" route was picked; this bridge filters them by default (only `config.provider`'s models are advertised) — point the profile's provider at a real route |
204
- | Need detailed diagnostics | `ACP_DEBUG=1 dsh --profile acp-enhanced` (stderr lifecycle trace) |
205
-
206
- ## Development
207
-
208
- ```sh
209
- node scripts/acp-client.mjs # end-to-end smoke (needs an API key)
210
- node scripts/acp-client-tools.mjs # client-tool tests (mocks Zed fs/terminal/elicitation/plan)
211
- node scripts/acp-mcp-test.mjs # MCP mount test (no model calls)
212
- node scripts/acp-smoke-keyless.mjs # keyless boot smoke (CI)
213
- node scripts/acp-resume-test.mjs # session resume test
214
- ```
215
-
216
- ## Known limitations
217
-
218
- Baseline prompts only (no image/audio attachments), no `additionalDirectories`, text
219
- streams at block granularity, one in-flight prompt per session. MCP supports stdio and
220
- streamable HTTP (legacy SSE / `acp` transports are not advertised).
221
- `session/close` / `session/fork` / `session/resume` are not implemented (capabilities
222
- undeclared, compliant clients will not call them); `session/delete` removes the persisted
223
- directory directly because dsh persistence has no official delete API.