dsh-acp-enhanced 0.6.0 → 0.7.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.
package/README-zh.md CHANGED
@@ -72,7 +72,9 @@ ACP 线上。
72
72
  (列表以等宽代码块排版,一眼全见),其余(`/compact` `/goal` `/permission`
73
73
  `/plan`…)直通 harness 命令注册表,全部**不经过模型 turn** 即时执行。所有
74
74
  userInvocable 技能也会作为命令广播,`/ask-matt`、`/code-review`、`/tdd` 等能被
75
- 编辑器放行到达桥,技能正文按 dsh-tool-skill 的用户调用方式注入消息
75
+ 编辑器放行到达桥,技能正文按 dsh-tool-skill 的用户调用方式注入消息。斜杠命令
76
+ 旁粘贴的图片会作为命令附件随行(例如 `/goal` 目标的参考截图),与 Web 端
77
+ composer 的提交方式一致
76
78
 
77
79
  ### MCP
78
80
 
@@ -185,34 +187,13 @@ node scripts/acp-client.mjs # 官方默认路由,无需 env
185
187
  DSH_ACP_PROVIDER=... DSH_ACP_MODEL=... node scripts/acp-client.mjs # 自定义路由时再传
186
188
  ```
187
189
 
188
- ### 可选:web_search 走同一个网关
190
+ ### Web 搜索
189
191
 
190
- 若网关实现 OpenAI Responses 的 `web_search` 服务端工具,可把搜索也路由到网关(复用
191
- 同一凭据)。装子包并给 profile 的 `cordis.patch.yml` 追加两段:
192
-
193
- ```sh
194
- dsh plugin --profile acp-enhanced add dsh-web-search-openrouter
195
- ```
196
-
197
- ```yaml
198
- - id: web
199
- config:
200
- searchProvider: openai-responses # 子包注册在 ctx.web 上的搜索 provider id(固定值)
201
-
202
- - insert:
203
- - id: web-search-openrouter
204
- name: 'dsh-web-search-openrouter'
205
- config:
206
- enabled: true
207
- baseURL: http://<gateway-host>:<port>/v1
208
- model: <your-model-id>
209
- apiKeyEnv: <KEY_ENV_NAME>
210
- ```
211
-
212
- > ⚠️ `searchProvider` 必须**精确等于** `openai-responses`——这是
213
- > `dsh-web-search-openrouter` 注册在 `ctx.web` 上的搜索 provider id,**不是**网关的
214
- > LLM provider id(即上面 `DSH_ACP_PROVIDER` 填的那个)。web 插件按 id 精确匹配,
215
- > 填错时配置期不会报错,直到首次搜索才抛 `WEB_PROVIDER_CONFIGURED_MISSING`。
192
+ bridge 自身不携带、也不推荐任何搜索 provider:模型侧 `web_search` 工具走 `web`
193
+ seam 的 `searchProvider`,往 profile 里挂任意 `ctx.web` provider 即可——带
194
+ `dsh.bundle` 的包用 `dsh plugin --profile acp-enhanced add <package>` 安装,普通包
195
+ 走用户层 `insert` 挂载(见下节)。你的 dsh 部署里有哪些 provider 是 profile 层的
196
+ 事,与 bridge 无关。
216
197
 
217
198
  ### 管理 profile 的插件
218
199
 
@@ -226,8 +207,8 @@ profile 的插件树由三层组合而成,后层修补前层:
226
207
  `@deepseek-ai/dsh-base` 在前,随后是每个声明了 `dsh.bundle` 的已安装包(如
227
208
  `dsh-acp-enhanced`),按数组顺序排列。
228
209
  2. **用户层**:`~/.dsh/profiles/acp-enhanced/cordis.patch.yml`——按 id 定位的行配置
229
- 覆写、`disabled: true` 行禁用,以及 `insert` 挂载(无 `dsh.bundle` 的包——如上面
230
- 的 `dsh-web-search-openrouter`——就靠它装配)。
210
+ 覆写、`disabled: true` 行禁用,以及 `insert` 挂载(无 `dsh.bundle` 的包——如手工
211
+ 挂载的自写 provider——就靠它装配)。
231
212
  3. **临时覆盖**:`dsh --profile acp-enhanced --patch extra.yml`。
232
213
 
233
214
  调整插件集:
@@ -252,18 +233,71 @@ dsh --profile acp-enhanced --dump-config # 查看组合后的完整
252
233
  ```
253
234
 
254
235
  - **无 `dsh.bundle` 的包自身不会装配**——它只作为普通依赖安装(带一次性警告),需要
255
- 像上面的 `web-search-openrouter` 行那样在用户层 `insert` 挂载;要改已有行的配置,
256
- 用 `- id: <行>` + `config:` 覆写——patch 条目是整行替换、不做合并。
236
+ 自己在用户层 `insert` 挂载;要改已有行的配置,用 `- id: <行>` + `config:` 覆写——
237
+ patch 条目是整行替换、不做合并。
257
238
 
258
239
  改动在**下一个**进程生效:Zed 为每个 agent 线程拉起一个全新的
259
240
  `dsh --profile acp-enhanced`,编辑 profile 后新开 agent 线程(或重启 Zed)即可。
260
241
 
242
+ ## 兼容性
243
+
244
+ 同一个桥可运行在 **0.1.0-rc.6** 至 **0.1.2-rc.1** 的每一代 harness 上。0.1.2 线重写了本桥消费的多个
245
+ API,桥在运行期同时吸收各代——不分叉、不加版本开关:
246
+
247
+ | API | ≤ 0.1.1-rc.2(旧代) | ≥ 0.1.2-alpha.2(projection 代) | 桥的做法 |
248
+ |---|---|---|---|
249
+ | 会话的运行中 preset | `resolveSessionPreset({header, events})` 导出 | 导出已移除;`agentPreset` session projection | 自行折叠事件日志(最后一个 `agent-preset/selected` 胜出、header 兜底)——两代语义一致 |
250
+ | preset 解析失败 | `UnknownPresetError` / `PresetMountError` | `RemoteError`,错误码 `agent-preset/*` | `isPresetClientError`:RemoteError 按 `isDSHRemoteError` + `code` 鸭子类型识别;旧类按 `presetId` 结构识别(绝不跨副本 `instanceof`) |
251
+ | `permissionPresets.current(x)` | `current(events)` | `current(session)`(经 `permissionState`) | `currentPermissionMode` 每次调用前探测服务实例 |
252
+ | 会话事件日志读取 | 同步 `session.events` 数组 | `session.events` 已移除(0.1.2-rc.1);改为 `snapshotEvents()` / `ownEvents()` / `eventAt()` | `sessionEventsOf`:有 `snapshotEvents()` 用之,否则用活数组 |
253
+ | 注册表 `execute` 签名 | `execute(agent, line, signal)` | `execute(agent, line, images, signal)`(images 插在 line 与 signal 之间,0.1.1-rc.1 起) | `executeRegistryCommand` 按声明参数个数探测(`Remote` 装饰器不包裹方法) |
254
+ | `userQuestions` 注册 | `registerProvider({ask})` | `user-questions/request` Cordis waterfall(0.1.2-alpha.2 起) | 探测服务实例;waterfall 监听只应答本桥会话、其余经 `next()` 传递 |
255
+
256
+ 两条不变量保证其安全性(openma 的 `deepseek-harness-acp` 适配器独立得出了同样结论):**只值导入纯
257
+ helper**(`createUserMessage`、`ReasoningEffortId`、`SessionId`、`defineTool`…… 外来副本功能等价);**服务的代际问题按服务实例探测回答**——决定服务代际的是启动它的 CLI,不是本包的依赖范围。`dsh-agent-presets`
258
+ 按 *命名空间* 导入:0.1.2-alpha.1 删除了其命名导出,命名导入会在 ESM 链接期直接失败。
259
+
260
+ 从干净依赖树校验两代:
261
+
262
+ ```sh
263
+ node scripts/compat-check.mjs # 分别安装 0.1.0-rc.6 与 0.1.2-alpha.2+ 两套,逐一导入本桥
264
+ ```
265
+
266
+ ### 开发检出:仓库锁定 CLI + 独立 home
267
+
268
+ 启动器**从检出目录**(`link:` 安装)运行时,按以下顺序解析 dsh CLI:
269
+
270
+ 1. `$DSH_PATH` —— 显式指定的 dsh 二进制,或其 `node_modules/.bin/dsh` 内含 dsh 的目录
271
+ 2. 仓库锁定的 CLI —— `<repo>/node_modules/.bin/dsh`(本包的 `@deepseek-ai/dsh`
272
+ devDependency,当前 0.1.2-rc.1)
273
+ 3. 全局兜底 —— PATH / npx 缓存 / npm 前缀 里的 `dsh`(旧行为;未 `pnpm install` 的全新检出退化为它)
274
+
275
+ 命中 (1) 或 (2) 时,profile 在**独立 home**(`DSH_ACP_HOME`,默认 `~/.dsh-acp`)下启动:dsh
276
+ 每次启动都会把自身依赖闭包 heal 进 `$DSH_HOME/profiles/node_modules`——该目录被同 home 下所有
277
+ profile 共享、内容随最后启动的 CLI 翻转——因此第二个 CLI 代际不得与运行中的 `dsh web`
278
+ 等共享 home。此路径永不触碰默认 home。harness 注入到子进程的 `DSH_HOME=$HOME/.dsh`
279
+ (dsh 会向每个 agent/工具进程导出它)会被识别并覆盖而非沿用;只有指向默认 home 之外的
280
+ `DSH_HOME` 才被尊重;确要将锁定 CLI 跑在默认 home 上,请显式设 `DSH_ACP_HOME=$HOME/.dsh`。
281
+
282
+ 一次性引导独立 home(建**不含** `dsh-mnemon` 的 profile——它不支持 0.1.2-alpha harness——并逐字移植旧
283
+ profile 的用户层行、迁移凭据/设置、挂 0.1.2-alpha `standard` preset 所需的
284
+ `subagent-model-selection-settings` 宿主服务、关闭 DeepSeek 插件清单上报):
285
+
286
+ ```sh
287
+ scripts/init-acp-home.sh # 幂等;重跑不会覆盖你的文件
288
+ ```
289
+
290
+ 两代 harness 都把会话持久化在 `$DSH_HOME/sessions/<slug>/<id>/session.jsonl.zstd`,且新代可读旧代日志(已验证:历史回放与 preset 折叠跨代工作)。因此旧线程只需把会话历史拷到新 home——`scripts/init-acp-home.sh` 会打印这条命令(或加 `--copy-sessions`);默认不拷贝,因为默认 home 的目录里还有全部 web profile 会话。
291
+
261
292
  ## 故障排查
262
293
 
263
294
  | 症状 | 处理 |
264
295
  |---|---|
265
296
  | `exec: dsh: not found`(status 127) | 用随附 `dsh-acp-zed.sh` 启动器(自定位 node/dsh) |
266
297
  | `no API key for provider route "xxx"` | 写入 `~/.dsh/.credentials.yaml`,或在 agent_servers 里设 `env.DEEPSEEK_API_KEY` |
298
+ | `SyntaxError: ... 'PresetMountError'` | 你在 0.1.2-alpha 宿主上运行 0.7.0 之前的桥副本——升级本包 |
299
+ | `modelSelectionSettings requires ... in the Host scope` | 0.1.2-alpha 宿主缺少 `subagent-model-selection-settings` 行——运行 `scripts/init-acp-home.sh`(或按脚本模板在用户层补 insert 行) |
300
+ | 宿主升级后旧线程变空白 | 会话存放在 `$DSH_HOME/sessions/<slug>/`;把旧 home 的历史拷进独立 home(`scripts/init-acp-home.sh --copy-sessions`)即可继续 |
267
301
  | 无法切换模型 | 保存的 `reasoning_effort` 默认值(或会话当前 effort)被带到新模型上。0.3.6 起本桥按模型记住上次使用的强度(随 profile 持久化):不被新模型支持的 effort 会被该模型记忆值替换——没有记忆则回退其默认值,再无默认则取第一个可选值,既不会切换失败也不会出现 "unknown"。另检查:是否选到了不可路由的"幽灵 provider"——本桥默认过滤(只广播 `config.provider` 的模型),确认 profile 的 provider 指向真实路由 |
268
302
  | 上下文用量不显示 | 选到了不可路由的"幽灵 provider";本桥默认过滤(只广播 `config.provider` 的模型),确认 profile 的 provider 指向真实路由 |
269
303
  | 需要详细诊断 | `ACP_DEBUG=1 dsh --profile acp-enhanced`(stderr 生命周期 trace) |
@@ -271,6 +305,7 @@ dsh --profile acp-enhanced --dump-config # 查看组合后的完整
271
305
  ## 开发
272
306
 
273
307
  ```sh
308
+ node scripts/compat-check.mjs # 跨代链接检查(0.1.0-rc.6 + 0.1.2-alpha.2+ 临时安装)
274
309
  node scripts/acp-client.mjs # 端到端冒烟(需要 API key)
275
310
  node scripts/acp-client-tools.mjs # 客户端工具测试(模拟 Zed 的 fs/terminal/elicitation/plan)
276
311
  node scripts/acp-mcp-test.mjs # MCP 挂载测试(无模型调用)
@@ -279,8 +314,21 @@ node scripts/acp-resume-test.mjs # 会话恢复测试
279
314
  node scripts/codec-image-test.mjs # 图片编解码单元测试(无网络,假 store)
280
315
  node scripts/terminal-codec-test.mjs # 终端卡片编解码单元测试(无网络)
281
316
  node scripts/acp-image-e2e.mjs # 图片能力端到端(vision 模型段需 API key)
317
+ scripts/init-acp-home.sh # 引导/刷新独立 home(~/.dsh-acp)
282
318
  ```
283
319
 
320
+ harness 包的 devDependency 与锁定的 `@deepseek-ai/dsh` CLI 声明相同的 range(如
321
+ `^0.1.2-rc.1`),让仓库依赖树与全新 CLI 安装解析出同一个连贯家族——在此用精确 patch
322
+ 锁定、与 CLI 的 range 闭包混存会得到分裂闭包(同名包两个版本),profile 启动时报
323
+ export-not-found。改这些锁定后务必整体重建 lockfile(`rm -rf node_modules pnpm-lock.yaml
324
+ && pnpm install`):原地增量安装既会留下污染 profile heal 的残留 store 条目,还会保留
325
+ lockfile 里的陈旧 peer 解析——从 0.1.2-alpha.2 原地升到 0.1.2-rc.1 时,rc.1 各包的
326
+ snapshot 里仍挂着 `dsh-session-persistence@0.1.2-alpha.3`(旧代 peer),boot 与
327
+ session/new 全部通过,直到第一个 turn 才以 `TypeError: Cannot read properties of
328
+ undefined (reading 'length')`(PersistenceCoordinator)崩掉。`pnpm-workspace.yaml` 放行
329
+ 了 CLI 闭包的构建脚本(node-pty prebuild、koffi)——仓库 CLI 启动 profile 时它们就是
330
+ 运行时依赖。
331
+
284
332
  ## 已知限制
285
333
 
286
334
  不支持音频附件(不声明 audio 能力)、文本按块粒度流式、每会话同时一个 in-flight
package/README.md CHANGED
@@ -85,7 +85,9 @@ over the ACP wire.
85
85
  through the harness command registry — all executed **without a model turn**. Every
86
86
  user-invocable skill is advertised as a command too, so `/ask-matt`, `/code-review`,
87
87
  `/tdd`, … reach the bridge instead of being rejected by the editor, and the skill's
88
- instructions are injected into the message (dsh-tool-skill-style user invocation)
88
+ instructions are injected into the message (dsh-tool-skill-style user invocation).
89
+ Images pasted next to a slash line ride along as command attachments (e.g. reference
90
+ screenshots for a `/goal` objective), the same way the Web composer submits them
89
91
 
90
92
  ### MCP
91
93
 
@@ -220,36 +222,14 @@ node scripts/acp-client.mjs # official default route, no env;
220
222
  DSH_ACP_PROVIDER=... DSH_ACP_MODEL=... node scripts/acp-client.mjs # only for a custom route
221
223
  ```
222
224
 
223
- ### Optional: route web_search through the same gateway
225
+ ### Web search
224
226
 
225
- If the gateway implements the OpenAI Responses `web_search` server tool, you can route
226
- search through it too (reusing the same credential). Install the sub-package and append
227
- two blocks to the profile's `cordis.patch.yml`:
228
-
229
- ```sh
230
- dsh plugin --profile acp-enhanced add dsh-web-search-openrouter
231
- ```
232
-
233
- ```yaml
234
- - id: web
235
- config:
236
- searchProvider: openai-responses # the search provider id this sub-package registers on ctx.web (fixed value)
237
-
238
- - insert:
239
- - id: web-search-openrouter
240
- name: 'dsh-web-search-openrouter'
241
- config:
242
- enabled: true
243
- baseURL: http://<gateway-host>:<port>/v1
244
- model: <your-model-id>
245
- apiKeyEnv: <KEY_ENV_NAME>
246
- ```
247
-
248
- > ⚠️ `searchProvider` must be **exactly** `openai-responses` — the search provider id
249
- > `dsh-web-search-openrouter` registers on `ctx.web`. It is **not** your gateway's LLM
250
- > provider id (the one you put in `DSH_ACP_PROVIDER` above). The `web` plugin matches it
251
- > exactly, so a wrong value produces no error at config time and only fails at the first
252
- > search with `WEB_PROVIDER_CONFIGURED_MISSING`.
227
+ The bridge ships no search provider and takes no position on which one you use: the
228
+ model-facing `web_search` tool rides on the `web` seam's `searchProvider`, so mount any
229
+ `ctx.web` provider into the profile — a package with `dsh.bundle` via
230
+ `dsh plugin --profile acp-enhanced add <package>`, or a plain package via your user-layer
231
+ `insert` rows (see below). Which provider exists in your dsh deployment is a profile
232
+ concern, not a bridge one.
253
233
 
254
234
  ### Managing the profile's plugins
255
235
 
@@ -265,7 +245,7 @@ before it:
265
245
  `dsh.bundle` (like `dsh-acp-enhanced`), in array order.
266
246
  2. **Your user layer** — `~/.dsh/profiles/acp-enhanced/cordis.patch.yml`: id-targeted
267
247
  row config overrides, `disabled: true` row disables, and `insert` lists (how a
268
- package without `dsh.bundle` — like `dsh-web-search-openrouter` above — gets
248
+ package without `dsh.bundle` — e.g. a hand-mounted custom provider — gets
269
249
  mounted).
270
250
  3. **Per-run overlays** — `dsh --profile acp-enhanced --patch extra.yml`.
271
251
 
@@ -294,20 +274,83 @@ consequences worth knowing:
294
274
 
295
275
  - **A package without `dsh.bundle` loads nothing by itself** — it installs as a plain
296
276
  dependency (with a one-time warning) and needs your own `insert` entry in the user
297
- layer, like the `web-search-openrouter` row above. To change an existing row's
298
- config, override it with `- id: <row>` + `config:` — patch entries replace the
299
- whole row config, they do not merge.
277
+ layer. To change an existing row's config, override it with `- id: <row>` + `config:`
278
+ — patch entries replace the whole row config, they do not merge.
300
279
 
301
280
  Changes take effect in the **next** process: Zed spawns a fresh
302
281
  `dsh --profile acp-enhanced` for every agent thread, so open a new agent thread (or
303
282
  restart Zed) after editing the profile.
304
283
 
284
+ ## Compatibility
285
+
286
+ One bridge binary runs against every harness generation from **0.1.0-rc.6** through
287
+ **0.1.2-rc.1**. The 0.1.2 line rewrote three APIs this bridge consumes, and the
288
+ bridge absorbs every generation at runtime — no fork, no version flag:
289
+
290
+ | API | ≤ 0.1.1-rc.2 (legacy) | ≥ 0.1.2-alpha.2 (projection) | Bridge behavior |
291
+ |---|---|---|---|
292
+ | running preset of a session | `resolveSessionPreset({header, events})` export | export removed; `agentPreset` session projection | folds the log itself (last `agent-preset/selected` wins, header fallback) — identical semantics in both |
293
+ | preset resolution failure | `UnknownPresetError` / `PresetMountError` | `RemoteError`, codes `agent-preset/*` | `isPresetClientError`: RemoteError duck-typed by `isDSHRemoteError` + `code`, legacy classes identified structurally by `presetId` (never cross-copy `instanceof`) |
294
+ | `permissionPresets.current(x)` | `current(events)` | `current(session)` (via `permissionState`) | `currentPermissionMode` probes the service instance per call |
295
+ | session event log reads | synchronous `session.events` array | `session.events` removed (0.1.2-rc.1); `snapshotEvents()` / `ownEvents()` / `eventAt()` | `sessionEventsOf` reads `snapshotEvents()` when present, the live array otherwise |
296
+ | registry `execute` signature | `execute(agent, line, signal)` | `execute(agent, line, images, signal)` (images between line and signal, 0.1.1-rc.1+) | `executeRegistryCommand` probes the declared arity (the `Remote` decorator never wraps the method) |
297
+ | `userQuestions` registration | `registerProvider({ask})` | `user-questions/request` Cordis waterfall (0.1.2-alpha.2+) | probes the service instance; waterfall listener answers bridge-owned requests and delegates via `next()` |
298
+
299
+ Two invariants make this safe (same conclusions the openma `deepseek-harness-acp` adapter
300
+ reached independently): **value-import pure helpers only** (`createUserMessage`,
301
+ `ReasoningEffortId`, `SessionId`, `defineTool`, … — a foreign copy is functionally
302
+ equivalent), and **service-generation questions are answered by probing the service
303
+ instance**, because the booting CLI — not this package's dependency range — decides the
304
+ service generation. `dsh-agent-presets` is imported as a *namespace*: 0.1.2-alpha.1
305
+ removed its named exports, and a named import would fail at ESM link time.
306
+
307
+ Check both generations from a clean tree:
308
+
309
+ ```sh
310
+ node scripts/compat-check.mjs # installs 0.1.0-rc.6 + 0.1.2-alpha.2+ sets, imports the bridge from each
311
+ ```
312
+
313
+ ### Dev checkout: repo-pinned CLI, isolated home
314
+
315
+ When the launcher runs **from a checkout** (`link:` install), it resolves the dsh CLI in
316
+ this order:
317
+
318
+ 1. `$DSH_PATH` — an explicit dsh binary, or a directory whose `node_modules/.bin/dsh` holds one
319
+ 2. the repo-pinned CLI — `<repo>/node_modules/.bin/dsh` (this package's `@deepseek-ai/dsh`
320
+ devDependency, currently 0.1.2-rc.1)
321
+ 3. global fallback — `dsh` on PATH / npx cache / npm prefix (the legacy behavior; a fresh
322
+ clone without `pnpm install` degrades to it)
323
+
324
+ Whenever (1) or (2) wins, the profile boots under an **isolated home**
325
+ (`DSH_ACP_HOME`, default `~/.dsh-acp`): dsh heals its whole dependency closure into
326
+ `$DSH_HOME/profiles/node_modules` on every boot — a dir shared by every profile under that
327
+ home whose content flips to whichever CLI booted last — so a second CLI generation must not
328
+ share a home with e.g. a running `dsh web`. The default home is never touched by this path.
329
+ Harness-injected `DSH_HOME=$HOME/.dsh` in the child environment (dsh exports it into every
330
+ agent/tool process) is detected and overridden, not honored — only a `DSH_HOME` pointing
331
+ away from the default home is respected; to force the pinned CLI onto the default home,
332
+ set `DSH_ACP_HOME=$HOME/.dsh` deliberately.
333
+
334
+ Bootstrap the isolated home once (profile without `dsh-mnemon` — it does not support the
335
+ 0.1.2-alpha harness — plus your old profile's user rows ported verbatim and credentials/settings
336
+ migration, the `subagent-model-selection-settings` host service the 0.1.2-alpha `standard`
337
+ preset requires, and the DeepSeek plugin-package inventory reporter disabled):
338
+
339
+ ```sh
340
+ scripts/init-acp-home.sh # idempotent; re-runs never clobber your files
341
+ ```
342
+
343
+ Both harness generations persist sessions under `$DSH_HOME/sessions/<slug>/<id>/session.jsonl.zstd`, and the new generation reads old-generation logs (verified: history replay and the preset fold work cross-generation). Old threads therefore only need their session history copied to the new home — `scripts/init-acp-home.sh` prints the one-liner (or pass `--copy-sessions`); it copies nothing by default, because the default home's tree also holds every web-profile session.
344
+
305
345
  ## Troubleshooting
306
346
 
307
347
  | Symptom | Fix |
308
348
  |---|---|
309
349
  | `exec: dsh: not found` (status 127) | Use the shipped `dsh-acp-zed.sh` launcher (locates node/dsh itself) |
310
350
  | `no API key for provider route "xxx"` | Write `~/.dsh/.credentials.yaml`, or set `env.DEEPSEEK_API_KEY` on the agent_servers entry |
351
+ | `SyntaxError: ... 'PresetMountError'` | You are running a pre-0.7.0 bridge copy against a 0.1.2-alpha host — update this package |
352
+ | `modelSelectionSettings requires ... in the Host scope` | A 0.1.2-alpha host without the `subagent-model-selection-settings` row — run `scripts/init-acp-home.sh` (or add the insert row to your user layer, see the script) |
353
+ | Old threads start empty after a host upgrade | The sessions live under `$DSH_HOME/sessions/<slug>/`; copy the old home's history to the isolated home (`scripts/init-acp-home.sh --copy-sessions`) and the new host resumes them |
311
354
  | Cannot switch models | The saved `reasoning_effort` default (or the session's current effort) is carried onto the new model. Since 0.3.6 the bridge remembers the last effort per model (per-profile JSON): an unsupported carried effort is replaced by that model's remembered effort, else its own default, else its first offered effort — never an "unknown" dropdown, never a failed 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 |
312
355
  | 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 |
313
356
  | Need detailed diagnostics | `ACP_DEBUG=1 dsh --profile acp-enhanced` (stderr lifecycle trace) |
@@ -315,16 +358,33 @@ restart Zed) after editing the profile.
315
358
  ## Development
316
359
 
317
360
  ```sh
361
+ node scripts/compat-check.mjs # cross-generation link check (0.1.0-rc.6 + 0.1.2-alpha.2+ scratch installs)
318
362
  node scripts/acp-client.mjs # end-to-end smoke (needs an API key)
319
363
  node scripts/acp-client-tools.mjs # client-tool tests (mocks Zed fs/terminal/elicitation/plan)
320
364
  node scripts/acp-mcp-test.mjs # MCP mount test (no model calls)
321
365
  node scripts/acp-smoke-keyless.mjs # keyless boot smoke (CI)
322
366
  node scripts/acp-resume-test.mjs # session resume test
323
367
  node scripts/codec-image-test.mjs # image-codec unit tests (no network, fake store)
324
- node scripts/terminal-codec-test.mjs # terminal-card codec unit tests (no network)
368
+ node scripts/terminal-codec-test.mjs # terminal-card codec unit tests (no network)
325
369
  node scripts/acp-image-e2e.mjs # image capability e2e (vision-model leg needs an API key)
370
+ scripts/init-acp-home.sh # bootstrap/refresh the isolated home (~/.dsh-acp)
326
371
  ```
327
372
 
373
+ DevDependency pins for the harness packages use the same ranges the pinned
374
+ `@deepseek-ai/dsh` CLI declares (e.g. `^0.1.2-rc.1`), so the repo's tree and a fresh
375
+ CLI install resolve one coherent family — exact patch pins here mixed with the CLI's
376
+ range-resolved closure produce a split closure (two versions of one name) that breaks
377
+ profile boots with export-not-found errors. After changing those pins, regenerate the
378
+ whole lockfile (`rm -rf node_modules pnpm-lock.yaml && pnpm install`): an incremental
379
+ install both leaves stale store entries poisoning the profile heal AND retains stale
380
+ lockfile peer resolutions — bumping 0.1.2-alpha.2 → 0.1.2-rc.1 in place left
381
+ `dsh-session-persistence@0.1.2-alpha.3` (old-generation peers) wired into the rc.1
382
+ packages' snapshots, which passes boot and session/new and only breaks the first turn
383
+ with `TypeError: Cannot read properties of undefined (reading 'length')` from
384
+ PersistenceCoordinator.
385
+ `pnpm-workspace.yaml` approves the CLI closure's build scripts (node-pty prebuilds, koffi)
386
+ — they are runtime requirements when the repo CLI boots the profile.
387
+
328
388
  ## Known limitations
329
389
 
330
390
  Audio attachments are not supported (audio capability is not advertised), text streams at
package/lib/codec.js CHANGED
@@ -57,6 +57,44 @@ export function promptHasUnsupportedContent(prompt) {
57
57
  return prompt.some((block) => block.type !== 'text' && block.type !== 'resource_link')
58
58
  }
59
59
 
60
+ /**
61
+ * Flatten an ACP prompt's text blocks to the line a composer submits: text
62
+ * blocks concatenate verbatim in wire order, while images and resource links
63
+ * are composer attachments, not input-box text. This is the line slash
64
+ * commands are parsed from — an image pasted next to a slash line must neither
65
+ * prefix it nor pollute its arguments (the Web composer's `matchEnter`
66
+ * contract: line text plus a separate images payload).
67
+ * @param prompt - ACP `session/prompt` content, in wire order.
68
+ * @returns the concatenated text-block content.
69
+ */
70
+ export function acpPromptLineText(prompt) {
71
+ return (prompt ?? []).flatMap((block) => block?.type === 'text' && typeof block.text === 'string' ? [block.text] : []).join('')
72
+ }
73
+
74
+ /**
75
+ * Build the composer's image payload for a slash-command submission from an
76
+ * ACP prompt: one `{ data, mediaType, name? }` upload object per image block,
77
+ * in wire order — the registry `execute` images slot's exact shape (the
78
+ * composer serializes `{ type: 'image', mediaType, data, name }`, and the
79
+ * command service's `admitEncodedImages` reads `data`/`mediaType`/`name`).
80
+ * The service admits the payload into the attachment store itself; the store
81
+ * is content-addressed, so an image `convertPrompt` already saved resolves to
82
+ * the same reference and nothing is duplicated. An image whose media type is
83
+ * not a raster cannot reach a command (`convertPrompt` rejects it before
84
+ * dispatch), so a non-raster here is simply skipped.
85
+ * @param prompt - ACP `session/prompt` content, in wire order.
86
+ * @returns upload objects ready for `commands.execute`'s images slot.
87
+ */
88
+ export function acpPromptCommandImages(prompt) {
89
+ return (prompt ?? []).flatMap((block) => {
90
+ if (block?.type !== 'image' || typeof block.data !== 'string' || block.data.length === 0) return []
91
+ const mediaType = canonicalImageMediaType(block.mimeType)
92
+ if (mediaType === undefined) return []
93
+ const name = imageName(block.uri)
94
+ return [{ data: block.data, mediaType, ...name === undefined ? {} : { name } }]
95
+ })
96
+ }
97
+
60
98
  /** Raster media types the harness attachment seam admits (dsh-attachment). */
61
99
  const IMAGE_MEDIA_TYPES = new Set(['image/png', 'image/jpeg', 'image/webp', 'image/gif'])
62
100
 
package/lib/index.js CHANGED
@@ -45,11 +45,21 @@ import Schema from '@deepseek-ai/schemastery'
45
45
  import { AgentSideConnection, ndJsonStream, PROTOCOL_VERSION, RequestError } from '@agentclientprotocol/sdk'
46
46
  import { createUserMessage, errorChain, ReasoningEffortId } from '@deepseek-ai/dsh-llm'
47
47
  import { installModelSelection } from '@deepseek-ai/dsh-agent'
48
- import { resolveSessionPreset, UnknownPresetError, PresetMountError } from '@deepseek-ai/dsh-agent-presets'
48
+ // Version tolerance: every harness value this bridge imports is a pure helper
49
+ // (no service identity), so a copy resolved from this package's own
50
+ // node_modules is functionally equivalent to the host tree's even when the
51
+ // booting CLI pins a different generation. dsh-agent-presets is the one
52
+ // exception in kind, not in rule: 0.1.2-alpha.1 removed its named exports
53
+ // (resolveSessionPreset, UnknownPresetError, PresetMountError), so a named
54
+ // import would fail at ESM link time on that generation. It is imported as a
55
+ // namespace instead; nothing is taken from it at link time (see
56
+ // isPresetClientError, which uses the legacy classes only through guarded
57
+ // property access).
58
+ import * as agentPresetsModule from '@deepseek-ai/dsh-agent-presets'
49
59
  import { renderSkillContent } from '@deepseek-ai/dsh-skill'
50
60
  import { defineTool } from '@deepseek-ai/dsh-tools'
51
61
  import { SessionId } from '@deepseek-ai/dsh-session'
52
- import { attachmentIngestOf, convertPrompt, PromptImageError, sanitizeWireTitle, turnEndToStopReason, UnsupportedPromptContentError, usageTelemetry } from './codec.js'
62
+ import { attachmentIngestOf, acpPromptCommandImages, acpPromptLineText, convertPrompt, PromptImageError, sanitizeWireTitle, turnEndToStopReason, UnsupportedPromptContentError, usageTelemetry } from './codec.js'
53
63
  import { isTerminalToolName, parseShellExitStatus, resultText, shellCallCwd, stripShellPrefix, toolKindFor } from './terminal-codec.js'
54
64
 
55
65
  /** Agent version advertised on the ACP wire — read from package.json so the
@@ -432,6 +442,34 @@ export function apply(ctx, config) {
432
442
  /** Resolve the permission-presets service, tolerating a lazy mount. */
433
443
  const permissionPresets = () => ctx.get('permissionPresets')
434
444
 
445
+ /**
446
+ * Read one session's committed events as an array, tolerant of both harness
447
+ * API generations: 0.1.2-rc.1 replaced the synchronous `session.events`
448
+ * getter with `snapshotEvents()` (a cached frozen snapshot, invalidated on
449
+ * every append — plus `ownEvents()`/`eventAt()`), while earlier generations
450
+ * expose the live log array directly.
451
+ */
452
+ const sessionEventsOf = (session) => (
453
+ typeof session.snapshotEvents === 'function' ? session.snapshotEvents() : session.events
454
+ )
455
+
456
+ /** The effective permission preset of one session, across harness
457
+ * generations: 0.1.2-alpha resolves through the permissions session
458
+ * projection (`current(session)`), while earlier generations fold the
459
+ * event log (`current(events)`). Probed per service instance —
460
+ * the booting CLI decides the service's generation, so this package's
461
+ * dependency range is not evidence. The probe is `permissionState`, a
462
+ * 0.1.2-alpha method; passing the wrong argument shape to either
463
+ * generation does not throw reliably (the projection path can silently
464
+ * answer the default state), so the probe is load-bearing. The legacy
465
+ * event-log argument goes through sessionEventsOf (0.1.2-rc.1 sessions
466
+ * no longer expose a synchronous `events` array). */
467
+ const currentPermissionMode = (permission, session) => (
468
+ typeof permission.permissionState === 'function'
469
+ ? permission.current(session)
470
+ : permission.current(sessionEventsOf(session))
471
+ )
472
+
435
473
  /**
436
474
  * The terminal-output meta dialect the connected client renders, exactly as
437
475
  * codex-acp resolves it: Zed declares `_meta.terminal_output` support on
@@ -458,8 +496,41 @@ export function apply(ctx, config) {
458
496
  }
459
497
 
460
498
  /** The preset a session actually runs, read from its log: the last
461
- * `agent-preset/selected` event wins over the creation header. */
462
- const runningPresetOf = (session) => resolveSessionPreset(session)
499
+ * `agent-preset/selected` event wins over the creation header. Folded here
500
+ * rather than resolved through dsh-agent-presets so the read is
501
+ * generation-agnostic: the legacy `resolveSessionPreset` export and the
502
+ * 0.1.2-alpha `agentPreset` session projection define this exact fold —
503
+ * the last selection event wins, the creation header is the fallback, and
504
+ * a deployment that composes none yields `undefined`. */
505
+ const runningPresetOf = (session) => {
506
+ const events = sessionEventsOf(session)
507
+ for (let index = events.length - 1; index >= 0; index -= 1) {
508
+ const event = events[index]
509
+ if (event?.type === 'agent-preset/selected') return event.data.agentPreset
510
+ }
511
+ return session.header.agentPreset
512
+ }
513
+
514
+ /** Whether a preset-resolution failure is the caller's setup mistake (an
515
+ * unknown or unmountable preset id) rather than a server fault; callers
516
+ * map it to invalid params. Generation-tolerant across the dsh 0.1.x
517
+ * line: 0.1.2-alpha throws RemoteError with a stable `agent-preset/*`
518
+ * code — identified structurally (`isDSHRemoteError`), never instanceof,
519
+ * because that class lives in the host tree; earlier generations throw
520
+ * UnknownPresetError / PresetMountError, which both carry `presetId`.
521
+ * instanceof against the namespace-imported classes stays as a last
522
+ * resort for deployments where the roster service and this module
523
+ * happened to resolve the same package copy. */
524
+ const isPresetClientError = (error) => {
525
+ if (!(error instanceof Error)) return false
526
+ if (error.isDSHRemoteError === true) {
527
+ return typeof error.code === 'string' && error.code.startsWith('agent-preset/')
528
+ }
529
+ if (error.presetId !== undefined) return true
530
+ const { UnknownPresetError, PresetMountError } = agentPresetsModule
531
+ return (UnknownPresetError !== undefined && error instanceof UnknownPresetError)
532
+ || (PresetMountError !== undefined && error instanceof PresetMountError)
533
+ }
463
534
 
464
535
  /** Whether one live session has produced anything yet. A preset swap is only
465
536
  * legal while it is blank (dsh-agent-presets product rule): swapping tools
@@ -467,7 +538,7 @@ export function apply(ctx, config) {
467
538
  * make. Checks both the live turn marker and persisted user messages, so the
468
539
  * same rule holds for resumed sessions whose on-disk logs may not carry
469
540
  * `turn/start`. */
470
- const isBlankSession = (session) => !session.events.some((event) => (
541
+ const isBlankSession = (session) => !sessionEventsOf(session).some((event) => (
471
542
  event.type === 'turn/start' || event.type === 'user/message'
472
543
  ))
473
544
 
@@ -484,7 +555,7 @@ export function apply(ctx, config) {
484
555
  *
485
556
  * @param requested - preset id, or undefined for the roster default.
486
557
  * @returns `{ agentPreset, setup }`, or `{}` without a roster.
487
- * @throws UnknownPresetError / PresetMountError (mapped by callers).
558
+ * @throws a roster resolution failure (isPresetClientError — mapped by callers).
488
559
  */
489
560
  async function composePreset(requested) {
490
561
  const presets = agentPresets()
@@ -510,7 +581,7 @@ export function apply(ctx, config) {
510
581
  try {
511
582
  const { meta, events } = await persistence.load(sessionId)
512
583
  return {
513
- preset: resolveSessionPreset({ header: meta, events }),
584
+ preset: runningPresetOf({ header: meta, events }),
514
585
  blank: isBlankSession({ header: meta, events }),
515
586
  }
516
587
  } catch {
@@ -1132,7 +1203,7 @@ export function apply(ctx, config) {
1132
1203
  name: 'Permission preset',
1133
1204
  description: 'Sandbox mode and approval policy bundle for this session.',
1134
1205
  category: 'mode',
1135
- currentValue: permission.current(record.agent.session.events),
1206
+ currentValue: currentPermissionMode(permission, record.agent.session),
1136
1207
  options: permission.names.map((presetName) => {
1137
1208
  const spec = permission.presets[presetName]
1138
1209
  return {
@@ -1480,96 +1551,116 @@ export function apply(ctx, config) {
1480
1551
  }
1481
1552
  },
1482
1553
  })))
1483
- // The UI provider that renders those questions as an editor form.
1484
- clientToolDisposers.push(userQuestions.registerProvider({
1485
- ask: async (request) => {
1486
- const record = ownedRecord(request.agent)
1487
- if (record === undefined) {
1488
- throw new Error('ask_user_question is only usable inside a bridge-owned session')
1554
+ // The UI provider that renders those questions as an editor form. The
1555
+ // registration seam differs per harness generation, so it is probed on
1556
+ // the service instance (whose generation is the booting CLI's) rather
1557
+ // than assumed from this package's imports — migration plan rule 2.1.3:
1558
+ // ≤0.1.1-rc.2 exposes `userQuestions.registerProvider({ ask })` (one
1559
+ // active provider); ≥0.1.2-alpha.2 removed that method and asks
1560
+ // answerers to listen on the `user-questions/request` Cordis waterfall
1561
+ // instead, claiming a request by returning an answer or delegating it
1562
+ // via `next()`.
1563
+ const answerQuestion = async (request) => {
1564
+ const record = ownedRecord(request.agent)
1565
+ if (record === undefined) {
1566
+ throw new Error('ask_user_question is only usable inside a bridge-owned session')
1567
+ }
1568
+ // Editor forms cannot mix a fixed option list with free text in one
1569
+ // field, so every option-backed question gains a companion
1570
+ // `<id>__custom` text field: the form equivalent of dsh's native
1571
+ // "type your own answer" row, for when none of the offered options
1572
+ // fit. Option-free questions are already free-text fields.
1573
+ const usedKeys = new Set(request.questions.map((question) => question.id))
1574
+ const customKeys = new Map()
1575
+ const properties = {}
1576
+ const required = []
1577
+ for (const question of request.questions) {
1578
+ required.push(question.id)
1579
+ const options = question.options ?? []
1580
+ // Titled options (const/title/description) instead of a bare enum
1581
+ // so Zed renders each option's description under its label, like
1582
+ // the native question card. A question without options becomes a
1583
+ // plain text field: an optionless multi-select would otherwise
1584
+ // render as an empty, unanswerable checkbox list.
1585
+ const titled = options.map((option) => ({
1586
+ const: option.label,
1587
+ title: option.label,
1588
+ ...option.description === undefined ? {} : { description: option.description },
1589
+ }))
1590
+ const heading = question.header === undefined ? {} : { title: question.header }
1591
+ if (options.length === 0) {
1592
+ properties[question.id] = { type: 'string', description: question.question, ...heading }
1593
+ } else if (question.multiSelect === true) {
1594
+ properties[question.id] = { type: 'array', items: { anyOf: titled }, description: question.question, ...heading }
1595
+ } else {
1596
+ properties[question.id] = { type: 'string', oneOf: titled, description: question.question, ...heading }
1489
1597
  }
1490
- // Editor forms cannot mix a fixed option list with free text in one
1491
- // field, so every option-backed question gains a companion
1492
- // `<id>__custom` text field: the form equivalent of dsh's native
1493
- // "type your own answer" row, for when none of the offered options
1494
- // fit. Option-free questions are already free-text fields.
1495
- const usedKeys = new Set(request.questions.map((question) => question.id))
1496
- const customKeys = new Map()
1497
- const properties = {}
1498
- const required = []
1499
- for (const question of request.questions) {
1500
- required.push(question.id)
1501
- const options = question.options ?? []
1502
- // Titled options (const/title/description) instead of a bare enum
1503
- // so Zed renders each option's description under its label, like
1504
- // the native question card. A question without options becomes a
1505
- // plain text field: an optionless multi-select would otherwise
1506
- // render as an empty, unanswerable checkbox list.
1507
- const titled = options.map((option) => ({
1508
- const: option.label,
1509
- title: option.label,
1510
- ...option.description === undefined ? {} : { description: option.description },
1511
- }))
1512
- const heading = question.header === undefined ? {} : { title: question.header }
1513
- if (options.length === 0) {
1514
- properties[question.id] = { type: 'string', description: question.question, ...heading }
1515
- } else if (question.multiSelect === true) {
1516
- properties[question.id] = { type: 'array', items: { anyOf: titled }, description: question.question, ...heading }
1517
- } else {
1518
- properties[question.id] = { type: 'string', oneOf: titled, description: question.question, ...heading }
1519
- }
1520
- if (options.length > 0) {
1521
- let customKey = `${question.id}__custom`
1522
- while (usedKeys.has(customKey)) customKey = `${customKey}_`
1523
- usedKeys.add(customKey)
1524
- customKeys.set(question.id, customKey)
1525
- properties[customKey] = {
1526
- type: 'string',
1527
- title: 'Custom answer',
1528
- description: question.multiSelect === true
1529
- ? 'None of the options fit? Type your own answer; it is returned alongside your selections.'
1530
- : 'None of the options fit? Type your own answer; it replaces the selection.',
1531
- }
1598
+ if (options.length > 0) {
1599
+ let customKey = `${question.id}__custom`
1600
+ while (usedKeys.has(customKey)) customKey = `${customKey}_`
1601
+ usedKeys.add(customKey)
1602
+ customKeys.set(question.id, customKey)
1603
+ properties[customKey] = {
1604
+ type: 'string',
1605
+ title: 'Custom answer',
1606
+ description: question.multiSelect === true
1607
+ ? 'None of the options fit? Type your own answer; it is returned alongside your selections.'
1608
+ : 'None of the options fit? Type your own answer; it replaces the selection.',
1532
1609
  }
1533
1610
  }
1534
- const response = await conn.unstable_createElicitation({
1535
- sessionId: record.agent.session.id,
1536
- mode: 'form',
1537
- message: request.questions.map((question) => question.question).join('\n'),
1538
- requestedSchema: { type: 'object', properties, required },
1539
- })
1540
- if (response.action !== 'accept') {
1541
- throw new Error(`the user ${response.action === 'decline' ? 'declined' : 'cancelled'} the question`)
1542
- }
1543
- const content = response.content ?? {}
1544
- return {
1545
- answers: request.questions.map((question) => {
1546
- const value = content[question.id]
1547
- const customKey = customKeys.get(question.id)
1548
- if (customKey === undefined) {
1549
- // Option-free question: the typed text IS the answer, reported
1550
- // as the custom ("other") answer the way the native UI does.
1551
- const text = typeof value === 'string' ? value.trim() : ''
1552
- return { id: question.id, selected: [], ...text === '' ? {} : { custom: text } }
1553
- }
1554
- const selected = Array.isArray(value)
1555
- ? value.filter((entry) => typeof entry === 'string')
1556
- : typeof value === 'string' ? [value] : []
1557
- const customRaw = content[customKey]
1558
- const custom = typeof customRaw === 'string' && customRaw.trim() !== '' ? customRaw.trim() : undefined
1559
- if (custom === undefined) {
1560
- return { id: question.id, selected }
1561
- }
1562
- // A custom answer replaces a single selection and accompanies a
1563
- // multi-select, matching the native question card.
1564
- return {
1565
- id: question.id,
1566
- selected: question.multiSelect === true ? selected : [],
1567
- custom,
1568
- }
1569
- }),
1570
- }
1571
- },
1572
- }))
1611
+ }
1612
+ const response = await conn.unstable_createElicitation({
1613
+ sessionId: record.agent.session.id,
1614
+ mode: 'form',
1615
+ message: request.questions.map((question) => question.question).join('\n'),
1616
+ requestedSchema: { type: 'object', properties, required },
1617
+ })
1618
+ if (response.action !== 'accept') {
1619
+ throw new Error(`the user ${response.action === 'decline' ? 'declined' : 'cancelled'} the question`)
1620
+ }
1621
+ const content = response.content ?? {}
1622
+ return {
1623
+ answers: request.questions.map((question) => {
1624
+ const value = content[question.id]
1625
+ const customKey = customKeys.get(question.id)
1626
+ if (customKey === undefined) {
1627
+ // Option-free question: the typed text IS the answer, reported
1628
+ // as the custom ("other") answer the way the native UI does.
1629
+ const text = typeof value === 'string' ? value.trim() : ''
1630
+ return { id: question.id, selected: [], ...text === '' ? {} : { custom: text } }
1631
+ }
1632
+ const selected = Array.isArray(value)
1633
+ ? value.filter((entry) => typeof entry === 'string')
1634
+ : typeof value === 'string' ? [value] : []
1635
+ const customRaw = content[customKey]
1636
+ const custom = typeof customRaw === 'string' && customRaw.trim() !== '' ? customRaw.trim() : undefined
1637
+ if (custom === undefined) {
1638
+ return { id: question.id, selected }
1639
+ }
1640
+ // A custom answer replaces a single selection and accompanies a
1641
+ // multi-select, matching the native question card.
1642
+ return {
1643
+ id: question.id,
1644
+ selected: question.multiSelect === true ? selected : [],
1645
+ custom,
1646
+ }
1647
+ }),
1648
+ }
1649
+ }
1650
+ if (typeof userQuestions.registerProvider === 'function') {
1651
+ // Legacy generation (≤0.1.1-rc.2): one active provider on the service.
1652
+ clientToolDisposers.push(userQuestions.registerProvider({ ask: answerQuestion }))
1653
+ } else {
1654
+ // Projection generation (≥0.1.2-alpha.2): answer the
1655
+ // `user-questions/request` waterfall. A listener on the root context
1656
+ // is untagged, so the scoped dispatch admits it for every agent; a
1657
+ // request this bridge does not own is delegated to the next answerer
1658
+ // (falling through to NO_PROVIDER when no one else claims it).
1659
+ clientToolDisposers.push(ctx.on('user-questions/request', async (request, next) => {
1660
+ if (ownedRecord(request.agent) === undefined) return next()
1661
+ return answerQuestion(request)
1662
+ }))
1663
+ }
1573
1664
  }
1574
1665
  }
1575
1666
 
@@ -1824,6 +1915,32 @@ export function apply(ctx, config) {
1824
1915
  return `switched to agent preset ${preset.id}`
1825
1916
  }
1826
1917
 
1918
+ /**
1919
+ * Execute one registry slash command, tolerant of both harness API
1920
+ * generations. dsh-commands 0.1.1-rc.1+ admits composer images between the
1921
+ * line and the cancellation signal (`execute(agent, line, images, signal)`,
1922
+ * images as `{ data, mediaType, name }` upload objects), while 0.1.0-rc.x
1923
+ * took `(agent, line, signal)` and could not carry images. Calling the
1924
+ * newer shape against the older service lands the signal in the images slot
1925
+ * and leaves `signal` undefined (`signal.aborted` throws), and the reverse
1926
+ * lands an array where the signal belongs — so the shapes must be probed,
1927
+ * not guessed. The declared arity is a faithful probe: the `Remote`
1928
+ * decorator only records a marker initializer and never wraps the method,
1929
+ * so `execute.length` equals the declared parameter count (3 on 0.1.0-rc.x,
1930
+ * 4 on 0.1.1-rc.1+). Images accompanying a slash line only reach the older
1931
+ * generation when an attachment store is also mounted, which the 0.1.0-rc.x
1932
+ * seam never provides (convertPrompt rejects them before dispatch) — so the
1933
+ * legacy call needs no images handling.
1934
+ */
1935
+ function executeRegistryCommand(agent, line, prompt) {
1936
+ const signal = new AbortController().signal
1937
+ const execute = commands.execute
1938
+ if (typeof execute.length === 'number' && execute.length >= 4) {
1939
+ return execute.call(commands, agent, line, acpPromptCommandImages(prompt), signal)
1940
+ }
1941
+ return execute.call(commands, agent, line, signal)
1942
+ }
1943
+
1827
1944
  /** Refresh every client-visible surface a command may have mutated. */
1828
1945
  function refreshAfterCommand(record) {
1829
1946
  const permission = permissionPresets()
@@ -1832,7 +1949,7 @@ export function apply(ctx, config) {
1832
1949
  sessionId: record.agent.session.id,
1833
1950
  update: {
1834
1951
  sessionUpdate: 'current_mode_update',
1835
- currentModeId: permission.current(record.agent.session.events),
1952
+ currentModeId: currentPermissionMode(permission, record.agent.session),
1836
1953
  },
1837
1954
  })
1838
1955
  }
@@ -1964,7 +2081,7 @@ export function apply(ctx, config) {
1964
2081
  * steps, usage, plan flips, …) is omitted.
1965
2082
  */
1966
2083
  async function replayHistory(record) {
1967
- const events = record.agent.session.events
2084
+ const events = sessionEventsOf(record.agent.session)
1968
2085
  for (const event of events) {
1969
2086
  try {
1970
2087
  switch (event.type) {
@@ -2069,66 +2186,71 @@ export function apply(ctx, config) {
2069
2186
  },
2070
2187
 
2071
2188
  async newSession(params) {
2072
- assertOpen()
2073
- const additionalDirectories = normalizeSessionParams(params)
2074
- const sessionId = SessionId(randomUUID())
2075
- const requestedPreset = process.env.DSH_ACP_PRESET ?? config.preset
2076
- let composition
2077
2189
  try {
2078
- composition = await composePreset(requestedPreset)
2079
- } catch (error) {
2080
- // A bad DSH_ACP_PRESET / preset config is a client-side setup mistake,
2081
- // not a server fault: surface the roster's detail as invalid params.
2082
- const detail = error instanceof Error ? error.message : String(error)
2083
- if (error instanceof UnknownPresetError || error instanceof PresetMountError) {
2084
- throw invalidParams(detail)
2190
+ assertOpen()
2191
+ const additionalDirectories = normalizeSessionParams(params)
2192
+ const sessionId = SessionId(randomUUID())
2193
+ const requestedPreset = process.env.DSH_ACP_PRESET ?? config.preset
2194
+ let composition
2195
+ try {
2196
+ composition = await composePreset(requestedPreset)
2197
+ } catch (error) {
2198
+ // A bad DSH_ACP_PRESET / preset config is a client-side setup mistake,
2199
+ // not a server fault: surface the roster's detail as invalid params.
2200
+ const detail = error instanceof Error ? error.message : String(error)
2201
+ if (isPresetClientError(error)) {
2202
+ throw invalidParams(detail)
2203
+ }
2204
+ throw error
2085
2205
  }
2086
- throw error
2087
- }
2088
- const handle = await agents.create({
2089
- sessionId,
2090
- meta: {
2091
- cwd: params.cwd,
2092
- ...composition.agentPreset === undefined ? {} : { agentPreset: composition.agentPreset },
2093
- },
2094
- agentOptions: {
2095
- ...config.provider === undefined ? {} : { provider: config.provider },
2096
- ...config.model === undefined ? {} : { model: config.model },
2097
- },
2098
- ...composition.setup === undefined ? {} : { setup: composition.setup },
2099
- })
2100
- if (closed) {
2101
- await handle.dispose()
2102
- throw internalError('connection closed during session/new')
2103
- }
2104
- const record = makeRecord(handle)
2105
- record.additionalDirectories = additionalDirectories
2106
- sessions.set(sessionId, record)
2107
- await syncMcpServers(params.mcpServers, params.cwd)
2108
- const permission = permissionPresets()
2109
- const configOptions = await buildConfigOptions(record)
2110
- // Arm strictly after the handler's last await (see
2111
- // publishCommandsAfterResponse): the SDK enqueues the response into
2112
- // its FIFO write queue in the microtask continuation of this
2113
- // handler's promise, which always precedes the immediate — so the
2114
- // broadcast can never overtake the response.
2115
- publishCommandsAfterResponse(record)
2116
- return {
2117
- sessionId,
2118
- ...permission === undefined ? {} : {
2119
- modes: {
2120
- currentModeId: permission.current(handle.agent.session.events),
2121
- availableModes: permission.names.map((presetName) => {
2122
- const spec = permission.presets[presetName]
2123
- return {
2124
- id: presetName,
2125
- name: spec?.name ?? presetName,
2126
- ...spec?.description === undefined ? {} : { description: spec.description },
2127
- }
2128
- }),
2206
+ const handle = await agents.create({
2207
+ sessionId,
2208
+ meta: {
2209
+ cwd: params.cwd,
2210
+ ...composition.agentPreset === undefined ? {} : { agentPreset: composition.agentPreset },
2129
2211
  },
2130
- },
2131
- configOptions,
2212
+ agentOptions: {
2213
+ ...config.provider === undefined ? {} : { provider: config.provider },
2214
+ ...config.model === undefined ? {} : { model: config.model },
2215
+ },
2216
+ ...composition.setup === undefined ? {} : { setup: composition.setup },
2217
+ })
2218
+ if (closed) {
2219
+ await handle.dispose()
2220
+ throw internalError('connection closed during session/new')
2221
+ }
2222
+ const record = makeRecord(handle)
2223
+ record.additionalDirectories = additionalDirectories
2224
+ sessions.set(sessionId, record)
2225
+ await syncMcpServers(params.mcpServers, params.cwd)
2226
+ const permission = permissionPresets()
2227
+ const configOptions = await buildConfigOptions(record)
2228
+ // Arm strictly after the handler's last await (see
2229
+ // publishCommandsAfterResponse): the SDK enqueues the response into
2230
+ // its FIFO write queue in the microtask continuation of this
2231
+ // handler's promise, which always precedes the immediate — so the
2232
+ // broadcast can never overtake the response.
2233
+ publishCommandsAfterResponse(record)
2234
+ return {
2235
+ sessionId,
2236
+ ...permission === undefined ? {} : {
2237
+ modes: {
2238
+ currentModeId: currentPermissionMode(permission, handle.agent.session),
2239
+ availableModes: permission.names.map((presetName) => {
2240
+ const spec = permission.presets[presetName]
2241
+ return {
2242
+ id: presetName,
2243
+ name: spec?.name ?? presetName,
2244
+ ...spec?.description === undefined ? {} : { description: spec.description },
2245
+ }
2246
+ }),
2247
+ },
2248
+ },
2249
+ configOptions,
2250
+ }
2251
+ } catch (error) {
2252
+ if (process.env.ACP_DEBUG) process.stderr.write(`[acp-debug] newSession failed: ${error?.stack ?? String(error)}\n`)
2253
+ throw error
2132
2254
  }
2133
2255
  },
2134
2256
 
@@ -2152,7 +2274,7 @@ export function apply(ctx, config) {
2152
2274
  return {
2153
2275
  ...permission === undefined ? {} : {
2154
2276
  modes: {
2155
- currentModeId: permission.current(live.agent.session.events),
2277
+ currentModeId: currentPermissionMode(permission, live.agent.session),
2156
2278
  availableModes: permission.names.map((presetName) => {
2157
2279
  const spec = permission.presets[presetName]
2158
2280
  return { id: presetName, name: spec?.name ?? presetName }
@@ -2170,7 +2292,7 @@ export function apply(ctx, config) {
2170
2292
  // Same mapping as session/new: a roster that cannot supply the
2171
2293
  // session's logged preset is reported as a client mistake.
2172
2294
  const detail = error instanceof Error ? error.message : String(error)
2173
- if (error instanceof UnknownPresetError || error instanceof PresetMountError) {
2295
+ if (isPresetClientError(error)) {
2174
2296
  throw invalidParams(detail)
2175
2297
  }
2176
2298
  throw error
@@ -2203,7 +2325,7 @@ export function apply(ctx, config) {
2203
2325
  return {
2204
2326
  ...permission === undefined ? {} : {
2205
2327
  modes: {
2206
- currentModeId: permission.current(record.agent.session.events),
2328
+ currentModeId: currentPermissionMode(permission, record.agent.session),
2207
2329
  availableModes: permission.names.map((presetName) => {
2208
2330
  const spec = permission.presets[presetName]
2209
2331
  return {
@@ -2260,8 +2382,13 @@ export function apply(ctx, config) {
2260
2382
  // permission/plan/…) runs through the harness command registry
2261
2383
  // without a model turn. An unresolved slash falls through — the
2262
2384
  // /skill-name gesture is claimed inside the agent's next step.
2263
- const trimmed = text.trim()
2264
- const commandMatch = trimmed.match(/^\/(\w[\w-]*)\b/)
2385
+ // The command line is the prompt's text blocks, not the display
2386
+ // text: pasted images are composer attachments riding alongside the
2387
+ // line (forwarded to the registry as a separate images payload), and
2388
+ // their `[image: …]` display placeholders must neither prefix the
2389
+ // line (which would hide the slash) nor pollute its arguments.
2390
+ const commandLine = acpPromptLineText(params.prompt).trim()
2391
+ const commandMatch = commandLine.match(/^\/(\w[\w-]*)\b/)
2265
2392
  const respond = (reply) => {
2266
2393
  notify({
2267
2394
  sessionId: record.agent.session.id,
@@ -2275,17 +2402,17 @@ export function apply(ctx, config) {
2275
2402
  }
2276
2403
  if (commandMatch?.[1] === 'status') return respond(await statusText(record))
2277
2404
  if (commandMatch?.[1] === 'model') {
2278
- return respond(await modelCommandText(record, trimmed.slice(commandMatch[0].length).trim()))
2405
+ return respond(await modelCommandText(record, commandLine.slice(commandMatch[0].length).trim()))
2279
2406
  }
2280
2407
  if (commandMatch?.[1] === 'preset') {
2281
- const reply = await presetCommandText(record, trimmed.slice(commandMatch[0].length).trim())
2408
+ const reply = await presetCommandText(record, commandLine.slice(commandMatch[0].length).trim())
2282
2409
  refreshAfterCommand(record)
2283
2410
  return respond(reply)
2284
2411
  }
2285
2412
  if (commandMatch !== null && commandMatch[1] !== undefined) {
2286
2413
  let execution
2287
2414
  try {
2288
- execution = await commands.execute(record.agent, trimmed, new AbortController().signal)
2415
+ execution = await executeRegistryCommand(record.agent, commandLine, params.prompt)
2289
2416
  } catch (error) {
2290
2417
  return respond(`⚠ /${commandMatch[1]} failed: ${error.message ?? String(error)}`)
2291
2418
  }
@@ -2432,7 +2559,7 @@ export function apply(ctx, config) {
2432
2559
  // unknown/broken preset id is a client mistake (invalid params),
2433
2560
  // while a composition that fails to mount is a server fault.
2434
2561
  const detail = error instanceof Error ? error.message : String(error)
2435
- if (error instanceof UnknownPresetError || error instanceof PresetMountError) {
2562
+ if (isPresetClientError(error)) {
2436
2563
  throw invalidParams(detail)
2437
2564
  }
2438
2565
  throw internalError(detail)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-acp-enhanced",
3
- "version": "0.6.0",
3
+ "version": "0.7.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",
@@ -38,38 +38,39 @@
38
38
  "zod": "^4.4.3"
39
39
  },
40
40
  "peerDependencies": {
41
- "@deepseek-ai/cordis": "^4.0.1-rc.1",
42
- "@deepseek-ai/cordis-plugin-include": "^1.0.6-rc.1",
43
- "@deepseek-ai/cordis-plugin-loader": "^1.0.2-rc.1",
44
- "@deepseek-ai/dsh-agent": "^0.1.0-rc.6",
45
- "@deepseek-ai/dsh-agent-instructions": "^0.1.0-rc.6",
46
- "@deepseek-ai/dsh-agent-presets": "^0.1.0-rc.6",
47
- "@deepseek-ai/dsh-invariants": "^0.1.0-rc.6",
48
- "@deepseek-ai/dsh-llm": "^0.1.0-rc.6",
49
- "@deepseek-ai/dsh-mcp-client": "^0.1.0-rc.6",
50
- "@deepseek-ai/dsh-permission-presets": "^0.1.0-rc.6",
51
- "@deepseek-ai/dsh-session": "^0.1.0-rc.6",
52
- "@deepseek-ai/dsh-session-query": "^0.1.0-rc.6",
53
- "@deepseek-ai/dsh-skill": "^0.1.0-rc.6",
54
- "@deepseek-ai/dsh-tools": "^0.1.0-rc.6",
55
- "@deepseek-ai/dsh-user-approval": "^0.1.0-rc.6"
41
+ "@deepseek-ai/cordis": "^4.0.1-rc.1 || ^4.0.2",
42
+ "@deepseek-ai/cordis-plugin-include": "^1.0.6-rc.1 || ^1.0.7",
43
+ "@deepseek-ai/cordis-plugin-loader": "^1.0.2-rc.1 || ^1.0.3",
44
+ "@deepseek-ai/dsh-agent": "^0.1.0-rc.6 || ^0.1.1-rc.1 || ^0.1.2-alpha.2",
45
+ "@deepseek-ai/dsh-agent-instructions": "^0.1.0-rc.6 || ^0.1.1-rc.1 || ^0.1.2-alpha.2",
46
+ "@deepseek-ai/dsh-agent-presets": "^0.1.0-rc.6 || ^0.1.1-rc.1 || ^0.1.2-alpha.2",
47
+ "@deepseek-ai/dsh-invariants": "^0.1.0-rc.6 || ^0.1.1-rc.1 || ^0.1.2-alpha.2",
48
+ "@deepseek-ai/dsh-llm": "^0.1.0-rc.6 || ^0.1.1-rc.1 || ^0.1.2-alpha.2",
49
+ "@deepseek-ai/dsh-mcp-client": "^0.1.0-rc.6 || ^0.1.1-rc.1 || ^0.1.2-alpha.2",
50
+ "@deepseek-ai/dsh-permission-presets": "^0.1.0-rc.6 || ^0.1.1-rc.1 || ^0.1.2-alpha.2",
51
+ "@deepseek-ai/dsh-session": "^0.1.0-rc.6 || ^0.1.1-rc.1 || ^0.1.2-alpha.2",
52
+ "@deepseek-ai/dsh-session-query": "^0.1.0-rc.6 || ^0.1.1-rc.1 || ^0.1.2-alpha.2",
53
+ "@deepseek-ai/dsh-skill": "^0.1.0-rc.6 || ^0.1.1-rc.1 || ^0.1.2-alpha.2",
54
+ "@deepseek-ai/dsh-tools": "^0.1.0-rc.6 || ^0.1.1-rc.1 || ^0.1.2-alpha.2",
55
+ "@deepseek-ai/dsh-user-approval": "^0.1.0-rc.6 || ^0.1.1-rc.1 || ^0.1.2-alpha.2"
56
56
  },
57
57
  "devDependencies": {
58
- "@deepseek-ai/cordis": "4.0.1-rc.4",
59
- "@deepseek-ai/cordis-plugin-include": "1.0.6-rc.4",
60
- "@deepseek-ai/cordis-plugin-loader": "1.0.2-rc.4",
61
- "@deepseek-ai/dsh-agent": "0.1.0-rc.6",
62
- "@deepseek-ai/dsh-agent-instructions": "0.1.0-rc.6",
63
- "@deepseek-ai/dsh-agent-presets": "0.1.0-rc.6",
64
- "@deepseek-ai/dsh-invariants": "0.1.0-rc.6",
65
- "@deepseek-ai/dsh-llm": "0.1.0-rc.6",
66
- "@deepseek-ai/dsh-mcp-client": "0.1.0-rc.6",
67
- "@deepseek-ai/dsh-permission-presets": "0.1.0-rc.6",
68
- "@deepseek-ai/dsh-session": "0.1.0-rc.6",
69
- "@deepseek-ai/dsh-session-query": "0.1.0-rc.6",
70
- "@deepseek-ai/dsh-skill": "0.1.0-rc.6",
71
- "@deepseek-ai/dsh-tools": "0.1.0-rc.6",
72
- "@deepseek-ai/dsh-user-approval": "0.1.0-rc.6"
58
+ "@deepseek-ai/cordis": "^4.0.2",
59
+ "@deepseek-ai/cordis-plugin-include": "^1.0.7",
60
+ "@deepseek-ai/cordis-plugin-loader": "^1.0.3",
61
+ "@deepseek-ai/dsh": "0.1.2-rc.1",
62
+ "@deepseek-ai/dsh-agent": "^0.1.2-rc.1",
63
+ "@deepseek-ai/dsh-agent-instructions": "^0.1.2-rc.1",
64
+ "@deepseek-ai/dsh-agent-presets": "^0.1.2-rc.1",
65
+ "@deepseek-ai/dsh-invariants": "^0.1.2-rc.1",
66
+ "@deepseek-ai/dsh-llm": "^0.1.2-rc.1",
67
+ "@deepseek-ai/dsh-mcp-client": "^0.1.2-rc.1",
68
+ "@deepseek-ai/dsh-permission-presets": "^0.1.2-rc.1",
69
+ "@deepseek-ai/dsh-session": "^0.1.2-rc.1",
70
+ "@deepseek-ai/dsh-session-query": "^0.1.2-rc.1",
71
+ "@deepseek-ai/dsh-skill": "^0.1.2-rc.1",
72
+ "@deepseek-ai/dsh-tools": "^0.1.2-rc.1",
73
+ "@deepseek-ai/dsh-user-approval": "^0.1.2-rc.1"
73
74
  },
74
75
  "license": "MIT",
75
76
  "author": {
@@ -4,15 +4,35 @@
4
4
  # Zed (a GUI app) spawns agent processes with a minimal PATH that usually does
5
5
  # NOT include node or dsh, so this wrapper locates both itself:
6
6
  # - node: PATH, /opt/homebrew/bin, /usr/local/bin, ~/.nvm/versions/node/*
7
- # - dsh: PATH, the npx cache (~/.npm/_npx/*/node_modules/.bin), the global
8
- # npm prefix bin dir, /opt/homebrew/bin, /usr/local/bin
9
7
  # and prepends the node dir to PATH so dsh's `#!/usr/bin/env node` shebang
10
8
  # resolves.
11
9
  #
10
+ # dsh CLI resolution (ecosystem order, cf. the DSH_PATH convention):
11
+ # 1. $DSH_PATH — an explicit dsh binary, or a directory whose
12
+ # node_modules/.bin/dsh holds one
13
+ # 2. the repo-pinned CLI: <repo>/node_modules/.bin/dsh (this package's
14
+ # @deepseek-ai/dsh devDependency — the version the bridge tracks)
15
+ # 3. global fallback: PATH, the npx cache (~/.npm/_npx/*/node_modules/.bin),
16
+ # the global npm prefix bin dir, /opt/homebrew/bin, /usr/local/bin
17
+ #
18
+ # Isolated DSH_HOME: dsh heals its whole dependency closure into
19
+ # $DSH_HOME/profiles/node_modules on every boot — a dir shared by every
20
+ # profile under that home, whose content flips to whichever CLI booted last.
21
+ # A second CLI generation under one home would let a running profile (e.g.
22
+ # `dsh web`) lazily resolve mismatched module versions mid-process. So
23
+ # whenever (1) or (2) resolves the CLI, this launcher boots the profile under
24
+ # its own home: DSH_HOME=${DSH_ACP_HOME:-$HOME/.dsh-acp} (exported only when
25
+ # DSH_HOME is not already set). The default home and the global `dsh web`
26
+ # stack stay untouched; the global fallback (3) keeps the default home and
27
+ # the pre-existing profile there, so a fresh clone without `pnpm install`
28
+ # degrades to the legacy behavior. Create the isolated-home profile once with
29
+ # scripts/init-acp-home.sh.
30
+ #
12
31
  # The profile resolves DEEPSEEK_API_KEY through the dsh credentials service
13
- # (~/.dsh/.credentials.yaml), so no environment plumbing is required; an
14
- # explicit DEEPSEEK_API_KEY from Zed's agent_servers.env wins, and a running
15
- # `dsh web` process is a final fallback source.
32
+ # (~/.dsh/.credentials.yaml — copied into the isolated home by the same
33
+ # script), so no environment plumbing is required; an explicit
34
+ # DEEPSEEK_API_KEY from Zed's agent_servers.env wins, and a running `dsh web`
35
+ # process is a final fallback source.
16
36
  #
17
37
  # stdout stays the ACP JSON-RPC wire; diagnostics go to stderr.
18
38
  set -u
@@ -33,24 +53,75 @@ if [ -n "${NODE_BIN}" ]; then
33
53
  export PATH="$(dirname "${NODE_BIN}"):${PATH}"
34
54
  fi
35
55
 
36
- DASH_BIN="$(command -v dsh 2>/dev/null || true)"
37
- if [ -z "${DASH_BIN}" ]; then
38
- for candidate in \
39
- "$HOME"/.npm/_npx/*/node_modules/.bin/dsh \
40
- "$(npm prefix -g 2>/dev/null)/bin/dsh" \
41
- /opt/homebrew/bin/dsh \
42
- /usr/local/bin/dsh; do
43
- if [ -x "${candidate}" ]; then
44
- DASH_BIN="${candidate}"
45
- break
46
- fi
47
- done
56
+ # The repo root: resolve this script through symlinks (the profile links this
57
+ # package from the repo) with pwd -P, so the pinned CLI is found even when
58
+ # the launcher is reached via node_modules/.
59
+ REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P)"
60
+
61
+ DASH_BIN=""
62
+ DSH_SOURCE=""
63
+ if [ -n "${DSH_PATH:-}" ]; then
64
+ # 1. Explicit override: a dsh binary, or a directory containing one under
65
+ # node_modules/.bin (e.g. a dsh checkout).
66
+ if [ -x "${DSH_PATH}" ]; then
67
+ DASH_BIN="${DSH_PATH}"
68
+ elif [ -x "${DSH_PATH}/node_modules/.bin/dsh" ]; then
69
+ DASH_BIN="${DSH_PATH}/node_modules/.bin/dsh"
70
+ else
71
+ echo "dsh-acp-zed: DSH_PATH is set but holds no dsh ('${DSH_PATH}')" >&2
72
+ exit 127
73
+ fi
74
+ DSH_SOURCE="dshpath"
75
+ elif [ -x "${REPO_DIR}/node_modules/.bin/dsh" ]; then
76
+ # 2. Repo-pinned CLI (this package's devDependency).
77
+ DASH_BIN="${REPO_DIR}/node_modules/.bin/dsh"
78
+ DSH_SOURCE="repo"
79
+ else
80
+ # 3. Global fallback (legacy resolution).
81
+ DASH_BIN="$(command -v dsh 2>/dev/null || true)"
82
+ if [ -z "${DASH_BIN}" ]; then
83
+ for candidate in \
84
+ "$HOME"/.npm/_npx/*/node_modules/.bin/dsh \
85
+ "$(npm prefix -g 2>/dev/null)/bin/dsh" \
86
+ /opt/homebrew/bin/dsh \
87
+ /usr/local/bin/dsh; do
88
+ if [ -x "${candidate}" ]; then
89
+ DASH_BIN="${candidate}"
90
+ break
91
+ fi
92
+ done
93
+ fi
94
+ DSH_SOURCE="global"
48
95
  fi
49
96
  if [ -z "${NODE_BIN}" ] || [ -z "${DASH_BIN}" ]; then
50
97
  echo "dsh-acp-zed: cannot locate node and/or dsh (node='${NODE_BIN}' dsh='${DASH_BIN}'); install them or set PATH" >&2
51
98
  exit 127
52
99
  fi
53
100
 
101
+ # The isolated home applies whenever the CLI came from DSH_PATH or the repo
102
+ # pin — never for the global fallback, which shares the default home with the
103
+ # rest of the machine. An exported DSH_HOME is honored only when it points
104
+ # AWAY from the default home: harness processes inject DSH_HOME=$HOME/.dsh
105
+ # into every child (agents, tools), and honoring that inherited value here
106
+ # would boot the pinned CLI against the shared default home — flipping its
107
+ # module-fallback closure under a running profile. To force the pinned CLI
108
+ # onto the default home deliberately, set DSH_ACP_HOME=$HOME/.dsh.
109
+ ISOLATED_HOME=0
110
+ if [ "${DSH_SOURCE}" != "global" ]; then
111
+ ISOLATED_HOME=1
112
+ if [ -z "${DSH_HOME:-}" ] || [ "${DSH_HOME}" = "$HOME/.dsh" ]; then
113
+ export DSH_HOME="${DSH_ACP_HOME:-$HOME/.dsh-acp}"
114
+ fi
115
+ fi
116
+
117
+ # Guard the isolated-home path: booting a profile without the bridge installed
118
+ # would start an agent stack that never speaks ACP on stdio, which Zed reports
119
+ # as an opaque hang. Point at the one-shot setup script instead.
120
+ if [ "${ISOLATED_HOME}" = 1 ] && [ ! -d "${DSH_HOME}/profiles/acp-enhanced" ]; then
121
+ echo "dsh-acp-zed: profile 'acp-enhanced' missing under the isolated home '${DSH_HOME}'; bootstrap it once with: ${REPO_DIR}/scripts/init-acp-home.sh" >&2
122
+ exit 127
123
+ fi
124
+
54
125
  if [ -z "${DEEPSEEK_API_KEY:-}" ]; then
55
126
  WEB_PID="$(pgrep -f 'dsh web' | head -n 1)"
56
127
  if [ -n "${WEB_PID}" ]; then
@@ -68,8 +139,12 @@ fi
68
139
  # on the profile root. The bridge persists small per-model state (the last
69
140
  # reasoning effort per model) next to the profile's own files.
70
141
  if [ -z "${DSH_ACP_PROFILE_DIR:-}" ]; then
71
- ACP_LAUNCHER_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -L)"
72
- export DSH_ACP_PROFILE_DIR="$(cd "${ACP_LAUNCHER_DIR}/../../.." && pwd -L)"
142
+ if [ "${ISOLATED_HOME}" = 1 ]; then
143
+ export DSH_ACP_PROFILE_DIR="${DSH_HOME}/profiles/acp-enhanced"
144
+ else
145
+ ACP_LAUNCHER_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -L)"
146
+ export DSH_ACP_PROFILE_DIR="$(cd "${ACP_LAUNCHER_DIR}/../../.." && pwd -L)"
147
+ fi
73
148
  fi
74
149
 
75
150
  exec "${DASH_BIN}" --profile acp-enhanced "$@"