dsh-acp-enhanced 0.4.0 → 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 +31 -6
- package/README.md +41 -11
- package/cordis.patch.yml +109 -4
- package/lib/index.js +325 -38
- package/lib/terminal-codec.js +96 -0
- package/package.json +15 -1
package/README-zh.md
CHANGED
|
@@ -31,13 +31,22 @@ ACP 线上。
|
|
|
31
31
|
绝不出现空的 "unknown" 选择
|
|
32
32
|
- **权限预设**:read-only / workspace-write / full-access 三种会话模式
|
|
33
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
|
+
历史记录永远不会横跨两套工具面
|
|
34
41
|
|
|
35
42
|
### Zed 深度集成
|
|
36
43
|
|
|
37
44
|
- **工具卡片**:折叠态即显示一行摘要——`Read <路径>`、shell 命令显示模型自己给出的意图描述
|
|
38
45
|
(`description`,Codex 风格,展开可见完整命令)、`Search: <模式>`、
|
|
39
46
|
`Fetch: <URL>` 等。卡片正文遵循 ACP 最佳实践:文件编辑渲染为真实 **diff 视图**、
|
|
40
|
-
|
|
47
|
+
**bash/pwsh 命令渲染为真实终端卡片**(codex-acp 线格式:命令 + 输出 + 退出码
|
|
48
|
+
pill 都在终端面板里,告别 raw-JSON 卡片)、其他执行器渲染为高亮代码块并在下方
|
|
49
|
+
附输出、涉及文件以**可点击路径**呈现(点击直达);
|
|
41
50
|
`rawInput` / `rawOutput` 保留在展开区备查,按工具类型渲染图标,
|
|
42
51
|
状态机为进行中 → 完成/失败
|
|
43
52
|
- **Zed 文件与终端**:`zed_read_text_file` / `zed_write_text_file` / `zed_terminal` 把
|
|
@@ -57,9 +66,11 @@ ACP 线上。
|
|
|
57
66
|
### 命令
|
|
58
67
|
|
|
59
68
|
- **Slash 命令**:输入 `/` 即可见命令列表(`available_commands_update`):`/status`
|
|
60
|
-
查看路由与遥测、`/model`
|
|
61
|
-
|
|
62
|
-
|
|
69
|
+
查看路由与遥测、`/model` 列出或切换模型、`/preset` 列出或切换 agent 预设
|
|
70
|
+
(列表以等宽代码块排版,一眼全见),其余(`/compact` `/goal` `/permission`
|
|
71
|
+
`/plan`…)直通 harness 命令注册表,全部**不经过模型 turn** 即时执行。所有
|
|
72
|
+
userInvocable 技能也会作为命令广播,`/ask-matt`、`/code-review`、`/tdd` 等能被
|
|
73
|
+
编辑器放行到达桥,技能正文按 dsh-tool-skill 的用户调用方式注入消息
|
|
63
74
|
|
|
64
75
|
### MCP
|
|
65
76
|
|
|
@@ -110,7 +121,8 @@ Zed 会用极简 PATH 拉起 agent,因此用随附启动器 `scripts/dsh-acp-z
|
|
|
110
121
|
"args": ["/absolute/path/to/dsh-acp-enhanced/scripts/dsh-acp-zed.sh"],
|
|
111
122
|
"env": {
|
|
112
123
|
"DSH_ACP_PROVIDER": "deepseek-official", // 官方 provider id
|
|
113
|
-
"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 / 自定义)
|
|
114
126
|
}
|
|
115
127
|
}
|
|
116
128
|
}
|
|
@@ -118,7 +130,8 @@ Zed 会用极简 PATH 拉起 agent,因此用随附启动器 `scripts/dsh-acp-z
|
|
|
118
130
|
```
|
|
119
131
|
|
|
120
132
|
> 这两项 env 与包自带 patch 的缺省值一致,**省略也能工作**——显式写上只是让路由意图
|
|
121
|
-
>
|
|
133
|
+
> 一目了然。`DSH_ACP_PRESET` 在名册侧默认 `standard`;想让每个新会话从一开始就是
|
|
134
|
+
> 某个特定模式就设置它。API key 不必写进 Zed:存入 `~/.dsh/.credentials.yaml`
|
|
122
135
|
> (`DEEPSEEK_API_KEY`)由 dsh 凭据服务解析即可;启动脚本还会兜底继承正在运行的
|
|
123
136
|
> `dsh web` 进程的 key。
|
|
124
137
|
|
|
@@ -129,6 +142,7 @@ Zed 会用极简 PATH 拉起 agent,因此用随附启动器 `scripts/dsh-acp-z
|
|
|
129
142
|
// ...上面的 type/command/args/env...
|
|
130
143
|
"default_config_options": {
|
|
131
144
|
"model": "deepseek-official/deepseek-v4-flash",
|
|
145
|
+
"agent_preset": "standard",
|
|
132
146
|
"plan_mode": false,
|
|
133
147
|
"reasoning_effort": "high"
|
|
134
148
|
},
|
|
@@ -217,6 +231,7 @@ node scripts/acp-mcp-test.mjs # MCP 挂载测试(无模型调用)
|
|
|
217
231
|
node scripts/acp-smoke-keyless.mjs # keyless 冒烟(CI 用)
|
|
218
232
|
node scripts/acp-resume-test.mjs # 会话恢复测试
|
|
219
233
|
node scripts/codec-image-test.mjs # 图片编解码单元测试(无网络,假 store)
|
|
234
|
+
node scripts/terminal-codec-test.mjs # 终端卡片编解码单元测试(无网络)
|
|
220
235
|
node scripts/acp-image-e2e.mjs # 图片能力端到端(vision 模型段需 API key)
|
|
221
236
|
```
|
|
222
237
|
|
|
@@ -232,3 +247,13 @@ prompt。MCP 支持 stdio 与 streamable HTTP(不声明 legacy SSE / `acp` 传
|
|
|
232
247
|
用;`workspace-write` 下对附加根的写入会先被拒绝、需升级/批准,`danger-full-access`
|
|
233
248
|
下所有根均可写。真正的多根写支持需改 dsh 核心(`dsh-sandbox-policy` /
|
|
234
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
|
@@ -34,6 +34,14 @@ over the ACP wire.
|
|
|
34
34
|
its first offered effort — instead of an empty "unknown" selection
|
|
35
35
|
- **Permission presets**: read-only / workspace-write / full-access session modes
|
|
36
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.
|
|
37
45
|
|
|
38
46
|
### Zed deep integration
|
|
39
47
|
|
|
@@ -41,10 +49,12 @@ over the ACP wire.
|
|
|
41
49
|
model's own intent line for shell commands (`description`, Codex-style — the
|
|
42
50
|
exact command stays one click away), `Search: <pattern>`, `Fetch: <url>`, etc.
|
|
43
51
|
The card body follows the ACP best practice: file edits render as a real
|
|
44
|
-
**diff**,
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
|
48
58
|
- **Zed files & terminal**: `zed_read_text_file` / `zed_write_text_file` / `zed_terminal`
|
|
49
59
|
put file edits into Zed's "edited files" area (diff + accept/reject) and commands into a
|
|
50
60
|
real Zed terminal
|
|
@@ -66,11 +76,13 @@ over the ACP wire.
|
|
|
66
76
|
### Commands
|
|
67
77
|
|
|
68
78
|
- **Slash commands**: typing `/` reveals the command list (`available_commands_update`):
|
|
69
|
-
`/status` shows the route and telemetry, `/model` lists or switches the model
|
|
70
|
-
render as monospace code blocks — readable
|
|
71
|
-
else (`/compact` `/goal` `/permission` `/plan`…) runs straight
|
|
72
|
-
command registry — all executed **without a model turn
|
|
73
|
-
|
|
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)
|
|
74
86
|
|
|
75
87
|
### MCP
|
|
76
88
|
|
|
@@ -124,7 +136,8 @@ Zed spawns agents with a minimal PATH, so use the shipped launcher
|
|
|
124
136
|
"args": ["/absolute/path/to/dsh-acp-enhanced/scripts/dsh-acp-zed.sh"],
|
|
125
137
|
"env": {
|
|
126
138
|
"DSH_ACP_PROVIDER": "deepseek-official", // the official provider id
|
|
127
|
-
"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)
|
|
128
141
|
}
|
|
129
142
|
}
|
|
130
143
|
}
|
|
@@ -132,7 +145,9 @@ Zed spawns agents with a minimal PATH, so use the shipped launcher
|
|
|
132
145
|
```
|
|
133
146
|
|
|
134
147
|
> Both env vars match the shipped patch's defaults, so **they can be omitted entirely** —
|
|
135
|
-
> 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:
|
|
136
151
|
> store it in `~/.dsh/.credentials.yaml` (`DEEPSEEK_API_KEY`) and the dsh credentials
|
|
137
152
|
> service resolves it; the launcher also falls back to a running `dsh web` process's key.
|
|
138
153
|
|
|
@@ -143,6 +158,7 @@ Optional: pin the panel's default config options (all still changeable in the pa
|
|
|
143
158
|
// ...the type/command/args/env above...
|
|
144
159
|
"default_config_options": {
|
|
145
160
|
"model": "deepseek-official/deepseek-v4-flash",
|
|
161
|
+
"agent_preset": "standard",
|
|
146
162
|
"plan_mode": false,
|
|
147
163
|
"reasoning_effort": "high"
|
|
148
164
|
},
|
|
@@ -236,6 +252,7 @@ node scripts/acp-mcp-test.mjs # MCP mount test (no model calls)
|
|
|
236
252
|
node scripts/acp-smoke-keyless.mjs # keyless boot smoke (CI)
|
|
237
253
|
node scripts/acp-resume-test.mjs # session resume test
|
|
238
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)
|
|
239
256
|
node scripts/acp-image-e2e.mjs # image capability e2e (vision-model leg needs an API key)
|
|
240
257
|
```
|
|
241
258
|
|
|
@@ -255,3 +272,16 @@ work in every root; under `workspace-write` a write under an additional root is
|
|
|
255
272
|
first and needs escalation/approval, while `danger-full-access` writes everywhere.
|
|
256
273
|
True multi-root write enforcement belongs in dsh core (`dsh-sandbox-policy` /
|
|
257
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/index.js
CHANGED
|
@@ -45,17 +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 {
|
|
51
|
-
|
|
52
|
-
convertPrompt,
|
|
53
|
-
PromptImageError,
|
|
54
|
-
sanitizeWireTitle,
|
|
55
|
-
turnEndToStopReason,
|
|
56
|
-
UnsupportedPromptContentError,
|
|
57
|
-
usageTelemetry,
|
|
58
|
-
} 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'
|
|
59
54
|
|
|
60
55
|
/** Agent version advertised on the ACP wire — read from package.json so the
|
|
61
56
|
* handshake can never drift from the released package version. */
|
|
@@ -112,7 +107,7 @@ function rememberEffort(provider, model, effort) {
|
|
|
112
107
|
|
|
113
108
|
export const name = 'acp-enhanced'
|
|
114
109
|
/** The bridge creates and owns agents; every other concern is carried by the composition. */
|
|
115
|
-
export const inject = ['agents', 'llm', 'approval', 'tools', 'commands', 'systemPrompt']
|
|
110
|
+
export const inject = ['agents', 'llm', 'approval', 'tools', 'commands', 'skills', 'systemPrompt']
|
|
116
111
|
|
|
117
112
|
export const Config = Schema.object({
|
|
118
113
|
/** Initial provider route for every created agent. */
|
|
@@ -126,6 +121,11 @@ export const Config = Schema.object({
|
|
|
126
121
|
* but fail to dispatch with a MISSING_CREDENTIAL error. Set true to list
|
|
127
122
|
* every served provider's models regardless. */
|
|
128
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),
|
|
129
129
|
})
|
|
130
130
|
|
|
131
131
|
/** Preserve invalid-parameter detail in the SDK wire error message. */
|
|
@@ -138,27 +138,6 @@ function internalError(detail) {
|
|
|
138
138
|
return RequestError.internalError(undefined, detail)
|
|
139
139
|
}
|
|
140
140
|
|
|
141
|
-
/**
|
|
142
|
-
* Map a dsh tool name to the ACP ToolKind used for icons and card UX.
|
|
143
|
-
*
|
|
144
|
-
* Kind and content must agree: Zed treats kind == 'execute' as a terminal tool
|
|
145
|
-
* and kind == 'edit' as a diff tool, and for both it HIDES the rawInput
|
|
146
|
-
* section. `zed_terminal` is genuinely terminal; write/edit tools map to
|
|
147
|
-
* 'edit' only because the bridge always pairs them with a `diff` content
|
|
148
|
-
* block (see toolCallContentFor) — the diff replaces the raw dump as the card
|
|
149
|
-
* body. Local executors like bash/run_code stay 'other' and carry the command
|
|
150
|
-
* as a markdown code block, keeping the raw sections available too.
|
|
151
|
-
*/
|
|
152
|
-
function toolKindFor(name) {
|
|
153
|
-
if (name === 'zed_terminal') return 'execute'
|
|
154
|
-
if (/^fs_.*read|read_text|cat|show/.test(name)) return 'read'
|
|
155
|
-
if (/search|find|grep/.test(name)) return 'search'
|
|
156
|
-
if (/fetch|http/.test(name)) return 'fetch'
|
|
157
|
-
if (/think/.test(name)) return 'think'
|
|
158
|
-
if (/write|edit|patch|apply/.test(name)) return 'edit'
|
|
159
|
-
return 'other'
|
|
160
|
-
}
|
|
161
|
-
|
|
162
141
|
/** Parse a tool's raw arguments JSON into a JSON value for ACP rawInput. */
|
|
163
142
|
function parseToolArguments(raw) {
|
|
164
143
|
try {
|
|
@@ -418,6 +397,7 @@ export function apply(ctx, config) {
|
|
|
418
397
|
const approval = ctx.approval
|
|
419
398
|
const tools = ctx.tools
|
|
420
399
|
const commands = ctx.commands
|
|
400
|
+
const skills = ctx.skills
|
|
421
401
|
const logger = ctx.logger
|
|
422
402
|
/** The user-questions service (mounted by dsh-base); absent in minimal deployments. */
|
|
423
403
|
const userQuestions = ctx.get('userQuestions')
|
|
@@ -434,6 +414,82 @@ export function apply(ctx, config) {
|
|
|
434
414
|
/** Resolve the permission-presets service, tolerating a lazy mount. */
|
|
435
415
|
const permissionPresets = () => ctx.get('permissionPresets')
|
|
436
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
|
+
|
|
437
493
|
// Multi-root workspaces: describe the session's roots to the model. Zed
|
|
438
494
|
// passes the primary cwd plus every additional workspace root on the
|
|
439
495
|
// session lifecycle requests; the sandbox policy still resolves a single
|
|
@@ -494,6 +550,35 @@ export function apply(ctx, config) {
|
|
|
494
550
|
*/
|
|
495
551
|
function toolCallUpdateFor(record, event) {
|
|
496
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
|
+
}
|
|
497
582
|
const kind = toolKindFor(event.data.name)
|
|
498
583
|
const content = toolCallContentFor(event.data.name, parsedArgs, kind)
|
|
499
584
|
const locations = toolCallLocationsFor(kind, parsedArgs)
|
|
@@ -524,10 +609,48 @@ export function apply(ctx, config) {
|
|
|
524
609
|
/** Wire update for a harness `tool/result`: terminal status plus, for
|
|
525
610
|
* executors, the output as a friendly code block under the command. */
|
|
526
611
|
function toolResultUpdateFor(record, event, callId, elapsed) {
|
|
527
|
-
const preview = resultPreview(event)
|
|
528
612
|
const call = record.callArgs.get(callId)
|
|
529
613
|
record.callArgs.delete(callId)
|
|
530
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)
|
|
531
654
|
let content
|
|
532
655
|
if (!isError && preview !== undefined && call !== undefined
|
|
533
656
|
&& call.name !== 'zed_terminal' && EXECUTOR_NAME.test(call.name)) {
|
|
@@ -962,7 +1085,27 @@ export function apply(ctx, config) {
|
|
|
962
1085
|
}),
|
|
963
1086
|
})
|
|
964
1087
|
}
|
|
965
|
-
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')
|
|
966
1109
|
if (planMode !== undefined) {
|
|
967
1110
|
options.push({
|
|
968
1111
|
id: 'plan_mode',
|
|
@@ -1419,6 +1562,7 @@ export function apply(ctx, config) {
|
|
|
1419
1562
|
const BUILTIN_COMMANDS = [
|
|
1420
1563
|
{ name: 'status', description: 'Show session status: model route, context usage, telemetry.' },
|
|
1421
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).' },
|
|
1422
1566
|
]
|
|
1423
1567
|
|
|
1424
1568
|
/**
|
|
@@ -1442,6 +1586,21 @@ export function apply(ctx, config) {
|
|
|
1442
1586
|
} catch (error) {
|
|
1443
1587
|
logger.warn(`acp-enhanced: command listing failed: ${String(error)}`)
|
|
1444
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
|
+
}
|
|
1445
1604
|
notify({
|
|
1446
1605
|
sessionId: record.agent.session.id,
|
|
1447
1606
|
update: { sessionUpdate: 'available_commands_update', availableCommands: list },
|
|
@@ -1469,6 +1628,7 @@ export function apply(ctx, config) {
|
|
|
1469
1628
|
const last = record.lastUsage
|
|
1470
1629
|
const lines = [
|
|
1471
1630
|
`route ${selection.provider ?? '?'}/${selection.model ?? '?'}`,
|
|
1631
|
+
`preset ${runningPresetOf(record.agent.session) ?? 'host'}`,
|
|
1472
1632
|
`turns ${record.turnCount}`,
|
|
1473
1633
|
...last === undefined ? [] : [
|
|
1474
1634
|
`context ${last.used}/${last.size}`,
|
|
@@ -1500,6 +1660,45 @@ export function apply(ctx, config) {
|
|
|
1500
1660
|
return `switched to ${matches[0].provider}/${matches[0].model}`
|
|
1501
1661
|
}
|
|
1502
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
|
+
|
|
1503
1702
|
/** Refresh every client-visible surface a command may have mutated. */
|
|
1504
1703
|
function refreshAfterCommand(record) {
|
|
1505
1704
|
const permission = permissionPresets()
|
|
@@ -1515,7 +1714,9 @@ export function apply(ctx, config) {
|
|
|
1515
1714
|
broadcastConfig(record).catch((error) => {
|
|
1516
1715
|
logger.warn(`acp-enhanced: config rebroadcast after command failed: ${String(error)}`)
|
|
1517
1716
|
})
|
|
1518
|
-
publishCommands(record)
|
|
1717
|
+
publishCommands(record).catch((error) => {
|
|
1718
|
+
logger.warn(`acp-enhanced: command broadcast after command failed: ${String(error)}`)
|
|
1719
|
+
})
|
|
1519
1720
|
}
|
|
1520
1721
|
|
|
1521
1722
|
// ── session records + history replay ─────────────────────────────────────
|
|
@@ -1742,13 +1943,30 @@ export function apply(ctx, config) {
|
|
|
1742
1943
|
assertOpen()
|
|
1743
1944
|
const additionalDirectories = normalizeSessionParams(params)
|
|
1744
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
|
+
}
|
|
1745
1959
|
const handle = await agents.create({
|
|
1746
1960
|
sessionId,
|
|
1747
|
-
meta: {
|
|
1961
|
+
meta: {
|
|
1962
|
+
cwd: params.cwd,
|
|
1963
|
+
...composition.agentPreset === undefined ? {} : { agentPreset: composition.agentPreset },
|
|
1964
|
+
},
|
|
1748
1965
|
agentOptions: {
|
|
1749
1966
|
...config.provider === undefined ? {} : { provider: config.provider },
|
|
1750
1967
|
...config.model === undefined ? {} : { model: config.model },
|
|
1751
1968
|
},
|
|
1969
|
+
...composition.setup === undefined ? {} : { setup: composition.setup },
|
|
1752
1970
|
})
|
|
1753
1971
|
if (closed) {
|
|
1754
1972
|
await handle.dispose()
|
|
@@ -1803,12 +2021,26 @@ export function apply(ctx, config) {
|
|
|
1803
2021
|
configOptions: await buildConfigOptions(live),
|
|
1804
2022
|
}
|
|
1805
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
|
+
}
|
|
1806
2037
|
const handle = await agents.resume({
|
|
1807
2038
|
resumeSessionId: sessionId,
|
|
1808
2039
|
agentOptions: {
|
|
1809
2040
|
...config.provider === undefined ? {} : { provider: config.provider },
|
|
1810
2041
|
...config.model === undefined ? {} : { model: config.model },
|
|
1811
2042
|
},
|
|
2043
|
+
...composition.setup === undefined ? {} : { setup: composition.setup },
|
|
1812
2044
|
})
|
|
1813
2045
|
if (closed) {
|
|
1814
2046
|
await handle.dispose()
|
|
@@ -1873,7 +2105,9 @@ export function apply(ctx, config) {
|
|
|
1873
2105
|
// Insurance: by the first prompt the client is guaranteed to know the
|
|
1874
2106
|
// session, so re-advertise the command surface (idempotent) — covers
|
|
1875
2107
|
// any client that missed the post-session/new broadcast.
|
|
1876
|
-
publishCommands(record)
|
|
2108
|
+
publishCommands(record).catch((error) => {
|
|
2109
|
+
logger.warn(`acp-enhanced: command broadcast failed: ${String(error)}`)
|
|
2110
|
+
})
|
|
1877
2111
|
|
|
1878
2112
|
// Adapter-level slash commands never reach the model: /status and
|
|
1879
2113
|
// /model are built in, any other registered slash (compact/goal/
|
|
@@ -1897,6 +2131,11 @@ export function apply(ctx, config) {
|
|
|
1897
2131
|
if (commandMatch?.[1] === 'model') {
|
|
1898
2132
|
return respond(await modelCommandText(record, trimmed.slice(commandMatch[0].length).trim()))
|
|
1899
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
|
+
}
|
|
1900
2139
|
if (commandMatch !== null && commandMatch[1] !== undefined) {
|
|
1901
2140
|
let execution
|
|
1902
2141
|
try {
|
|
@@ -1912,6 +2151,27 @@ export function apply(ctx, config) {
|
|
|
1912
2151
|
}
|
|
1913
2152
|
}
|
|
1914
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
|
+
|
|
1915
2175
|
if (ctx.agents.get(record.agent.id) !== record.agent) {
|
|
1916
2176
|
throw internalError('prompt was not queued: the agent was disposed outside the bridge')
|
|
1917
2177
|
}
|
|
@@ -2002,15 +2262,42 @@ export function apply(ctx, config) {
|
|
|
2002
2262
|
applyPermissionPreset(record, value, permission)
|
|
2003
2263
|
break
|
|
2004
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
|
+
}
|
|
2005
2292
|
case 'plan_mode': {
|
|
2006
2293
|
if (typeof value !== 'boolean') throw invalidParams('plan_mode value must be a boolean')
|
|
2007
|
-
const planMode =
|
|
2294
|
+
const planMode = serviceForAgent(record.agent, 'planMode')
|
|
2008
2295
|
if (planMode === undefined) throw internalError('plan mode is not mounted')
|
|
2009
2296
|
planMode.set(record.agent, value)
|
|
2010
2297
|
break
|
|
2011
2298
|
}
|
|
2012
2299
|
default:
|
|
2013
|
-
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)`)
|
|
2014
2301
|
}
|
|
2015
2302
|
const configOptions = await broadcastConfig(record)
|
|
2016
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"
|