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 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
- shell 命令渲染为高亮代码块并在下方附输出、涉及文件以**可点击路径**呈现(点击直达);
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
- (`/compact` `/goal` `/permission` `/plan`…)直通 harness 命令注册表,全部**不经过
57
- 模型 turn** 即时执行;未解析的 slash 放行给模型(`/skill-name` 技能手势)
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" // 官方模型 id
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
- > 一目了然。API key 不必写进 Zed:存入 `~/.dsh/.credentials.yaml`
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
- 仅 baseline prompt(无图片/音频附件)、文本按块粒度流式、每会话同时一个 in-flight
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**, shell commands as a syntax-highlighted code block with the output
39
- beneath, and touched files as **clickable locations** that open the file —
40
- with `rawInput` / `rawOutput` kept one click away for transparency, plus
41
- per-kind icons and a proper in-progress → completed/failed status lifecycle
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 (listings
64
- render as monospace code blocks — readable at a glance), everything
65
- else (`/compact` `/goal` `/permission` `/plan`…) runs straight through the harness
66
- command registry — all executed **without a model turn**; unresolved slashes fall
67
- through to the model (the `/skill-name` skill gesture)
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" // the official model id
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. The API key does not have to live in Zed:
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
- Baseline prompts only (no image/audio attachments), text streams at block granularity,
237
- one in-flight prompt per session. MCP supports stdio and streamable HTTP (legacy SSE /
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
- # Both default to the DeepSeek official route. Overriding `agent-default-model`
15
- # keeps the bridge's per-session selection fallback on the same route (before
16
- # any request header exists), so the first prompt never falls back to a
17
- # non-routable default.
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 { acpPromptToText, promptHasUnsupportedContent, sanitizeWireTitle, turnEndToStopReason, usageTelemetry } from './codec.js'
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 planMode = ctx.get('planMode')
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
- promptCapabilities: { image: false, audio: false, embeddedContext: false },
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: { cwd: params.cwd },
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
- if (promptHasUnsupportedContent(params.prompt)) {
1814
- throw invalidParams('only text and resource_link prompt content is supported')
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: [{ type: 'text', text }], source: { kind: 'user' } })
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 = ctx.get('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.6",
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"