dsh-acp-enhanced 0.3.6 → 0.5.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 +39 -7
- package/README.md +52 -14
- package/cordis.patch.yml +109 -4
- package/lib/codec.js +185 -0
- package/lib/index.js +376 -35
- package/lib/terminal-codec.js +96 -0
- package/package.json +15 -1
package/README-zh.md
CHANGED
|
@@ -16,6 +16,11 @@ ACP 线上。
|
|
|
16
16
|
`agent_thought_chunk`),取消/重试不留半截输出
|
|
17
17
|
- **完整遥测**:上下文用量环 + 缓存命中率 / TPS / 输入-输出-推理 token / 工具耗时 /
|
|
18
18
|
轮次计数(`usage_update._meta` 携带全量明细)
|
|
19
|
+
- **图片支持(多模态)**:当 dsh 组合挂载了附件存储(dsh 0.1.1-rc.2+,`dsh-base`
|
|
20
|
+
默认装配 `dsh-attachment-local`)时,会声明 `promptCapabilities.image` 并把粘贴/
|
|
21
|
+
上传的图片持久化进 harness 附件存储——支持视觉的模型(如 `deepseek-v4-flash-vision-exp`)
|
|
22
|
+
可按线序原生读取,图文交替不乱序。旧版栈(无附件存储)自动降级:不声明 image、
|
|
23
|
+
收到图片 prompt 明确报错。
|
|
19
24
|
|
|
20
25
|
### 模型与权限
|
|
21
26
|
|
|
@@ -26,13 +31,22 @@ ACP 线上。
|
|
|
26
31
|
绝不出现空的 "unknown" 选择
|
|
27
32
|
- **权限预设**:read-only / workspace-write / full-access 三种会话模式
|
|
28
33
|
- **审批**:工具调用弹出原生 allow-once / reject-once 审批
|
|
34
|
+
- **Agent 预设**:每个会话的模型侧组合(工具 + 提示词段)来自 dsh agent-presets
|
|
35
|
+
名册。`standard` 为完整编码 agent(默认),`minimal`(极简模式)只有裸 shell +
|
|
36
|
+
文件编辑器,**不含** subagent/web/todo/plan 等工具——极简 agent 不会泄漏任何
|
|
37
|
+
host 层工具;`code` 与 `cordis` 随 dsh CLI 附带,`~/.dsh/.agent-presets` 下你
|
|
38
|
+
自己的预设也会自动出现。通过 `agent_preset` 配置项、`/preset` 命令或
|
|
39
|
+
`DSH_ACP_PRESET` 环境变量(会话默认)选择;**仅空会话可切换**(还没跑过对话),
|
|
40
|
+
历史记录永远不会横跨两套工具面
|
|
29
41
|
|
|
30
42
|
### Zed 深度集成
|
|
31
43
|
|
|
32
44
|
- **工具卡片**:折叠态即显示一行摘要——`Read <路径>`、shell 命令显示模型自己给出的意图描述
|
|
33
45
|
(`description`,Codex 风格,展开可见完整命令)、`Search: <模式>`、
|
|
34
46
|
`Fetch: <URL>` 等。卡片正文遵循 ACP 最佳实践:文件编辑渲染为真实 **diff 视图**、
|
|
35
|
-
|
|
47
|
+
**bash/pwsh 命令渲染为真实终端卡片**(codex-acp 线格式:命令 + 输出 + 退出码
|
|
48
|
+
pill 都在终端面板里,告别 raw-JSON 卡片)、其他执行器渲染为高亮代码块并在下方
|
|
49
|
+
附输出、涉及文件以**可点击路径**呈现(点击直达);
|
|
36
50
|
`rawInput` / `rawOutput` 保留在展开区备查,按工具类型渲染图标,
|
|
37
51
|
状态机为进行中 → 完成/失败
|
|
38
52
|
- **Zed 文件与终端**:`zed_read_text_file` / `zed_write_text_file` / `zed_terminal` 把
|
|
@@ -52,9 +66,11 @@ ACP 线上。
|
|
|
52
66
|
### 命令
|
|
53
67
|
|
|
54
68
|
- **Slash 命令**:输入 `/` 即可见命令列表(`available_commands_update`):`/status`
|
|
55
|
-
查看路由与遥测、`/model`
|
|
56
|
-
|
|
57
|
-
|
|
69
|
+
查看路由与遥测、`/model` 列出或切换模型、`/preset` 列出或切换 agent 预设
|
|
70
|
+
(列表以等宽代码块排版,一眼全见),其余(`/compact` `/goal` `/permission`
|
|
71
|
+
`/plan`…)直通 harness 命令注册表,全部**不经过模型 turn** 即时执行。所有
|
|
72
|
+
userInvocable 技能也会作为命令广播,`/ask-matt`、`/code-review`、`/tdd` 等能被
|
|
73
|
+
编辑器放行到达桥,技能正文按 dsh-tool-skill 的用户调用方式注入消息
|
|
58
74
|
|
|
59
75
|
### MCP
|
|
60
76
|
|
|
@@ -105,7 +121,8 @@ Zed 会用极简 PATH 拉起 agent,因此用随附启动器 `scripts/dsh-acp-z
|
|
|
105
121
|
"args": ["/absolute/path/to/dsh-acp-enhanced/scripts/dsh-acp-zed.sh"],
|
|
106
122
|
"env": {
|
|
107
123
|
"DSH_ACP_PROVIDER": "deepseek-official", // 官方 provider id
|
|
108
|
-
"DSH_ACP_MODEL": "deepseek-v4-flash"
|
|
124
|
+
"DSH_ACP_MODEL": "deepseek-v4-flash", // 官方模型 id
|
|
125
|
+
"DSH_ACP_PRESET": "standard" // 可选:agent 预设 id(minimal / standard / code / cordis / 自定义)
|
|
109
126
|
}
|
|
110
127
|
}
|
|
111
128
|
}
|
|
@@ -113,7 +130,8 @@ Zed 会用极简 PATH 拉起 agent,因此用随附启动器 `scripts/dsh-acp-z
|
|
|
113
130
|
```
|
|
114
131
|
|
|
115
132
|
> 这两项 env 与包自带 patch 的缺省值一致,**省略也能工作**——显式写上只是让路由意图
|
|
116
|
-
>
|
|
133
|
+
> 一目了然。`DSH_ACP_PRESET` 在名册侧默认 `standard`;想让每个新会话从一开始就是
|
|
134
|
+
> 某个特定模式就设置它。API key 不必写进 Zed:存入 `~/.dsh/.credentials.yaml`
|
|
117
135
|
> (`DEEPSEEK_API_KEY`)由 dsh 凭据服务解析即可;启动脚本还会兜底继承正在运行的
|
|
118
136
|
> `dsh web` 进程的 key。
|
|
119
137
|
|
|
@@ -124,6 +142,7 @@ Zed 会用极简 PATH 拉起 agent,因此用随附启动器 `scripts/dsh-acp-z
|
|
|
124
142
|
// ...上面的 type/command/args/env...
|
|
125
143
|
"default_config_options": {
|
|
126
144
|
"model": "deepseek-official/deepseek-v4-flash",
|
|
145
|
+
"agent_preset": "standard",
|
|
127
146
|
"plan_mode": false,
|
|
128
147
|
"reasoning_effort": "high"
|
|
129
148
|
},
|
|
@@ -211,11 +230,14 @@ node scripts/acp-client-tools.mjs # 客户端工具测试(模拟 Zed 的 f
|
|
|
211
230
|
node scripts/acp-mcp-test.mjs # MCP 挂载测试(无模型调用)
|
|
212
231
|
node scripts/acp-smoke-keyless.mjs # keyless 冒烟(CI 用)
|
|
213
232
|
node scripts/acp-resume-test.mjs # 会话恢复测试
|
|
233
|
+
node scripts/codec-image-test.mjs # 图片编解码单元测试(无网络,假 store)
|
|
234
|
+
node scripts/terminal-codec-test.mjs # 终端卡片编解码单元测试(无网络)
|
|
235
|
+
node scripts/acp-image-e2e.mjs # 图片能力端到端(vision 模型段需 API key)
|
|
214
236
|
```
|
|
215
237
|
|
|
216
238
|
## 已知限制
|
|
217
239
|
|
|
218
|
-
|
|
240
|
+
不支持音频附件(不声明 audio 能力)、文本按块粒度流式、每会话同时一个 in-flight
|
|
219
241
|
prompt。MCP 支持 stdio 与 streamable HTTP(不声明 legacy SSE / `acp` 传输)。
|
|
220
242
|
`session/close` / `session/fork` / `session/resume` 未实现(不声明能力,合规客户端
|
|
221
243
|
不会调用);`session/delete` 因 dsh 持久化无官方删除 API,采用直接删除后端目录的方式。
|
|
@@ -225,3 +247,13 @@ prompt。MCP 支持 stdio 与 streamable HTTP(不声明 legacy SSE / `acp` 传
|
|
|
225
247
|
用;`workspace-write` 下对附加根的写入会先被拒绝、需升级/批准,`danger-full-access`
|
|
226
248
|
下所有根均可写。真正的多根写支持需改 dsh 核心(`dsh-sandbox-policy` /
|
|
227
249
|
`dsh-sandbox-local` 需要根列表而非单根)。
|
|
250
|
+
|
|
251
|
+
Agent 预设接管了模型侧相关行:自带 `cordis.patch.yml` 会禁用 preset 拥有的 dsh-base
|
|
252
|
+
行(tool-bash/fs/subagent/todo/web/…——与官方 dsh-web-app/tui 清单逐行一致,仅少
|
|
253
|
+
`hmr`),并挂载 `agent-presets` 名册(默认 `standard`;`code`/`minimal`/`cordis`
|
|
254
|
+
随 dsh CLI 附带,`~/.dsh/.agent-presets` 下的自定义预设目录自动收录)。bundle 自带
|
|
255
|
+
patch 会自动装配(package.json `dsh.bundle.patch`)——**不要**把它复制进 profile
|
|
256
|
+
的用户层 `cordis.patch.yml`,否则 loader 在启动时因重复 entry id 拒绝装配。**升级**
|
|
257
|
+
一个已有自定义用户层 patch 的 profile 时,用户层只保留你自己的定制行(例如
|
|
258
|
+
acp-enhanced 行的 `includeAllProviders: true`,同时 restate provider/model/preset——
|
|
259
|
+
patch 条目是整体替换、不做合并)。升级前创建的会话恢复时会落到名册默认预设上。
|
package/README.md
CHANGED
|
@@ -18,6 +18,12 @@ over the ACP wire.
|
|
|
18
18
|
torn output
|
|
19
19
|
- **Full telemetry**: context usage ring plus cache hit rate / TPS / input-output-reasoning
|
|
20
20
|
tokens / tool timing / turn counts (`usage_update._meta` carries the full breakdown)
|
|
21
|
+
- **Image support (multimodal)**: when the dsh composition mounts an attachment store
|
|
22
|
+
(dsh 0.1.1-rc.2+ with `dsh-attachment-local`, the default in dsh-base), `promptCapabilities.image`
|
|
23
|
+
is advertised and pasted/uploaded images are ingested into the harness's durable attachment
|
|
24
|
+
store — a vision-capable model (e.g. `deepseek-v4-flash-vision-exp`) reads them natively,
|
|
25
|
+
in wire order with surrounding text. Older stacks (no attachment store) automatically
|
|
26
|
+
downgrade: image is not advertised and an image prompt is refused with a clear error.
|
|
21
27
|
|
|
22
28
|
### Model & permissions
|
|
23
29
|
|
|
@@ -28,6 +34,14 @@ over the ACP wire.
|
|
|
28
34
|
its first offered effort — instead of an empty "unknown" selection
|
|
29
35
|
- **Permission presets**: read-only / workspace-write / full-access session modes
|
|
30
36
|
- **Approval**: native allow-once / reject-once prompts per tool call
|
|
37
|
+
- **Agent presets**: per-session model-facing composition (tools + prompt sections)
|
|
38
|
+
from the dsh agent-presets roster. `standard` is the full coding agent (default),
|
|
39
|
+
`minimal` (极简模式) is a bare shell + files editor with **no** subagent/web/todo/plan
|
|
40
|
+
tools — nothing from the host layer leaks into a minimal agent; `code` and `cordis`
|
|
41
|
+
ship alongside, and your own presets under `~/.dsh/.agent-presets` appear too.
|
|
42
|
+
Choose via the `agent_preset` config option, the `/preset` command, or the
|
|
43
|
+
`DSH_ACP_PRESET` env var (per-session default); switching is only allowed while the
|
|
44
|
+
session is still blank (no turn has run), so history never straddles two tool sets.
|
|
31
45
|
|
|
32
46
|
### Zed deep integration
|
|
33
47
|
|
|
@@ -35,10 +49,12 @@ over the ACP wire.
|
|
|
35
49
|
model's own intent line for shell commands (`description`, Codex-style — the
|
|
36
50
|
exact command stays one click away), `Search: <pattern>`, `Fetch: <url>`, etc.
|
|
37
51
|
The card body follows the ACP best practice: file edits render as a real
|
|
38
|
-
**diff**,
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
52
|
+
**diff**, **bash/pwsh commands as a real terminal card** (codex-acp wire
|
|
53
|
+
shape: command line + output + exit pill inside a terminal panel — no more
|
|
54
|
+
raw-JSON cards), other executors as a syntax-highlighted code block, and
|
|
55
|
+
touched files as **clickable locations** that open the file — with `rawInput`
|
|
56
|
+
/ `rawOutput` kept one click away for transparency, plus per-kind icons and a
|
|
57
|
+
proper in-progress → completed/failed status lifecycle
|
|
42
58
|
- **Zed files & terminal**: `zed_read_text_file` / `zed_write_text_file` / `zed_terminal`
|
|
43
59
|
put file edits into Zed's "edited files" area (diff + accept/reject) and commands into a
|
|
44
60
|
real Zed terminal
|
|
@@ -60,11 +76,13 @@ over the ACP wire.
|
|
|
60
76
|
### Commands
|
|
61
77
|
|
|
62
78
|
- **Slash commands**: typing `/` reveals the command list (`available_commands_update`):
|
|
63
|
-
`/status` shows the route and telemetry, `/model` lists or switches the model
|
|
64
|
-
render as monospace code blocks — readable
|
|
65
|
-
else (`/compact` `/goal` `/permission` `/plan`…) runs straight
|
|
66
|
-
command registry — all executed **without a model turn
|
|
67
|
-
|
|
79
|
+
`/status` shows the route and telemetry, `/model` lists or switches the model, `/preset`
|
|
80
|
+
lists or switches the agent preset (listings render as monospace code blocks — readable
|
|
81
|
+
at a glance), everything else (`/compact` `/goal` `/permission` `/plan`…) runs straight
|
|
82
|
+
through the harness command registry — all executed **without a model turn**. Every
|
|
83
|
+
user-invocable skill is advertised as a command too, so `/ask-matt`, `/code-review`,
|
|
84
|
+
`/tdd`, … reach the bridge instead of being rejected by the editor, and the skill's
|
|
85
|
+
instructions are injected into the message (dsh-tool-skill-style user invocation)
|
|
68
86
|
|
|
69
87
|
### MCP
|
|
70
88
|
|
|
@@ -118,7 +136,8 @@ Zed spawns agents with a minimal PATH, so use the shipped launcher
|
|
|
118
136
|
"args": ["/absolute/path/to/dsh-acp-enhanced/scripts/dsh-acp-zed.sh"],
|
|
119
137
|
"env": {
|
|
120
138
|
"DSH_ACP_PROVIDER": "deepseek-official", // the official provider id
|
|
121
|
-
"DSH_ACP_MODEL": "deepseek-v4-flash"
|
|
139
|
+
"DSH_ACP_MODEL": "deepseek-v4-flash", // the official model id
|
|
140
|
+
"DSH_ACP_PRESET": "standard" // optional: agent preset id (minimal / standard / code / cordis / yours)
|
|
122
141
|
}
|
|
123
142
|
}
|
|
124
143
|
}
|
|
@@ -126,7 +145,9 @@ Zed spawns agents with a minimal PATH, so use the shipped launcher
|
|
|
126
145
|
```
|
|
127
146
|
|
|
128
147
|
> Both env vars match the shipped patch's defaults, so **they can be omitted entirely** —
|
|
129
|
-
> writing them out just makes the route explicit.
|
|
148
|
+
> writing them out just makes the route explicit. `DSH_ACP_PRESET` defaults to `standard`
|
|
149
|
+
> on the roster side; set it when you want every new session to start in a specific mode.
|
|
150
|
+
> The API key does not have to live in Zed:
|
|
130
151
|
> store it in `~/.dsh/.credentials.yaml` (`DEEPSEEK_API_KEY`) and the dsh credentials
|
|
131
152
|
> service resolves it; the launcher also falls back to a running `dsh web` process's key.
|
|
132
153
|
|
|
@@ -137,6 +158,7 @@ Optional: pin the panel's default config options (all still changeable in the pa
|
|
|
137
158
|
// ...the type/command/args/env above...
|
|
138
159
|
"default_config_options": {
|
|
139
160
|
"model": "deepseek-official/deepseek-v4-flash",
|
|
161
|
+
"agent_preset": "standard",
|
|
140
162
|
"plan_mode": false,
|
|
141
163
|
"reasoning_effort": "high"
|
|
142
164
|
},
|
|
@@ -229,13 +251,16 @@ node scripts/acp-client-tools.mjs # client-tool tests (mocks Zed fs/terminal
|
|
|
229
251
|
node scripts/acp-mcp-test.mjs # MCP mount test (no model calls)
|
|
230
252
|
node scripts/acp-smoke-keyless.mjs # keyless boot smoke (CI)
|
|
231
253
|
node scripts/acp-resume-test.mjs # session resume test
|
|
254
|
+
node scripts/codec-image-test.mjs # image-codec unit tests (no network, fake store)
|
|
255
|
+
node scripts/terminal-codec-test.mjs # terminal-card codec unit tests (no network)
|
|
256
|
+
node scripts/acp-image-e2e.mjs # image capability e2e (vision-model leg needs an API key)
|
|
232
257
|
```
|
|
233
258
|
|
|
234
259
|
## Known limitations
|
|
235
260
|
|
|
236
|
-
|
|
237
|
-
one in-flight prompt per session. MCP supports stdio and streamable HTTP
|
|
238
|
-
`acp` transports are not advertised).
|
|
261
|
+
Audio attachments are not supported (audio capability is not advertised), text streams at
|
|
262
|
+
block granularity, one in-flight prompt per session. MCP supports stdio and streamable HTTP
|
|
263
|
+
(legacy SSE / `acp` transports are not advertised).
|
|
239
264
|
`session/close` / `session/fork` / `session/resume` are not implemented (capabilities
|
|
240
265
|
undeclared, compliant clients will not call them); `session/delete` removes the
|
|
241
266
|
persisted directory directly because dsh persistence has no official delete API.
|
|
@@ -247,3 +272,16 @@ work in every root; under `workspace-write` a write under an additional root is
|
|
|
247
272
|
first and needs escalation/approval, while `danger-full-access` writes everywhere.
|
|
248
273
|
True multi-root write enforcement belongs in dsh core (`dsh-sandbox-policy` /
|
|
249
274
|
`dsh-sandbox-local` would need a root list instead of a single root).
|
|
275
|
+
|
|
276
|
+
Agent presets take over the model-facing rows: the shipped `cordis.patch.yml` disables
|
|
277
|
+
the dsh-base rows a preset owns (tool-bash/fs/subagent/todo/web/… — exactly the official
|
|
278
|
+
dsh-web-app/tui list minus `hmr`) and mounts the `agent-presets` roster (`standard`
|
|
279
|
+
default; `code`/`minimal`/`cordis` ship with the dsh CLI, your own preset dirs under
|
|
280
|
+
`~/.dsh/.agent-presets` are picked up automatically). The bundle's own patch applies
|
|
281
|
+
automatically (package.json `dsh.bundle.patch`) — do **not** copy it into the profile's
|
|
282
|
+
user-layer `cordis.patch.yml`, or the loader rejects the duplicate entry ids at boot.
|
|
283
|
+
When **upgrading** a profile that already carries a customized user-layer patch, keep
|
|
284
|
+
only your custom row configs there (e.g. `includeAllProviders: true` on the
|
|
285
|
+
acp-enhanced row, restating provider/model/preset since patch entries replace whole
|
|
286
|
+
rows, they do not merge). A session created before the upgrade resumes under the
|
|
287
|
+
roster's default preset.
|
package/cordis.patch.yml
CHANGED
|
@@ -11,18 +11,123 @@
|
|
|
11
11
|
# agent_servers env, or export before `dsh --profile <name>`):
|
|
12
12
|
# DSH_ACP_PROVIDER provider id, e.g. deepseek-official or your gateway id
|
|
13
13
|
# DSH_ACP_MODEL model id, e.g. deepseek-v4-flash
|
|
14
|
-
#
|
|
15
|
-
#
|
|
16
|
-
#
|
|
17
|
-
#
|
|
14
|
+
# DSH_ACP_PRESET agent preset id, e.g. minimal / standard / code / cordis
|
|
15
|
+
# Both route values default to the DeepSeek official route. Overriding
|
|
16
|
+
# `agent-default-model` keeps the bridge's per-session selection fallback on
|
|
17
|
+
# the same route (before any request header exists), so the first prompt never
|
|
18
|
+
# falls back to a non-routable default.
|
|
18
19
|
- id: agent-default-model
|
|
19
20
|
config:
|
|
20
21
|
provider: !!js "process.env.DSH_ACP_PROVIDER ?? 'deepseek-official'"
|
|
21
22
|
model: !!js "process.env.DSH_ACP_MODEL ?? 'deepseek-v4-flash'"
|
|
22
23
|
|
|
24
|
+
# ── agent presets ───────────────────────────────────────────────────────────
|
|
25
|
+
#
|
|
26
|
+
# The model-facing plugin set moves INTO the dsh agent-presets roster: every
|
|
27
|
+
# session composes its tools/prompt sections from one preset directory
|
|
28
|
+
# (standard/code/minimal/cordis, plus user-authored presets in
|
|
29
|
+
# ~/.dsh/.agent-presets). The dsh CLI's profile-boot overlays the shipped
|
|
30
|
+
# `config/agent-presets/` root onto the `agent-presets` row automatically, and
|
|
31
|
+
# the roster itself appends the user root (`includeUserRoot` default).
|
|
32
|
+
#
|
|
33
|
+
# The bridge composes each session from this roster through
|
|
34
|
+
# `presets.mount()` (inside the agent factory's `setup`) and exposes the choice
|
|
35
|
+
# on the ACP wire as the `agent_preset` config option + `/preset` command.
|
|
36
|
+
#
|
|
37
|
+
# Every dsh-base model-facing row a preset now owns is disabled here, so a
|
|
38
|
+
# `minimal` (极简模式) agent does not leak host-layer tools — an agent's tool
|
|
39
|
+
# registry view resolves agent → preset → global. This mirrors the official
|
|
40
|
+
# dsh-web-app / dsh-tui patch exactly (minus `hmr`, which is web-app-specific).
|
|
41
|
+
- id: agent-instructions
|
|
42
|
+
disabled: true
|
|
43
|
+
|
|
44
|
+
- id: command-compact
|
|
45
|
+
disabled: true
|
|
46
|
+
|
|
47
|
+
- id: compaction-basic
|
|
48
|
+
disabled: true
|
|
49
|
+
|
|
50
|
+
- id: plan-mode
|
|
51
|
+
disabled: true
|
|
52
|
+
|
|
53
|
+
- id: skill-filesystem
|
|
54
|
+
disabled: true
|
|
55
|
+
|
|
56
|
+
- id: tool-bash
|
|
57
|
+
disabled: true
|
|
58
|
+
|
|
59
|
+
- id: tool-fs
|
|
60
|
+
disabled: true
|
|
61
|
+
|
|
62
|
+
- id: tool-fs-search
|
|
63
|
+
disabled: true
|
|
64
|
+
|
|
65
|
+
- id: tool-goal
|
|
66
|
+
disabled: true
|
|
67
|
+
|
|
68
|
+
- id: tool-jobs
|
|
69
|
+
disabled: true
|
|
70
|
+
|
|
71
|
+
- id: tool-pwsh
|
|
72
|
+
disabled: true
|
|
73
|
+
|
|
74
|
+
- id: tool-ralph
|
|
75
|
+
disabled: true
|
|
76
|
+
|
|
77
|
+
- id: tool-result-pruner
|
|
78
|
+
disabled: true
|
|
79
|
+
|
|
80
|
+
- id: tool-skill
|
|
81
|
+
disabled: true
|
|
82
|
+
|
|
83
|
+
- id: tool-str-replace-editor
|
|
84
|
+
disabled: true
|
|
85
|
+
|
|
86
|
+
- id: tool-subagent
|
|
87
|
+
disabled: true
|
|
88
|
+
|
|
89
|
+
- id: tool-subagent-control
|
|
90
|
+
disabled: true
|
|
91
|
+
|
|
92
|
+
- id: tool-subagent-fork
|
|
93
|
+
disabled: true
|
|
94
|
+
|
|
95
|
+
- id: tool-subagent-list-agents
|
|
96
|
+
disabled: true
|
|
97
|
+
|
|
98
|
+
- id: tool-todo
|
|
99
|
+
disabled: true
|
|
100
|
+
|
|
101
|
+
- id: tool-web
|
|
102
|
+
disabled: true
|
|
103
|
+
|
|
104
|
+
- id: tool-workflow
|
|
105
|
+
disabled: true
|
|
106
|
+
|
|
107
|
+
- id: workflow-worker-thread
|
|
108
|
+
disabled: true
|
|
109
|
+
|
|
23
110
|
- insert:
|
|
111
|
+
# The agent-preset roster: `ctx.agentPresets`, the service the bridge
|
|
112
|
+
# composes every session's model-facing world from. No `roots` here — the
|
|
113
|
+
# dsh CLI's profile-boot overlays its shipped `config/agent-presets/`
|
|
114
|
+
# directory (standard/code/minimal/cordis, system trust) onto this row
|
|
115
|
+
# whenever a composition carries it, and the roster appends the
|
|
116
|
+
# user-authored `$DSH_HOME/.agent-presets` root.
|
|
117
|
+
- id: agent-presets
|
|
118
|
+
name: '@deepseek-ai/dsh-agent-presets'
|
|
119
|
+
config:
|
|
120
|
+
default: !!js "process.env.DSH_ACP_PRESET ?? 'standard'"
|
|
121
|
+
|
|
122
|
+
# Host-plane services the `cordis` preset's plugin-experimentation tool
|
|
123
|
+
# waits on (dynamicCordisRunner/cordisInspect); dsh-base carries no such
|
|
124
|
+
# row — the official web app and dsh-tui insert it the same way.
|
|
125
|
+
- id: cordis-host-runner
|
|
126
|
+
name: '@deepseek-ai/dsh-cordis-host-runner'
|
|
127
|
+
|
|
24
128
|
- id: acp-enhanced
|
|
25
129
|
name: 'dsh-acp-enhanced'
|
|
26
130
|
config:
|
|
27
131
|
provider: !!js "process.env.DSH_ACP_PROVIDER ?? 'deepseek-official'"
|
|
28
132
|
model: !!js "process.env.DSH_ACP_MODEL ?? 'deepseek-v4-flash'"
|
|
133
|
+
preset: !!js "process.env.DSH_ACP_PRESET"
|
package/lib/codec.js
CHANGED
|
@@ -57,6 +57,191 @@ export function promptHasUnsupportedContent(prompt) {
|
|
|
57
57
|
return prompt.some((block) => block.type !== 'text' && block.type !== 'resource_link')
|
|
58
58
|
}
|
|
59
59
|
|
|
60
|
+
/** Raster media types the harness attachment seam admits (dsh-attachment). */
|
|
61
|
+
const IMAGE_MEDIA_TYPES = new Set(['image/png', 'image/jpeg', 'image/webp', 'image/gif'])
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Canonicalize an ACP image MIME type for the harness attachment store.
|
|
65
|
+
* @param mimeType - the client-declared type (may be `image/jpg`, which
|
|
66
|
+
* the raster vocabulary spells `image/jpeg`).
|
|
67
|
+
* @returns the harness media type, or `undefined` when the value is not a
|
|
68
|
+
* raster we ingest.
|
|
69
|
+
*/
|
|
70
|
+
export function canonicalImageMediaType(mimeType) {
|
|
71
|
+
const lower = String(mimeType ?? '').trim().toLowerCase()
|
|
72
|
+
const mapped = lower === 'image/jpg' ? 'image/jpeg' : lower
|
|
73
|
+
return IMAGE_MEDIA_TYPES.has(mapped) ? mapped : undefined
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Error for prompt content this adapter does not advertise. */
|
|
77
|
+
export class UnsupportedPromptContentError extends Error {
|
|
78
|
+
constructor(contentType) {
|
|
79
|
+
super(`unsupported prompt content type: ${contentType}`)
|
|
80
|
+
this.name = 'UnsupportedPromptContentError'
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Error when an advertised image cannot be ingested (limits, decode, store). */
|
|
85
|
+
export class PromptImageError extends Error {
|
|
86
|
+
constructor(message, options) {
|
|
87
|
+
super(message, options)
|
|
88
|
+
this.name = 'PromptImageError'
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Narrow an unknown `ctx.attachments` value to the ingest surface used by
|
|
94
|
+
* {@link convertPrompt}. Capability detection instead of version detection:
|
|
95
|
+
* the service exists (with methods) on dsh 0.1.1-rc.2+, while the 0.1.0-rc.x
|
|
96
|
+
* seam is an empty shell without `validateImage`/`saveImage` — and a
|
|
97
|
+
* deployment without the attachment-local row has no service at all. All
|
|
98
|
+
* three fall back to `undefined` here, so the caller simply does not
|
|
99
|
+
* advertise image support.
|
|
100
|
+
* @param value - `ctx.get('attachments')` (or anything shaped like it).
|
|
101
|
+
* @returns the ingest surface, or `undefined` when absent/empty.
|
|
102
|
+
*/
|
|
103
|
+
export function attachmentIngestOf(value) {
|
|
104
|
+
if (value === null || typeof value !== 'object') return undefined
|
|
105
|
+
const candidate = value
|
|
106
|
+
if (typeof candidate.validateImage !== 'function' || typeof candidate.saveImage !== 'function') {
|
|
107
|
+
return undefined
|
|
108
|
+
}
|
|
109
|
+
const limits = candidate.imageLimits
|
|
110
|
+
if (limits === undefined
|
|
111
|
+
|| typeof limits.maxImagesPerMessage !== 'number'
|
|
112
|
+
|| typeof limits.maxMessageImageBytes !== 'number'
|
|
113
|
+
|| typeof limits.maxImageBytes !== 'number') {
|
|
114
|
+
return undefined
|
|
115
|
+
}
|
|
116
|
+
return candidate
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function decodeImageData(data) {
|
|
120
|
+
if (typeof data !== 'string' || data.length === 0) throw new PromptImageError('image data is empty')
|
|
121
|
+
const decoded = Buffer.from(data, 'base64')
|
|
122
|
+
if (decoded.byteLength === 0) throw new PromptImageError('image data is empty')
|
|
123
|
+
return new Uint8Array(decoded)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Display name from an image URI's leaf, with local path info stripped. */
|
|
127
|
+
function imageName(uri) {
|
|
128
|
+
if (typeof uri !== 'string' || uri.length === 0) return undefined
|
|
129
|
+
let leaf
|
|
130
|
+
try {
|
|
131
|
+
leaf = new URL(uri).pathname.split('/').filter(Boolean).at(-1)
|
|
132
|
+
} catch {
|
|
133
|
+
leaf = uri.split(/[/\\]/).filter(Boolean).at(-1)
|
|
134
|
+
}
|
|
135
|
+
if (leaf === undefined || leaf.length === 0) return undefined
|
|
136
|
+
try {
|
|
137
|
+
return decodeURIComponent(leaf)
|
|
138
|
+
} catch {
|
|
139
|
+
return leaf
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Flush accumulated text into the block list (keeps 图文交替 wire order). */
|
|
144
|
+
function flushText(parts, blocks) {
|
|
145
|
+
const text = parts.join('')
|
|
146
|
+
parts.length = 0
|
|
147
|
+
if (text.length > 0) blocks.push({ type: 'text', text })
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Convert an ACP prompt's content blocks into harness user-message content
|
|
152
|
+
* blocks. Text and resource links concatenate in wire order; when the
|
|
153
|
+
* composition provides an attachment ingest, ACP `image` blocks are decoded,
|
|
154
|
+
* admission-checked against the store limits, and durably committed with
|
|
155
|
+
* `saveImage`, keeping block order with surrounding text. Binary `resource`
|
|
156
|
+
* payloads and audio stay rejected — silently dropping them would be worse
|
|
157
|
+
* than refusing.
|
|
158
|
+
* @param prompt - ACP `session/prompt` content, in wire order.
|
|
159
|
+
* @param attachments - `ctx.attachments` ingest when the composition mounted
|
|
160
|
+
* one; omit (or pass `undefined`) to refuse images.
|
|
161
|
+
* @returns `{ blocks, displayText }` ready for `createUserMessage` plus a
|
|
162
|
+
* human-readable text rendering (used for titles, transcripts, commands).
|
|
163
|
+
* @throws UnsupportedPromptContentError for audio/binary blocks, or images
|
|
164
|
+
* with no ingest; PromptImageError when advertised image bytes fail
|
|
165
|
+
* admission.
|
|
166
|
+
*/
|
|
167
|
+
export async function convertPrompt(prompt, attachments) {
|
|
168
|
+
const preparedImages = []
|
|
169
|
+
for (const block of prompt) {
|
|
170
|
+
if (block?.type !== 'image') continue
|
|
171
|
+
if (attachments === undefined) throw new UnsupportedPromptContentError('image')
|
|
172
|
+
const mediaType = canonicalImageMediaType(block.mimeType)
|
|
173
|
+
if (mediaType === undefined) throw new PromptImageError(`unsupported image media type: ${block.mimeType}`)
|
|
174
|
+
const data = decodeImageData(block.data)
|
|
175
|
+
preparedImages.push({
|
|
176
|
+
data,
|
|
177
|
+
mediaType,
|
|
178
|
+
...imageName(block.uri) === undefined ? {} : { name: imageName(block.uri) },
|
|
179
|
+
})
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
if (preparedImages.length > 0) {
|
|
183
|
+
const { maxImagesPerMessage, maxMessageImageBytes, maxImageBytes } = attachments.imageLimits
|
|
184
|
+
if (preparedImages.length > maxImagesPerMessage) {
|
|
185
|
+
throw new PromptImageError('prompt exceeds the configured image-count limit')
|
|
186
|
+
}
|
|
187
|
+
const totalBytes = preparedImages.reduce((sum, image) => sum + image.data.byteLength, 0)
|
|
188
|
+
if (totalBytes > maxMessageImageBytes) {
|
|
189
|
+
throw new PromptImageError('prompt exceeds the configured aggregate image-byte limit')
|
|
190
|
+
}
|
|
191
|
+
for (const image of preparedImages) {
|
|
192
|
+
if (image.data.byteLength > maxImageBytes) {
|
|
193
|
+
throw new PromptImageError('image exceeds the configured encoded-byte limit')
|
|
194
|
+
}
|
|
195
|
+
try {
|
|
196
|
+
await attachments.validateImage({ data: image.data, mediaType: image.mediaType, ...image.name === undefined ? {} : { name: image.name } })
|
|
197
|
+
} catch (error) {
|
|
198
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
199
|
+
throw new PromptImageError(`image validation failed: ${message}`, { cause: error })
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const parts = []
|
|
205
|
+
const display = []
|
|
206
|
+
const blocks = []
|
|
207
|
+
let imageIndex = 0
|
|
208
|
+
for (const block of prompt) {
|
|
209
|
+
switch (block?.type) {
|
|
210
|
+
case 'text':
|
|
211
|
+
parts.push(block.text)
|
|
212
|
+
display.push(block.text)
|
|
213
|
+
break
|
|
214
|
+
case 'resource_link':
|
|
215
|
+
// Mirror the baseline bridge's textual reference so plain clients
|
|
216
|
+
// keep file mentions without the bridge dropping them.
|
|
217
|
+
parts.push(`\n[resource_link name=${JSON.stringify(block.name)} uri=${JSON.stringify(block.uri)}]\n`)
|
|
218
|
+
display.push(`@${block.name}`)
|
|
219
|
+
break
|
|
220
|
+
case 'image': {
|
|
221
|
+
if (attachments === undefined) throw new UnsupportedPromptContentError('image')
|
|
222
|
+
const prepared = preparedImages[imageIndex]
|
|
223
|
+
imageIndex += 1
|
|
224
|
+
if (prepared === undefined) throw new PromptImageError('image block was not prepared')
|
|
225
|
+
flushText(parts, blocks)
|
|
226
|
+
let attachment
|
|
227
|
+
try {
|
|
228
|
+
attachment = await attachments.saveImage(prepared)
|
|
229
|
+
} catch (error) {
|
|
230
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
231
|
+
throw new PromptImageError(message, { cause: error })
|
|
232
|
+
}
|
|
233
|
+
blocks.push({ type: 'image', attachment })
|
|
234
|
+
display.push(`[image${prepared.name === undefined ? '' : `: ${prepared.name}`}]`)
|
|
235
|
+
break
|
|
236
|
+
}
|
|
237
|
+
default:
|
|
238
|
+
throw new UnsupportedPromptContentError(block?.type ?? 'unknown')
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
flushText(parts, blocks)
|
|
242
|
+
return { blocks, displayText: display.join(' ').trim() }
|
|
243
|
+
}
|
|
244
|
+
|
|
60
245
|
/** Kramdown attribute-style inline markup (SiYuan exports), including
|
|
61
246
|
* truncation-damaged tails — titles are byte-budgeted upstream, so a cut
|
|
62
247
|
* can land mid-attribute (unterminated `"` or no closing `]`):
|
package/lib/index.js
CHANGED
|
@@ -45,9 +45,12 @@ 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'
|
|
49
|
+
import { renderSkillContent } from '@deepseek-ai/dsh-skill'
|
|
48
50
|
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
49
51
|
import { SessionId } from '@deepseek-ai/dsh-session'
|
|
50
|
-
import {
|
|
52
|
+
import { attachmentIngestOf, convertPrompt, PromptImageError, sanitizeWireTitle, turnEndToStopReason, UnsupportedPromptContentError, usageTelemetry } from './codec.js'
|
|
53
|
+
import { isTerminalToolName, parseShellExitStatus, resultText, shellCallCwd, stripShellPrefix, toolKindFor } from './terminal-codec.js'
|
|
51
54
|
|
|
52
55
|
/** Agent version advertised on the ACP wire — read from package.json so the
|
|
53
56
|
* handshake can never drift from the released package version. */
|
|
@@ -104,7 +107,7 @@ function rememberEffort(provider, model, effort) {
|
|
|
104
107
|
|
|
105
108
|
export const name = 'acp-enhanced'
|
|
106
109
|
/** The bridge creates and owns agents; every other concern is carried by the composition. */
|
|
107
|
-
export const inject = ['agents', 'llm', 'approval', 'tools', 'commands', 'systemPrompt']
|
|
110
|
+
export const inject = ['agents', 'llm', 'approval', 'tools', 'commands', 'skills', 'systemPrompt']
|
|
108
111
|
|
|
109
112
|
export const Config = Schema.object({
|
|
110
113
|
/** Initial provider route for every created agent. */
|
|
@@ -118,6 +121,11 @@ export const Config = Schema.object({
|
|
|
118
121
|
* but fail to dispatch with a MISSING_CREDENTIAL error. Set true to list
|
|
119
122
|
* every served provider's models regardless. */
|
|
120
123
|
includeAllProviders: Schema.boolean().default(false),
|
|
124
|
+
/** Initial agent preset every created agent is composed from, when an
|
|
125
|
+
* `agent-presets` roster is mounted. Absent adopts the roster's own
|
|
126
|
+
* default. The `DSH_ACP_PRESET` environment variable overrides this value
|
|
127
|
+
* when set. */
|
|
128
|
+
preset: Schema.string().default(undefined),
|
|
121
129
|
})
|
|
122
130
|
|
|
123
131
|
/** Preserve invalid-parameter detail in the SDK wire error message. */
|
|
@@ -130,27 +138,6 @@ function internalError(detail) {
|
|
|
130
138
|
return RequestError.internalError(undefined, detail)
|
|
131
139
|
}
|
|
132
140
|
|
|
133
|
-
/**
|
|
134
|
-
* Map a dsh tool name to the ACP ToolKind used for icons and card UX.
|
|
135
|
-
*
|
|
136
|
-
* Kind and content must agree: Zed treats kind == 'execute' as a terminal tool
|
|
137
|
-
* and kind == 'edit' as a diff tool, and for both it HIDES the rawInput
|
|
138
|
-
* section. `zed_terminal` is genuinely terminal; write/edit tools map to
|
|
139
|
-
* 'edit' only because the bridge always pairs them with a `diff` content
|
|
140
|
-
* block (see toolCallContentFor) — the diff replaces the raw dump as the card
|
|
141
|
-
* body. Local executors like bash/run_code stay 'other' and carry the command
|
|
142
|
-
* as a markdown code block, keeping the raw sections available too.
|
|
143
|
-
*/
|
|
144
|
-
function toolKindFor(name) {
|
|
145
|
-
if (name === 'zed_terminal') return 'execute'
|
|
146
|
-
if (/^fs_.*read|read_text|cat|show/.test(name)) return 'read'
|
|
147
|
-
if (/search|find|grep/.test(name)) return 'search'
|
|
148
|
-
if (/fetch|http/.test(name)) return 'fetch'
|
|
149
|
-
if (/think/.test(name)) return 'think'
|
|
150
|
-
if (/write|edit|patch|apply/.test(name)) return 'edit'
|
|
151
|
-
return 'other'
|
|
152
|
-
}
|
|
153
|
-
|
|
154
141
|
/** Parse a tool's raw arguments JSON into a JSON value for ACP rawInput. */
|
|
155
142
|
function parseToolArguments(raw) {
|
|
156
143
|
try {
|
|
@@ -410,6 +397,7 @@ export function apply(ctx, config) {
|
|
|
410
397
|
const approval = ctx.approval
|
|
411
398
|
const tools = ctx.tools
|
|
412
399
|
const commands = ctx.commands
|
|
400
|
+
const skills = ctx.skills
|
|
413
401
|
const logger = ctx.logger
|
|
414
402
|
/** The user-questions service (mounted by dsh-base); absent in minimal deployments. */
|
|
415
403
|
const userQuestions = ctx.get('userQuestions')
|
|
@@ -426,6 +414,82 @@ export function apply(ctx, config) {
|
|
|
426
414
|
/** Resolve the permission-presets service, tolerating a lazy mount. */
|
|
427
415
|
const permissionPresets = () => ctx.get('permissionPresets')
|
|
428
416
|
|
|
417
|
+
/**
|
|
418
|
+
* The terminal-output meta dialect the connected client renders, exactly as
|
|
419
|
+
* codex-acp resolves it: Zed declares `_meta.terminal_output` support on
|
|
420
|
+
* initialize; older clients stream via `terminal_output_delta`.
|
|
421
|
+
*/
|
|
422
|
+
const terminalOutputMode = () => clientCaps._meta?.['terminal_output'] === true
|
|
423
|
+
? 'terminal_output'
|
|
424
|
+
: 'terminal_output_delta'
|
|
425
|
+
|
|
426
|
+
/** Resolve the agent-presets roster, tolerating its absence (a profile that
|
|
427
|
+
* mounts no roster composes every session from the host layer). */
|
|
428
|
+
const agentPresets = () => ctx.get('agentPresets')
|
|
429
|
+
|
|
430
|
+
/**
|
|
431
|
+
* Resolve one service the way a joined agent sees it: through its preset
|
|
432
|
+
* scope chain when a roster is mounted (preset `isolate` realms hide e.g.
|
|
433
|
+
* `planMode`/`compaction` from the root context), falling back to the host
|
|
434
|
+
* context otherwise. Mirrors dsh-tui's `serviceForAgent`.
|
|
435
|
+
*/
|
|
436
|
+
const serviceForAgent = (agent, key) => {
|
|
437
|
+
const scoped = agentPresets()?.serviceFor?.(agent, key)
|
|
438
|
+
if (scoped !== undefined) return scoped
|
|
439
|
+
return ctx.get(key)
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
/** The preset a session actually runs, read from its log: the last
|
|
443
|
+
* `agent-preset/selected` event wins over the creation header. */
|
|
444
|
+
const runningPresetOf = (session) => resolveSessionPreset(session)
|
|
445
|
+
|
|
446
|
+
/** Whether one live session has produced anything yet. A preset swap is only
|
|
447
|
+
* legal while it is blank (dsh-agent-presets product rule): swapping tools
|
|
448
|
+
* mid-conversation would strand logged tool calls the new composition cannot
|
|
449
|
+
* make. */
|
|
450
|
+
const isBlankSession = (session) => !session.events.some((event) => event.type === 'turn/start')
|
|
451
|
+
|
|
452
|
+
/**
|
|
453
|
+
* Resolve the preset a new/resumed session will run under, and the setup hook
|
|
454
|
+
* that installs it inside the agent factory's `setup(agentCtx)`.
|
|
455
|
+
*
|
|
456
|
+
* Mirrors dsh-tui / dsh-host-apiproxy `composeAgent`: the id is resolved
|
|
457
|
+
* BEFORE `agents.create` because the session boundary snapshots `meta` before
|
|
458
|
+
* asynchronous setup begins, while the mount itself must run in `setup` so a
|
|
459
|
+
* composition failure rolls the whole creation back. A deployment without a
|
|
460
|
+
* roster returns an empty composition — every session then shares the host
|
|
461
|
+
* composition (the pre-preset behavior).
|
|
462
|
+
*
|
|
463
|
+
* @param requested - preset id, or undefined for the roster default.
|
|
464
|
+
* @returns `{ agentPreset, setup }`, or `{}` without a roster.
|
|
465
|
+
* @throws UnknownPresetError / PresetMountError (mapped by callers).
|
|
466
|
+
*/
|
|
467
|
+
async function composePreset(requested) {
|
|
468
|
+
const presets = agentPresets()
|
|
469
|
+
if (presets === undefined) return {}
|
|
470
|
+
const resolvedId = (await presets.resolveMountable(requested)).id
|
|
471
|
+
return {
|
|
472
|
+
agentPreset: resolvedId,
|
|
473
|
+
setup: async (agentCtx) => {
|
|
474
|
+
await presets.mount(agentCtx, resolvedId)
|
|
475
|
+
},
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
/** The preset a persisted session runs, or undefined when unrecorded. */
|
|
480
|
+
async function persistedPresetOf(sessionId) {
|
|
481
|
+
const persistence = ctx.get('sessionPersistence')
|
|
482
|
+
if (persistence === undefined) return undefined
|
|
483
|
+
try {
|
|
484
|
+
const { meta, events } = await persistence.load(sessionId)
|
|
485
|
+
return resolveSessionPreset({ header: meta, events })
|
|
486
|
+
} catch {
|
|
487
|
+
// A missing/corrupt artifact leaves resume itself to report the failure;
|
|
488
|
+
// the preset lookup must not mask it with a second, misleading error.
|
|
489
|
+
return undefined
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
|
|
429
493
|
// Multi-root workspaces: describe the session's roots to the model. Zed
|
|
430
494
|
// passes the primary cwd plus every additional workspace root on the
|
|
431
495
|
// session lifecycle requests; the sandbox policy still resolves a single
|
|
@@ -486,6 +550,35 @@ export function apply(ctx, config) {
|
|
|
486
550
|
*/
|
|
487
551
|
function toolCallUpdateFor(record, event) {
|
|
488
552
|
const parsedArgs = parseToolArguments(event.data.arguments)
|
|
553
|
+
if (isTerminalToolName(event.data.name)) {
|
|
554
|
+
// codex-acp terminal-card shape: kind 'execute' plus a terminal content
|
|
555
|
+
// block and terminal_info meta, so Zed renders the command and output in
|
|
556
|
+
// a terminal panel instead of a raw-JSON card. rawInput keeps the exact
|
|
557
|
+
// command + resolved cwd for transparency.
|
|
558
|
+
const command = typeof parsedArgs === 'object' && parsedArgs !== null && typeof parsedArgs.command === 'string'
|
|
559
|
+
? parsedArgs.command
|
|
560
|
+
: typeof event.data.arguments === 'string' ? event.data.arguments : event.data.name
|
|
561
|
+
const cwd = shellCallCwd(parsedArgs, record.agent.session)
|
|
562
|
+
if (record.callArgs.size >= 64) record.callArgs.delete(record.callArgs.keys().next().value)
|
|
563
|
+
record.callArgs.set(event.data.callId, { name: event.data.name, parsedArgs })
|
|
564
|
+
return {
|
|
565
|
+
sessionUpdate: 'tool_call',
|
|
566
|
+
toolCallId: event.data.callId,
|
|
567
|
+
name: event.data.name,
|
|
568
|
+
title: stripShellPrefix(command) || event.data.name,
|
|
569
|
+
kind: 'execute',
|
|
570
|
+
status: 'in_progress',
|
|
571
|
+
content: [{ type: 'terminal', terminalId: event.data.callId }],
|
|
572
|
+
rawInput: { command, cwd },
|
|
573
|
+
_meta: {
|
|
574
|
+
turn: event.data.turn,
|
|
575
|
+
step: event.data.step,
|
|
576
|
+
name: event.data.name,
|
|
577
|
+
argumentsPreview: event.data.arguments.slice(0, 200),
|
|
578
|
+
terminal_info: { cwd, terminal_id: event.data.callId },
|
|
579
|
+
},
|
|
580
|
+
}
|
|
581
|
+
}
|
|
489
582
|
const kind = toolKindFor(event.data.name)
|
|
490
583
|
const content = toolCallContentFor(event.data.name, parsedArgs, kind)
|
|
491
584
|
const locations = toolCallLocationsFor(kind, parsedArgs)
|
|
@@ -516,10 +609,48 @@ export function apply(ctx, config) {
|
|
|
516
609
|
/** Wire update for a harness `tool/result`: terminal status plus, for
|
|
517
610
|
* executors, the output as a friendly code block under the command. */
|
|
518
611
|
function toolResultUpdateFor(record, event, callId, elapsed) {
|
|
519
|
-
const preview = resultPreview(event)
|
|
520
612
|
const call = record.callArgs.get(callId)
|
|
521
613
|
record.callArgs.delete(callId)
|
|
522
614
|
const isError = event.data.error !== undefined
|
|
615
|
+
if (call !== undefined && isTerminalToolName(call.name)) {
|
|
616
|
+
// Close the terminal panel from the codex-acp terminal-card shape opened
|
|
617
|
+
// by toolCallUpdateFor: stream the rendered output (minus the exit
|
|
618
|
+
// marker the shell tool appends) via terminal_output(_delta) and finish
|
|
619
|
+
// with terminal_exit, while rawOutput keeps the structured
|
|
620
|
+
// { formatted_output, exit_code } shape. A failed call carries no
|
|
621
|
+
// formatted body (the real exit code lives inside the text marker the
|
|
622
|
+
// error path does not produce), so the panel closes status-failed bare.
|
|
623
|
+
const parsed = isError ? undefined : parseShellExitStatus(resultText(event) ?? '')
|
|
624
|
+
const body = parsed?.body ?? ''
|
|
625
|
+
const meta = {
|
|
626
|
+
terminal_exit: {
|
|
627
|
+
exit_code: parsed?.exitCode ?? 0,
|
|
628
|
+
signal: parsed?.signal ?? null,
|
|
629
|
+
terminal_id: callId,
|
|
630
|
+
},
|
|
631
|
+
}
|
|
632
|
+
if (body.length > 0) {
|
|
633
|
+
Object.assign(meta, terminalOutputMode() === 'terminal_output'
|
|
634
|
+
? { terminal_output: { data: body, terminal_id: callId } }
|
|
635
|
+
: { terminal_output_delta: { data: body, terminal_id: callId } })
|
|
636
|
+
}
|
|
637
|
+
return {
|
|
638
|
+
sessionUpdate: 'tool_call_update',
|
|
639
|
+
toolCallId: callId,
|
|
640
|
+
name: call.name,
|
|
641
|
+
status: isError ? 'failed' : 'completed',
|
|
642
|
+
...(!isError && body.length > 0) ? { rawOutput: { formatted_output: body, exit_code: parsed.exitCode } } : {},
|
|
643
|
+
_meta: {
|
|
644
|
+
turn: event.data.turn,
|
|
645
|
+
step: event.data.step,
|
|
646
|
+
elapsedMs: elapsed,
|
|
647
|
+
count: record.toolStats.count,
|
|
648
|
+
totalMs: record.toolStats.totalMs,
|
|
649
|
+
...meta,
|
|
650
|
+
},
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
const preview = resultPreview(event)
|
|
523
654
|
let content
|
|
524
655
|
if (!isError && preview !== undefined && call !== undefined
|
|
525
656
|
&& call.name !== 'zed_terminal' && EXECUTOR_NAME.test(call.name)) {
|
|
@@ -560,6 +691,21 @@ export function apply(ctx, config) {
|
|
|
560
691
|
// Final accounting when the adapter reported usage on the message
|
|
561
692
|
// rather than as a stream chunk.
|
|
562
693
|
if (event.data.usage !== undefined) emitUsage(record, event.data.usage, event)
|
|
694
|
+
// Model-produced image blocks never stream through the text chunk
|
|
695
|
+
// path; surface them as a wire placeholder so the reply is not
|
|
696
|
+
// silently missing a block (ACP clients render the text).
|
|
697
|
+
for (const block of event.data.message?.content ?? []) {
|
|
698
|
+
if (block?.type === 'image' && block.attachment?.attachmentId !== undefined) {
|
|
699
|
+
notify({
|
|
700
|
+
sessionId: session.header.id,
|
|
701
|
+
update: {
|
|
702
|
+
sessionUpdate: 'agent_message_chunk',
|
|
703
|
+
messageId: record.messageId,
|
|
704
|
+
content: { type: 'text', text: `[image attachment ${block.attachment.attachmentId}]` },
|
|
705
|
+
},
|
|
706
|
+
})
|
|
707
|
+
}
|
|
708
|
+
}
|
|
563
709
|
break
|
|
564
710
|
case 'turn/start': {
|
|
565
711
|
record.turnCount += 1
|
|
@@ -939,7 +1085,27 @@ export function apply(ctx, config) {
|
|
|
939
1085
|
}),
|
|
940
1086
|
})
|
|
941
1087
|
}
|
|
942
|
-
const
|
|
1088
|
+
const presets = agentPresets()
|
|
1089
|
+
if (presets !== undefined) {
|
|
1090
|
+
// Broken presets must not be offered: mounting one always fails.
|
|
1091
|
+
const mountable = (await presets.list()).filter((preset) => preset.broken === undefined)
|
|
1092
|
+
if (mountable.length > 0) {
|
|
1093
|
+
options.push({
|
|
1094
|
+
id: 'agent_preset',
|
|
1095
|
+
type: 'select',
|
|
1096
|
+
name: 'Agent preset',
|
|
1097
|
+
description: 'Model-facing tool/prompt composition for this session (switch only while blank).',
|
|
1098
|
+
category: 'model_config',
|
|
1099
|
+
currentValue: runningPresetOf(record.agent.session) ?? '',
|
|
1100
|
+
options: mountable.map((preset) => ({
|
|
1101
|
+
value: preset.id,
|
|
1102
|
+
name: preset.name ?? preset.id,
|
|
1103
|
+
...preset.description === undefined ? {} : { description: preset.description },
|
|
1104
|
+
})),
|
|
1105
|
+
})
|
|
1106
|
+
}
|
|
1107
|
+
}
|
|
1108
|
+
const planMode = serviceForAgent(record.agent, 'planMode')
|
|
943
1109
|
if (planMode !== undefined) {
|
|
944
1110
|
options.push({
|
|
945
1111
|
id: 'plan_mode',
|
|
@@ -1396,6 +1562,7 @@ export function apply(ctx, config) {
|
|
|
1396
1562
|
const BUILTIN_COMMANDS = [
|
|
1397
1563
|
{ name: 'status', description: 'Show session status: model route, context usage, telemetry.' },
|
|
1398
1564
|
{ name: 'model', description: 'List the model catalog, or switch with /model <provider/model | substring>.' },
|
|
1565
|
+
{ name: 'preset', description: 'List agent presets, or switch with /preset <id> (blank session only).' },
|
|
1399
1566
|
]
|
|
1400
1567
|
|
|
1401
1568
|
/**
|
|
@@ -1419,6 +1586,21 @@ export function apply(ctx, config) {
|
|
|
1419
1586
|
} catch (error) {
|
|
1420
1587
|
logger.warn(`acp-enhanced: command listing failed: ${String(error)}`)
|
|
1421
1588
|
}
|
|
1589
|
+
// User-invocable skills (e.g. /ask-matt) must be advertised as commands or
|
|
1590
|
+
// the Zed client rejects the slash before it reaches the bridge (its
|
|
1591
|
+
// available_skills only covers native agents, and it validates ACP
|
|
1592
|
+
// connections against available_commands locally).
|
|
1593
|
+
try {
|
|
1594
|
+
const summaries = await skills.list({ scope: record.agent, cwd: record.agent.session.header.cwd })
|
|
1595
|
+
for (const skill of summaries) {
|
|
1596
|
+
if (!skill.invocation?.userInvocable) continue
|
|
1597
|
+
if (seen.has(skill.name)) continue
|
|
1598
|
+
seen.add(skill.name)
|
|
1599
|
+
list.push({ name: skill.name, description: `Run the ${skill.name} skill` })
|
|
1600
|
+
}
|
|
1601
|
+
} catch (error) {
|
|
1602
|
+
logger.warn(`acp-enhanced: skill command listing failed: ${String(error)}`)
|
|
1603
|
+
}
|
|
1422
1604
|
notify({
|
|
1423
1605
|
sessionId: record.agent.session.id,
|
|
1424
1606
|
update: { sessionUpdate: 'available_commands_update', availableCommands: list },
|
|
@@ -1446,6 +1628,7 @@ export function apply(ctx, config) {
|
|
|
1446
1628
|
const last = record.lastUsage
|
|
1447
1629
|
const lines = [
|
|
1448
1630
|
`route ${selection.provider ?? '?'}/${selection.model ?? '?'}`,
|
|
1631
|
+
`preset ${runningPresetOf(record.agent.session) ?? 'host'}`,
|
|
1449
1632
|
`turns ${record.turnCount}`,
|
|
1450
1633
|
...last === undefined ? [] : [
|
|
1451
1634
|
`context ${last.used}/${last.size}`,
|
|
@@ -1477,6 +1660,45 @@ export function apply(ctx, config) {
|
|
|
1477
1660
|
return `switched to ${matches[0].provider}/${matches[0].model}`
|
|
1478
1661
|
}
|
|
1479
1662
|
|
|
1663
|
+
/** The /preset command: list the roster or switch by exact id/substring. */
|
|
1664
|
+
async function presetCommandText(record, query) {
|
|
1665
|
+
const presets = agentPresets()
|
|
1666
|
+
if (presets === undefined) return 'no agent-presets roster is mounted in this profile'
|
|
1667
|
+
const roster = (await presets.list()).filter((preset) => preset.broken === undefined)
|
|
1668
|
+
const current = runningPresetOf(record.agent.session)
|
|
1669
|
+
if (query.trim().length === 0) {
|
|
1670
|
+
const lines = roster.map((preset) => {
|
|
1671
|
+
const mark = preset.id === current ? '* ' : ' '
|
|
1672
|
+
return `${mark}${preset.id}${preset.name !== undefined ? ` — ${preset.name}` : ''}`
|
|
1673
|
+
})
|
|
1674
|
+
return lines.length > 0 ? ['```', ...lines, '```'].join('\n') : 'no agent presets available'
|
|
1675
|
+
}
|
|
1676
|
+
const needle = query.trim().toLowerCase()
|
|
1677
|
+
const matches = roster.filter((preset) => (
|
|
1678
|
+
preset.id.toLowerCase().includes(needle) || preset.name?.toLowerCase().includes(needle)
|
|
1679
|
+
))
|
|
1680
|
+
if (matches.length === 0) return `no agent preset matches "${query}"`
|
|
1681
|
+
if (matches.length > 1) return `ambiguous: ${matches.map((preset) => preset.id).join(', ')}`
|
|
1682
|
+
const target = matches[0]
|
|
1683
|
+
if (target.id === current) return `already on agent preset ${target.id}`
|
|
1684
|
+
if (!isBlankSession(record.agent.session)) {
|
|
1685
|
+
return `cannot switch agent preset on a non-blank session (current: ${current ?? 'host'})`
|
|
1686
|
+
}
|
|
1687
|
+
let preset
|
|
1688
|
+
try {
|
|
1689
|
+
preset = await presets.recompose(record.agent.ctx, target.id)
|
|
1690
|
+
} catch (error) {
|
|
1691
|
+
// A listed preset can still fail to mount (a missing service in this
|
|
1692
|
+
// deployment); report it as a command failure, not a wire crash.
|
|
1693
|
+
const detail = error instanceof Error ? error.message : String(error)
|
|
1694
|
+
return `⚠ /preset failed: ${detail}`
|
|
1695
|
+
}
|
|
1696
|
+
// The switch is a logged session fact (model-visible ⟺ logged), so a
|
|
1697
|
+
// resume re-resolves the NEW composition.
|
|
1698
|
+
record.agent.session.append('agent-preset/selected', { agentPreset: preset.id })
|
|
1699
|
+
return `switched to agent preset ${preset.id}`
|
|
1700
|
+
}
|
|
1701
|
+
|
|
1480
1702
|
/** Refresh every client-visible surface a command may have mutated. */
|
|
1481
1703
|
function refreshAfterCommand(record) {
|
|
1482
1704
|
const permission = permissionPresets()
|
|
@@ -1492,7 +1714,9 @@ export function apply(ctx, config) {
|
|
|
1492
1714
|
broadcastConfig(record).catch((error) => {
|
|
1493
1715
|
logger.warn(`acp-enhanced: config rebroadcast after command failed: ${String(error)}`)
|
|
1494
1716
|
})
|
|
1495
|
-
publishCommands(record)
|
|
1717
|
+
publishCommands(record).catch((error) => {
|
|
1718
|
+
logger.warn(`acp-enhanced: command broadcast after command failed: ${String(error)}`)
|
|
1719
|
+
})
|
|
1496
1720
|
}
|
|
1497
1721
|
|
|
1498
1722
|
// ── session records + history replay ─────────────────────────────────────
|
|
@@ -1585,6 +1809,11 @@ export function apply(ctx, config) {
|
|
|
1585
1809
|
const parts = []
|
|
1586
1810
|
for (const block of content ?? []) {
|
|
1587
1811
|
if (block?.type === 'text' && typeof block.text === 'string') parts.push(block.text)
|
|
1812
|
+
else if (block?.type === 'image' && block.attachment?.attachmentId !== undefined) {
|
|
1813
|
+
// Model-produced images replay as a textual reference — the wire
|
|
1814
|
+
// surface does not carry attachment bytes.
|
|
1815
|
+
parts.push(`[image attachment ${block.attachment.attachmentId}]`)
|
|
1816
|
+
}
|
|
1588
1817
|
}
|
|
1589
1818
|
return parts.join('\n')
|
|
1590
1819
|
}
|
|
@@ -1689,7 +1918,15 @@ export function apply(ctx, config) {
|
|
|
1689
1918
|
// workspace root on session/new / session/load instead of showing
|
|
1690
1919
|
// the "doesn't currently support multi-root workspaces" callout).
|
|
1691
1920
|
sessionCapabilities: { list: {}, delete: {}, additionalDirectories: {} },
|
|
1692
|
-
|
|
1921
|
+
// Image support is a live capability: the harness advertises
|
|
1922
|
+
// `image: true` only when the composition mounted a working
|
|
1923
|
+
// attachment store (duck-typed, so dsh 0.1.1-rc.2+ with
|
|
1924
|
+
// dsh-attachment-local enables it and older stacks report false).
|
|
1925
|
+
promptCapabilities: {
|
|
1926
|
+
image: attachmentIngestOf(ctx.get('attachments')) !== undefined,
|
|
1927
|
+
audio: false,
|
|
1928
|
+
embeddedContext: false,
|
|
1929
|
+
},
|
|
1693
1930
|
// Stdio MCP servers always work; streamable HTTP maps onto
|
|
1694
1931
|
// dsh-mcp-client's second transport. Legacy SSE does not.
|
|
1695
1932
|
mcpCapabilities: { http: true, sse: false },
|
|
@@ -1706,13 +1943,30 @@ export function apply(ctx, config) {
|
|
|
1706
1943
|
assertOpen()
|
|
1707
1944
|
const additionalDirectories = normalizeSessionParams(params)
|
|
1708
1945
|
const sessionId = SessionId(randomUUID())
|
|
1946
|
+
const requestedPreset = process.env.DSH_ACP_PRESET ?? config.preset
|
|
1947
|
+
let composition
|
|
1948
|
+
try {
|
|
1949
|
+
composition = await composePreset(requestedPreset)
|
|
1950
|
+
} catch (error) {
|
|
1951
|
+
// A bad DSH_ACP_PRESET / preset config is a client-side setup mistake,
|
|
1952
|
+
// not a server fault: surface the roster's detail as invalid params.
|
|
1953
|
+
const detail = error instanceof Error ? error.message : String(error)
|
|
1954
|
+
if (error instanceof UnknownPresetError || error instanceof PresetMountError) {
|
|
1955
|
+
throw invalidParams(detail)
|
|
1956
|
+
}
|
|
1957
|
+
throw error
|
|
1958
|
+
}
|
|
1709
1959
|
const handle = await agents.create({
|
|
1710
1960
|
sessionId,
|
|
1711
|
-
meta: {
|
|
1961
|
+
meta: {
|
|
1962
|
+
cwd: params.cwd,
|
|
1963
|
+
...composition.agentPreset === undefined ? {} : { agentPreset: composition.agentPreset },
|
|
1964
|
+
},
|
|
1712
1965
|
agentOptions: {
|
|
1713
1966
|
...config.provider === undefined ? {} : { provider: config.provider },
|
|
1714
1967
|
...config.model === undefined ? {} : { model: config.model },
|
|
1715
1968
|
},
|
|
1969
|
+
...composition.setup === undefined ? {} : { setup: composition.setup },
|
|
1716
1970
|
})
|
|
1717
1971
|
if (closed) {
|
|
1718
1972
|
await handle.dispose()
|
|
@@ -1767,12 +2021,26 @@ export function apply(ctx, config) {
|
|
|
1767
2021
|
configOptions: await buildConfigOptions(live),
|
|
1768
2022
|
}
|
|
1769
2023
|
}
|
|
2024
|
+
const runningPreset = await persistedPresetOf(sessionId)
|
|
2025
|
+
let composition
|
|
2026
|
+
try {
|
|
2027
|
+
composition = await composePreset(runningPreset)
|
|
2028
|
+
} catch (error) {
|
|
2029
|
+
// Same mapping as session/new: a roster that cannot supply the
|
|
2030
|
+
// session's logged preset is reported as a client mistake.
|
|
2031
|
+
const detail = error instanceof Error ? error.message : String(error)
|
|
2032
|
+
if (error instanceof UnknownPresetError || error instanceof PresetMountError) {
|
|
2033
|
+
throw invalidParams(detail)
|
|
2034
|
+
}
|
|
2035
|
+
throw error
|
|
2036
|
+
}
|
|
1770
2037
|
const handle = await agents.resume({
|
|
1771
2038
|
resumeSessionId: sessionId,
|
|
1772
2039
|
agentOptions: {
|
|
1773
2040
|
...config.provider === undefined ? {} : { provider: config.provider },
|
|
1774
2041
|
...config.model === undefined ? {} : { model: config.model },
|
|
1775
2042
|
},
|
|
2043
|
+
...composition.setup === undefined ? {} : { setup: composition.setup },
|
|
1776
2044
|
})
|
|
1777
2045
|
if (closed) {
|
|
1778
2046
|
await handle.dispose()
|
|
@@ -1810,16 +2078,36 @@ export function apply(ctx, config) {
|
|
|
1810
2078
|
if (record.inflight !== undefined) {
|
|
1811
2079
|
throw invalidParams('a prompt is already in flight for this session')
|
|
1812
2080
|
}
|
|
1813
|
-
|
|
1814
|
-
|
|
2081
|
+
// Capability-gated content conversion: text/resource_link always work;
|
|
2082
|
+
// image blocks are ingested through the composition's attachment
|
|
2083
|
+
// store when one is mounted (advertised on initialize). A client that
|
|
2084
|
+
// sends unsupported content gets the exact failing kind — never a
|
|
2085
|
+
// silent drop — and an image that fails admission (bad type, over
|
|
2086
|
+
// limits, store rejection) surfaces its precise reason.
|
|
2087
|
+
let blocks
|
|
2088
|
+
let text
|
|
2089
|
+
try {
|
|
2090
|
+
const ingest = attachmentIngestOf(ctx.get('attachments'))
|
|
2091
|
+
const converted = await convertPrompt(params.prompt, ingest)
|
|
2092
|
+
blocks = converted.blocks
|
|
2093
|
+
text = converted.displayText
|
|
2094
|
+
} catch (error) {
|
|
2095
|
+
if (error instanceof UnsupportedPromptContentError) {
|
|
2096
|
+
throw invalidParams(error.message)
|
|
2097
|
+
}
|
|
2098
|
+
if (error instanceof PromptImageError) {
|
|
2099
|
+
throw invalidParams(`image rejected: ${error.message}`)
|
|
2100
|
+
}
|
|
2101
|
+
throw error
|
|
1815
2102
|
}
|
|
1816
|
-
const text = acpPromptToText(params.prompt)
|
|
1817
2103
|
if (text.trim().length === 0) throw invalidParams('empty prompt')
|
|
1818
2104
|
|
|
1819
2105
|
// Insurance: by the first prompt the client is guaranteed to know the
|
|
1820
2106
|
// session, so re-advertise the command surface (idempotent) — covers
|
|
1821
2107
|
// any client that missed the post-session/new broadcast.
|
|
1822
|
-
publishCommands(record)
|
|
2108
|
+
publishCommands(record).catch((error) => {
|
|
2109
|
+
logger.warn(`acp-enhanced: command broadcast failed: ${String(error)}`)
|
|
2110
|
+
})
|
|
1823
2111
|
|
|
1824
2112
|
// Adapter-level slash commands never reach the model: /status and
|
|
1825
2113
|
// /model are built in, any other registered slash (compact/goal/
|
|
@@ -1843,6 +2131,11 @@ export function apply(ctx, config) {
|
|
|
1843
2131
|
if (commandMatch?.[1] === 'model') {
|
|
1844
2132
|
return respond(await modelCommandText(record, trimmed.slice(commandMatch[0].length).trim()))
|
|
1845
2133
|
}
|
|
2134
|
+
if (commandMatch?.[1] === 'preset') {
|
|
2135
|
+
const reply = await presetCommandText(record, trimmed.slice(commandMatch[0].length).trim())
|
|
2136
|
+
refreshAfterCommand(record)
|
|
2137
|
+
return respond(reply)
|
|
2138
|
+
}
|
|
1846
2139
|
if (commandMatch !== null && commandMatch[1] !== undefined) {
|
|
1847
2140
|
let execution
|
|
1848
2141
|
try {
|
|
@@ -1858,10 +2151,31 @@ export function apply(ctx, config) {
|
|
|
1858
2151
|
}
|
|
1859
2152
|
}
|
|
1860
2153
|
|
|
2154
|
+
// The slash is not a harness command: it may be a skill gesture
|
|
2155
|
+
// (`/skill-name`, e.g. `/ask-matt`). Zed would normally reject unknown
|
|
2156
|
+
// slashes, but we advertise user-invocable skills in publishCommands,
|
|
2157
|
+
// so this path is reachable. Load the skill body and append it to the
|
|
2158
|
+
// message so the model reads the skill's instructions, exactly like
|
|
2159
|
+
// dsh-tool-skill's user-invocation injection would.
|
|
2160
|
+
if (commandMatch !== null && commandMatch[1] !== undefined) {
|
|
2161
|
+
const skillName = commandMatch[1]
|
|
2162
|
+
try {
|
|
2163
|
+
const skill = await skills.get(skillName, {
|
|
2164
|
+
scope: record.agent,
|
|
2165
|
+
cwd: record.agent.session.header.cwd,
|
|
2166
|
+
})
|
|
2167
|
+
if (skill !== undefined && skill.invocation?.userInvocable !== false) {
|
|
2168
|
+
blocks = [...blocks, { type: 'text', text: renderSkillContent(skill) }]
|
|
2169
|
+
}
|
|
2170
|
+
} catch (error) {
|
|
2171
|
+
logger.warn(`acp-enhanced: skill gesture "${skillName}" failed: ${String(error)}`)
|
|
2172
|
+
}
|
|
2173
|
+
}
|
|
2174
|
+
|
|
1861
2175
|
if (ctx.agents.get(record.agent.id) !== record.agent) {
|
|
1862
2176
|
throw internalError('prompt was not queued: the agent was disposed outside the bridge')
|
|
1863
2177
|
}
|
|
1864
|
-
const message = createUserMessage({ content:
|
|
2178
|
+
const message = createUserMessage({ content: blocks, source: { kind: 'user' } })
|
|
1865
2179
|
if (process.env.ACP_DEBUG) process.stderr.write(`[acp-debug] followup queued, agent phase=${record.agent.phase?.kind} inboxPending=${record.agent.inbox?.hasPending}\n`)
|
|
1866
2180
|
const stopReason = await new Promise((resolve, reject) => {
|
|
1867
2181
|
const inflight = {
|
|
@@ -1948,15 +2262,42 @@ export function apply(ctx, config) {
|
|
|
1948
2262
|
applyPermissionPreset(record, value, permission)
|
|
1949
2263
|
break
|
|
1950
2264
|
}
|
|
2265
|
+
case 'agent_preset': {
|
|
2266
|
+
if (typeof value !== 'string' || value.length === 0) {
|
|
2267
|
+
throw invalidParams('agent_preset value must be a non-empty preset id')
|
|
2268
|
+
}
|
|
2269
|
+
const presets = agentPresets()
|
|
2270
|
+
if (presets === undefined) throw internalError('agent presets are not mounted')
|
|
2271
|
+
if (!isBlankSession(record.agent.session)) {
|
|
2272
|
+
throw invalidParams('agent preset can only be switched while the session is blank (no turn has run)')
|
|
2273
|
+
}
|
|
2274
|
+
let preset
|
|
2275
|
+
try {
|
|
2276
|
+
preset = await presets.recompose(record.agent.ctx, value)
|
|
2277
|
+
} catch (error) {
|
|
2278
|
+
// Map the roster's typed failures onto the wire error shape: an
|
|
2279
|
+
// unknown/broken preset id is a client mistake (invalid params),
|
|
2280
|
+
// while a composition that fails to mount is a server fault.
|
|
2281
|
+
const detail = error instanceof Error ? error.message : String(error)
|
|
2282
|
+
if (error instanceof UnknownPresetError || error instanceof PresetMountError) {
|
|
2283
|
+
throw invalidParams(detail)
|
|
2284
|
+
}
|
|
2285
|
+
throw internalError(detail)
|
|
2286
|
+
}
|
|
2287
|
+
// The switch is a logged session fact (model-visible ⟺ logged), so a
|
|
2288
|
+
// resume re-resolves the NEW composition.
|
|
2289
|
+
record.agent.session.append('agent-preset/selected', { agentPreset: preset.id })
|
|
2290
|
+
break
|
|
2291
|
+
}
|
|
1951
2292
|
case 'plan_mode': {
|
|
1952
2293
|
if (typeof value !== 'boolean') throw invalidParams('plan_mode value must be a boolean')
|
|
1953
|
-
const planMode =
|
|
2294
|
+
const planMode = serviceForAgent(record.agent, 'planMode')
|
|
1954
2295
|
if (planMode === undefined) throw internalError('plan mode is not mounted')
|
|
1955
2296
|
planMode.set(record.agent, value)
|
|
1956
2297
|
break
|
|
1957
2298
|
}
|
|
1958
2299
|
default:
|
|
1959
|
-
throw invalidParams(`unknown config option "${params.configId}" (available: model, reasoning_effort, permission_preset, plan_mode)`)
|
|
2300
|
+
throw invalidParams(`unknown config option "${params.configId}" (available: model, reasoning_effort, permission_preset, agent_preset, plan_mode)`)
|
|
1960
2301
|
}
|
|
1961
2302
|
const configOptions = await broadcastConfig(record)
|
|
1962
2303
|
return { configOptions }
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Terminal-card wire helpers for the ACP bridge — the pure slice of the
|
|
3
|
+
* codex-acp-style bash/pwsh presentation. Kept separate from `index.js` so the
|
|
4
|
+
* parsing and mapping rules are unit-testable without a live profile (mirrors
|
|
5
|
+
* `codec.js` for the image surface).
|
|
6
|
+
*
|
|
7
|
+
* @module dsh-acp-enhanced/terminal-codec
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { isAbsolute, resolve } from 'node:path'
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Map a dsh tool name to the ACP ToolKind used for icons and card UX.
|
|
14
|
+
*
|
|
15
|
+
* Kind and content must agree: Zed treats kind == 'execute' as a terminal tool
|
|
16
|
+
* and kind == 'edit' as a diff tool, and for both it HIDES the rawInput
|
|
17
|
+
* section. Genuine terminals (`zed_terminal`, plus the model-facing shell
|
|
18
|
+
* executors `bash`/`pwsh`) map to 'execute': for bash/pwsh the bridge emits
|
|
19
|
+
* the codex-acp terminal-card wire shape (terminal content + terminal_info /
|
|
20
|
+
* terminal_output / terminal_exit meta), so the command and output render in a
|
|
21
|
+
* terminal panel instead of a raw-JSON card. Write/edit tools map to 'edit'
|
|
22
|
+
* only because the bridge always pairs them with a `diff` content block — the
|
|
23
|
+
* diff replaces the raw dump as the card body. Remaining local executors like
|
|
24
|
+
* run_code stay 'other' and carry the command as a markdown code block,
|
|
25
|
+
* keeping the raw sections available too.
|
|
26
|
+
*/
|
|
27
|
+
export function toolKindFor(name) {
|
|
28
|
+
if (name === 'bash' || name === 'pwsh' || name === 'zed_terminal') return 'execute'
|
|
29
|
+
if (/^fs_.*read|read_text|cat|show/.test(name)) return 'read'
|
|
30
|
+
if (/search|find|grep/.test(name)) return 'search'
|
|
31
|
+
if (/fetch|http/.test(name)) return 'fetch'
|
|
32
|
+
if (/think/.test(name)) return 'think'
|
|
33
|
+
if (/write|edit|patch|apply/.test(name)) return 'edit'
|
|
34
|
+
return 'other'
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Whether a tool's result is presented through the Zed terminal panel. Only
|
|
38
|
+
* the model-facing shell executors are terminal-presented; `zed_terminal`
|
|
39
|
+
* runs client-side and its result comes from the client, not from dsh. */
|
|
40
|
+
export function isTerminalToolName(name) {
|
|
41
|
+
return name === 'bash' || name === 'pwsh'
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Strip a `bash -lc`-style shell prefix from a command line, codex-acp style.
|
|
45
|
+
* Handles both single- and double-quoted payloads so the terminal card title
|
|
46
|
+
* shows the command itself, not the wrapping evaluator. */
|
|
47
|
+
export function stripShellPrefix(command) {
|
|
48
|
+
const withoutShell = String(command ?? '').replace(/^(?:\/bin\/)?(?:bash|zsh|sh)\s+(?:-[lc]+\s+)?/, '')
|
|
49
|
+
const wrapped = withoutShell
|
|
50
|
+
if (wrapped.length >= 2
|
|
51
|
+
&& ((wrapped.startsWith("'") && wrapped.endsWith("'"))
|
|
52
|
+
|| (wrapped.startsWith('"') && wrapped.endsWith('"')))) {
|
|
53
|
+
return wrapped.slice(1, -1)
|
|
54
|
+
}
|
|
55
|
+
return withoutShell
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Full text of a dsh tool result, or `undefined` when the tool failed. */
|
|
59
|
+
export function resultText(event) {
|
|
60
|
+
if (event.data.error !== undefined) return undefined
|
|
61
|
+
const parts = []
|
|
62
|
+
for (const block of event.data.message?.content ?? []) {
|
|
63
|
+
for (const inner of block?.content ?? []) {
|
|
64
|
+
if (inner?.type === 'text' && typeof inner.text === 'string') parts.push(inner.text)
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return parts.join('\n')
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Recover the terminal exit pill from a rendered shell-tool result — the
|
|
72
|
+
* inverse of the `[exit code: N]` / `[killed by signal: X]` markers the shell
|
|
73
|
+
* tools append (guaranteed to be the last line, prefixed with a newline).
|
|
74
|
+
* Returns the marker-free body plus exit code/signal; a body without a marker
|
|
75
|
+
* is a clean exit with code 0.
|
|
76
|
+
*/
|
|
77
|
+
export function parseShellExitStatus(text) {
|
|
78
|
+
const signal = /\n\[killed by signal: ([^\]\n]+)\]$/.exec(text)
|
|
79
|
+
if (signal?.[1] !== undefined) {
|
|
80
|
+
return { body: text.slice(0, signal.index), exitCode: 0, signal: signal[1] }
|
|
81
|
+
}
|
|
82
|
+
const exit = /\n\[exit code: (\d+)\]$/.exec(text)
|
|
83
|
+
if (exit?.[1] !== undefined) {
|
|
84
|
+
return { body: text.slice(0, exit.index), exitCode: Number(exit[1]), signal: null }
|
|
85
|
+
}
|
|
86
|
+
return { body: text, exitCode: 0, signal: null }
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Resolve the cwd a bash/pwsh call runs in, for the terminal_info card. */
|
|
90
|
+
export function shellCallCwd(args, session) {
|
|
91
|
+
const headerCwd = session?.header?.cwd
|
|
92
|
+
if (typeof args === 'object' && args !== null && typeof args.workdir === 'string' && args.workdir.length > 0) {
|
|
93
|
+
return isAbsolute(args.workdir) ? args.workdir : headerCwd === undefined ? args.workdir : resolve(headerCwd, args.workdir)
|
|
94
|
+
}
|
|
95
|
+
return headerCwd ?? process.cwd()
|
|
96
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-acp-enhanced",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.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",
|
|
@@ -43,12 +43,14 @@
|
|
|
43
43
|
"@deepseek-ai/cordis-plugin-loader": "^1.0.2-rc.1",
|
|
44
44
|
"@deepseek-ai/dsh-agent": "^0.1.0-rc.6",
|
|
45
45
|
"@deepseek-ai/dsh-agent-instructions": "^0.1.0-rc.6",
|
|
46
|
+
"@deepseek-ai/dsh-agent-presets": "^0.1.0-rc.6",
|
|
46
47
|
"@deepseek-ai/dsh-invariants": "^0.1.0-rc.6",
|
|
47
48
|
"@deepseek-ai/dsh-llm": "^0.1.0-rc.6",
|
|
48
49
|
"@deepseek-ai/dsh-mcp-client": "^0.1.0-rc.6",
|
|
49
50
|
"@deepseek-ai/dsh-permission-presets": "^0.1.0-rc.6",
|
|
50
51
|
"@deepseek-ai/dsh-session": "^0.1.0-rc.6",
|
|
51
52
|
"@deepseek-ai/dsh-session-query": "^0.1.0-rc.6",
|
|
53
|
+
"@deepseek-ai/dsh-skill": "^0.1.0-rc.6",
|
|
52
54
|
"@deepseek-ai/dsh-tools": "^0.1.0-rc.6",
|
|
53
55
|
"@deepseek-ai/dsh-user-approval": "^0.1.0-rc.6"
|
|
54
56
|
},
|
|
@@ -58,16 +60,28 @@
|
|
|
58
60
|
"@deepseek-ai/cordis-plugin-loader": "1.0.2-rc.4",
|
|
59
61
|
"@deepseek-ai/dsh-agent": "0.1.0-rc.6",
|
|
60
62
|
"@deepseek-ai/dsh-agent-instructions": "0.1.0-rc.6",
|
|
63
|
+
"@deepseek-ai/dsh-agent-presets": "0.1.0-rc.6",
|
|
61
64
|
"@deepseek-ai/dsh-invariants": "0.1.0-rc.6",
|
|
62
65
|
"@deepseek-ai/dsh-llm": "0.1.0-rc.6",
|
|
63
66
|
"@deepseek-ai/dsh-mcp-client": "0.1.0-rc.6",
|
|
64
67
|
"@deepseek-ai/dsh-permission-presets": "0.1.0-rc.6",
|
|
65
68
|
"@deepseek-ai/dsh-session": "0.1.0-rc.6",
|
|
66
69
|
"@deepseek-ai/dsh-session-query": "0.1.0-rc.6",
|
|
70
|
+
"@deepseek-ai/dsh-skill": "0.1.0-rc.6",
|
|
67
71
|
"@deepseek-ai/dsh-tools": "0.1.0-rc.6",
|
|
68
72
|
"@deepseek-ai/dsh-user-approval": "0.1.0-rc.6"
|
|
69
73
|
},
|
|
70
74
|
"license": "MIT",
|
|
75
|
+
"author": {
|
|
76
|
+
"name": "grunmin",
|
|
77
|
+
"url": "https://github.com/grunmin"
|
|
78
|
+
},
|
|
79
|
+
"contributors": [
|
|
80
|
+
{
|
|
81
|
+
"name": "Mickey Sun",
|
|
82
|
+
"url": "https://github.com/sunstrikes"
|
|
83
|
+
}
|
|
84
|
+
],
|
|
71
85
|
"repository": {
|
|
72
86
|
"type": "git",
|
|
73
87
|
"url": "git+https://github.com/grunmin/dsh-acp-enhanced.git"
|