dsh-acp-enhanced 0.2.0 → 0.3.0

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 +103 -224
  2. package/README.md +89 -180
  3. package/lib/index.js +130 -1
  4. package/package.json +1 -1
package/README-en.md CHANGED
@@ -3,65 +3,82 @@
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
- | **Reasoning streaming** | reasoning blocks (`blockType: 'reasoning'`) commit the same way, sent as `agent_thought_chunk` | the model's thinking scrolls live in Zed's thinking area |
17
- | **Token & context telemetry** | standard `usage_update` (`used` = context pressure, `size` = model context window) | Context meter in the agent status bar |
18
- | **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) |
19
- | **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 |
20
- | **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 |
21
- | **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) |
22
- | **Permission presets** | `session/set_config_option` (`permission_preset`) **and** ACP session modes via `session/set_mode` | Mode switcher / config-option UI |
23
- | **Approval** | `session/request_permission` (allow-once / reject-once per tool call) | Native permission prompt |
24
- | **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** |
25
- | **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 |
26
- | **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 |
27
- | **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) |
28
- | **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 |
29
- | **Empty-option suppression** | no `reasoning_effort` option is advertised when the routed model exposes no efforts | No empty, unclickable "Reasoning effort" chip |
30
- | **MCP servers** | `session/new` / `session/load` `mcpServers` mount `@deepseek-ai/dsh-mcp-client` per entry (stdio + streamable HTTP, resolved from the host dsh install for a single module instance); tools join as `mcp__<serverName>__<tool>`; a failing server never takes the session down | tools from any MCP server (database, browser, filesystem, …) land directly in the model's tool list |
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
31
60
 
32
61
  ## Preview
33
62
 
34
- After picking **dsh-acp-enhanced** in Zed's AI Agent panel, you get:
63
+ After picking **dsh-acp-enhanced** in Zed's AI Agent panel:
35
64
 
36
65
  <img src="assets/screenshots/approval-config-context.png" alt="Approval popup, model/reasoning-effort switches, context ring" width="560">
37
66
 
38
- - Tool calls that need permission pop a **native approval prompt** (allow-once /
39
- reject-once); below the input box sit the **model**, **reasoning effort**,
40
- **permission preset**, and **plan mode** config options plus the **context usage
41
- ring** (`usage_update` telemetry with cache hit rate, TPS, and more).
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.
42
70
 
43
71
  <img src="assets/screenshots/tool-cards-elicitation.png" alt="Tool call inputs and outputs, native Zed question form" width="320">
44
72
 
45
- - **Tool cards** expand to show each call's full arguments (e.g. the exact bash
46
- command) and the result preview (`rawInput` / `rawOutput`); when dsh needs your
47
- confirmation or a choice, the question arrives as a **native Zed form**
48
- (`ask_user_question` → `elicitation/create`) — click an option, no typing.
49
-
50
- > **Repository layout** — this repo contains two independent packages:
51
- > - `dsh-acp-enhanced` (repo root): the enhanced ACP bridge (`lib/index.js`).
52
- > - `packages/dsh-web-search-openrouter/`: a standalone `ctx.web` search provider that
53
- > routes `web_search` through any OpenAI-Responses gateway instead of DeepSeek's
54
- > Anthropic `/messages` endpoint. It deliberately does **not** couple to the ACP
55
- > bridge, so any profile (the Web GUI included) can mount it.
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.
56
76
 
57
77
  ## Quick start
58
78
 
59
- This package follows the official dsh plugin conventions (it declares `dsh.bundle`),
60
- so installation is identical to any official bundle: **one command** —
61
- `dsh plugin --profile <name> add <pkg>` auto-initializes the profile (the first layer
62
- `dsh-base` already carries the whole agent stack), installs the package, and
63
- **auto-appends it to the profile's bundle layers**. The shipped patch inserts the
64
- `acp-enhanced` row and overrides the default model route — **no profile YAML to write**.
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.
65
82
 
66
83
  ### Install (2 steps)
67
84
 
@@ -71,16 +88,12 @@ so installation is identical to any official bundle: **one command** —
71
88
  dsh plugin --profile acp-enhanced add dsh-acp-enhanced
72
89
  ```
73
90
 
74
- > When hacking on the code itself, use `link:` to a local checkout instead (live
75
- > edits, no registry round-trip):
91
+ > When hacking on the code, use `link:` to a local checkout instead (live edits):
76
92
  > `dsh plugin --profile acp-enhanced add "link:/absolute/path/to/dsh-acp-enhanced"`
77
93
 
78
- **Step 2 — register in Zed** (the model route and credentials all come from `env`; no
79
- patch to write)
80
-
81
- Register the agent under `agent_servers` in `~/.config/zed/settings.json`. Zed (a GUI
82
- app) spawns agent processes with a minimal PATH, so use the shipped launcher
83
- `scripts/dsh-acp-zed.sh` (it locates `node`/`dsh` itself).
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)
84
97
 
85
98
  #### Most common: DeepSeek official API (the default route)
86
99
 
@@ -101,16 +114,12 @@ app) spawns agent processes with a minimal PATH, so use the shipped launcher
101
114
  }
102
115
  ```
103
116
 
104
- > This is the setup the author uses daily (macOS). `DSH_ACP_PROVIDER` / `DSH_ACP_MODEL`
105
- > match the shipped patch's defaults (`deepseek-official` / `deepseek-v4-flash`), so
106
- > **both can be omitted entirely** — writing them out just makes the route explicit in
107
- > your Zed config. The API key does not have to live in Zed: store it in
108
- > `~/.dsh/.credentials.yaml` (`DEEPSEEK_API_KEY`, mode 600) and the dsh credentials
109
- > service resolves it; the launcher additionally falls back to inheriting the key from
110
- > a running `dsh web` process.
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.
111
121
 
112
- Optional: pin the panel's default config options (model / plan mode / reasoning
113
- effort; all still changeable in the panel at any time):
122
+ Optional: pin the panel's default config options (all still changeable in the panel):
114
123
 
115
124
  ```jsonc
116
125
  "dsh-acp-enhanced": {
@@ -128,8 +137,8 @@ effort; all still changeable in the panel at any time):
128
137
 
129
138
  #### Extended: route through an OpenAI-Responses gateway (e.g. a company model gateway)
130
139
 
131
- Same install path; only the env values change to the provider/model the gateway
132
- exposes plus the key env var it requires:
140
+ Same install path; only the env values change to the provider/model the gateway exposes
141
+ plus the key env var it requires:
133
142
 
134
143
  ```jsonc
135
144
  "dsh-acp-enhanced": {
@@ -144,39 +153,32 @@ exposes plus the key env var it requires:
144
153
  }
145
154
  ```
146
155
 
147
- > `<KEY_ENV_NAME>` is the env var the provider reads for its key (gateway adapters
148
- > usually declare their own `apiKeyEnv`) — alternatively store it in
149
- > `~/.dsh/.credentials.yaml` and let the dsh credentials service manage it. Every
150
- > route uses the same install path; only the env values differ.
156
+ > `<KEY_ENV_NAME>` can also be omitted and the key stored in
157
+ > `~/.dsh/.credentials.yaml` instead.
151
158
 
152
159
  Zed hot-reloads settings. Open the **AI Agent panel** (`Cmd+Shift+A`) → pick
153
- **dsh-acp-enhanced** in the top **agent selector** → send your first message. Replies
154
- stream in real time, the status bar shows context usage, and the panel exposes Model /
155
- Permission preset / Plan mode config options plus read-only / workspace-write /
156
- full-access modes; the thread archive lists and resumes past sessions.
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.
157
164
 
158
165
  Verify locally (no Zed needed):
159
166
 
160
167
  ```sh
161
168
  node scripts/acp-client.mjs # official default route, no env; expect ALL CHECKS PASSED
162
169
  DSH_ACP_PROVIDER=... DSH_ACP_MODEL=... node scripts/acp-client.mjs # only for a custom route
163
- env -i HOME=$HOME PATH=/usr/bin:/bin node scripts/acp-client.mjs \
164
- /bin/bash /absolute/path/to/dsh-acp-enhanced/scripts/dsh-acp-zed.sh # Zed-like spawn
165
170
  ```
166
171
 
167
172
  ### Optional: route web_search through the same gateway
168
173
 
169
- The bridge does not depend on it. If the gateway implements the OpenAI Responses
170
- `web_search` server tool, you can route search through it too (reusing the same
171
- credential). Install it as a plain dependency and append two blocks to the profile's
172
- `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`:
173
177
 
174
178
  ```sh
175
- dsh plugin --profile acp-enhanced add "link:/absolute/path/to/dsh-acp-enhanced/packages/dsh-web-search-openrouter"
179
+ dsh plugin --profile acp-enhanced add dsh-web-search-openrouter
176
180
  ```
177
181
 
178
- `~/.dsh/profiles/acp-enhanced/cordis.patch.yml` (`<provider>` is your gateway provider id):
179
-
180
182
  ```yaml
181
183
  - id: web
182
184
  config:
@@ -192,153 +194,30 @@ dsh plugin --profile acp-enhanced add "link:/absolute/path/to/dsh-acp-enhanced/p
192
194
  apiKeyEnv: <KEY_ENV_NAME>
193
195
  ```
194
196
 
195
- ### Troubleshooting
197
+ ## Troubleshooting
196
198
 
197
- | Symptom | Cause & fix |
199
+ | Symptom | Fix |
198
200
  |---|---|
199
- | `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. |
200
- | `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. |
201
- | Agent does not appear after editing settings | Run `zed: reload settings` (command palette) or restart Zed. |
202
- | "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. |
203
- | `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. |
204
- | Need detailed diagnostics | Start with `ACP_DEBUG=1 dsh --profile acp-enhanced` (lifecycle trace on stderr). |
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
205
 
206
206
  ## Development
207
207
 
208
208
  ```sh
209
- node scripts/acp-client.mjs # end-to-end smoke test (needs a routable provider)
209
+ node scripts/acp-client.mjs # end-to-end smoke (needs an API key)
210
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 (mounts a minimal stdio MCP server, no model calls)
212
- node scripts/acp-smoke-keyless.mjs # keyless boot smoke (CI: auto-creates a profile → initialize → session/new)
213
- node scripts/acp-resume-test.mjs # resume tests (two processes: create+persist → load+replay → continue)
214
- ACP_DEBUG=1 dsh --profile acp-enhanced # lifecycle trace on stderr
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
215
214
  ```
216
215
 
217
- The smoke client drives initialize → session/new → prompt (verifying block streaming,
218
- `usage_update`, `tool_call`), config-option and mode switching, a second prompt after
219
- switching, and `session/cancel`. `acp-client-tools.mjs` uses the SDK's
220
- `ClientSideConnection` to mock Zed: it declares
221
- `fs.readTextFile/writeTextFile/terminal/elicitation` capabilities and verifies that
222
- `zed_*` tool calls arrive as `fs/write_text_file`, `fs/read_text_file`,
223
- `terminal/create` requests, that `ask_user_question` arrives as an `elicitation/create`
224
- form (with enum options), that the `plan_mode` boolean toggle emits ACP `plan` updates
225
- (on → entry, off → cleared), and that the `reasoning_effort` option is route-conditional
226
- (present with non-empty options on routes with efforts, suppressed on routes without).
227
- `acp-mcp-test.mjs` uses `scripts/fixtures/mcp-echo-server.mjs` to verify that
228
- `session/new` `mcpServers` are really mounted (the server receives initialize and
229
- tools/list) and that an identical list is reused, not re-mounted.
230
-
231
- ## Design notes
232
-
233
- - **Block-level streaming**: text deltas accumulate per block index; a committed
234
- `block-end` goes on the wire immediately. A retry restarts the same index, so the
235
- torn tail of a cancelled attempt never reaches the client — ACP has no undo, and
236
- this is the cleanest boundary.
237
- - **Telemetry**: every provider `usage` sample is broadcast as `usage_update`
238
- (used = input + cache read + cache write; size = the routed model's context
239
- window), with the full breakdown in `_meta`: input/output/cache/reasoning tokens,
240
- `cacheHitRate`, `tps` (generated tokens / step wall-clock), step elapsed, turn
241
- count, and cumulative tool-call stats.
242
- - **Tool-call visibility**: `tool_call` notifications carry `kind` and `rawInput`
243
- (`JSON.parse` of the arguments, falling back to the raw string), so Zed's tool
244
- cards expand to show the exact arguments (bash command, written file, ...);
245
- `tool_call_update` carries `rawOutput` (a bounded text preview extracted from the
246
- `ToolResultMessage`, truncated at 12k). **A key constraint on `kind` mapping**: Zed
247
- treats `kind == 'execute'` as a terminal tool and `kind == 'edit'` as a diff tool,
248
- and **hides rawInput for both**. So only `zed_terminal` (a real editor terminal)
249
- maps to `execute`; bash/run_code/write tools stay `other` so rawInput renders —
250
- otherwise the card shows only the tool name with no command. Also note the dsh
251
- `tool/result` event carries `toolCallId` on `message.content[0].toolCallId`
252
- (the `ToolResultBlock`), not on the event root — missing it makes the SDK reject
253
- the whole `tool_call_update`. History replay (resume) carries the same fields.
254
- - **Session config**: the `model` select enumerates the live model catalog
255
- (`ctx.llm.listProviders` → `listModels` → `resolveModelInfo`), `reasoning_effort`
256
- enumerates the routed model's efforts, `permission_preset` enumerates the mounted
257
- presets. Writes go through `llm.resolveCallConfig` + `installModelSelection` (the
258
- same mechanism the Web api-proxy uses) or `permissionPresets.apply`.
259
- - **Model grouped-select wire shape**: the `model` option's groups must use the ACP
260
- shape `{ group: <id>, name: <label>, options: [...] }`. An early version emitted
261
- `{ groupName, options }`; Zed (`agent-client-protocol-schema` 1.4.0) silently
262
- skipped the whole group on deserialization (`DefaultOnError` + `VecSkipError`),
263
- leaving the dropdown empty — and the SDK mock client does not validate agent
264
- responses, so tests missed it. Now `acp-client-tools.mjs` runs
265
- `zSessionConfigOption.safeParse` on every config option, so this class of wire bug
266
- cannot slip through again.
267
- - **Reasoning-effort route limitation**: the `reasoning_effort` option is advertised
268
- only when the routed model **exposes** efforts (`resolveModelInfo().reasoning.efforts`
269
- non-empty). On routes without efforts, explicitly setting one is rejected by the
270
- adapter (`does not support reasoning effort "high"`) — so the absence of an effort
271
- dropdown there is **correct behavior**, not a bug; switching to a route that exposes
272
- efforts makes the dropdown reappear automatically.
273
- - **Model-catalog filtering (`includeAllProviders`, default off)**: by default only
274
- `config.provider` models are advertised, keeping "ghost providers" (adapters that
275
- are mounted but not routable — e.g. a `deepseek-official` with no usable API key)
276
- out of the dropdown. Those models look switchable but every later prompt fails with
277
- `MISSING_CREDENTIAL` (`no API key for provider route "xxx"`) — in Zed that shows up
278
- as "cannot switch models, and no `usage_update` arrives because the turn failed"
279
- (the Web GUI shows an unavailable banner for the current item; Zed does not, so the
280
- same data looks broken there). Set `includeAllProviders: true` when multiple
281
- providers are genuinely usable.
282
- - **Default model cannot be poisoned**: `applySelection` persists the new selection as
283
- the `agent-default-model` default only when `selected.provider === config.provider`
284
- (or explicit `includeAllProviders`). An accidental switch to a non-routable provider
285
- therefore affects only the current session and never corrupts the default route of
286
- every later session.
287
- - **Client-forwarding tools (Zed fs / terminal)**: on `initialize` the bridge reads
288
- `clientCapabilities` and registers `zed_read_text_file` / `zed_write_text_file` /
289
- `zed_terminal` (`ctx.tools.register` + `defineTool`) only when the client declares
290
- the matching capabilities. Tool bodies forward to the editor via
291
- `conn.readTextFile` / `conn.writeTextFile` / `conn.createTerminal`:
292
- `zed_write_text_file` lands edits on Zed's own buffer (the "Edited files" section
293
- with diff + accept/reject); `zed_terminal` runs the command in a real Zed terminal
294
- and polls output (`terminal/output` is cumulative — take the last one), killing after
295
- 120s. Clients without those capabilities (e.g. pure automation) never see these tools.
296
- - **Zed form elicitation**: when the client declares `elicitation.form`, the bridge
297
- registers the `ask_user_question` tool (mirroring `dsh-tool-ask-user`'s definition
298
- through the `ctx.userQuestions` seam) plus the matching UI provider: questions map
299
- to an ACP `elicitation/create` (form mode) JSON Schema (single choice → `string` +
300
- `enum`, multi → `array`, none → bare `string`); the user's native-form answer maps
301
- back to `AskUserQuestionAnswer` for the model. decline/cancel end the tool call with
302
- an error the model can route around. Note Zed's elicitation capability is an object
303
- (`form: {}`), not a boolean — check for presence, not `=== true`.
304
- - **Plan panel**: the `plan_mode` boolean config option toggles DSH plan mode via
305
- `ctx.planMode.set(agent, active)`; `plan/mode` flips in `session/event` map to ACP
306
- `plan` updates — one "planning" entry while active, cleared on exit. DSH plan mode
307
- has no structured task list, so this is a state indicator, not a task list. The ACP
308
- `plan` update is **flat** (`{ sessionUpdate: 'plan', entries: [...] }`), not
309
- `{ plan: {...} }`.
310
- - **Session resume (session/load)**: `initialize` declares `loadSession: true`;
311
- `session/load` resumes the persisted agent via `ctx.agents.resume({ resumeSessionId })`
312
- (`dsh-session-persistence-jsonl`, mounted by dsh-base), then replays history from the
313
- event log: `user/message` (only `source.kind === 'user'` — synthetic injections like
314
- system reminders and skill content are filtered) → `user_message_chunk`,
315
- `assistant/message` text → `agent_message_chunk`, `tool/call`/`tool/result` →
316
- `tool_call`/`tool_call_update`. Zed inserts the thread before the load RPC
317
- completes, so the replay notifications reach it. After replay the session behaves
318
- like a fresh one for further prompts.
319
- - **Session archive list (session/list + session/delete)**: `initialize` declares
320
- `sessionCapabilities: { list: {}, delete: {} }`; `session/list` enumerates
321
- materialized sessions via `ctx.sessionPersistence.list()` (`SessionHeader`:
322
- id/cwd/createdAt), titles come from live `session/title` events or are read
323
- best-effort from the stored log (the last `session/title` event; oversized logs are
324
- skipped), sorted by `updatedAt` descending. `session/delete` disposes the live agent
325
- (`sessions.delete` + `dispose`), then removes the session's directory via
326
- `persistence.locate(header)` — note dsh's persistence surface has **no official
327
- delete API**, so this removes the backend directory directly. Live title/activity
328
- changes are pushed as `session_info_update` notifications (`session/title` and
329
- `turn/end` events).
330
- - **Empty-effort suppression**: when the routed model exposes no reasoning efforts,
331
- the `reasoning_effort` option is not advertised — Zed renders no empty, inoperable
332
- "Reasoning effort" chip. Switching to a model with efforts makes the option reappear
333
- (every switch replays `config_option_update`).
334
- - **Modes**: permission presets are presented as ACP session modes, so Zed's mode
335
- switcher drives the sandbox/approval presets.
336
- - **Known limitations** (inherited from the official bridge): baseline prompts only
337
- (no image/audio attachments), no `additionalDirectories`, committed text streams at
338
- block granularity, and one in-flight prompt per session. MCP servers (stdio +
339
- streamable HTTP) are supported; legacy SSE and `acp` transports are not advertised.
340
- Session resume and the archive list are supported (see above), but
341
- `session/close` / `session/fork` / `session/resume` are not implemented (the
342
- capabilities are not declared, so conforming clients do not call them);
343
- `session/delete` removes the backend directory directly because dsh's persistence
344
- surface has no official delete API.
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.
package/README.md CHANGED
@@ -3,76 +3,86 @@
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
- | **推理流式** | reasoning 块(`blockType: 'reasoning'`)同样按块提交,发送 `agent_thought_chunk` | 模型的思考过程实时滚动(Zed 的 thinking 区域) |
17
- | **Token 与上下文遥测** | 标准 `usage_update`(`used` = 上下文压力,`size` = 模型上下文窗口) | agent 状态栏中的上下文仪表 |
18
- | **缓存命中率 / TPS / 输入-输出-推理 token / 工具耗时 / 轮次计数** | `usage_update._meta` + `tool_call` / `tool_call_update` 的 `_meta` | 每一步都有原始数字(`_meta` 扩展字段携带完整明细) |
19
- | **工具调用可见性** | `tool_call` 携带 `rawInput`(解析后的参数对象)与 `kind`(read/edit/execute/…);`tool_call_update` 携带 `rawOutput`(结果预览,最多 12k 字符) | 工具卡片能展开看到**具体参数**(如 bash 执行的命令)与**执行结果**,并按工具类型渲染图标 |
20
- | **模型切换** | `session/set_config_option`,`model` 下拉框(取值来自实时的 `provider/model` 模型目录;分组线格式为 ACP 规范的 `{ group, name, options }`) | 配置项 UI——可切换路由内任意模型 |
21
- | **推理强度** | `session/set_config_option`,`reasoning_effort` 下拉框(仅当当前模型路由**暴露**可选的 reasoning efforts 时) | 配置项 UI(仅当路由暴露可用 efforts 时出现,见"设计说明") |
22
- | **权限预设** | `session/set_config_option`(`permission_preset`)**以及**通过 `session/set_mode` 的 ACP 会话模式 | 模式切换器 / 配置项 UI |
23
- | **审批** | `session/request_permission`(每个工具调用 allow-once / reject-once) | 原生审批弹窗 |
24
- | **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 真实终端** 里 |
25
- | **Zed 表单提问** | 注册 `ask_user_question` 工具 + `userQuestions` provider,转发为 `elicitation/create`(form 模式) | DSH 需要用户确认/选择时,问题以 **Zed 原生表单** 弹出,选项即点即答 |
26
- | **Plan 面板** | `plan_mode` 布尔配置项(Zed 侧开关)+ `plan/mode` 事件映射为 ACP `plan` update | Zed 底部出现 **Plan 状态条**:plan mode 开时显示"规划中",关时清空 |
27
- | **会话恢复(resume)** | 声明 `loadSession` 能力 + `session/load` 走 `agents.resume` 加载持久化会话,并把历史回放为 `user_message_chunk` / `agent_message_chunk` / `tool_call` | 在 Zed 里可以**继续之前的对话线程**(长排查不丢上下文) |
28
- | **会话归档列表** | 声明 `sessionCapabilities.list/delete`;`session/list` 从持久化存储(`ctx.sessionPersistence.list()`)枚举会话(标题从存储日志的 `session/title` 事件读取),`session/delete` 释放在线 agent 并删除其持久化目录;`session/title` / `turn/end` 实时推送 `session_info_update` | Zed 的**历史线程归档**能看到本项目的全部会话(带标题、按更新时间排序),可点击恢复,也可删除 |
29
- | **空选项抑制** | 当前模型路由无 reasoning efforts 时不广播 `reasoning_effort` 配置项 | 不再出现一个空的、点不动的"Reasoning effort"chip |
30
- | **MCP servers** | `session/new` / `session/load` 的 `mcpServers` 逐项挂载 `@deepseek-ai/dsh-mcp-client`(stdio + streamable HTTP,从宿主 dsh 安装解析、保证单实例);工具注册为 `mcp__<serverName>__<tool>`;失败的 server 不会拖垮会话 | 任意 MCP server 的工具(数据库、浏览器、文件系统…)直接进入模型工具列表 |
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
+ ### 命令
42
+
43
+ - **Slash 命令**:输入 `/` 即可见命令列表(`available_commands_update`):`/status`
44
+ 查看路由与遥测、`/model` 列出或切换模型,其余(`/compact` `/goal` `/permission`
45
+ `/plan`…)直通 harness 命令注册表,全部**不经过模型 turn** 即时执行;未解析的
46
+ slash 放行给模型(`/skill-name` 技能手势)
47
+
48
+ ### MCP
49
+
50
+ - **MCP servers**:`session/new` 的 `mcpServers` 挂载任意 MCP server(stdio +
51
+ streamable HTTP),工具以 `mcp__<server>__<tool>` 注入;失败的 server 不会拖垮会话
31
52
 
32
53
  ## 效果预览
33
54
 
34
- 在 Zed 的 AI Agent 面板中选择 **dsh-acp-enhanced** 后,你会看到:
55
+ 在 Zed 的 AI Agent 面板中选择 **dsh-acp-enhanced** 后:
35
56
 
36
57
  <img src="assets/screenshots/approval-config-context.png" alt="审批弹窗与模型/推理强度切换、上下文环" width="560">
37
58
 
38
- - 工具调用需要许可时弹出**原生审批弹窗**(allow-once / reject-once);输入框下方是
39
- **模型**、**推理强度**、**权限预设**、**Plan mode** 配置项与**上下文用量环**
40
- (`usage_update` 遥测,含缓存命中率、TPS 等明细)。
59
+ - 工具调用需要许可时弹出**原生审批弹窗**;输入框下方是模型、推理强度、权限预设、
60
+ Plan mode 配置项与上下文用量环。
41
61
 
42
62
  <img src="assets/screenshots/tool-cards-elicitation.png" alt="工具调用入参与输出、Zed 原生提问表单" width="320">
43
63
 
44
- - **工具卡片**可展开查看每次调用的完整入参(如 bash 执行的命令)与结果预览
45
- (`rawInput` / `rawOutput`);DSH 需要你确认或选择时,以 **Zed 原生表单**弹出
46
- (`ask_user_question` → `elicitation/create`),选项即点即答,无需手动输入。
47
-
48
- > **仓库结构** —— 本仓库包含两个相互独立的包:
49
- > - `dsh-acp-enhanced`(仓库根目录):增强版 ACP 桥接器(`lib/index.js`)。
50
- > - `packages/dsh-web-search-openrouter/`:独立的 `ctx.web` 搜索 provider,让 `web_search`
51
- > 走任意 OpenAI-Responses 网关,而非 DeepSeek 的 Anthropic `/messages` 端点。它刻意
52
- > **不**与 ACP 桥接器耦合,因此任何 profile(包括 Web GUI)都可挂载。
64
+ - **工具卡片**可展开查看完整入参与结果预览;DSH 需要确认/选择时以 **Zed 原生表单**
65
+ 弹出,选项即点即答。
53
66
 
54
67
  ## 快速开始
55
68
 
56
- 本包遵循 dsh 官方插件规范(声明了 `dsh.bundle`),所以安装与官方组合包完全一致:
57
- **一条命令完成** —— `dsh plugin --profile <名> add <包>` 会自动初始化 profile(首层
58
- `dsh-base` 已含整套 agent 栈)、安装包,并把本包**自动追加进 bundle 层**。包自带的
59
- patch 会插入 `acp-enhanced` 行并覆写默认模型路由,**全程无需手写 profile YAML**。
69
+ 本包遵循 dsh 官方插件规范(声明了 `dsh.bundle`),安装与官方组合包一致:**一条命令**
70
+ 完成,自动初始化 profile、安装包、追加 bundle 层,全程无需手写 profile YAML。
60
71
 
61
72
  ### 安装(2 步)
62
73
 
63
- **第 1 步:安装**(从 npm registry 安装,无需下载源码)
74
+ **第 1 步:安装**(从 npm registry,无需下载源码)
64
75
 
65
76
  ```sh
66
77
  dsh plugin --profile acp-enhanced add dsh-acp-enhanced
67
78
  ```
68
79
 
69
- > 开发/改源码时改用 `link:` 指向本地 checkout(改动实时生效,跳过 registry):
80
+ > 开发/改源码时用 `link:` 指向本地 checkout(改动实时生效):
70
81
  > `dsh plugin --profile acp-enhanced add "link:/absolute/path/to/dsh-acp-enhanced"`
71
82
 
72
- **第 2 步:注册进 Zed**(模型路由与凭据全部通过 `env` 传入,无需写 patch)
73
-
74
- 在 `~/.config/zed/settings.json` 的 `agent_servers` 里注册。Zed(GUI 应用)会用极简 PATH
75
- 拉起 agent 进程,因此用随附启动器 `scripts/dsh-acp-zed.sh`(它自己会定位 `node`/`dsh`)。
83
+ **第 2 步:注册进 Zed**(在 `~/.config/zed/settings.json` 的 `agent_servers` 里注册;
84
+ Zed 会用极简 PATH 拉起 agent,因此用随附启动器 `scripts/dsh-acp-zed.sh` 定位
85
+ `node`/`dsh`)
76
86
 
77
87
  #### 最常见:DeepSeek 官方 API(默认路由)
78
88
 
@@ -93,13 +103,12 @@ dsh plugin --profile acp-enhanced add dsh-acp-enhanced
93
103
  }
94
104
  ```
95
105
 
96
- > 这是本项目作者日常使用的配置(macOS)。`DSH_ACP_PROVIDER` / `DSH_ACP_MODEL` 与包自带
97
- > patch 的缺省值(`deepseek-official` / `deepseek-v4-flash`)一致,**所以也可以直接省略**
98
- > ——显式写上只是让路由意图在 Zed 配置里一目了然。API key 不必写进 Zed:写入
99
- > `~/.dsh/.credentials.yaml`(`DEEPSEEK_API_KEY`,600 权限)由 dsh 凭据服务解析即可;
100
- > 启动脚本还会兜底继承正在运行的 `dsh web` 进程的 key。
106
+ > 这两项 env 与包自带 patch 的缺省值一致,**省略也能工作**——显式写上只是让路由意图
107
+ > 一目了然。API key 不必写进 Zed:存入 `~/.dsh/.credentials.yaml`
108
+ > (`DEEPSEEK_API_KEY`)由 dsh 凭据服务解析即可;启动脚本还会兜底继承正在运行的
109
+ > `dsh web` 进程的 key。
101
110
 
102
- 可选:固定面板默认项(模型 / plan mode / 推理强度;都可以随时在面板里改,这只是初始值):
111
+ 可选:固定面板默认项(都可随时在面板里改):
103
112
 
104
113
  ```jsonc
105
114
  "dsh-acp-enhanced": {
@@ -132,35 +141,29 @@ dsh plugin --profile acp-enhanced add dsh-acp-enhanced
132
141
  }
133
142
  ```
134
143
 
135
- > `<KEY_ENV_NAME>` 是 provider 声明读取的 key 环境变量名(网关适配器通常有自己的
136
- > `apiKeyEnv`);同样可以不写在 Zed 里,而是存进 `~/.dsh/.credentials.yaml` 由凭据服务
137
- > 统一管理。路由换哪种模型都走同一条安装路径,只是 env 值不同。
144
+ > `<KEY_ENV_NAME>` 也可以省掉,把 key 存进 `~/.dsh/.credentials.yaml` 统一管理。
138
145
 
139
- Zed 会热重载设置。打开 **AI Agent 面板**(`Cmd+Shift+A`)→ 顶部 **agent 选择器** 选
140
- **dsh-acp-enhanced** → 输入第一条消息即可。回复实时流式返回,状态栏显示上下文用量,面板
141
- 顶部有 Model / Permission preset / Plan mode 配置项与 read-only / workspace-write /
142
- full-access 模式,线程归档里能看到并恢复历史会话。
146
+ Zed 会热重载设置。打开 **AI Agent 面板**(`Cmd+Shift+A`)→ agent 选择器选
147
+ **dsh-acp-enhanced** → 输入第一条消息即可:回复实时流式返回,状态栏显示上下文用量,
148
+ 面板顶部有 Model / Permission preset / Plan mode 配置项与三种模式,线程归档可恢复
149
+ 历史会话。
143
150
 
144
151
  本地验证(无需 Zed):
145
152
 
146
153
  ```sh
147
154
  node scripts/acp-client.mjs # 官方默认路由,无需 env;期望 ALL CHECKS PASSED
148
155
  DSH_ACP_PROVIDER=... DSH_ACP_MODEL=... node scripts/acp-client.mjs # 自定义路由时再传
149
- env -i HOME=$HOME PATH=/usr/bin:/bin node scripts/acp-client.mjs \
150
- /bin/bash /absolute/path/to/dsh-acp-enhanced/scripts/dsh-acp-zed.sh # 模拟 Zed 的 spawn 方式
151
156
  ```
152
157
 
153
158
  ### 可选:web_search 走同一个网关
154
159
 
155
- 桥接器本身不依赖它。若网关实现 OpenAI Responses 的 `web_search` 服务端工具,可把搜索也
156
- 路由到网关(复用同一凭据)。装一个普通依赖 + 在 profile 的 `cordis.patch.yml` 追加两段:
160
+ 若网关实现 OpenAI Responses 的 `web_search` 服务端工具,可把搜索也路由到网关(复用
161
+ 同一凭据)。装子包并给 profile 的 `cordis.patch.yml` 追加两段:
157
162
 
158
163
  ```sh
159
- dsh plugin --profile acp-enhanced add "link:/absolute/path/to/dsh-acp-enhanced/packages/dsh-web-search-openrouter"
164
+ dsh plugin --profile acp-enhanced add dsh-web-search-openrouter
160
165
  ```
161
166
 
162
- `~/.dsh/profiles/acp-enhanced/cordis.patch.yml`(`<provider>` 填你的网关 provider id):
163
-
164
167
  ```yaml
165
168
  - id: web
166
169
  config:
@@ -176,123 +179,29 @@ dsh plugin --profile acp-enhanced add "link:/absolute/path/to/dsh-acp-enhanced/p
176
179
  apiKeyEnv: <KEY_ENV_NAME>
177
180
  ```
178
181
 
179
- ### 故障排查
182
+ ## 故障排查
180
183
 
181
- | 症状 | 原因与解决办法 |
184
+ | 症状 | 处理 |
182
185
  |---|---|
183
- | `Server exited with status 127` / `exec: dsh: not found` | Zed 的 PATH 缺少 `node`/`dsh`。请用随附的 `dsh-acp-zed.sh` 启动器(它会解析两者);可用 `bash scripts/dsh-acp-zed.sh` 在干净 shell 中验证。 |
184
- | `no API key for provider route "deepseek-official"` | 无法解析 key。写入 `~/.dsh/.credentials.yaml`(见第 2 步),或在 agent_servers 条目里设置 `env.DEEPSEEK_API_KEY`。 |
185
- | 编辑设置后 agent 未出现 | 执行 `zed: reload settings`(命令面板)或重启 Zed。 |
186
- | 在 Zed 里"无法切换模型"或"上下文用量不显示" | 通常是选到了不可路由的幽灵 provider(例如某个适配器已挂载但没有任何可用的 API key)。本桥接器默认已过滤幽灵分组(只广播 `config.provider` 模型),若仍出现请确认 profile 的 `config.provider` 指向真实可用的路由,并把被污染的 `agent-default-model` 默认重置回该路由。见"设计说明"。 |
187
- | `session/new` 报 `additionalDirectories is not supported` | ACP 桥接器仅支持 baseline;Zed 默认不会发送额外目录——若自定义配置发送了就移除它。 |
188
- | 需要详细诊断 | 用 `ACP_DEBUG=1 dsh --profile acp-enhanced` 启动(stderr 上的生命周期 trace)。 |
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) |
189
190
 
190
191
  ## 开发
191
192
 
192
193
  ```sh
193
- node scripts/acp-client.mjs # 端到端冒烟测试(需要 DEEPSEEK_API_KEY)
194
- DEEPSEEK_API_KEY=... node scripts/acp-client.mjs
195
- node scripts/acp-client-tools.mjs # 客户端工具测试(模拟 Zed 的 fs/terminal/elicitation/plan 能力)
196
- node scripts/acp-mcp-test.mjs # MCP 挂载测试(挂载一个最小 stdio MCP server,无模型调用)
197
- node scripts/acp-smoke-keyless.mjs # keyless 冒烟(CI:自动建 profile → initialize → session/new)
198
- node scripts/acp-resume-test.mjs # 会话恢复测试(两个进程:创建持久化 → 加载回放 → 续聊)
199
- ACP_DEBUG=1 dsh --profile acp-enhanced # stderr 上的详细生命周期 trace
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 # 会话恢复测试
200
199
  ```
201
200
 
202
- 该冒烟客户端驱动 initialize → session/new → prompt(验证块级流式、`usage_update`、
203
- `tool_call`)、配置项与模式切换、切换后的第二次 prompt,以及 `session/cancel`。
204
- `acp-client-tools.mjs` 用 SDK 的 `ClientSideConnection` 模拟 Zed:声明
205
- `fs.readTextFile/writeTextFile/terminal/elicitation` 能力,验证模型调用 `zed_*` 工具时请求以
206
- `fs/write_text_file`、`fs/read_text_file`、`terminal/create` 正确到达客户端,`ask_user_question`
207
- 以 `elicitation/create` 表单(含 enum 选项)到达客户端,`plan_mode` 布尔开关触发 ACP `plan`
208
- update(开→条目、关→清空),并验证 `reasoning_effort` 选项按路由条件出现(有 efforts 的路由
209
- 出现、无 efforts 的路由抑制)。`acp-mcp-test.mjs` 用 `scripts/fixtures/mcp-echo-server.mjs`
210
- 验证 `session/new` 的 `mcpServers` 被真实挂载(server 收到 initialize 与 tools/list)且相同
211
- 列表不重复挂载。
212
-
213
- ## 设计说明
214
-
215
- - **块级流式输出**:文本增量按块索引累积;`block-end` 一旦确认就立即上送线上。重试会重启
216
- 同一个索引,因此被取消尝试的残留尾部永远到不了客户端——ACP 没有撤销机制,这是最干净的边界。
217
- - **遥测**:每个 provider 的 `usage` 样本都会以 `usage_update` 广播(used = 输入 + 缓存命中
218
- + 缓存写入;size = 所路由模型的上下文窗口),完整明细在 `_meta` 中:输入/输出/缓存/推理
219
- token、`cacheHitRate`、`tps`(生成 token / step 墙钟耗时)、step 耗时、轮次计数,以及累计的
220
- 工具调用统计。
221
- - **工具调用可见性**:`tool_call` 通知带 `kind` 与 `rawInput`(`JSON.parse` 参数,失败则回退为
222
- 字符串),Zed 的工具卡片因此能展开看到具体参数(bash 的命令、写入的文件等);
223
- `tool_call_update` 带 `rawOutput`(从 `ToolResultMessage` 的文本块提取结果预览,截断 12k)。
224
- **`kind` 映射有个关键约束**:Zed 把 `kind == 'execute'` 当**终端工具**、`kind == 'edit'`
225
- 当 **diff 工具**,两者都会**隐藏 rawInput**。所以只有真正在 Zed 里开终端的 `zed_terminal`
226
- 用 `execute`;bash/run_code/写文件等一律 `other`(rawInput 正常显示),否则就会出现"卡片只
227
- 显示 bash 字样、看不到命令"的现象。另注意 dsh 的 `tool/result` 事件里 `toolCallId` 在
228
- `message.content[0].toolCallId`(`ToolResultBlock`)上,不在事件根——漏取会导致 SDK 校验
229
- 拒绝整条 `tool_call_update`。历史回放(resume)同样携带这些字段。
230
- - **会话配置**:`model` 下拉框枚举实时模型目录(`ctx.llm.listProviders` → `listModels` →
231
- `resolveModelInfo`),`reasoning_effort` 下拉框枚举当前路由的可用强度,`permission_preset`
232
- 枚举已挂载的预设。修改走 `llm.resolveCallConfig` 与 `installModelSelection`(与 Web
233
- api-proxy 使用的同一机制)或 `permissionPresets.apply` 写路径。
234
- - **模型分组线格式**:`model` 选项的分组必须是 ACP 规范的
235
- `{ group: <id>, name: <label>, options: [...] }`。早期版本发成了 `{ groupName, options }`,
236
- Zed(`agent-client-protocol-schema` 1.4.0)反序列化时把整个组跳过(`DefaultOnError` +
237
- `VecSkipError`),于是 `model` 下拉框变空、模型无法选择——而 SDK 的 mock 客户端不校验
238
- 响应所以测试没拦住;现在 `acp-client-tools.mjs` 会对每个 config option 跑
239
- `zSessionConfigOption.safeParse`,这类线格式错误不会再漏网。
240
- - **推理强度的路由限制**:`reasoning_effort` 选项只在模型路由**暴露** efforts 时广播
241
- (`resolveModelInfo().reasoning.efforts` 非空)。对不暴露 efforts 的路由,显式设置强度会被
242
- 适配器拒绝(`does not support reasoning effort "high"`)——所以这类路由下 Zed 里没有推理
243
- 强度下拉是**正确行为**,不是桥接器 bug;换到暴露 efforts 的路由后下拉框会自动出现。
244
- - **模型目录过滤(`includeAllProviders`,默认关)**:默认只广播 `config.provider` 的模型,
245
- 避免把"幽灵 provider"(已挂载但不可路由的适配器,例如没有可用 API key 而仍挂载的
246
- `deepseek-official`)列进下拉框。这些模型在列表里看起来可切换,但一旦选中,后续每次
247
- prompt 都会以 `MISSING_CREDENTIAL`(`no API key for provider route "xxx"`)失败——在
248
- Zed 里表现为"无法切换模型、且因 turn 失败而不再收到 `usage_update`,上下文用量不显示"
249
- (Web GUI 会对不可路由的当前项显示 unavailable 横幅,Zed 没有,所以同样的数据在 Zed
250
- 里看起来就是坏的)。需要多 provider 都可用时设 `includeAllProviders: true`。
251
- - **默认模型不被污染**:`applySelection` 只有在 `selected.provider === config.provider`(或
252
- 显式 `includeAllProviders`)时才把新选择持久化为 `agent-default-model` 默认值。否则一次
253
- 误切到不可路由的 provider 只会作用于当前会话,不会写坏后续所有新会话的默认路由。
254
- - **客户端转发工具(Zed fs / terminal)**:`initialize` 时读取 `clientCapabilities`,仅在客户端
255
- 声明对应能力时,向 agent 注册 `zed_read_text_file` / `zed_write_text_file` / `zed_terminal`
256
- 三个工具(`ctx.tools.register` + `defineTool`)。工具体通过 `conn.readTextFile` /
257
- `conn.writeTextFile` / `conn.createTerminal` 把请求转发给编辑器:`zed_write_text_file` 让
258
- 文件编辑落在 Zed 自己的 buffer 上,出现在 agent 面板的"编辑文件"区(diff + 接受/拒绝);
259
- `zed_terminal` 让命令跑在 Zed 真实终端里并轮询输出(`terminal/output` 是累计内容,取最后
260
- 一次即可),120s 超时后 kill。无这些能力的客户端(如纯自动化测试)不会看到这些工具。
261
- - **Zed 表单提问(elicitation)**:客户端声明 `elicitation.form` 时,桥接器注册
262
- `ask_user_question` 工具(复刻 `dsh-tool-ask-user` 的定义,走 `ctx.userQuestions` seam)
263
- 以及对应的 UI provider:把问题映射成 ACP `elicitation/create`(form 模式)的 JSON Schema
264
- (单选 → `string`+`enum`,多选 → `array`,无选项 → 裸 `string`),用户在 Zed 里以原生
265
- 表单作答后,答案映射回 `AskUserQuestionAnswer` 喂回模型。decline/cancel 会以错误结束该次
266
- 工具调用,模型可据此改道。注意 Zed 的 elicitation 能力是对象(`form: {}`)而非布尔,
267
- 判断用"存在"而非 `=== true`。
268
- - **Plan 面板**:`plan_mode` 布尔配置项走 `ctx.planMode.set(agent, active)` 切换 DSH plan
269
- mode(Zed 的布尔开关即点即用);`session/event` 里的 `plan/mode` 翻转被映射为 ACP `plan`
270
- update——开时一条"规划中"条目,关时清空。DSH 的 plan mode 没有结构化任务列表,所以这是
271
- 状态指示而非任务清单。注意 ACP 的 `plan` update 是**扁平**形状
272
- (`{ sessionUpdate: 'plan', entries: [...] }`),不是 `{ plan: {...} }`。
273
- - **会话恢复(session/load)**:`initialize` 声明 `loadSession: true`;`session/load` 通过
274
- `ctx.agents.resume({ resumeSessionId })` 从持久化存储(`dsh-session-persistence-jsonl`,
275
- dsh-base 已挂载)恢复 agent,然后按事件日志回放历史:`user/message`(仅
276
- `source.kind === 'user'` 的真实人类消息,过滤 system-reminder 等合成注入)→
277
- `user_message_chunk`,`assistant/message` 的文本 → `agent_message_chunk`,
278
- `tool/call`/`tool/result` → `tool_call`/`tool_call_update`。Zed 在线程插入后才完成 load
279
- RPC,所以回放通知能被线程接收。回放完成后该会话与新建会话一样支持继续 prompt。
280
- - **会话归档列表(session/list + session/delete)**:`initialize` 声明
281
- `sessionCapabilities: { list: {}, delete: {} }`;`session/list` 用
282
- `ctx.sessionPersistence.list()` 枚举已物化的会话(`SessionHeader`:id/cwd/createdAt),
283
- 标题优先取实时 `session/title` 事件记录,缺失时用 `persistence.readRaw(id)` 扫存储日志里
284
- 最后一个 `session/title` 事件(>8MB 的日志跳过);按 `updatedAt` 倒序返回。`session/delete`
285
- 先释放在线 agent(`sessions.delete` + `dispose`),再通过 `persistence.locate(header)` 拿到
286
- 该会话目录物理路径并整体删除——注意 dsh 持久化面**没有官方的删除 API**,这一步是直接删
287
- 后端目录。实时标题/活动变化通过 `session_info_update` 通知推送(`session/title` 与
288
- `turn/end` 事件)。
289
- - **空 effort 抑制**:当前路由模型不暴露 reasoning efforts 时,不广播 `reasoning_effort`
290
- 配置项——Zed 就不会渲染一个空的、无法操作的"Reasoning effort"chip。切到带 efforts 的
291
- 模型后该选项自动重新出现(每次切换都会重播 `config_option_update`)。
292
- - **模式**:权限预设被呈现为 ACP 会话模式,因此 Zed 的模式切换器驱动 sandbox/approval 预设。
293
- - **已知限制**(继承自官方桥接器):仅 baseline prompt(无图片/音频附件)、不支持
294
- `additionalDirectories`、已确认文本按块粒度流式,且每个会话同时只能有一个
295
- in-flight prompt。MCP servers(stdio + streamable HTTP)已支持,但 legacy SSE
296
- 传输与 `acp` 传输不声明。`session/close` / `session/fork` / `session/resume` 未实现
297
- (不声明能力,合规客户端不会调用);`session/delete` 因 dsh 持久化面没有官方删除
298
- API,采用直接删除后端目录的方式。
201
+ ## 已知限制
202
+
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
+ 采用直接删除后端目录的方式。
package/lib/index.js CHANGED
@@ -43,7 +43,7 @@ import { acpPromptToText, promptHasUnsupportedContent, turnEndToStopReason, usag
43
43
 
44
44
  export const name = 'acp-enhanced'
45
45
  /** The bridge creates and owns agents; every other concern is carried by the composition. */
46
- export const inject = ['agents', 'llm', 'approval', 'tools']
46
+ export const inject = ['agents', 'llm', 'approval', 'tools', 'commands']
47
47
 
48
48
  export const Config = Schema.object({
49
49
  /** Initial provider route for every created agent. */
@@ -121,6 +121,7 @@ export function apply(ctx, config) {
121
121
  const llm = ctx.llm
122
122
  const approval = ctx.approval
123
123
  const tools = ctx.tools
124
+ const commands = ctx.commands
124
125
  const logger = ctx.logger
125
126
  /** The user-questions service (mounted by dsh-base); absent in minimal deployments. */
126
127
  const userQuestions = ctx.get('userQuestions')
@@ -1001,6 +1002,94 @@ export function apply(ctx, config) {
1001
1002
  }
1002
1003
  }
1003
1004
 
1005
+ // ── slash commands (adapter built-ins + harness command registry) ────────
1006
+
1007
+ /** Adapter-level commands, always first in the advertised list. */
1008
+ const BUILTIN_COMMANDS = [
1009
+ { name: 'status', description: 'Show session status: model route, context usage, telemetry.' },
1010
+ { name: 'model', description: 'List the model catalog, or switch with /model <provider/model | substring>.' },
1011
+ ]
1012
+
1013
+ /**
1014
+ * Advertise the full command surface for a session as ACP
1015
+ * `available_commands_update`: adapter built-ins plus whatever the harness
1016
+ * command registry (compact/goal/permission/plan/…) serves for the agent.
1017
+ */
1018
+ async function publishCommands(record) {
1019
+ const list = [...BUILTIN_COMMANDS]
1020
+ const seen = new Set(list.map((command) => command.name))
1021
+ try {
1022
+ for (const descriptor of commands.list(record.agent)) {
1023
+ if (seen.has(descriptor.name)) continue
1024
+ seen.add(descriptor.name)
1025
+ list.push({
1026
+ name: descriptor.name,
1027
+ description: descriptor.description,
1028
+ ...descriptor.input === undefined ? {} : { input: descriptor.input },
1029
+ })
1030
+ }
1031
+ } catch (error) {
1032
+ logger.warn(`acp-enhanced: command listing failed: ${String(error)}`)
1033
+ }
1034
+ notify({
1035
+ sessionId: record.agent.session.id,
1036
+ update: { sessionUpdate: 'available_commands_update', availableCommands: list },
1037
+ })
1038
+ }
1039
+
1040
+ /** Text summary of one session: route, turns, last usage telemetry. */
1041
+ async function statusText(record) {
1042
+ const selection = record.selection.current
1043
+ const last = record.lastUsage
1044
+ const lines = [
1045
+ `route: ${selection.provider ?? '?'}/${selection.model ?? '?'}`,
1046
+ `turns: ${record.turnCount}`,
1047
+ ...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 ?? '?'}`,
1050
+ ],
1051
+ ]
1052
+ return lines.join('\n')
1053
+ }
1054
+
1055
+ /** The /model command: list the live catalog or switch by exact id/substring. */
1056
+ async function modelCommandText(record, query) {
1057
+ const catalog = await modelCatalog()
1058
+ const current = record.selection.current
1059
+ if (query.trim().length === 0) {
1060
+ const lines = catalog.flatMap((group) => group.models.map((model) => {
1061
+ const mark = `${group.id}/${model.id}` === `${current.provider}/${current.model}` ? '* ' : ' '
1062
+ return `${mark}${group.id}/${model.id}`
1063
+ }))
1064
+ return lines.length > 0 ? lines.join('\n') : 'no models available'
1065
+ }
1066
+ const needle = query.trim().toLowerCase()
1067
+ const matches = catalog.flatMap((group) => group.models.map((model) => ({ provider: group.id, model: model.id })))
1068
+ .filter((candidate) => `${candidate.provider}/${candidate.model}`.toLowerCase().includes(needle) || candidate.model.toLowerCase().includes(needle))
1069
+ if (matches.length === 0) return `no model matches "${query}"`
1070
+ if (matches.length > 1) return `ambiguous: ${matches.map((m) => `${m.provider}/${m.model}`).join(', ')}`
1071
+ await applySelection(record, { provider: matches[0].provider, model: matches[0].model })
1072
+ return `switched to ${matches[0].provider}/${matches[0].model}`
1073
+ }
1074
+
1075
+ /** Refresh every client-visible surface a command may have mutated. */
1076
+ function refreshAfterCommand(record) {
1077
+ const permission = permissionPresets()
1078
+ if (permission !== undefined) {
1079
+ notify({
1080
+ sessionId: record.agent.session.id,
1081
+ update: {
1082
+ sessionUpdate: 'current_mode_update',
1083
+ currentModeId: permission.current(record.agent.session.events),
1084
+ },
1085
+ })
1086
+ }
1087
+ broadcastConfig(record).catch((error) => {
1088
+ logger.warn(`acp-enhanced: config rebroadcast after command failed: ${String(error)}`)
1089
+ })
1090
+ publishCommands(record)
1091
+ }
1092
+
1004
1093
  // ── session records + history replay ─────────────────────────────────────
1005
1094
 
1006
1095
  /** Build the bridge-owned protocol record for a fresh or resumed agent. */
@@ -1238,6 +1327,7 @@ export function apply(ctx, config) {
1238
1327
  const record = makeRecord(handle)
1239
1328
  sessions.set(sessionId, record)
1240
1329
  await syncMcpServers(params.mcpServers, params.cwd)
1330
+ publishCommands(record)
1241
1331
  const permission = permissionPresets()
1242
1332
  return {
1243
1333
  sessionId,
@@ -1299,6 +1389,7 @@ export function apply(ctx, config) {
1299
1389
  // Zed inserts the thread before the load RPC completes; replay the
1300
1390
  // conversation history as notifications so the thread renders.
1301
1391
  await replayHistory(record)
1392
+ publishCommands(record)
1302
1393
  const permission = permissionPresets()
1303
1394
  return {
1304
1395
  ...permission === undefined ? {} : {
@@ -1329,6 +1420,44 @@ export function apply(ctx, config) {
1329
1420
  }
1330
1421
  const text = acpPromptToText(params.prompt)
1331
1422
  if (text.trim().length === 0) throw invalidParams('empty prompt')
1423
+
1424
+ // Adapter-level slash commands never reach the model: /status and
1425
+ // /model are built in, any other registered slash (compact/goal/
1426
+ // permission/plan/…) runs through the harness command registry
1427
+ // without a model turn. An unresolved slash falls through — the
1428
+ // /skill-name gesture is claimed inside the agent's next step.
1429
+ const trimmed = text.trim()
1430
+ const commandMatch = trimmed.match(/^\/(\w[\w-]*)\b/)
1431
+ const respond = (reply) => {
1432
+ notify({
1433
+ sessionId: record.agent.session.id,
1434
+ update: {
1435
+ sessionUpdate: 'agent_message_chunk',
1436
+ messageId: randomUUID(),
1437
+ content: { type: 'text', text: reply },
1438
+ },
1439
+ })
1440
+ return { stopReason: 'end_turn' }
1441
+ }
1442
+ if (commandMatch?.[1] === 'status') return respond(await statusText(record))
1443
+ if (commandMatch?.[1] === 'model') {
1444
+ return respond(await modelCommandText(record, trimmed.slice(commandMatch[0].length).trim()))
1445
+ }
1446
+ if (commandMatch !== null && commandMatch[1] !== undefined) {
1447
+ let execution
1448
+ try {
1449
+ execution = await commands.execute(record.agent, trimmed, new AbortController().signal)
1450
+ } catch (error) {
1451
+ return respond(`⚠ /${commandMatch[1]} failed: ${error.message ?? String(error)}`)
1452
+ }
1453
+ if (execution !== undefined) {
1454
+ const { result } = execution
1455
+ const reply = result.text ?? (result.kind === 'success' ? `/${commandMatch[1]} ✓` : `/${commandMatch[1]} failed`)
1456
+ refreshAfterCommand(record)
1457
+ return respond(result.kind === 'error' ? `⚠ ${reply}` : reply)
1458
+ }
1459
+ }
1460
+
1332
1461
  if (ctx.agents.get(record.agent.id) !== record.agent) {
1333
1462
  throw internalError('prompt was not queued: the agent was disposed outside the bridge')
1334
1463
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-acp-enhanced",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
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",