dsh-acp-enhanced 0.3.2 → 0.3.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README-zh.md CHANGED
@@ -26,9 +26,12 @@ ACP 线上。
26
26
 
27
27
  ### Zed 深度集成
28
28
 
29
- - **工具卡片**:折叠态即显示一行摘要——`Read <路径>`、执行的命令、`Search: <模式>`、
30
- `Fetch: <URL>` 等;展开可见每次调用的完整参数与结果预览(`rawInput` / `rawOutput`),
31
- 按工具类型渲染图标
29
+ - **工具卡片**:折叠态即显示一行摘要——`Read <路径>`、shell 命令显示模型自己给出的意图描述
30
+ (`description`,Codex 风格,展开可见完整命令)、`Search: <模式>`、
31
+ `Fetch: <URL>` 等。卡片正文遵循 ACP 最佳实践:文件编辑渲染为真实 **diff 视图**、
32
+ shell 命令渲染为高亮代码块并在下方附输出、涉及文件以**可点击路径**呈现(点击直达);
33
+ `rawInput` / `rawOutput` 保留在展开区备查,按工具类型渲染图标,
34
+ 状态机为进行中 → 完成/失败
32
35
  - **Zed 文件与终端**:`zed_read_text_file` / `zed_write_text_file` / `zed_terminal` 把
33
36
  文件编辑放进 Zed 的"编辑文件"区(diff + 接受/拒绝)、命令跑在 Zed 真实终端
34
37
  - **原生表单提问**:`ask_user_question` → `elicitation/create` 表单,选项即点即答
@@ -38,6 +41,10 @@ ACP 线上。
38
41
 
39
42
  - **恢复与归档**:`session/load` 恢复历史线程(完整回放);`session/list` /
40
43
  `session/delete` 管理线程归档(带标题、按更新时间排序);标题实时推送
44
+ - **多根工作区**:`sessionCapabilities.additionalDirectories` 已声明,Zed 不再提示
45
+ "This agent doesn't currently support multi-root workspaces",而是把所有工作区根
46
+ 通过 `session/new` / `session/load` 传入。所有根都会写进系统提示词并在
47
+ `session/list` 上回报;沙箱仍以主 `cwd` 为唯一可写根(见已知限制)
41
48
 
42
49
  ### 命令
43
50
 
@@ -189,7 +196,8 @@ dsh plugin --profile acp-enhanced add dsh-web-search-openrouter
189
196
  |---|---|
190
197
  | `exec: dsh: not found`(status 127) | 用随附 `dsh-acp-zed.sh` 启动器(自定位 node/dsh) |
191
198
  | `no API key for provider route "xxx"` | 写入 `~/.dsh/.credentials.yaml`,或在 agent_servers 里设 `env.DEEPSEEK_API_KEY` |
192
- | 无法切换模型 / 上下文用量不显示 | 选到了不可路由的"幽灵 provider";本桥默认过滤(只广播 `config.provider` 的模型),确认 profile 的 provider 指向真实路由 |
199
+ | 无法切换模型 | 保存的 `reasoning_effort` 默认值(或会话当前 effort)被带到新模型上;0.3.3 起新模型不支持的 effort 会自动回退为该模型默认值,不再让整个切换失败。另检查:是否选到了不可路由的"幽灵 provider"——本桥默认过滤(只广播 `config.provider` 的模型),确认 profile 的 provider 指向真实路由 |
200
+ | 上下文用量不显示 | 选到了不可路由的"幽灵 provider";本桥默认过滤(只广播 `config.provider` 的模型),确认 profile 的 provider 指向真实路由 |
193
201
  | 需要详细诊断 | `ACP_DEBUG=1 dsh --profile acp-enhanced`(stderr 生命周期 trace) |
194
202
 
195
203
  ## 开发
@@ -204,8 +212,13 @@ node scripts/acp-resume-test.mjs # 会话恢复测试
204
212
 
205
213
  ## 已知限制
206
214
 
207
- 仅 baseline prompt(无图片/音频附件)、不支持 `additionalDirectories`、文本按块粒度
208
- 流式、每会话同时一个 in-flight prompt。MCP 支持 stdio 与 streamable HTTP(不声明
209
- legacy SSE / `acp` 传输)。`session/close` / `session/fork` / `session/resume` 未实现
210
- (不声明能力,合规客户端不会调用);`session/delete` 因 dsh 持久化无官方删除 API,
211
- 采用直接删除后端目录的方式。
215
+ 仅 baseline prompt(无图片/音频附件)、文本按块粒度流式、每会话同时一个 in-flight
216
+ prompt。MCP 支持 stdio 与 streamable HTTP(不声明 legacy SSE / `acp` 传输)。
217
+ `session/close` / `session/fork` / `session/resume` 未实现(不声明能力,合规客户端
218
+ 不会调用);`session/delete` 因 dsh 持久化无官方删除 API,采用直接删除后端目录的方式。
219
+
220
+ 多根工作区已声明、模型可见所有根,但 dsh 沙箱策略每会话只解析**一个可写根**(主
221
+ `cwd`,即 `session.header.cwd`),本地沙箱也只为该根开放写权限。读操作在所有根均可
222
+ 用;`workspace-write` 下对附加根的写入会先被拒绝、需升级/批准,`danger-full-access`
223
+ 下所有根均可写。真正的多根写支持需改 dsh 核心(`dsh-sandbox-policy` /
224
+ `dsh-sandbox-local` 需要根列表而非单根)。
package/README.md CHANGED
@@ -30,9 +30,13 @@ over the ACP wire.
30
30
  ### Zed deep integration
31
31
 
32
32
  - **Tool cards**: one-line summary in the collapsed header — `Read <path>`, the
33
- executed command, `Search: <pattern>`, `Fetch: <url>`, etc. — with the full
34
- arguments and result preview (`rawInput` / `rawOutput`) one click away, plus
35
- per-kind icons
33
+ model's own intent line for shell commands (`description`, Codex-style — the
34
+ exact command stays one click away), `Search: <pattern>`, `Fetch: <url>`, etc.
35
+ The card body follows the ACP best practice: file edits render as a real
36
+ **diff**, shell commands as a syntax-highlighted code block with the output
37
+ beneath, and touched files as **clickable locations** that open the file —
38
+ with `rawInput` / `rawOutput` kept one click away for transparency, plus
39
+ per-kind icons and a proper in-progress → completed/failed status lifecycle
36
40
  - **Zed files & terminal**: `zed_read_text_file` / `zed_write_text_file` / `zed_terminal`
37
41
  put file edits into Zed's "edited files" area (diff + accept/reject) and commands into a
38
42
  real Zed terminal
@@ -45,6 +49,11 @@ over the ACP wire.
45
49
  - **Resume & archive**: `session/load` restores past threads (full replay);
46
50
  `session/list` / `session/delete` manage the thread archive (titled, sorted by last
47
51
  activity); live title updates
52
+ - **Multi-root workspaces**: `sessionCapabilities.additionalDirectories` is advertised,
53
+ so Zed no longer shows "this agent doesn't currently support multi-root workspaces"
54
+ and instead passes every workspace root on `session/new` / `session/load`. All roots
55
+ are described to the model in the system prompt and reported on `session/list`; the
56
+ sandbox keeps the primary `cwd` as its single writable root (see Known limitations)
48
57
 
49
58
  ### Commands
50
59
 
@@ -206,7 +215,8 @@ dsh plugin --profile acp-enhanced add dsh-web-search-openrouter
206
215
  |---|---|
207
216
  | `exec: dsh: not found` (status 127) | Use the shipped `dsh-acp-zed.sh` launcher (locates node/dsh itself) |
208
217
  | `no API key for provider route "xxx"` | Write `~/.dsh/.credentials.yaml`, or set `env.DEEPSEEK_API_KEY` on the agent_servers entry |
209
- | Cannot switch models / context usage missing | A "phantom provider" route was picked; this bridge filters them by default (only `config.provider`'s models are advertised) — point the profile's provider at a real route |
218
+ | Cannot switch models | The saved `reasoning_effort` default (or the session's current effort) is carried onto the new model; since 0.3.3 an unsupported effort resets to the model's default instead of failing the switch. Also check: a "phantom provider" route was picked — this bridge filters them by default (only `config.provider`'s models are advertised), so point the profile's provider at a real route |
219
+ | Context usage missing | A "phantom provider" route was picked; this bridge filters them by default (only `config.provider`'s models are advertised) — point the profile's provider at a real route |
210
220
  | Need detailed diagnostics | `ACP_DEBUG=1 dsh --profile acp-enhanced` (stderr lifecycle trace) |
211
221
 
212
222
  ## Development
@@ -221,9 +231,17 @@ node scripts/acp-resume-test.mjs # session resume test
221
231
 
222
232
  ## Known limitations
223
233
 
224
- Baseline prompts only (no image/audio attachments), no `additionalDirectories`, text
225
- streams at block granularity, one in-flight prompt per session. MCP supports stdio and
226
- streamable HTTP (legacy SSE / `acp` transports are not advertised).
234
+ Baseline prompts only (no image/audio attachments), text streams at block granularity,
235
+ one in-flight prompt per session. MCP supports stdio and streamable HTTP (legacy SSE /
236
+ `acp` transports are not advertised).
227
237
  `session/close` / `session/fork` / `session/resume` are not implemented (capabilities
228
- undeclared, compliant clients will not call them); `session/delete` removes the persisted
229
- directory directly because dsh persistence has no official delete API.
238
+ undeclared, compliant clients will not call them); `session/delete` removes the
239
+ persisted directory directly because dsh persistence has no official delete API.
240
+
241
+ Multi-root workspaces are advertised and all roots are visible to the model, but dsh's
242
+ sandbox policy resolves **one writable root per session** (the primary `cwd`, i.e.
243
+ `session.header.cwd`) and the local sandboxes bind exactly that root for writes. Reads
244
+ work in every root; under `workspace-write` a write under an additional root is denied
245
+ first and needs escalation/approval, while `danger-full-access` writes everywhere.
246
+ True multi-root write enforcement belongs in dsh core (`dsh-sandbox-policy` /
247
+ `dsh-sandbox-local` would need a root list instead of a single root).
package/lib/index.js CHANGED
@@ -23,6 +23,12 @@
23
23
  * - **Session modes**: permission presets are exposed as ACP modes
24
24
  * (`session/set_mode`), so the client's mode UI drives the sandbox/approval
25
25
  * preset of the session.
26
+ * - **Multi-root workspaces**: `sessionCapabilities.additionalDirectories` is
27
+ * advertised, so Zed passes every workspace root on the session lifecycle
28
+ * requests instead of warning "this agent doesn't currently support
29
+ * multi-root workspaces". All roots are described to the model in the
30
+ * system prompt; the sandbox still enforces one writable root (the primary
31
+ * `cwd`) — writes under an additional root go through escalation.
26
32
  *
27
33
  * Stdout is reserved for ACP JSON-RPC; diagnostics go to stderr only.
28
34
  *
@@ -32,7 +38,7 @@
32
38
  import { randomUUID } from 'node:crypto'
33
39
  import { rm } from 'node:fs/promises'
34
40
  import { createRequire } from 'node:module'
35
- import { isAbsolute, dirname } from 'node:path'
41
+ import { isAbsolute, dirname, resolve } from 'node:path'
36
42
  import { Readable, Writable } from 'node:stream'
37
43
  import Schema from '@deepseek-ai/schemastery'
38
44
  import { AgentSideConnection, ndJsonStream, PROTOCOL_VERSION, RequestError } from '@agentclientprotocol/sdk'
@@ -48,7 +54,7 @@ const AGENT_VERSION = createRequire(import.meta.url)('../package.json').version
48
54
 
49
55
  export const name = 'acp-enhanced'
50
56
  /** The bridge creates and owns agents; every other concern is carried by the composition. */
51
- export const inject = ['agents', 'llm', 'approval', 'tools', 'commands']
57
+ export const inject = ['agents', 'llm', 'approval', 'tools', 'commands', 'systemPrompt']
52
58
 
53
59
  export const Config = Schema.object({
54
60
  /** Initial provider route for every created agent. */
@@ -77,12 +83,13 @@ function internalError(detail) {
77
83
  /**
78
84
  * Map a dsh tool name to the ACP ToolKind used for icons and card UX.
79
85
  *
80
- * Zed treats kind == 'execute' as a TERMINAL tool and kind == 'edit' as a
81
- * diff tool, and for both it HIDES rawInput (the "Raw Input:" section). Only
82
- * genuinely terminal tools (zed_terminal) should be 'execute'; local executors
83
- * like bash/run_code must stay 'other' so the editor shows the exact arguments
84
- * (the command) plus the output. Write/edit tools likewise stay 'other' unless
85
- * the bridge also emits diff content — otherwise the card would show nothing.
86
+ * Kind and content must agree: Zed treats kind == 'execute' as a terminal tool
87
+ * and kind == 'edit' as a diff tool, and for both it HIDES the rawInput
88
+ * section. `zed_terminal` is genuinely terminal; write/edit tools map to
89
+ * 'edit' only because the bridge always pairs them with a `diff` content
90
+ * block (see toolCallContentFor) — the diff replaces the raw dump as the card
91
+ * body. Local executors like bash/run_code stay 'other' and carry the command
92
+ * as a markdown code block, keeping the raw sections available too.
86
93
  */
87
94
  function toolKindFor(name) {
88
95
  if (name === 'zed_terminal') return 'execute'
@@ -90,6 +97,7 @@ function toolKindFor(name) {
90
97
  if (/search|find|grep/.test(name)) return 'search'
91
98
  if (/fetch|http/.test(name)) return 'fetch'
92
99
  if (/think/.test(name)) return 'think'
100
+ if (/write|edit|patch|apply/.test(name)) return 'edit'
93
101
  return 'other'
94
102
  }
95
103
 
@@ -120,6 +128,94 @@ function mdEscape(text) {
120
128
  return String(text).replace(/([\\*_`[\]<>])/g, '\\$1')
121
129
  }
122
130
 
131
+ /** Executor-class tool names (command runners) shared by title/content/kind. */
132
+ const EXECUTOR_NAME = /bash|pwsh|powershell|shell|exec|run_code|execute|terminal/
133
+
134
+ /** Fenced-code language hint per executor tool (best effort — highlight only). */
135
+ function executorLang(name) {
136
+ if (/pwsh|powershell/.test(name)) return 'powershell'
137
+ if (/run_code/.test(name)) return 'typescript'
138
+ return 'bash'
139
+ }
140
+
141
+ /** Shared file-path argument lookup (key order = display preference). */
142
+ function pathArg(obj) {
143
+ for (const key of ['path', 'file_path', 'filePath', 'file', 'target']) {
144
+ if (typeof obj[key] === 'string' && obj[key].trim() !== '') return obj[key].trim()
145
+ }
146
+ return undefined
147
+ }
148
+
149
+ /** Cap a long text body for a card, marking the cut so nothing looks lost. */
150
+ function boundBody(text, max) {
151
+ const body = String(text ?? '')
152
+ return body.length > max ? `${body.slice(0, max)}\n… (truncated)` : body
153
+ }
154
+
155
+ /** A markdown fenced code block, widening the fence when the text itself
156
+ * contains ``` runs so the body always renders literally. */
157
+ function codeFence(text, lang) {
158
+ const body = String(text ?? '')
159
+ const ticks = body.includes('```') ? '````' : '```'
160
+ return `${ticks}${lang}\n${boundBody(body, 12000)}\n${ticks}`
161
+ }
162
+
163
+ /**
164
+ * ACP content blocks giving the card a friendly body — the protocol's
165
+ * best-practice surface (clients render these INSTEAD of the raw JSON dump,
166
+ * which stays available as rawInput/rawOutput for transparency):
167
+ * - write/edit tools → a `diff` block rendered as a real diff card;
168
+ * - executors → the command as a syntax-highlighted code block.
169
+ */
170
+ function toolCallContentFor(name, argumentsValue, kind) {
171
+ const obj = argumentsValue !== null && typeof argumentsValue === 'object' && !Array.isArray(argumentsValue)
172
+ ? argumentsValue
173
+ : {}
174
+ if (kind === 'edit') {
175
+ const path = pathArg(obj)
176
+ if (path === undefined) return undefined
177
+ const newText = ['content', 'new_string', 'new_str', 'newText'].find((key) => typeof obj[key] === 'string')
178
+ if (newText === undefined) return undefined
179
+ const oldKey = ['old_string', 'old_str', 'oldText'].find((key) => typeof obj[key] === 'string')
180
+ return [{
181
+ type: 'diff',
182
+ path,
183
+ newText: boundBody(obj[newText], 8000),
184
+ ...oldKey === undefined ? {} : { oldText: boundBody(obj[oldKey], 8000) },
185
+ }]
186
+ }
187
+ if (name !== 'zed_terminal' && EXECUTOR_NAME.test(name)) {
188
+ const command = typeof obj.command === 'string' ? obj.command
189
+ : typeof obj.cmd === 'string' ? obj.cmd
190
+ : typeof obj.script === 'string' ? obj.script
191
+ : Array.isArray(obj.args) && obj.args.every((part) => typeof part === 'string')
192
+ ? obj.args.join(' ')
193
+ : undefined
194
+ if (typeof command === 'string' && command.trim() !== '') {
195
+ return [{ type: 'content', content: { type: 'text', text: codeFence(command.trim(), executorLang(name)) } }]
196
+ }
197
+ }
198
+ return undefined
199
+ }
200
+
201
+ /**
202
+ * Files the call touches, as ACP `locations` — the "follow the agent"
203
+ * surface. Editors render them as clickable chips that open the file (and
204
+ * scroll to `line`), so a read or an edit is one click from its target.
205
+ */
206
+ function toolCallLocationsFor(kind, argumentsValue) {
207
+ if (kind !== 'read' && kind !== 'edit' && kind !== 'delete' && kind !== 'move') return undefined
208
+ const obj = argumentsValue !== null && typeof argumentsValue === 'object' && !Array.isArray(argumentsValue)
209
+ ? argumentsValue
210
+ : {}
211
+ const path = pathArg(obj)
212
+ if (path === undefined) return undefined
213
+ const line = [obj.line, obj.offset].find((value) => (
214
+ typeof value === 'number' && Number.isFinite(value) && value >= 1
215
+ ))
216
+ return [{ path, ...line === undefined ? {} : { line } }]
217
+ }
218
+
123
219
  /**
124
220
  * One-line human-readable summary of a tool call for the ACP `title` field.
125
221
  * Zed renders this as the collapsed tool-call header, so a bare tool name
@@ -146,11 +242,16 @@ function toolCallTitle(name, argumentsValue) {
146
242
  )
147
243
 
148
244
  // Execute-kind tools (plain-text label): show the command itself.
149
- if (name === 'zed_terminal' || /bash|shell|exec|run_code|execute|terminal/.test(name)) {
245
+ if (name === 'zed_terminal' || EXECUTOR_NAME.test(name)) {
150
246
  // Only zed_terminal maps to kind 'execute' (Zed renders its label as plain
151
247
  // text); local executors like bash/run_code are kind 'other' and render as
152
248
  // markdown, so their command must be escaped to stay literal.
153
249
  const literal = (text) => (name === 'zed_terminal' ? text : mdEscape(text))
250
+ // Codex-style card: the collapsed title is the model's own intent line
251
+ // (bash/pwsh mark `description` required — "Show working tree status"),
252
+ // and the exact command stays visible in rawInput when the card expands.
253
+ const description = str('description')
254
+ if (description) return clipOneLine(literal(description), 100)
154
255
  const command = str('command') ?? str('cmd') ?? str('script')
155
256
  if (command) return clipOneLine(literal(command), 100)
156
257
  const argv = obj.args
@@ -161,7 +262,7 @@ function toolCallTitle(name, argumentsValue) {
161
262
 
162
263
  // File-content tools: `Read <path>` (incl. line range when present).
163
264
  if (/read|cat|show|view/.test(name)) {
164
- const path = str('path') ?? str('file_path') ?? str('filePath') ?? str('file')
265
+ const path = pathArg(obj)
165
266
  if (path) {
166
267
  const range = [num('line') ?? num('offset'), num('limit')].filter((value) => value !== undefined)
167
268
  const suffix = range.length === 2 && Number.isFinite(range[0]) && Number.isFinite(range[1]) && range[1] > 0
@@ -175,7 +276,7 @@ function toolCallTitle(name, argumentsValue) {
175
276
 
176
277
  // File-modifying tools: `Write <path>` / `Edit <path>`.
177
278
  if (/write|edit|patch|apply/.test(name)) {
178
- const path = str('path') ?? str('file_path') ?? str('filePath') ?? str('file') ?? str('target')
279
+ const path = pathArg(obj)
179
280
  if (path) return clipOneLine(`Write ${mdEscape(path)}`, 120)
180
281
  }
181
282
 
@@ -275,6 +376,24 @@ export function apply(ctx, config) {
275
376
  /** Resolve the permission-presets service, tolerating a lazy mount. */
276
377
  const permissionPresets = () => ctx.get('permissionPresets')
277
378
 
379
+ // Multi-root workspaces: describe the session's roots to the model. Zed
380
+ // passes the primary cwd plus every additional workspace root on the
381
+ // session lifecycle requests; the sandbox policy still resolves a single
382
+ // writable root (the primary cwd), so the text tells the model how writes
383
+ // behave under each policy. Reads are unrestricted by the file sandbox.
384
+ ctx.systemPrompt?.context({
385
+ name: 'acp:workspace-roots',
386
+ order: 115,
387
+ text: (context) => {
388
+ const session = context.agent?.session
389
+ if (session === undefined) return ''
390
+ return renderWorkspaceRoots(
391
+ session.header.cwd,
392
+ sessions.get(session.id)?.additionalDirectories ?? [],
393
+ )
394
+ },
395
+ })
396
+
278
397
  /** Return the bridge-owned record for an agent, rejecting same-id impostors. */
279
398
  const ownedRecord = (agent) => {
280
399
  const record = sessions.get(agent.session.id)
@@ -309,6 +428,70 @@ export function apply(ctx, config) {
309
428
  inflight.reject(internalError(`turn failed: ${reason.error.message}`))
310
429
  }
311
430
 
431
+ /**
432
+ * Wire update for a harness `tool/call`: the one-line title header plus the
433
+ * protocol's friendly-body surfaces — `content` (diff for file edits, the
434
+ * command as a code block for executors), `locations` (clickable file chips),
435
+ * and `rawInput`/`rawOutput`-class transparency fields.
436
+ */
437
+ function toolCallUpdateFor(record, event) {
438
+ const parsedArgs = parseToolArguments(event.data.arguments)
439
+ const kind = toolKindFor(event.data.name)
440
+ const content = toolCallContentFor(event.data.name, parsedArgs, kind)
441
+ const locations = toolCallLocationsFor(kind, parsedArgs)
442
+ // Result-side card bodies need the call's own args (the executor output
443
+ // fence); remember them per callId with a small ceiling against
444
+ // aborted-call leaks.
445
+ if (record.callArgs.size >= 64) record.callArgs.delete(record.callArgs.keys().next().value)
446
+ record.callArgs.set(event.data.callId, { name: event.data.name, parsedArgs })
447
+ return {
448
+ sessionUpdate: 'tool_call',
449
+ toolCallId: event.data.callId,
450
+ name: event.data.name,
451
+ title: toolCallTitle(event.data.name, parsedArgs),
452
+ kind,
453
+ status: 'in_progress',
454
+ ...content === undefined ? {} : { content },
455
+ ...locations === undefined ? {} : { locations },
456
+ rawInput: parsedArgs,
457
+ _meta: {
458
+ turn: event.data.turn,
459
+ step: event.data.step,
460
+ name: event.data.name,
461
+ argumentsPreview: event.data.arguments.slice(0, 200),
462
+ },
463
+ }
464
+ }
465
+
466
+ /** Wire update for a harness `tool/result`: terminal status plus, for
467
+ * executors, the output as a friendly code block under the command. */
468
+ function toolResultUpdateFor(record, event, callId, elapsed) {
469
+ const preview = resultPreview(event)
470
+ const call = record.callArgs.get(callId)
471
+ record.callArgs.delete(callId)
472
+ const isError = event.data.error !== undefined
473
+ let content
474
+ if (!isError && preview !== undefined && call !== undefined
475
+ && call.name !== 'zed_terminal' && EXECUTOR_NAME.test(call.name)) {
476
+ content = [{ type: 'content', content: { type: 'text', text: codeFence(preview, executorLang(call.name)) } }]
477
+ }
478
+ return {
479
+ sessionUpdate: 'tool_call_update',
480
+ toolCallId: callId,
481
+ ...call === undefined ? {} : { name: call.name },
482
+ status: isError ? 'failed' : 'completed',
483
+ ...content === undefined ? {} : { content },
484
+ ...preview === undefined ? {} : { rawOutput: preview },
485
+ _meta: {
486
+ turn: event.data.turn,
487
+ step: event.data.step,
488
+ elapsedMs: elapsed,
489
+ count: record.toolStats.count,
490
+ totalMs: record.toolStats.totalMs,
491
+ },
492
+ }
493
+ }
494
+
312
495
  // ── session/event → ACP notifications ─────────────────────────────────────
313
496
 
314
497
  ctx.on('session/event', (session, event) => {
@@ -369,25 +552,9 @@ export function apply(ctx, config) {
369
552
  case 'tool/call': {
370
553
  record.toolStats.lastCallAt = Date.now()
371
554
  record.toolStats.lastName = event.data.name
372
- const parsedArgs = parseToolArguments(event.data.arguments)
373
555
  notify({
374
556
  sessionId: session.header.id,
375
- update: {
376
- sessionUpdate: 'tool_call',
377
- toolCallId: event.data.callId,
378
- // A one-line summary (not just the tool name) so Zed's collapsed
379
- // header shows what the call is doing — which file is read, which
380
- // command runs, what is searched — before expanding the card.
381
- title: toolCallTitle(event.data.name, parsedArgs),
382
- kind: toolKindFor(event.data.name),
383
- rawInput: parsedArgs,
384
- _meta: {
385
- turn: event.data.turn,
386
- step: event.data.step,
387
- name: event.data.name,
388
- argumentsPreview: event.data.arguments.slice(0, 200),
389
- },
390
- },
557
+ update: toolCallUpdateFor(record, event),
391
558
  })
392
559
  break
393
560
  }
@@ -400,22 +567,9 @@ export function apply(ctx, config) {
400
567
  record.toolStats.lastCallAt = undefined
401
568
  // The tool call id lives on the ToolResultBlock, not on the event root.
402
569
  const callId = event.data.message?.content?.[0]?.toolCallId ?? event.data.callId
403
- const preview = resultPreview(event)
404
570
  notify({
405
571
  sessionId: session.header.id,
406
- update: {
407
- sessionUpdate: 'tool_call_update',
408
- toolCallId: callId,
409
- status: event.data.error === undefined ? 'completed' : 'error',
410
- ...preview === undefined ? {} : { rawOutput: preview },
411
- _meta: {
412
- turn: event.data.turn,
413
- step: event.data.step,
414
- elapsedMs: elapsed,
415
- count: record.toolStats.count,
416
- totalMs: record.toolStats.totalMs,
417
- },
418
- },
572
+ update: toolResultUpdateFor(record, event, callId, elapsed),
419
573
  })
420
574
  break
421
575
  }
@@ -747,7 +901,22 @@ export function apply(ctx, config) {
747
901
 
748
902
  /** Resolve and apply a new model/effort selection to the session. */
749
903
  async function applySelection(record, next) {
750
- const resolved = await llm.resolveCallConfig(next)
904
+ let resolved
905
+ try {
906
+ resolved = await llm.resolveCallConfig(next)
907
+ } catch (error) {
908
+ // A reasoning effort can outlive the model that supported it: Zed
909
+ // re-applies its saved `default_config_options` on every session, and
910
+ // switching models carries the session's current effort onto the newly
911
+ // picked model, which may not offer it (glm-* and gpt-5.6-luna reject
912
+ // "max"). Rejecting the whole switch snapped Zed's dropdown back and
913
+ // made model switching itself appear broken, so drop the stale effort
914
+ // and resolve against the target model's own default instead. Any other
915
+ // failure (unknown route, missing credential) still surfaces.
916
+ if (error?.code !== 'UNSUPPORTED_REASONING_EFFORT' || next.reasoningEffort === undefined) throw error
917
+ logger.warn(`acp-enhanced: dropped reasoning effort "${next.reasoningEffort}" for ${next.provider}/${next.model} (unsupported); using the model default`)
918
+ resolved = await llm.resolveCallConfig({ ...next, reasoningEffort: undefined })
919
+ }
751
920
  const selected = {
752
921
  provider: resolved.provider,
753
922
  model: resolved.model,
@@ -1278,6 +1447,8 @@ export function apply(ctx, config) {
1278
1447
  return {
1279
1448
  agent: handle.agent,
1280
1449
  dispose: () => handle.dispose(),
1450
+ /** Additional workspace roots beyond the primary cwd (multi-root). */
1451
+ additionalDirectories: [],
1281
1452
  inflight: undefined,
1282
1453
  messageId: undefined,
1283
1454
  stepStartedAt: undefined,
@@ -1286,6 +1457,8 @@ export function apply(ctx, config) {
1286
1457
  title: undefined,
1287
1458
  lastActivityAt: undefined,
1288
1459
  toolStats: { count: 0, totalMs: 0, lastCallAt: undefined, lastName: undefined },
1460
+ /** callId → { name, parsedArgs } for result-time card bodies (bounded). */
1461
+ callArgs: new Map(),
1289
1462
  buffer: {},
1290
1463
  thoughtBuffer: {},
1291
1464
  contextWindow: undefined,
@@ -1397,36 +1570,14 @@ export function apply(ctx, config) {
1397
1570
  break
1398
1571
  }
1399
1572
  case 'tool/call': {
1400
- const parsedArgs = parseToolArguments(event.data.arguments)
1401
- await notifyNow(record, {
1402
- sessionUpdate: 'tool_call',
1403
- toolCallId: event.data.callId,
1404
- title: toolCallTitle(event.data.name, parsedArgs),
1405
- kind: toolKindFor(event.data.name),
1406
- rawInput: parsedArgs,
1407
- _meta: {
1408
- turn: event.data.turn,
1409
- step: event.data.step,
1410
- name: event.data.name,
1411
- argumentsPreview: String(event.data.arguments ?? '').slice(0, 200),
1412
- },
1413
- })
1573
+ // Same card shape as the live path — replayed threads render the
1574
+ // friendly body (diff / command block) and clickable locations too.
1575
+ await notifyNow(record, toolCallUpdateFor(record, event))
1414
1576
  break
1415
1577
  }
1416
1578
  case 'tool/result': {
1417
- const preview = resultPreview(event)
1418
1579
  const callId = event.data.message?.content?.[0]?.toolCallId ?? event.data.callId
1419
- await notifyNow(record, {
1420
- sessionUpdate: 'tool_call_update',
1421
- toolCallId: callId,
1422
- status: event.data.error === undefined ? 'completed' : 'error',
1423
- ...preview === undefined ? {} : { rawOutput: preview },
1424
- _meta: {
1425
- turn: event.data.turn,
1426
- step: event.data.step,
1427
- name: event.data.name,
1428
- },
1429
- })
1580
+ await notifyNow(record, toolResultUpdateFor(record, event, callId, 0))
1430
1581
  break
1431
1582
  }
1432
1583
  }
@@ -1459,7 +1610,10 @@ export function apply(ctx, config) {
1459
1610
  agentInfo: { name: 'deepseek-harness-acp-enhanced', version: AGENT_VERSION },
1460
1611
  agentCapabilities: {
1461
1612
  loadSession: true,
1462
- sessionCapabilities: { list: {}, delete: {} },
1613
+ // additionalDirectories: multi-root workspaces (Zed passes every
1614
+ // workspace root on session/new / session/load instead of showing
1615
+ // the "doesn't currently support multi-root workspaces" callout).
1616
+ sessionCapabilities: { list: {}, delete: {}, additionalDirectories: {} },
1463
1617
  promptCapabilities: { image: false, audio: false, embeddedContext: false },
1464
1618
  // Stdio MCP servers always work; streamable HTTP maps onto
1465
1619
  // dsh-mcp-client's second transport. Legacy SSE does not.
@@ -1475,7 +1629,7 @@ export function apply(ctx, config) {
1475
1629
 
1476
1630
  async newSession(params) {
1477
1631
  assertOpen()
1478
- validateSessionParams(params)
1632
+ const additionalDirectories = normalizeSessionParams(params)
1479
1633
  const sessionId = SessionId(randomUUID())
1480
1634
  const handle = await agents.create({
1481
1635
  sessionId,
@@ -1490,6 +1644,7 @@ export function apply(ctx, config) {
1490
1644
  throw internalError('connection closed during session/new')
1491
1645
  }
1492
1646
  const record = makeRecord(handle)
1647
+ record.additionalDirectories = additionalDirectories
1493
1648
  sessions.set(sessionId, record)
1494
1649
  await syncMcpServers(params.mcpServers, params.cwd)
1495
1650
  publishCommandsAfterResponse(record)
@@ -1515,15 +1670,14 @@ export function apply(ctx, config) {
1515
1670
 
1516
1671
  async loadSession(params) {
1517
1672
  assertOpen()
1518
- if (!isAbsolute(params.cwd)) throw invalidParams(`cwd must be an absolute path: ${params.cwd}`)
1519
- if (params.additionalDirectories !== undefined && params.additionalDirectories.length > 0) {
1520
- throw invalidParams('additionalDirectories is not supported')
1521
- }
1673
+ const additionalDirectories = normalizeSessionParams(params)
1522
1674
  await syncMcpServers(params.mcpServers, params.cwd)
1523
1675
  const sessionId = SessionId(params.sessionId)
1524
1676
  const live = sessions.get(sessionId)
1525
1677
  if (live !== undefined) {
1526
1678
  // Already live on this connection: return its state without replay.
1679
+ // The client's root list is authoritative for the loaded workspace.
1680
+ live.additionalDirectories = additionalDirectories
1527
1681
  const permission = permissionPresets()
1528
1682
  return {
1529
1683
  ...permission === undefined ? {} : {
@@ -1550,6 +1704,7 @@ export function apply(ctx, config) {
1550
1704
  throw internalError('connection closed during session/load')
1551
1705
  }
1552
1706
  const record = makeRecord(handle)
1707
+ record.additionalDirectories = additionalDirectories
1553
1708
  sessions.set(sessionId, record)
1554
1709
  // Zed inserts the thread before the load RPC completes; replay the
1555
1710
  // conversation history as notifications so the thread renders.
@@ -1760,9 +1915,11 @@ export function apply(ctx, config) {
1760
1915
  const live = sessions.get(header.id)
1761
1916
  const title = live?.title ?? await readStoredTitle(persistence, header.id)
1762
1917
  const updatedAtMs = live?.lastActivityAt ?? header.createdAt
1918
+ const liveDirs = live?.additionalDirectories ?? []
1763
1919
  out.push({
1764
1920
  sessionId: header.id,
1765
1921
  cwd: header.cwd ?? cwd ?? process.cwd(),
1922
+ ...liveDirs.length === 0 ? {} : { additionalDirectories: liveDirs },
1766
1923
  ...title === undefined ? {} : { title },
1767
1924
  updatedAt: new Date(updatedAtMs).toISOString(),
1768
1925
  })
@@ -1865,10 +2022,46 @@ export function apply(ctx, config) {
1865
2022
  ctx.effect(() => quiesce, 'acp-enhanced.connection')
1866
2023
  }
1867
2024
 
1868
- /** Reject session features outside the automation contract. */
1869
- function validateSessionParams(params) {
2025
+ /**
2026
+ * Validate `session/new`-style params and normalize the optional
2027
+ * `additionalDirectories` list. Entries must be absolute paths; they are
2028
+ * lexically resolved, deduplicated, and the primary cwd itself is dropped
2029
+ * (the schema treats the cwd as the first root — Zed never repeats it, but a
2030
+ * hand-rolled client may).
2031
+ */
2032
+ function normalizeSessionParams(params) {
1870
2033
  if (!isAbsolute(params.cwd)) throw invalidParams(`cwd must be an absolute path: ${params.cwd}`)
1871
- if (params.additionalDirectories !== undefined && params.additionalDirectories.length > 0) {
1872
- throw invalidParams('additionalDirectories is not supported')
2034
+ const raw = params.additionalDirectories
2035
+ if (raw === undefined || raw === null) return []
2036
+ if (!Array.isArray(raw)) throw invalidParams('additionalDirectories must be an array of absolute paths')
2037
+ const primary = resolve(params.cwd)
2038
+ const seen = new Set()
2039
+ const dirs = []
2040
+ for (const entry of raw) {
2041
+ if (typeof entry !== 'string' || !isAbsolute(entry)) {
2042
+ throw invalidParams(`additionalDirectories entries must be absolute paths: ${String(entry)}`)
2043
+ }
2044
+ const dir = resolve(entry)
2045
+ if (dir === primary || seen.has(dir)) continue
2046
+ seen.add(dir)
2047
+ dirs.push(dir)
1873
2048
  }
2049
+ return dirs
2050
+ }
2051
+
2052
+ /**
2053
+ * System-prompt context for a multi-root session (empty for single-root).
2054
+ * Reads are unrestricted by the file sandbox; writes follow the session
2055
+ * policy, whose single writable root is the primary cwd (dsh-sandbox-policy
2056
+ * resolves one `workspaceRoot` per session), so additional roots need
2057
+ * escalation under workspace-write and are unrestricted under
2058
+ * danger-full-access.
2059
+ */
2060
+ function renderWorkspaceRoots(cwd, dirs) {
2061
+ if (dirs.length === 0 || cwd === undefined) return ''
2062
+ return [
2063
+ `Multi-root workspace: relative paths resolve against the primary root ${JSON.stringify(cwd)}. The session also spans these additional workspace roots:`,
2064
+ ...dirs.map((dir) => `- ${dir}`),
2065
+ 'File reads work in every root. File writes follow the sandbox policy: in workspace-write mode only the primary root (plus temp areas) is writable without approval, so a write under an additional root must obtain wider access first; in danger-full-access mode every root is writable.',
2066
+ ].join('\n')
1874
2067
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-acp-enhanced",
3
- "version": "0.3.2",
3
+ "version": "0.3.5",
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",