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 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
- shell 命令渲染为高亮代码块并在下方附输出、涉及文件以**可点击路径**呈现(点击直达);
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
- (`/compact` `/goal` `/permission` `/plan`…)直通 harness 命令注册表,全部**不经过
62
- 模型 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 的用户调用方式注入消息
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" // 官方模型 id
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
- > 一目了然。API key 不必写进 Zed:存入 `~/.dsh/.credentials.yaml`
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**, shell commands as a syntax-highlighted code block with the output
45
- beneath, and touched files as **clickable locations** that open the file —
46
- with `rawInput` / `rawOutput` kept one click away for transparency, plus
47
- 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
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 (listings
70
- render as monospace code blocks — readable at a glance), everything
71
- else (`/compact` `/goal` `/permission` `/plan`…) runs straight through the harness
72
- command registry — all executed **without a model turn**; unresolved slashes fall
73
- 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)
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" // 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)
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. 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:
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
- # 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/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
- attachmentIngestOf,
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 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')
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: { cwd: params.cwd },
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 = ctx.get('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.4.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"