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 +80 -32
- package/README.md +95 -35
- package/lib/codec.js +38 -0
- package/lib/index.js +290 -163
- package/package.json +32 -31
- package/scripts/dsh-acp-zed.sh +94 -19
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
|
-
###
|
|
190
|
+
### Web 搜索
|
|
189
191
|
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
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
|
-
|
|
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
|
-
|
|
256
|
-
|
|
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
|
-
###
|
|
225
|
+
### Web search
|
|
224
226
|
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
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` —
|
|
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
|
|
298
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
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:
|
|
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
|
|
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
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
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
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
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
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
}
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
}
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
}
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
}
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
2079
|
-
|
|
2080
|
-
|
|
2081
|
-
|
|
2082
|
-
|
|
2083
|
-
|
|
2084
|
-
|
|
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
|
-
|
|
2087
|
-
|
|
2088
|
-
|
|
2089
|
-
|
|
2090
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
2264
|
-
|
|
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,
|
|
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,
|
|
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
|
|
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
|
|
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.
|
|
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.
|
|
59
|
-
"@deepseek-ai/cordis-plugin-include": "1.0.
|
|
60
|
-
"@deepseek-ai/cordis-plugin-loader": "1.0.
|
|
61
|
-
"@deepseek-ai/dsh
|
|
62
|
-
"@deepseek-ai/dsh-agent
|
|
63
|
-
"@deepseek-ai/dsh-agent-
|
|
64
|
-
"@deepseek-ai/dsh-
|
|
65
|
-
"@deepseek-ai/dsh-
|
|
66
|
-
"@deepseek-ai/dsh-
|
|
67
|
-
"@deepseek-ai/dsh-
|
|
68
|
-
"@deepseek-ai/dsh-
|
|
69
|
-
"@deepseek-ai/dsh-session
|
|
70
|
-
"@deepseek-ai/dsh-
|
|
71
|
-
"@deepseek-ai/dsh-
|
|
72
|
-
"@deepseek-ai/dsh-
|
|
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": {
|
package/scripts/dsh-acp-zed.sh
CHANGED
|
@@ -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
|
|
14
|
-
#
|
|
15
|
-
#
|
|
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
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
-
|
|
72
|
-
|
|
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 "$@"
|