dsh-acp-enhanced 0.1.1 → 0.2.1

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.
Files changed (4) hide show
  1. package/README-en.md +95 -216
  2. package/README.md +82 -172
  3. package/lib/index.js +155 -9
  4. package/package.json +4 -2
package/README-en.md CHANGED
@@ -3,63 +3,74 @@
3
3
  # dsh-acp-enhanced
4
4
 
5
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
7
- editors like **Zed** that speak ACP over JSON-RPC stdio.
8
-
9
- The official `@deepseek-ai/dsh-acp` bridge is deliberately automation-only: it commits
10
- text only after a whole message, carries no telemetry, and exposes no model/permission
11
- controls. This project is a drop-in replacement that surfaces what the Web GUI has:
12
-
13
- | Surface | ACP mechanism | What you see in Zed |
14
- |---|---|---|
15
- | **Block-level streaming** | `agent_message_chunk` per committed text block (`block-end`), grouped by `messageId` per model step | Text appears while the agent works; cancelled/retried blocks never leak torn output |
16
- | **Token & context telemetry** | standard `usage_update` (`used` = context pressure, `size` = model context window) | Context meter in the agent status bar |
17
- | **Cache hit rate / TPS / input-output-reasoning tokens / tool timing / turn count** | `usage_update._meta` + `tool_call` / `tool_call_update` `_meta` | Raw numbers every step (the `_meta` extension field carries the full breakdown) |
18
- | **Tool-call visibility** | `tool_call` carries `rawInput` (parsed arguments) and `kind` (read/edit/execute/…); `tool_call_update` carries `rawOutput` (result preview, capped at 12k chars) | Tool cards expand to show the **exact arguments** (e.g. the bash command) and the **result**, with kind-based icons |
19
- | **Model switching** | `session/set_config_option` with the `model` select (`provider/model` values from the live catalog; ACP grouped-select wire shape `{ group, name, options }`) | Config-option UI — switch to any model on the route |
20
- | **Reasoning effort** | `session/set_config_option` with the `reasoning_effort` select (only when the routed model **exposes** selectable efforts) | Config-option UI (appears only when the route exposes efforts; see Design notes) |
21
- | **Permission presets** | `session/set_config_option` (`permission_preset`) **and** ACP session modes via `session/set_mode` | Mode switcher / config-option UI |
22
- | **Approval** | `session/request_permission` (allow-once / reject-once per tool call) | Native permission prompt |
23
- | **Zed client file tools** | agent-side `zed_read_text_file` / `zed_write_text_file` / `zed_terminal` forwarded as `fs/read_text_file` / `fs/write_text_file` / `terminal/create` | Edits land in the agent panel's **"Edited files" section (diff + accept/reject)**; commands run in a **real Zed terminal** |
24
- | **Zed form elicitation** | `ask_user_question` tool + `userQuestions` provider forwarded as `elicitation/create` (form mode) | Questions pop up as **native Zed forms**; options answerable in one click |
25
- | **Plan panel** | `plan_mode` boolean config option + `plan/mode` events mapped to ACP `plan` updates | A **Plan status bar** at the bottom of the panel while plan mode is on; cleared when it leaves |
26
- | **Session resume** | `loadSession` capability + `session/load` resumes the persisted agent via `agents.resume` and replays history as `user_message_chunk` / `agent_message_chunk` / `tool_call` | **Continue a previous thread** in Zed (long investigations keep their context) |
27
- | **Session archive list** | `sessionCapabilities.list/delete`; `session/list` enumerates persisted sessions via `ctx.sessionPersistence.list()` (titles read from `session/title` events in the stored log), `session/delete` disposes the live agent and removes its persisted directory; `session/title` / `turn/end` push live `session_info_update`s | The **thread archive** shows all sessions (titled, sorted by last activity) — click to resume, delete to remove |
28
- | **Empty-option suppression** | no `reasoning_effort` option is advertised when the routed model exposes no efforts | No empty, unclickable "Reasoning effort" chip |
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
+ ### MCP
48
+
49
+ - **MCP servers**: `session/new` `mcpServers` mount any MCP server (stdio + streamable
50
+ HTTP); tools join as `mcp__<server>__<tool>`; a failing server never takes the session
51
+ down
29
52
 
30
53
  ## Preview
31
54
 
32
- After picking **dsh-acp-enhanced** in Zed's AI Agent panel, you get:
55
+ After picking **dsh-acp-enhanced** in Zed's AI Agent panel:
33
56
 
34
57
  <img src="assets/screenshots/approval-config-context.png" alt="Approval popup, model/reasoning-effort switches, context ring" width="560">
35
58
 
36
- - Tool calls that need permission pop a **native approval prompt** (allow-once /
37
- reject-once); below the input box sit the **model**, **reasoning effort**,
38
- **permission preset**, and **plan mode** config options plus the **context usage
39
- ring** (`usage_update` telemetry with cache hit rate, TPS, and more).
59
+ - Tool calls that need permission pop a **native approval prompt**; below the input box sit
60
+ the model, reasoning effort, permission preset, plan mode options and the context usage
61
+ ring.
40
62
 
41
63
  <img src="assets/screenshots/tool-cards-elicitation.png" alt="Tool call inputs and outputs, native Zed question form" width="320">
42
64
 
43
- - **Tool cards** expand to show each call's full arguments (e.g. the exact bash
44
- command) and the result preview (`rawInput` / `rawOutput`); when dsh needs your
45
- confirmation or a choice, the question arrives as a **native Zed form**
46
- (`ask_user_question` → `elicitation/create`) — click an option, no typing.
47
-
48
- > **Repository layout** — this repo contains two independent packages:
49
- > - `dsh-acp-enhanced` (repo root): the enhanced ACP bridge (`lib/index.js`).
50
- > - `packages/dsh-web-search-openrouter/`: a standalone `ctx.web` search provider that
51
- > routes `web_search` through any OpenAI-Responses gateway instead of DeepSeek's
52
- > Anthropic `/messages` endpoint. It deliberately does **not** couple to the ACP
53
- > bridge, so any profile (the Web GUI included) can mount it.
65
+ - **Tool cards** expand to show full arguments and result previews; when dsh needs your
66
+ confirmation or a choice, the question arrives as a **native Zed form** — click an
67
+ option, no typing.
54
68
 
55
69
  ## Quick start
56
70
 
57
- This package follows the official dsh plugin conventions (it declares `dsh.bundle`),
58
- so installation is identical to any official bundle: **one command** —
59
- `dsh plugin --profile <name> add <pkg>` auto-initializes the profile (the first layer
60
- `dsh-base` already carries the whole agent stack), installs the package, and
61
- **auto-appends it to the profile's bundle layers**. The shipped patch inserts the
62
- `acp-enhanced` row and overrides the default model route — **no profile YAML to write**.
71
+ This package follows the official dsh plugin conventions (it declares `dsh.bundle`), so
72
+ installation matches any official bundle: **one command** — auto-initializes the profile,
73
+ installs the package, appends the bundle layer; no profile YAML to write.
63
74
 
64
75
  ### Install (2 steps)
65
76
 
@@ -69,16 +80,12 @@ so installation is identical to any official bundle: **one command** —
69
80
  dsh plugin --profile acp-enhanced add dsh-acp-enhanced
70
81
  ```
71
82
 
72
- > When hacking on the code itself, use `link:` to a local checkout instead (live
73
- > edits, no registry round-trip):
83
+ > When hacking on the code, use `link:` to a local checkout instead (live edits):
74
84
  > `dsh plugin --profile acp-enhanced add "link:/absolute/path/to/dsh-acp-enhanced"`
75
85
 
76
- **Step 2 — register in Zed** (the model route and credentials all come from `env`; no
77
- patch to write)
78
-
79
- Register the agent under `agent_servers` in `~/.config/zed/settings.json`. Zed (a GUI
80
- app) spawns agent processes with a minimal PATH, so use the shipped launcher
81
- `scripts/dsh-acp-zed.sh` (it locates `node`/`dsh` itself).
86
+ **Step 2 — register in Zed** (under `agent_servers` in `~/.config/zed/settings.json`;
87
+ Zed spawns agents with a minimal PATH, so use the shipped launcher
88
+ `scripts/dsh-acp-zed.sh`, which locates `node`/`dsh` itself)
82
89
 
83
90
  #### Most common: DeepSeek official API (the default route)
84
91
 
@@ -99,16 +106,12 @@ app) spawns agent processes with a minimal PATH, so use the shipped launcher
99
106
  }
100
107
  ```
101
108
 
102
- > This is the setup the author uses daily (macOS). `DSH_ACP_PROVIDER` / `DSH_ACP_MODEL`
103
- > match the shipped patch's defaults (`deepseek-official` / `deepseek-v4-flash`), so
104
- > **both can be omitted entirely** — writing them out just makes the route explicit in
105
- > your Zed config. The API key does not have to live in Zed: store it in
106
- > `~/.dsh/.credentials.yaml` (`DEEPSEEK_API_KEY`, mode 600) and the dsh credentials
107
- > service resolves it; the launcher additionally falls back to inheriting the key from
108
- > a running `dsh web` process.
109
+ > Both env vars match the shipped patch's defaults, so **they can be omitted entirely** —
110
+ > writing them out just makes the route explicit. The API key does not have to live in Zed:
111
+ > store it in `~/.dsh/.credentials.yaml` (`DEEPSEEK_API_KEY`) and the dsh credentials
112
+ > service resolves it; the launcher also falls back to a running `dsh web` process's key.
109
113
 
110
- Optional: pin the panel's default config options (model / plan mode / reasoning
111
- effort; all still changeable in the panel at any time):
114
+ Optional: pin the panel's default config options (all still changeable in the panel):
112
115
 
113
116
  ```jsonc
114
117
  "dsh-acp-enhanced": {
@@ -126,8 +129,8 @@ effort; all still changeable in the panel at any time):
126
129
 
127
130
  #### Extended: route through an OpenAI-Responses gateway (e.g. a company model gateway)
128
131
 
129
- Same install path; only the env values change to the provider/model the gateway
130
- exposes plus the key env var it requires:
132
+ Same install path; only the env values change to the provider/model the gateway exposes
133
+ plus the key env var it requires:
131
134
 
132
135
  ```jsonc
133
136
  "dsh-acp-enhanced": {
@@ -142,39 +145,32 @@ exposes plus the key env var it requires:
142
145
  }
143
146
  ```
144
147
 
145
- > `<KEY_ENV_NAME>` is the env var the provider reads for its key (gateway adapters
146
- > usually declare their own `apiKeyEnv`) — alternatively store it in
147
- > `~/.dsh/.credentials.yaml` and let the dsh credentials service manage it. Every
148
- > route uses the same install path; only the env values differ.
148
+ > `<KEY_ENV_NAME>` can also be omitted and the key stored in
149
+ > `~/.dsh/.credentials.yaml` instead.
149
150
 
150
151
  Zed hot-reloads settings. Open the **AI Agent panel** (`Cmd+Shift+A`) → pick
151
- **dsh-acp-enhanced** in the top **agent selector** → send your first message. Replies
152
- stream in real time, the status bar shows context usage, and the panel exposes Model /
153
- Permission preset / Plan mode config options plus read-only / workspace-write /
154
- full-access modes; the thread archive lists and resumes past sessions.
152
+ **dsh-acp-enhanced** in the agent selector → send your first message: replies stream in
153
+ real time, the status bar shows context usage, the panel exposes Model / Permission preset
154
+ / Plan mode options plus three modes, and the thread archive lists and resumes past
155
+ sessions.
155
156
 
156
157
  Verify locally (no Zed needed):
157
158
 
158
159
  ```sh
159
160
  node scripts/acp-client.mjs # official default route, no env; expect ALL CHECKS PASSED
160
161
  DSH_ACP_PROVIDER=... DSH_ACP_MODEL=... node scripts/acp-client.mjs # only for a custom route
161
- env -i HOME=$HOME PATH=/usr/bin:/bin node scripts/acp-client.mjs \
162
- /bin/bash /absolute/path/to/dsh-acp-enhanced/scripts/dsh-acp-zed.sh # Zed-like spawn
163
162
  ```
164
163
 
165
164
  ### Optional: route web_search through the same gateway
166
165
 
167
- The bridge does not depend on it. If the gateway implements the OpenAI Responses
168
- `web_search` server tool, you can route search through it too (reusing the same
169
- credential). Install it as a plain dependency and append two blocks to the profile's
170
- `cordis.patch.yml`:
166
+ If the gateway implements the OpenAI Responses `web_search` server tool, you can route
167
+ search through it too (reusing the same credential). Install the sub-package and append
168
+ two blocks to the profile's `cordis.patch.yml`:
171
169
 
172
170
  ```sh
173
- dsh plugin --profile acp-enhanced add "link:/absolute/path/to/dsh-acp-enhanced/packages/dsh-web-search-openrouter"
171
+ dsh plugin --profile acp-enhanced add dsh-web-search-openrouter
174
172
  ```
175
173
 
176
- `~/.dsh/profiles/acp-enhanced/cordis.patch.yml` (`<provider>` is your gateway provider id):
177
-
178
174
  ```yaml
179
175
  - id: web
180
176
  config:
@@ -190,147 +186,30 @@ dsh plugin --profile acp-enhanced add "link:/absolute/path/to/dsh-acp-enhanced/p
190
186
  apiKeyEnv: <KEY_ENV_NAME>
191
187
  ```
192
188
 
193
- ### Troubleshooting
189
+ ## Troubleshooting
194
190
 
195
- | Symptom | Cause & fix |
191
+ | Symptom | Fix |
196
192
  |---|---|
197
- | `Server exited with status 127` / `exec: dsh: not found` | Zed's PATH lacks `node`/`dsh`. Use the shipped `dsh-acp-zed.sh` launcher (it resolves both); verify with `bash scripts/dsh-acp-zed.sh` in a clean shell. |
198
- | `no API key for provider route "deepseek-official"` | The key cannot be resolved. Write `~/.dsh/.credentials.yaml` (see step 2), or set `env.DEEPSEEK_API_KEY` in the agent_servers entry. |
199
- | Agent does not appear after editing settings | Run `zed: reload settings` (command palette) or restart Zed. |
200
- | "Cannot switch models" or "context usage not shown" in Zed | Usually a ghost provider is selected (an adapter that is mounted but has no usable API key). The bridge filters ghost groups by default (only `config.provider` models are advertised); if it persists, check that the profile's `config.provider` points at a real routable route and reset the polluted `agent-default-model` default to it. See Design notes. |
201
- | `session/new` reports `additionalDirectories is not supported` | The bridge only supports baseline sessions; Zed does not send extra directories by default — remove them if a custom config sends them. |
202
- | Need detailed diagnostics | Start with `ACP_DEBUG=1 dsh --profile acp-enhanced` (lifecycle trace on stderr). |
193
+ | `exec: dsh: not found` (status 127) | Use the shipped `dsh-acp-zed.sh` launcher (locates node/dsh itself) |
194
+ | `no API key for provider route "xxx"` | Write `~/.dsh/.credentials.yaml`, or set `env.DEEPSEEK_API_KEY` on the agent_servers entry |
195
+ | 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 |
196
+ | Need detailed diagnostics | `ACP_DEBUG=1 dsh --profile acp-enhanced` (stderr lifecycle trace) |
203
197
 
204
198
  ## Development
205
199
 
206
200
  ```sh
207
- node scripts/acp-client.mjs # end-to-end smoke test (needs a routable provider)
201
+ node scripts/acp-client.mjs # end-to-end smoke (needs an API key)
208
202
  node scripts/acp-client-tools.mjs # client-tool tests (mocks Zed fs/terminal/elicitation/plan)
209
- node scripts/acp-resume-test.mjs # resume tests (two processes: create+persist → load+replay → continue)
210
- ACP_DEBUG=1 dsh --profile acp-enhanced # lifecycle trace on stderr
203
+ node scripts/acp-mcp-test.mjs # MCP mount test (no model calls)
204
+ node scripts/acp-smoke-keyless.mjs # keyless boot smoke (CI)
205
+ node scripts/acp-resume-test.mjs # session resume test
211
206
  ```
212
207
 
213
- The smoke client drives initialize → session/new → prompt (verifying block streaming,
214
- `usage_update`, `tool_call`), config-option and mode switching, a second prompt after
215
- switching, and `session/cancel`. `acp-client-tools.mjs` uses the SDK's
216
- `ClientSideConnection` to mock Zed: it declares
217
- `fs.readTextFile/writeTextFile/terminal/elicitation` capabilities and verifies that
218
- `zed_*` tool calls arrive as `fs/write_text_file`, `fs/read_text_file`,
219
- `terminal/create` requests, that `ask_user_question` arrives as an `elicitation/create`
220
- form (with enum options), that the `plan_mode` boolean toggle emits ACP `plan` updates
221
- (on → entry, off → cleared), and that routes without reasoning efforts no longer
222
- advertise an empty `reasoning_effort`.
223
-
224
- ## Design notes
225
-
226
- - **Block-level streaming**: text deltas accumulate per block index; a committed
227
- `block-end` goes on the wire immediately. A retry restarts the same index, so the
228
- torn tail of a cancelled attempt never reaches the client — ACP has no undo, and
229
- this is the cleanest boundary.
230
- - **Telemetry**: every provider `usage` sample is broadcast as `usage_update`
231
- (used = input + cache read + cache write; size = the routed model's context
232
- window), with the full breakdown in `_meta`: input/output/cache/reasoning tokens,
233
- `cacheHitRate`, `tps` (generated tokens / step wall-clock), step elapsed, turn
234
- count, and cumulative tool-call stats.
235
- - **Tool-call visibility**: `tool_call` notifications carry `kind` and `rawInput`
236
- (`JSON.parse` of the arguments, falling back to the raw string), so Zed's tool
237
- cards expand to show the exact arguments (bash command, written file, ...);
238
- `tool_call_update` carries `rawOutput` (a bounded text preview extracted from the
239
- `ToolResultMessage`, truncated at 12k). **A key constraint on `kind` mapping**: Zed
240
- treats `kind == 'execute'` as a terminal tool and `kind == 'edit'` as a diff tool,
241
- and **hides rawInput for both**. So only `zed_terminal` (a real editor terminal)
242
- maps to `execute`; bash/run_code/write tools stay `other` so rawInput renders —
243
- otherwise the card shows only the tool name with no command. Also note the dsh
244
- `tool/result` event carries `toolCallId` on `message.content[0].toolCallId`
245
- (the `ToolResultBlock`), not on the event root — missing it makes the SDK reject
246
- the whole `tool_call_update`. History replay (resume) carries the same fields.
247
- - **Session config**: the `model` select enumerates the live model catalog
248
- (`ctx.llm.listProviders` → `listModels` → `resolveModelInfo`), `reasoning_effort`
249
- enumerates the routed model's efforts, `permission_preset` enumerates the mounted
250
- presets. Writes go through `llm.resolveCallConfig` + `installModelSelection` (the
251
- same mechanism the Web api-proxy uses) or `permissionPresets.apply`.
252
- - **Model grouped-select wire shape**: the `model` option's groups must use the ACP
253
- shape `{ group: <id>, name: <label>, options: [...] }`. An early version emitted
254
- `{ groupName, options }`; Zed (`agent-client-protocol-schema` 1.4.0) silently
255
- skipped the whole group on deserialization (`DefaultOnError` + `VecSkipError`),
256
- leaving the dropdown empty — and the SDK mock client does not validate agent
257
- responses, so tests missed it. Now `acp-client-tools.mjs` runs
258
- `zSessionConfigOption.safeParse` on every config option, so this class of wire bug
259
- cannot slip through again.
260
- - **Reasoning-effort route limitation**: the `reasoning_effort` option is advertised
261
- only when the routed model **exposes** efforts (`resolveModelInfo().reasoning.efforts`
262
- non-empty). On routes without efforts, explicitly setting one is rejected by the
263
- adapter (`does not support reasoning effort "high"`) — so the absence of an effort
264
- dropdown there is **correct behavior**, not a bug; switching to a route that exposes
265
- efforts makes the dropdown reappear automatically.
266
- - **Model-catalog filtering (`includeAllProviders`, default off)**: by default only
267
- `config.provider` models are advertised, keeping "ghost providers" (adapters that
268
- are mounted but not routable — e.g. a `deepseek-official` with no usable API key)
269
- out of the dropdown. Those models look switchable but every later prompt fails with
270
- `MISSING_CREDENTIAL` (`no API key for provider route "xxx"`) — in Zed that shows up
271
- as "cannot switch models, and no `usage_update` arrives because the turn failed"
272
- (the Web GUI shows an unavailable banner for the current item; Zed does not, so the
273
- same data looks broken there). Set `includeAllProviders: true` when multiple
274
- providers are genuinely usable.
275
- - **Default model cannot be poisoned**: `applySelection` persists the new selection as
276
- the `agent-default-model` default only when `selected.provider === config.provider`
277
- (or explicit `includeAllProviders`). An accidental switch to a non-routable provider
278
- therefore affects only the current session and never corrupts the default route of
279
- every later session.
280
- - **Client-forwarding tools (Zed fs / terminal)**: on `initialize` the bridge reads
281
- `clientCapabilities` and registers `zed_read_text_file` / `zed_write_text_file` /
282
- `zed_terminal` (`ctx.tools.register` + `defineTool`) only when the client declares
283
- the matching capabilities. Tool bodies forward to the editor via
284
- `conn.readTextFile` / `conn.writeTextFile` / `conn.createTerminal`:
285
- `zed_write_text_file` lands edits on Zed's own buffer (the "Edited files" section
286
- with diff + accept/reject); `zed_terminal` runs the command in a real Zed terminal
287
- and polls output (`terminal/output` is cumulative — take the last one), killing after
288
- 120s. Clients without those capabilities (e.g. pure automation) never see these tools.
289
- - **Zed form elicitation**: when the client declares `elicitation.form`, the bridge
290
- registers the `ask_user_question` tool (mirroring `dsh-tool-ask-user`'s definition
291
- through the `ctx.userQuestions` seam) plus the matching UI provider: questions map
292
- to an ACP `elicitation/create` (form mode) JSON Schema (single choice → `string` +
293
- `enum`, multi → `array`, none → bare `string`); the user's native-form answer maps
294
- back to `AskUserQuestionAnswer` for the model. decline/cancel end the tool call with
295
- an error the model can route around. Note Zed's elicitation capability is an object
296
- (`form: {}`), not a boolean — check for presence, not `=== true`.
297
- - **Plan panel**: the `plan_mode` boolean config option toggles DSH plan mode via
298
- `ctx.planMode.set(agent, active)`; `plan/mode` flips in `session/event` map to ACP
299
- `plan` updates — one "planning" entry while active, cleared on exit. DSH plan mode
300
- has no structured task list, so this is a state indicator, not a task list. The ACP
301
- `plan` update is **flat** (`{ sessionUpdate: 'plan', entries: [...] }`), not
302
- `{ plan: {...} }`.
303
- - **Session resume (session/load)**: `initialize` declares `loadSession: true`;
304
- `session/load` resumes the persisted agent via `ctx.agents.resume({ resumeSessionId })`
305
- (`dsh-session-persistence-jsonl`, mounted by dsh-base), then replays history from the
306
- event log: `user/message` (only `source.kind === 'user'` — synthetic injections like
307
- system reminders and skill content are filtered) → `user_message_chunk`,
308
- `assistant/message` text → `agent_message_chunk`, `tool/call`/`tool/result` →
309
- `tool_call`/`tool_call_update`. Zed inserts the thread before the load RPC
310
- completes, so the replay notifications reach it. After replay the session behaves
311
- like a fresh one for further prompts.
312
- - **Session archive list (session/list + session/delete)**: `initialize` declares
313
- `sessionCapabilities: { list: {}, delete: {} }`; `session/list` enumerates
314
- materialized sessions via `ctx.sessionPersistence.list()` (`SessionHeader`:
315
- id/cwd/createdAt), titles come from live `session/title` events or are read
316
- best-effort from the stored log (the last `session/title` event; oversized logs are
317
- skipped), sorted by `updatedAt` descending. `session/delete` disposes the live agent
318
- (`sessions.delete` + `dispose`), then removes the session's directory via
319
- `persistence.locate(header)` — note dsh's persistence surface has **no official
320
- delete API**, so this removes the backend directory directly. Live title/activity
321
- changes are pushed as `session_info_update` notifications (`session/title` and
322
- `turn/end` events).
323
- - **Empty-effort suppression**: when the routed model exposes no reasoning efforts,
324
- the `reasoning_effort` option is not advertised — Zed renders no empty, inoperable
325
- "Reasoning effort" chip. Switching to a model with efforts makes the option reappear
326
- (every switch replays `config_option_update`).
327
- - **Modes**: permission presets are presented as ACP session modes, so Zed's mode
328
- switcher drives the sandbox/approval presets.
329
- - **Known limitations** (inherited from the official bridge): baseline prompts only
330
- (no image/audio/MCP attachments), no `additionalDirectories`/MCP server attachment,
331
- committed text streams at block granularity, and one in-flight prompt per session.
332
- Session resume and the archive list are supported (see above), but
333
- `session/close` / `session/fork` / `session/resume` are not implemented (the
334
- capabilities are not declared, so conforming clients do not call them);
335
- `session/delete` removes the backend directory directly because dsh's persistence
336
- surface has no official delete API.
208
+ ## Known limitations
209
+
210
+ Baseline prompts only (no image/audio attachments), no `additionalDirectories`, text
211
+ streams at block granularity, one in-flight prompt per session. MCP supports stdio and
212
+ streamable HTTP (legacy SSE / `acp` transports are not advertised).
213
+ `session/close` / `session/fork` / `session/resume` are not implemented (capabilities
214
+ undeclared, compliant clients will not call them); `session/delete` removes the persisted
215
+ directory directly because dsh persistence has no official delete API.
package/README.md CHANGED
@@ -3,74 +3,79 @@
3
3
  # dsh-acp-enhanced
4
4
 
5
5
  面向 [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness)(dsh)的增强版
6
- [Agent Client Protocol](https://agentclientprotocol.com)(ACP)服务器,专为
7
- **Zed** 这类通过 JSON-RPC stdio 使用 ACP 的编辑器而设计。
8
-
9
- 官方 `@deepseek-ai/dsh-acp` 桥接器刻意保持"纯自动化":在整个消息结束后才提交文本,
10
- 不带遥测,也没有模型/权限控制。本项目是一个**即插即用替代品**,把 Web GUI 有的能力
11
- 全部暴露出来:
12
-
13
- | 能力 | ACP 机制 | 你在 Zed 中看到的 |
14
- |---|---|---|
15
- | **块级流式输出** | 每个已提交文本块(`block-end`)发送一条 `agent_message_chunk`,按每个模型 step 的 `messageId` 分组 | agent 工作时文本实时出现;被取消/重试的块不会残留撕裂的半截输出 |
16
- | **Token 与上下文遥测** | 标准 `usage_update`(`used` = 上下文压力,`size` = 模型上下文窗口) | agent 状态栏中的上下文仪表 |
17
- | **缓存命中率 / TPS / 输入-输出-推理 token / 工具耗时 / 轮次计数** | `usage_update._meta` + `tool_call` / `tool_call_update` 的 `_meta` | 每一步都有原始数字(`_meta` 扩展字段携带完整明细) |
18
- | **工具调用可见性** | `tool_call` 携带 `rawInput`(解析后的参数对象)与 `kind`(read/edit/execute/…);`tool_call_update` 携带 `rawOutput`(结果预览,最多 12k 字符) | 工具卡片能展开看到**具体参数**(如 bash 执行的命令)与**执行结果**,并按工具类型渲染图标 |
19
- | **模型切换** | `session/set_config_option`,`model` 下拉框(取值来自实时的 `provider/model` 模型目录;分组线格式为 ACP 规范的 `{ group, name, options }`) | 配置项 UI——可切换路由内任意模型 |
20
- | **推理强度** | `session/set_config_option`,`reasoning_effort` 下拉框(仅当当前模型路由**暴露**可选的 reasoning efforts 时) | 配置项 UI(仅当路由暴露可用 efforts 时出现,见"设计说明") |
21
- | **权限预设** | `session/set_config_option`(`permission_preset`)**以及**通过 `session/set_mode` 的 ACP 会话模式 | 模式切换器 / 配置项 UI |
22
- | **审批** | `session/request_permission`(每个工具调用 allow-once / reject-once) | 原生审批弹窗 |
23
- | **Zed 客户端文件工具** | agent 侧注册 `zed_read_text_file` / `zed_write_text_file` / `zed_terminal`,转发为 `fs/read_text_file` / `fs/write_text_file` / `terminal/create` | 文件编辑出现在 agent 面板的 **"编辑文件"区(带 diff + 接受/拒绝)**;命令跑在 **Zed 真实终端** 里 |
24
- | **Zed 表单提问** | 注册 `ask_user_question` 工具 + `userQuestions` provider,转发为 `elicitation/create`(form 模式) | DSH 需要用户确认/选择时,问题以 **Zed 原生表单** 弹出,选项即点即答 |
25
- | **Plan 面板** | `plan_mode` 布尔配置项(Zed 侧开关)+ `plan/mode` 事件映射为 ACP `plan` update | Zed 底部出现 **Plan 状态条**:plan mode 开时显示"规划中",关时清空 |
26
- | **会话恢复(resume)** | 声明 `loadSession` 能力 + `session/load` 走 `agents.resume` 加载持久化会话,并把历史回放为 `user_message_chunk` / `agent_message_chunk` / `tool_call` | 在 Zed 里可以**继续之前的对话线程**(长排查不丢上下文) |
27
- | **会话归档列表** | 声明 `sessionCapabilities.list/delete`;`session/list` 从持久化存储(`ctx.sessionPersistence.list()`)枚举会话(标题从存储日志的 `session/title` 事件读取),`session/delete` 释放在线 agent 并删除其持久化目录;`session/title` / `turn/end` 实时推送 `session_info_update` | Zed 的**历史线程归档**能看到本项目的全部会话(带标题、按更新时间排序),可点击恢复,也可删除 |
28
- | **空选项抑制** | 当前模型路由无 reasoning efforts 时不广播 `reasoning_effort` 配置项 | 不再出现一个空的、点不动的"Reasoning effort"chip |
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
+ - **工具卡片**:展开可见每次调用的完整参数与结果预览(`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 底部"规划中"状态条
35
+
36
+ ### 会话
37
+
38
+ - **恢复与归档**:`session/load` 恢复历史线程(完整回放);`session/list` /
39
+ `session/delete` 管理线程归档(带标题、按更新时间排序);标题实时推送
40
+
41
+ ### MCP
42
+
43
+ - **MCP servers**:`session/new` 的 `mcpServers` 挂载任意 MCP server(stdio +
44
+ streamable HTTP),工具以 `mcp__<server>__<tool>` 注入;失败的 server 不会拖垮会话
29
45
 
30
46
  ## 效果预览
31
47
 
32
- 在 Zed 的 AI Agent 面板中选择 **dsh-acp-enhanced** 后,你会看到:
48
+ 在 Zed 的 AI Agent 面板中选择 **dsh-acp-enhanced** 后:
33
49
 
34
50
  <img src="assets/screenshots/approval-config-context.png" alt="审批弹窗与模型/推理强度切换、上下文环" width="560">
35
51
 
36
- - 工具调用需要许可时弹出**原生审批弹窗**(allow-once / reject-once);输入框下方是
37
- **模型**、**推理强度**、**权限预设**、**Plan mode** 配置项与**上下文用量环**
38
- (`usage_update` 遥测,含缓存命中率、TPS 等明细)。
52
+ - 工具调用需要许可时弹出**原生审批弹窗**;输入框下方是模型、推理强度、权限预设、
53
+ Plan mode 配置项与上下文用量环。
39
54
 
40
55
  <img src="assets/screenshots/tool-cards-elicitation.png" alt="工具调用入参与输出、Zed 原生提问表单" width="320">
41
56
 
42
- - **工具卡片**可展开查看每次调用的完整入参(如 bash 执行的命令)与结果预览
43
- (`rawInput` / `rawOutput`);DSH 需要你确认或选择时,以 **Zed 原生表单**弹出
44
- (`ask_user_question` → `elicitation/create`),选项即点即答,无需手动输入。
45
-
46
- > **仓库结构** —— 本仓库包含两个相互独立的包:
47
- > - `dsh-acp-enhanced`(仓库根目录):增强版 ACP 桥接器(`lib/index.js`)。
48
- > - `packages/dsh-web-search-openrouter/`:独立的 `ctx.web` 搜索 provider,让 `web_search`
49
- > 走任意 OpenAI-Responses 网关,而非 DeepSeek 的 Anthropic `/messages` 端点。它刻意
50
- > **不**与 ACP 桥接器耦合,因此任何 profile(包括 Web GUI)都可挂载。
57
+ - **工具卡片**可展开查看完整入参与结果预览;DSH 需要确认/选择时以 **Zed 原生表单**
58
+ 弹出,选项即点即答。
51
59
 
52
60
  ## 快速开始
53
61
 
54
- 本包遵循 dsh 官方插件规范(声明了 `dsh.bundle`),所以安装与官方组合包完全一致:
55
- **一条命令完成** —— `dsh plugin --profile <名> add <包>` 会自动初始化 profile(首层
56
- `dsh-base` 已含整套 agent 栈)、安装包,并把本包**自动追加进 bundle 层**。包自带的
57
- patch 会插入 `acp-enhanced` 行并覆写默认模型路由,**全程无需手写 profile YAML**。
62
+ 本包遵循 dsh 官方插件规范(声明了 `dsh.bundle`),安装与官方组合包一致:**一条命令**
63
+ 完成,自动初始化 profile、安装包、追加 bundle 层,全程无需手写 profile YAML。
58
64
 
59
65
  ### 安装(2 步)
60
66
 
61
- **第 1 步:安装**(从 npm registry 安装,无需下载源码)
67
+ **第 1 步:安装**(从 npm registry,无需下载源码)
62
68
 
63
69
  ```sh
64
70
  dsh plugin --profile acp-enhanced add dsh-acp-enhanced
65
71
  ```
66
72
 
67
- > 开发/改源码时改用 `link:` 指向本地 checkout(改动实时生效,跳过 registry):
73
+ > 开发/改源码时用 `link:` 指向本地 checkout(改动实时生效):
68
74
  > `dsh plugin --profile acp-enhanced add "link:/absolute/path/to/dsh-acp-enhanced"`
69
75
 
70
- **第 2 步:注册进 Zed**(模型路由与凭据全部通过 `env` 传入,无需写 patch)
71
-
72
- 在 `~/.config/zed/settings.json` 的 `agent_servers` 里注册。Zed(GUI 应用)会用极简 PATH
73
- 拉起 agent 进程,因此用随附启动器 `scripts/dsh-acp-zed.sh`(它自己会定位 `node`/`dsh`)。
76
+ **第 2 步:注册进 Zed**(在 `~/.config/zed/settings.json` 的 `agent_servers` 里注册;
77
+ Zed 会用极简 PATH 拉起 agent,因此用随附启动器 `scripts/dsh-acp-zed.sh` 定位
78
+ `node`/`dsh`)
74
79
 
75
80
  #### 最常见:DeepSeek 官方 API(默认路由)
76
81
 
@@ -91,13 +96,12 @@ dsh plugin --profile acp-enhanced add dsh-acp-enhanced
91
96
  }
92
97
  ```
93
98
 
94
- > 这是本项目作者日常使用的配置(macOS)。`DSH_ACP_PROVIDER` / `DSH_ACP_MODEL` 与包自带
95
- > patch 的缺省值(`deepseek-official` / `deepseek-v4-flash`)一致,**所以也可以直接省略**
96
- > ——显式写上只是让路由意图在 Zed 配置里一目了然。API key 不必写进 Zed:写入
97
- > `~/.dsh/.credentials.yaml`(`DEEPSEEK_API_KEY`,600 权限)由 dsh 凭据服务解析即可;
98
- > 启动脚本还会兜底继承正在运行的 `dsh web` 进程的 key。
99
+ > 这两项 env 与包自带 patch 的缺省值一致,**省略也能工作**——显式写上只是让路由意图
100
+ > 一目了然。API key 不必写进 Zed:存入 `~/.dsh/.credentials.yaml`
101
+ > (`DEEPSEEK_API_KEY`)由 dsh 凭据服务解析即可;启动脚本还会兜底继承正在运行的
102
+ > `dsh web` 进程的 key。
99
103
 
100
- 可选:固定面板默认项(模型 / plan mode / 推理强度;都可以随时在面板里改,这只是初始值):
104
+ 可选:固定面板默认项(都可随时在面板里改):
101
105
 
102
106
  ```jsonc
103
107
  "dsh-acp-enhanced": {
@@ -130,35 +134,29 @@ dsh plugin --profile acp-enhanced add dsh-acp-enhanced
130
134
  }
131
135
  ```
132
136
 
133
- > `<KEY_ENV_NAME>` 是 provider 声明读取的 key 环境变量名(网关适配器通常有自己的
134
- > `apiKeyEnv`);同样可以不写在 Zed 里,而是存进 `~/.dsh/.credentials.yaml` 由凭据服务
135
- > 统一管理。路由换哪种模型都走同一条安装路径,只是 env 值不同。
137
+ > `<KEY_ENV_NAME>` 也可以省掉,把 key 存进 `~/.dsh/.credentials.yaml` 统一管理。
136
138
 
137
- Zed 会热重载设置。打开 **AI Agent 面板**(`Cmd+Shift+A`)→ 顶部 **agent 选择器** 选
138
- **dsh-acp-enhanced** → 输入第一条消息即可。回复实时流式返回,状态栏显示上下文用量,面板
139
- 顶部有 Model / Permission preset / Plan mode 配置项与 read-only / workspace-write /
140
- full-access 模式,线程归档里能看到并恢复历史会话。
139
+ Zed 会热重载设置。打开 **AI Agent 面板**(`Cmd+Shift+A`)→ agent 选择器选
140
+ **dsh-acp-enhanced** → 输入第一条消息即可:回复实时流式返回,状态栏显示上下文用量,
141
+ 面板顶部有 Model / Permission preset / Plan mode 配置项与三种模式,线程归档可恢复
142
+ 历史会话。
141
143
 
142
144
  本地验证(无需 Zed):
143
145
 
144
146
  ```sh
145
147
  node scripts/acp-client.mjs # 官方默认路由,无需 env;期望 ALL CHECKS PASSED
146
148
  DSH_ACP_PROVIDER=... DSH_ACP_MODEL=... node scripts/acp-client.mjs # 自定义路由时再传
147
- env -i HOME=$HOME PATH=/usr/bin:/bin node scripts/acp-client.mjs \
148
- /bin/bash /absolute/path/to/dsh-acp-enhanced/scripts/dsh-acp-zed.sh # 模拟 Zed 的 spawn 方式
149
149
  ```
150
150
 
151
151
  ### 可选:web_search 走同一个网关
152
152
 
153
- 桥接器本身不依赖它。若网关实现 OpenAI Responses 的 `web_search` 服务端工具,可把搜索也
154
- 路由到网关(复用同一凭据)。装一个普通依赖 + 在 profile 的 `cordis.patch.yml` 追加两段:
153
+ 若网关实现 OpenAI Responses 的 `web_search` 服务端工具,可把搜索也路由到网关(复用
154
+ 同一凭据)。装子包并给 profile 的 `cordis.patch.yml` 追加两段:
155
155
 
156
156
  ```sh
157
- dsh plugin --profile acp-enhanced add "link:/absolute/path/to/dsh-acp-enhanced/packages/dsh-web-search-openrouter"
157
+ dsh plugin --profile acp-enhanced add dsh-web-search-openrouter
158
158
  ```
159
159
 
160
- `~/.dsh/profiles/acp-enhanced/cordis.patch.yml`(`<provider>` 填你的网关 provider id):
161
-
162
160
  ```yaml
163
161
  - id: web
164
162
  config:
@@ -174,117 +172,29 @@ dsh plugin --profile acp-enhanced add "link:/absolute/path/to/dsh-acp-enhanced/p
174
172
  apiKeyEnv: <KEY_ENV_NAME>
175
173
  ```
176
174
 
177
- ### 故障排查
175
+ ## 故障排查
178
176
 
179
- | 症状 | 原因与解决办法 |
177
+ | 症状 | 处理 |
180
178
  |---|---|
181
- | `Server exited with status 127` / `exec: dsh: not found` | Zed 的 PATH 缺少 `node`/`dsh`。请用随附的 `dsh-acp-zed.sh` 启动器(它会解析两者);可用 `bash scripts/dsh-acp-zed.sh` 在干净 shell 中验证。 |
182
- | `no API key for provider route "deepseek-official"` | 无法解析 key。写入 `~/.dsh/.credentials.yaml`(见第 2 步),或在 agent_servers 条目里设置 `env.DEEPSEEK_API_KEY`。 |
183
- | 编辑设置后 agent 未出现 | 执行 `zed: reload settings`(命令面板)或重启 Zed。 |
184
- | 在 Zed 里"无法切换模型"或"上下文用量不显示" | 通常是选到了不可路由的幽灵 provider(例如某个适配器已挂载但没有任何可用的 API key)。本桥接器默认已过滤幽灵分组(只广播 `config.provider` 模型),若仍出现请确认 profile 的 `config.provider` 指向真实可用的路由,并把被污染的 `agent-default-model` 默认重置回该路由。见"设计说明"。 |
185
- | `session/new` 报 `additionalDirectories is not supported` | ACP 桥接器仅支持 baseline;Zed 默认不会发送额外目录——若自定义配置发送了就移除它。 |
186
- | 需要详细诊断 | 用 `ACP_DEBUG=1 dsh --profile acp-enhanced` 启动(stderr 上的生命周期 trace)。 |
179
+ | `exec: dsh: not found`(status 127) | 用随附 `dsh-acp-zed.sh` 启动器(自定位 node/dsh) |
180
+ | `no API key for provider route "xxx"` | 写入 `~/.dsh/.credentials.yaml`,或在 agent_servers 里设 `env.DEEPSEEK_API_KEY` |
181
+ | 无法切换模型 / 上下文用量不显示 | 选到了不可路由的"幽灵 provider";本桥默认过滤(只广播 `config.provider` 的模型),确认 profile 的 provider 指向真实路由 |
182
+ | 需要详细诊断 | `ACP_DEBUG=1 dsh --profile acp-enhanced`(stderr 生命周期 trace) |
187
183
 
188
184
  ## 开发
189
185
 
190
186
  ```sh
191
- node scripts/acp-client.mjs # 端到端冒烟测试(需要 DEEPSEEK_API_KEY)
192
- DEEPSEEK_API_KEY=... node scripts/acp-client.mjs
193
- node scripts/acp-client-tools.mjs # 客户端工具测试(模拟 Zed 的 fs/terminal/elicitation/plan 能力)
194
- node scripts/acp-resume-test.mjs # 会话恢复测试(两个进程:创建持久化 → 加载回放 → 续聊)
195
- ACP_DEBUG=1 dsh --profile acp-enhanced # stderr 上的详细生命周期 trace
187
+ node scripts/acp-client.mjs # 端到端冒烟(需要 API key)
188
+ node scripts/acp-client-tools.mjs # 客户端工具测试(模拟 Zed 的 fs/terminal/elicitation/plan)
189
+ node scripts/acp-mcp-test.mjs # MCP 挂载测试(无模型调用)
190
+ node scripts/acp-smoke-keyless.mjs # keyless 冒烟(CI 用)
191
+ node scripts/acp-resume-test.mjs # 会话恢复测试
196
192
  ```
197
193
 
198
- 该冒烟客户端驱动 initialize → session/new → prompt(验证块级流式、`usage_update`、
199
- `tool_call`)、配置项与模式切换、切换后的第二次 prompt,以及 `session/cancel`。
200
- `acp-client-tools.mjs` 用 SDK 的 `ClientSideConnection` 模拟 Zed:声明
201
- `fs.readTextFile/writeTextFile/terminal/elicitation` 能力,验证模型调用 `zed_*` 工具时请求以
202
- `fs/write_text_file`、`fs/read_text_file`、`terminal/create` 正确到达客户端,`ask_user_question`
203
- 以 `elicitation/create` 表单(含 enum 选项)到达客户端,`plan_mode` 布尔开关触发 ACP `plan`
204
- update(开→条目、关→清空),并验证无 reasoning efforts 的路由不再广播空 `reasoning_effort`。
205
-
206
- ## 设计说明
207
-
208
- - **块级流式输出**:文本增量按块索引累积;`block-end` 一旦确认就立即上送线上。重试会重启
209
- 同一个索引,因此被取消尝试的残留尾部永远到不了客户端——ACP 没有撤销机制,这是最干净的边界。
210
- - **遥测**:每个 provider 的 `usage` 样本都会以 `usage_update` 广播(used = 输入 + 缓存命中
211
- + 缓存写入;size = 所路由模型的上下文窗口),完整明细在 `_meta` 中:输入/输出/缓存/推理
212
- token、`cacheHitRate`、`tps`(生成 token / step 墙钟耗时)、step 耗时、轮次计数,以及累计的
213
- 工具调用统计。
214
- - **工具调用可见性**:`tool_call` 通知带 `kind` 与 `rawInput`(`JSON.parse` 参数,失败则回退为
215
- 字符串),Zed 的工具卡片因此能展开看到具体参数(bash 的命令、写入的文件等);
216
- `tool_call_update` 带 `rawOutput`(从 `ToolResultMessage` 的文本块提取结果预览,截断 12k)。
217
- **`kind` 映射有个关键约束**:Zed 把 `kind == 'execute'` 当**终端工具**、`kind == 'edit'`
218
- 当 **diff 工具**,两者都会**隐藏 rawInput**。所以只有真正在 Zed 里开终端的 `zed_terminal`
219
- 用 `execute`;bash/run_code/写文件等一律 `other`(rawInput 正常显示),否则就会出现"卡片只
220
- 显示 bash 字样、看不到命令"的现象。另注意 dsh 的 `tool/result` 事件里 `toolCallId` 在
221
- `message.content[0].toolCallId`(`ToolResultBlock`)上,不在事件根——漏取会导致 SDK 校验
222
- 拒绝整条 `tool_call_update`。历史回放(resume)同样携带这些字段。
223
- - **会话配置**:`model` 下拉框枚举实时模型目录(`ctx.llm.listProviders` → `listModels` →
224
- `resolveModelInfo`),`reasoning_effort` 下拉框枚举当前路由的可用强度,`permission_preset`
225
- 枚举已挂载的预设。修改走 `llm.resolveCallConfig` 与 `installModelSelection`(与 Web
226
- api-proxy 使用的同一机制)或 `permissionPresets.apply` 写路径。
227
- - **模型分组线格式**:`model` 选项的分组必须是 ACP 规范的
228
- `{ group: <id>, name: <label>, options: [...] }`。早期版本发成了 `{ groupName, options }`,
229
- Zed(`agent-client-protocol-schema` 1.4.0)反序列化时把整个组跳过(`DefaultOnError` +
230
- `VecSkipError`),于是 `model` 下拉框变空、模型无法选择——而 SDK 的 mock 客户端不校验
231
- 响应所以测试没拦住;现在 `acp-client-tools.mjs` 会对每个 config option 跑
232
- `zSessionConfigOption.safeParse`,这类线格式错误不会再漏网。
233
- - **推理强度的路由限制**:`reasoning_effort` 选项只在模型路由**暴露** efforts 时广播
234
- (`resolveModelInfo().reasoning.efforts` 非空)。对不暴露 efforts 的路由,显式设置强度会被
235
- 适配器拒绝(`does not support reasoning effort "high"`)——所以这类路由下 Zed 里没有推理
236
- 强度下拉是**正确行为**,不是桥接器 bug;换到暴露 efforts 的路由后下拉框会自动出现。
237
- - **模型目录过滤(`includeAllProviders`,默认关)**:默认只广播 `config.provider` 的模型,
238
- 避免把"幽灵 provider"(已挂载但不可路由的适配器,例如没有可用 API key 而仍挂载的
239
- `deepseek-official`)列进下拉框。这些模型在列表里看起来可切换,但一旦选中,后续每次
240
- prompt 都会以 `MISSING_CREDENTIAL`(`no API key for provider route "xxx"`)失败——在
241
- Zed 里表现为"无法切换模型、且因 turn 失败而不再收到 `usage_update`,上下文用量不显示"
242
- (Web GUI 会对不可路由的当前项显示 unavailable 横幅,Zed 没有,所以同样的数据在 Zed
243
- 里看起来就是坏的)。需要多 provider 都可用时设 `includeAllProviders: true`。
244
- - **默认模型不被污染**:`applySelection` 只有在 `selected.provider === config.provider`(或
245
- 显式 `includeAllProviders`)时才把新选择持久化为 `agent-default-model` 默认值。否则一次
246
- 误切到不可路由的 provider 只会作用于当前会话,不会写坏后续所有新会话的默认路由。
247
- - **客户端转发工具(Zed fs / terminal)**:`initialize` 时读取 `clientCapabilities`,仅在客户端
248
- 声明对应能力时,向 agent 注册 `zed_read_text_file` / `zed_write_text_file` / `zed_terminal`
249
- 三个工具(`ctx.tools.register` + `defineTool`)。工具体通过 `conn.readTextFile` /
250
- `conn.writeTextFile` / `conn.createTerminal` 把请求转发给编辑器:`zed_write_text_file` 让
251
- 文件编辑落在 Zed 自己的 buffer 上,出现在 agent 面板的"编辑文件"区(diff + 接受/拒绝);
252
- `zed_terminal` 让命令跑在 Zed 真实终端里并轮询输出(`terminal/output` 是累计内容,取最后
253
- 一次即可),120s 超时后 kill。无这些能力的客户端(如纯自动化测试)不会看到这些工具。
254
- - **Zed 表单提问(elicitation)**:客户端声明 `elicitation.form` 时,桥接器注册
255
- `ask_user_question` 工具(复刻 `dsh-tool-ask-user` 的定义,走 `ctx.userQuestions` seam)
256
- 以及对应的 UI provider:把问题映射成 ACP `elicitation/create`(form 模式)的 JSON Schema
257
- (单选 → `string`+`enum`,多选 → `array`,无选项 → 裸 `string`),用户在 Zed 里以原生
258
- 表单作答后,答案映射回 `AskUserQuestionAnswer` 喂回模型。decline/cancel 会以错误结束该次
259
- 工具调用,模型可据此改道。注意 Zed 的 elicitation 能力是对象(`form: {}`)而非布尔,
260
- 判断用"存在"而非 `=== true`。
261
- - **Plan 面板**:`plan_mode` 布尔配置项走 `ctx.planMode.set(agent, active)` 切换 DSH plan
262
- mode(Zed 的布尔开关即点即用);`session/event` 里的 `plan/mode` 翻转被映射为 ACP `plan`
263
- update——开时一条"规划中"条目,关时清空。DSH 的 plan mode 没有结构化任务列表,所以这是
264
- 状态指示而非任务清单。注意 ACP 的 `plan` update 是**扁平**形状
265
- (`{ sessionUpdate: 'plan', entries: [...] }`),不是 `{ plan: {...} }`。
266
- - **会话恢复(session/load)**:`initialize` 声明 `loadSession: true`;`session/load` 通过
267
- `ctx.agents.resume({ resumeSessionId })` 从持久化存储(`dsh-session-persistence-jsonl`,
268
- dsh-base 已挂载)恢复 agent,然后按事件日志回放历史:`user/message`(仅
269
- `source.kind === 'user'` 的真实人类消息,过滤 system-reminder 等合成注入)→
270
- `user_message_chunk`,`assistant/message` 的文本 → `agent_message_chunk`,
271
- `tool/call`/`tool/result` → `tool_call`/`tool_call_update`。Zed 在线程插入后才完成 load
272
- RPC,所以回放通知能被线程接收。回放完成后该会话与新建会话一样支持继续 prompt。
273
- - **会话归档列表(session/list + session/delete)**:`initialize` 声明
274
- `sessionCapabilities: { list: {}, delete: {} }`;`session/list` 用
275
- `ctx.sessionPersistence.list()` 枚举已物化的会话(`SessionHeader`:id/cwd/createdAt),
276
- 标题优先取实时 `session/title` 事件记录,缺失时用 `persistence.readRaw(id)` 扫存储日志里
277
- 最后一个 `session/title` 事件(>8MB 的日志跳过);按 `updatedAt` 倒序返回。`session/delete`
278
- 先释放在线 agent(`sessions.delete` + `dispose`),再通过 `persistence.locate(header)` 拿到
279
- 该会话目录物理路径并整体删除——注意 dsh 持久化面**没有官方的删除 API**,这一步是直接删
280
- 后端目录。实时标题/活动变化通过 `session_info_update` 通知推送(`session/title` 与
281
- `turn/end` 事件)。
282
- - **空 effort 抑制**:当前路由模型不暴露 reasoning efforts 时,不广播 `reasoning_effort`
283
- 配置项——Zed 就不会渲染一个空的、无法操作的"Reasoning effort"chip。切到带 efforts 的
284
- 模型后该选项自动重新出现(每次切换都会重播 `config_option_update`)。
285
- - **模式**:权限预设被呈现为 ACP 会话模式,因此 Zed 的模式切换器驱动 sandbox/approval 预设。
286
- - **已知限制**(继承自官方桥接器):仅 baseline prompt(无图片/音频/MCP 附件)、不支持
287
- `additionalDirectories`/MCP server 附加、已确认文本按块粒度流式,且每个会话同时只能有一个
288
- in-flight prompt。会话恢复与归档列表已支持(见上),但 `session/close` / `session/fork` /
289
- `session/resume` 未实现(不声明能力,合规客户端不会调用);`session/delete` 因 dsh 持久化
290
- 面没有官方删除 API,采用直接删除后端目录的方式。
194
+ ## 已知限制
195
+
196
+ 仅 baseline prompt(无图片/音频附件)、不支持 `additionalDirectories`、文本按块粒度
197
+ 流式、每会话同时一个 in-flight prompt。MCP 支持 stdio 与 streamable HTTP(不声明
198
+ legacy SSE / `acp` 传输)。`session/close` / `session/fork` / `session/resume` 未实现
199
+ (不声明能力,合规客户端不会调用);`session/delete` 因 dsh 持久化无官方删除 API,
200
+ 采用直接删除后端目录的方式。
package/lib/index.js CHANGED
@@ -341,9 +341,14 @@ export function apply(ctx, config) {
341
341
  switch (chunk.type) {
342
342
  case 'block-start':
343
343
  if (chunk.blockType === 'text') record.buffer[chunk.index] = ''
344
+ else if (chunk.blockType === 'reasoning') record.thoughtBuffer[chunk.index] = ''
344
345
  break
345
346
  case 'text-delta':
346
347
  if (record.buffer[chunk.index] !== undefined) record.buffer[chunk.index] += chunk.text
348
+ else if (record.thoughtBuffer[chunk.index] !== undefined) record.thoughtBuffer[chunk.index] += chunk.text
349
+ break
350
+ case 'reasoning-delta':
351
+ if (record.thoughtBuffer[chunk.index] !== undefined) record.thoughtBuffer[chunk.index] += chunk.text
347
352
  break
348
353
  case 'block-end': {
349
354
  const text = record.buffer[chunk.index]
@@ -358,6 +363,18 @@ export function apply(ctx, config) {
358
363
  },
359
364
  })
360
365
  }
366
+ const thought = record.thoughtBuffer[chunk.index]
367
+ delete record.thoughtBuffer[chunk.index]
368
+ if (chunk.block.type === 'reasoning' && thought !== undefined && thought.length > 0) {
369
+ notify({
370
+ sessionId: record.agent.session.id,
371
+ update: {
372
+ sessionUpdate: 'agent_thought_chunk',
373
+ messageId: record.messageId,
374
+ content: { type: 'text', text: thought },
375
+ },
376
+ })
377
+ }
361
378
  break
362
379
  }
363
380
  case 'usage':
@@ -886,6 +903,104 @@ export function apply(ctx, config) {
886
903
  }
887
904
  }
888
905
 
906
+ // ── per-session MCP servers (dsh-mcp-client mounts) ──────────────────────
907
+
908
+ const mcpMounts = new Map() // serverName → { configJson, fiber }
909
+ let mcpClientModule
910
+ let warnedNoMcpClient = false
911
+
912
+ function sanitizeMcpServerName(raw) {
913
+ const cleaned = String(raw ?? 'server').replace(/[^A-Za-z0-9_-]/g, '_').slice(0, 32)
914
+ return cleaned.length > 0 ? cleaned : 'server'
915
+ }
916
+
917
+ /** Project one ACP McpServer onto an mcp-client config, if supported. */
918
+ function mcpConfigFor(server, cwd, serverName) {
919
+ const envList = (list) => Object.fromEntries(
920
+ (Array.isArray(list) ? list : []).map((entry) => [entry.name, entry.value]),
921
+ )
922
+ if (typeof server.command === 'string') {
923
+ return {
924
+ transport: 'stdio',
925
+ serverName,
926
+ command: server.command,
927
+ args: Array.isArray(server.args) ? server.args : [],
928
+ env: envList(server.env),
929
+ cwd: typeof server.cwd === 'string' ? server.cwd : cwd,
930
+ // A dead or misconfigured server must not take the session down;
931
+ // its tools simply stay out of the model's tool list.
932
+ failOnStartupError: false,
933
+ }
934
+ }
935
+ if (server.type === 'http' && typeof server.url === 'string') {
936
+ return {
937
+ transport: 'streamable-http',
938
+ serverName,
939
+ url: server.url,
940
+ headers: envList(server.headers),
941
+ failOnStartupError: false,
942
+ }
943
+ }
944
+ return undefined
945
+ }
946
+
947
+ /**
948
+ * Mount/reuse/replace mcp-client instances for a session's server list.
949
+ * Mounts are connection-scoped: the latest session's list wins, so tools
950
+ * registered under `mcp__<serverName>__<tool>` are available to every
951
+ * session on this connection. `ctx.loader.import` resolves the module from
952
+ * the host dsh installation (single module instance, never a profile copy).
953
+ */
954
+ async function syncMcpServers(servers, cwd) {
955
+ if (servers === undefined || servers.length === 0) return
956
+ let module = mcpClientModule
957
+ if (module === undefined) {
958
+ try {
959
+ module = await ctx.loader.import('@deepseek-ai/dsh-mcp-client')
960
+ mcpClientModule = module
961
+ } catch (error) {
962
+ if (!warnedNoMcpClient) {
963
+ warnedNoMcpClient = true
964
+ logger.warn(`acp-enhanced: ignoring ${servers.length} MCP server(s): @deepseek-ai/dsh-mcp-client is not available: ${String(error.message ?? error)}`)
965
+ }
966
+ return
967
+ }
968
+ }
969
+ const taken = new Set()
970
+ for (const entry of servers) {
971
+ const base = sanitizeMcpServerName(entry.name)
972
+ let serverName = base
973
+ for (let n = 2; taken.has(serverName); n += 1) serverName = `${base.slice(0, 28)}_${n}`
974
+ const cfg = mcpConfigFor(entry, cwd, serverName)
975
+ if (cfg === undefined) {
976
+ logger.warn(`acp-enhanced: skipping MCP server "${serverName}": unsupported transport`)
977
+ continue
978
+ }
979
+ const configJson = JSON.stringify(cfg)
980
+ const existing = mcpMounts.get(serverName)
981
+ if (existing !== undefined) {
982
+ if (existing.configJson === configJson) {
983
+ taken.add(serverName)
984
+ continue
985
+ }
986
+ try {
987
+ void existing.fiber.dispose()
988
+ } catch (error) {
989
+ logger.warn(`acp-enhanced: disposing MCP server "${serverName}": ${String(error)}`)
990
+ }
991
+ mcpMounts.delete(serverName)
992
+ }
993
+ try {
994
+ const fiber = ctx.plugin(module, cfg)
995
+ mcpMounts.set(serverName, { configJson, fiber })
996
+ taken.add(serverName)
997
+ logger.info(`acp-enhanced: mounted MCP server "${serverName}" (${cfg.transport})`)
998
+ } catch (error) {
999
+ logger.warn(`acp-enhanced: failed to mount MCP server "${serverName}": ${String(error)}`)
1000
+ }
1001
+ }
1002
+ }
1003
+
889
1004
  // ── session records + history replay ─────────────────────────────────────
890
1005
 
891
1006
  /** Build the bridge-owned protocol record for a fresh or resumed agent. */
@@ -922,6 +1037,7 @@ export function apply(ctx, config) {
922
1037
  lastActivityAt: undefined,
923
1038
  toolStats: { count: 0, totalMs: 0, lastCallAt: undefined, lastName: undefined },
924
1039
  buffer: {},
1040
+ thoughtBuffer: {},
925
1041
  contextWindow: undefined,
926
1042
  lastUsage: undefined,
927
1043
  selection,
@@ -973,6 +1089,15 @@ export function apply(ctx, config) {
973
1089
  return parts.join('\n')
974
1090
  }
975
1091
 
1092
+ /** Extract reasoning text from assistant content blocks (replay only). */
1093
+ function thoughtFromBlocks(content) {
1094
+ const parts = []
1095
+ for (const block of content ?? []) {
1096
+ if (block?.type === 'reasoning' && typeof block.text === 'string') parts.push(block.text)
1097
+ }
1098
+ return parts.join('\n')
1099
+ }
1100
+
976
1101
  /**
977
1102
  * Replay a resumed session's conversation to the client as ACP session
978
1103
  * notifications. Zed inserts the thread before the load RPC completes, so
@@ -1001,12 +1126,22 @@ export function apply(ctx, config) {
1001
1126
  }
1002
1127
  case 'assistant/message': {
1003
1128
  const text = textFromBlocks(event.data.message.content)
1004
- if (text.trim().length === 0) break
1005
- await notifyNow(record, {
1006
- sessionUpdate: 'agent_message_chunk',
1007
- messageId: randomUUID(),
1008
- content: { type: 'text', text },
1009
- })
1129
+ const thought = thoughtFromBlocks(event.data.message.content)
1130
+ if (text.trim().length === 0 && thought.trim().length === 0) break
1131
+ if (text.trim().length > 0) {
1132
+ await notifyNow(record, {
1133
+ sessionUpdate: 'agent_message_chunk',
1134
+ messageId: randomUUID(),
1135
+ content: { type: 'text', text },
1136
+ })
1137
+ }
1138
+ if (thought.trim().length > 0) {
1139
+ await notifyNow(record, {
1140
+ sessionUpdate: 'agent_thought_chunk',
1141
+ messageId: randomUUID(),
1142
+ content: { type: 'text', text: thought },
1143
+ })
1144
+ }
1010
1145
  break
1011
1146
  }
1012
1147
  case 'tool/call':
@@ -1067,11 +1202,14 @@ export function apply(ctx, config) {
1067
1202
  syncClientTools()
1068
1203
  return Promise.resolve({
1069
1204
  protocolVersion: PROTOCOL_VERSION,
1070
- agentInfo: { name: 'deepseek-harness-acp-enhanced', version: '0.1.0' },
1205
+ agentInfo: { name: 'deepseek-harness-acp-enhanced', version: '0.2.0' },
1071
1206
  agentCapabilities: {
1072
1207
  loadSession: true,
1073
1208
  sessionCapabilities: { list: {}, delete: {} },
1074
1209
  promptCapabilities: { image: false, audio: false, embeddedContext: false },
1210
+ // Stdio MCP servers always work; streamable HTTP maps onto
1211
+ // dsh-mcp-client's second transport. Legacy SSE does not.
1212
+ mcpCapabilities: { http: true, sse: false },
1075
1213
  },
1076
1214
  authMethods: [],
1077
1215
  })
@@ -1099,6 +1237,7 @@ export function apply(ctx, config) {
1099
1237
  }
1100
1238
  const record = makeRecord(handle)
1101
1239
  sessions.set(sessionId, record)
1240
+ await syncMcpServers(params.mcpServers, params.cwd)
1102
1241
  const permission = permissionPresets()
1103
1242
  return {
1104
1243
  sessionId,
@@ -1125,7 +1264,7 @@ export function apply(ctx, config) {
1125
1264
  if (params.additionalDirectories !== undefined && params.additionalDirectories.length > 0) {
1126
1265
  throw invalidParams('additionalDirectories is not supported')
1127
1266
  }
1128
- if (params.mcpServers.length > 0) throw invalidParams('mcpServers is not supported')
1267
+ await syncMcpServers(params.mcpServers, params.cwd)
1129
1268
  const sessionId = SessionId(params.sessionId)
1130
1269
  const live = sessions.get(sessionId)
1131
1270
  if (live !== undefined) {
@@ -1385,6 +1524,14 @@ export function apply(ctx, config) {
1385
1524
  record.agent.cancel({ kind: 'user' })
1386
1525
  settlePrompt(record, 'cancelled')
1387
1526
  }
1527
+ for (const { fiber } of mcpMounts.values()) {
1528
+ try {
1529
+ void fiber.dispose()
1530
+ } catch (error) {
1531
+ logger.warn(`acp-enhanced: disposing MCP server mount failed: ${String(error)}`)
1532
+ }
1533
+ }
1534
+ mcpMounts.clear()
1388
1535
  quiescing = (async () => {
1389
1536
  const subagents = ctx.get('subagents')
1390
1537
  if (subagents?.drainContinuableDescendants !== undefined) {
@@ -1425,5 +1572,4 @@ function validateSessionParams(params) {
1425
1572
  if (params.additionalDirectories !== undefined && params.additionalDirectories.length > 0) {
1426
1573
  throw invalidParams('additionalDirectories is not supported')
1427
1574
  }
1428
- if (params.mcpServers.length > 0) throw invalidParams('mcpServers is not supported')
1429
1575
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-acp-enhanced",
3
- "version": "0.1.1",
3
+ "version": "0.2.1",
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",
@@ -32,7 +32,7 @@
32
32
  "test:client": "node scripts/acp-client.mjs"
33
33
  },
34
34
  "dependencies": {
35
- "@agentclientprotocol/sdk": "0.25.1",
35
+ "@agentclientprotocol/sdk": "1.3.0",
36
36
  "@deepseek-ai/schemastery": "^3.18.1",
37
37
  "zod": "^4.4.3"
38
38
  },
@@ -44,6 +44,7 @@
44
44
  "@deepseek-ai/dsh-agent-instructions": "^0.1.0-rc.6",
45
45
  "@deepseek-ai/dsh-invariants": "^0.1.0-rc.6",
46
46
  "@deepseek-ai/dsh-llm": "^0.1.0-rc.6",
47
+ "@deepseek-ai/dsh-mcp-client": "^0.1.0-rc.6",
47
48
  "@deepseek-ai/dsh-permission-presets": "^0.1.0-rc.6",
48
49
  "@deepseek-ai/dsh-session": "^0.1.0-rc.6",
49
50
  "@deepseek-ai/dsh-session-query": "^0.1.0-rc.6",
@@ -58,6 +59,7 @@
58
59
  "@deepseek-ai/dsh-agent-instructions": "0.1.0-rc.6",
59
60
  "@deepseek-ai/dsh-invariants": "0.1.0-rc.6",
60
61
  "@deepseek-ai/dsh-llm": "0.1.0-rc.6",
62
+ "@deepseek-ai/dsh-mcp-client": "0.1.0-rc.6",
61
63
  "@deepseek-ai/dsh-permission-presets": "0.1.0-rc.6",
62
64
  "@deepseek-ai/dsh-session": "0.1.0-rc.6",
63
65
  "@deepseek-ai/dsh-session-query": "0.1.0-rc.6",