dsh-acp-enhanced 0.3.2 → 0.3.6
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 +26 -10
- package/README.md +30 -10
- package/lib/index.js +349 -81
- package/package.json +1 -1
- package/scripts/dsh-acp-zed.sh +11 -0
package/README-zh.md
CHANGED
|
@@ -20,15 +20,21 @@ ACP 线上。
|
|
|
20
20
|
### 模型与权限
|
|
21
21
|
|
|
22
22
|
- **模型切换**:实时 `provider/model` 目录下拉(按 ACP 规范分组线格式)
|
|
23
|
-
- **推理强度**:`reasoning_effort` 下拉——仅当当前路由暴露可选 efforts
|
|
23
|
+
- **推理强度**:`reasoning_effort` 下拉——仅当当前路由暴露可选 efforts 时出现;
|
|
24
|
+
每个模型都会记住它上次使用的强度(按 profile 持久化),切回时自动恢复,
|
|
25
|
+
首次切换的模型则回退到它自己的默认值——没有默认值时取第一个可选值,
|
|
26
|
+
绝不出现空的 "unknown" 选择
|
|
24
27
|
- **权限预设**:read-only / workspace-write / full-access 三种会话模式
|
|
25
28
|
- **审批**:工具调用弹出原生 allow-once / reject-once 审批
|
|
26
29
|
|
|
27
30
|
### Zed 深度集成
|
|
28
31
|
|
|
29
|
-
- **工具卡片**:折叠态即显示一行摘要——`Read
|
|
30
|
-
|
|
31
|
-
|
|
32
|
+
- **工具卡片**:折叠态即显示一行摘要——`Read <路径>`、shell 命令显示模型自己给出的意图描述
|
|
33
|
+
(`description`,Codex 风格,展开可见完整命令)、`Search: <模式>`、
|
|
34
|
+
`Fetch: <URL>` 等。卡片正文遵循 ACP 最佳实践:文件编辑渲染为真实 **diff 视图**、
|
|
35
|
+
shell 命令渲染为高亮代码块并在下方附输出、涉及文件以**可点击路径**呈现(点击直达);
|
|
36
|
+
`rawInput` / `rawOutput` 保留在展开区备查,按工具类型渲染图标,
|
|
37
|
+
状态机为进行中 → 完成/失败
|
|
32
38
|
- **Zed 文件与终端**:`zed_read_text_file` / `zed_write_text_file` / `zed_terminal` 把
|
|
33
39
|
文件编辑放进 Zed 的"编辑文件"区(diff + 接受/拒绝)、命令跑在 Zed 真实终端
|
|
34
40
|
- **原生表单提问**:`ask_user_question` → `elicitation/create` 表单,选项即点即答
|
|
@@ -38,6 +44,10 @@ ACP 线上。
|
|
|
38
44
|
|
|
39
45
|
- **恢复与归档**:`session/load` 恢复历史线程(完整回放);`session/list` /
|
|
40
46
|
`session/delete` 管理线程归档(带标题、按更新时间排序);标题实时推送
|
|
47
|
+
- **多根工作区**:`sessionCapabilities.additionalDirectories` 已声明,Zed 不再提示
|
|
48
|
+
"This agent doesn't currently support multi-root workspaces",而是把所有工作区根
|
|
49
|
+
通过 `session/new` / `session/load` 传入。所有根都会写进系统提示词并在
|
|
50
|
+
`session/list` 上回报;沙箱仍以主 `cwd` 为唯一可写根(见已知限制)
|
|
41
51
|
|
|
42
52
|
### 命令
|
|
43
53
|
|
|
@@ -189,7 +199,8 @@ dsh plugin --profile acp-enhanced add dsh-web-search-openrouter
|
|
|
189
199
|
|---|---|
|
|
190
200
|
| `exec: dsh: not found`(status 127) | 用随附 `dsh-acp-zed.sh` 启动器(自定位 node/dsh) |
|
|
191
201
|
| `no API key for provider route "xxx"` | 写入 `~/.dsh/.credentials.yaml`,或在 agent_servers 里设 `env.DEEPSEEK_API_KEY` |
|
|
192
|
-
| 无法切换模型
|
|
202
|
+
| 无法切换模型 | 保存的 `reasoning_effort` 默认值(或会话当前 effort)被带到新模型上。0.3.6 起本桥按模型记住上次使用的强度(随 profile 持久化):不被新模型支持的 effort 会被该模型记忆值替换——没有记忆则回退其默认值,再无默认则取第一个可选值,既不会切换失败也不会出现 "unknown"。另检查:是否选到了不可路由的"幽灵 provider"——本桥默认过滤(只广播 `config.provider` 的模型),确认 profile 的 provider 指向真实路由 |
|
|
203
|
+
| 上下文用量不显示 | 选到了不可路由的"幽灵 provider";本桥默认过滤(只广播 `config.provider` 的模型),确认 profile 的 provider 指向真实路由 |
|
|
193
204
|
| 需要详细诊断 | `ACP_DEBUG=1 dsh --profile acp-enhanced`(stderr 生命周期 trace) |
|
|
194
205
|
|
|
195
206
|
## 开发
|
|
@@ -204,8 +215,13 @@ node scripts/acp-resume-test.mjs # 会话恢复测试
|
|
|
204
215
|
|
|
205
216
|
## 已知限制
|
|
206
217
|
|
|
207
|
-
仅 baseline prompt
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
218
|
+
仅 baseline prompt(无图片/音频附件)、文本按块粒度流式、每会话同时一个 in-flight
|
|
219
|
+
prompt。MCP 支持 stdio 与 streamable HTTP(不声明 legacy SSE / `acp` 传输)。
|
|
220
|
+
`session/close` / `session/fork` / `session/resume` 未实现(不声明能力,合规客户端
|
|
221
|
+
不会调用);`session/delete` 因 dsh 持久化无官方删除 API,采用直接删除后端目录的方式。
|
|
222
|
+
|
|
223
|
+
多根工作区已声明、模型可见所有根,但 dsh 沙箱策略每会话只解析**一个可写根**(主
|
|
224
|
+
`cwd`,即 `session.header.cwd`),本地沙箱也只为该根开放写权限。读操作在所有根均可
|
|
225
|
+
用;`workspace-write` 下对附加根的写入会先被拒绝、需升级/批准,`danger-full-access`
|
|
226
|
+
下所有根均可写。真正的多根写支持需改 dsh 核心(`dsh-sandbox-policy` /
|
|
227
|
+
`dsh-sandbox-local` 需要根列表而非单根)。
|
package/README.md
CHANGED
|
@@ -23,16 +23,22 @@ over the ACP wire.
|
|
|
23
23
|
|
|
24
24
|
- **Model switching**: live `provider/model` catalog dropdown (ACP grouped-select wire shape)
|
|
25
25
|
- **Reasoning effort**: `reasoning_effort` dropdown — only when the routed model exposes
|
|
26
|
-
selectable efforts
|
|
26
|
+
selectable efforts; each model remembers the effort it last used (persisted per profile),
|
|
27
|
+
so switching back restores it, and a first-time model falls back to its own default — or
|
|
28
|
+
its first offered effort — instead of an empty "unknown" selection
|
|
27
29
|
- **Permission presets**: read-only / workspace-write / full-access session modes
|
|
28
30
|
- **Approval**: native allow-once / reject-once prompts per tool call
|
|
29
31
|
|
|
30
32
|
### Zed deep integration
|
|
31
33
|
|
|
32
34
|
- **Tool cards**: one-line summary in the collapsed header — `Read <path>`, the
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
35
|
+
model's own intent line for shell commands (`description`, Codex-style — the
|
|
36
|
+
exact command stays one click away), `Search: <pattern>`, `Fetch: <url>`, etc.
|
|
37
|
+
The card body follows the ACP best practice: file edits render as a real
|
|
38
|
+
**diff**, shell commands as a syntax-highlighted code block with the output
|
|
39
|
+
beneath, and touched files as **clickable locations** that open the file —
|
|
40
|
+
with `rawInput` / `rawOutput` kept one click away for transparency, plus
|
|
41
|
+
per-kind icons and a proper in-progress → completed/failed status lifecycle
|
|
36
42
|
- **Zed files & terminal**: `zed_read_text_file` / `zed_write_text_file` / `zed_terminal`
|
|
37
43
|
put file edits into Zed's "edited files" area (diff + accept/reject) and commands into a
|
|
38
44
|
real Zed terminal
|
|
@@ -45,6 +51,11 @@ over the ACP wire.
|
|
|
45
51
|
- **Resume & archive**: `session/load` restores past threads (full replay);
|
|
46
52
|
`session/list` / `session/delete` manage the thread archive (titled, sorted by last
|
|
47
53
|
activity); live title updates
|
|
54
|
+
- **Multi-root workspaces**: `sessionCapabilities.additionalDirectories` is advertised,
|
|
55
|
+
so Zed no longer shows "this agent doesn't currently support multi-root workspaces"
|
|
56
|
+
and instead passes every workspace root on `session/new` / `session/load`. All roots
|
|
57
|
+
are described to the model in the system prompt and reported on `session/list`; the
|
|
58
|
+
sandbox keeps the primary `cwd` as its single writable root (see Known limitations)
|
|
48
59
|
|
|
49
60
|
### Commands
|
|
50
61
|
|
|
@@ -206,7 +217,8 @@ dsh plugin --profile acp-enhanced add dsh-web-search-openrouter
|
|
|
206
217
|
|---|---|
|
|
207
218
|
| `exec: dsh: not found` (status 127) | Use the shipped `dsh-acp-zed.sh` launcher (locates node/dsh itself) |
|
|
208
219
|
| `no API key for provider route "xxx"` | Write `~/.dsh/.credentials.yaml`, or set `env.DEEPSEEK_API_KEY` on the agent_servers entry |
|
|
209
|
-
| Cannot switch models
|
|
220
|
+
| Cannot switch models | The saved `reasoning_effort` default (or the session's current effort) is carried onto the new model. Since 0.3.6 the bridge remembers the last effort per model (per-profile JSON): an unsupported carried effort is replaced by that model's remembered effort, else its own default, else its first offered effort — never an "unknown" dropdown, never a failed switch. Also check: a "phantom provider" route was picked — this bridge filters them by default (only `config.provider`'s models are advertised), so point the profile's provider at a real route |
|
|
221
|
+
| Context usage missing | A "phantom provider" route was picked; this bridge filters them by default (only `config.provider`'s models are advertised) — point the profile's provider at a real route |
|
|
210
222
|
| Need detailed diagnostics | `ACP_DEBUG=1 dsh --profile acp-enhanced` (stderr lifecycle trace) |
|
|
211
223
|
|
|
212
224
|
## Development
|
|
@@ -221,9 +233,17 @@ node scripts/acp-resume-test.mjs # session resume test
|
|
|
221
233
|
|
|
222
234
|
## Known limitations
|
|
223
235
|
|
|
224
|
-
Baseline prompts only (no image/audio attachments),
|
|
225
|
-
|
|
226
|
-
|
|
236
|
+
Baseline prompts only (no image/audio attachments), text streams at block granularity,
|
|
237
|
+
one in-flight prompt per session. MCP supports stdio and streamable HTTP (legacy SSE /
|
|
238
|
+
`acp` transports are not advertised).
|
|
227
239
|
`session/close` / `session/fork` / `session/resume` are not implemented (capabilities
|
|
228
|
-
undeclared, compliant clients will not call them); `session/delete` removes the
|
|
229
|
-
directory directly because dsh persistence has no official delete API.
|
|
240
|
+
undeclared, compliant clients will not call them); `session/delete` removes the
|
|
241
|
+
persisted directory directly because dsh persistence has no official delete API.
|
|
242
|
+
|
|
243
|
+
Multi-root workspaces are advertised and all roots are visible to the model, but dsh's
|
|
244
|
+
sandbox policy resolves **one writable root per session** (the primary `cwd`, i.e.
|
|
245
|
+
`session.header.cwd`) and the local sandboxes bind exactly that root for writes. Reads
|
|
246
|
+
work in every root; under `workspace-write` a write under an additional root is denied
|
|
247
|
+
first and needs escalation/approval, while `danger-full-access` writes everywhere.
|
|
248
|
+
True multi-root write enforcement belongs in dsh core (`dsh-sandbox-policy` /
|
|
249
|
+
`dsh-sandbox-local` would need a root list instead of a single root).
|
package/lib/index.js
CHANGED
|
@@ -23,6 +23,12 @@
|
|
|
23
23
|
* - **Session modes**: permission presets are exposed as ACP modes
|
|
24
24
|
* (`session/set_mode`), so the client's mode UI drives the sandbox/approval
|
|
25
25
|
* preset of the session.
|
|
26
|
+
* - **Multi-root workspaces**: `sessionCapabilities.additionalDirectories` is
|
|
27
|
+
* advertised, so Zed passes every workspace root on the session lifecycle
|
|
28
|
+
* requests instead of warning "this agent doesn't currently support
|
|
29
|
+
* multi-root workspaces". All roots are described to the model in the
|
|
30
|
+
* system prompt; the sandbox still enforces one writable root (the primary
|
|
31
|
+
* `cwd`) — writes under an additional root go through escalation.
|
|
26
32
|
*
|
|
27
33
|
* Stdout is reserved for ACP JSON-RPC; diagnostics go to stderr only.
|
|
28
34
|
*
|
|
@@ -30,9 +36,10 @@
|
|
|
30
36
|
*/
|
|
31
37
|
|
|
32
38
|
import { randomUUID } from 'node:crypto'
|
|
33
|
-
import {
|
|
39
|
+
import { readFileSync } from 'node:fs'
|
|
40
|
+
import { rm, writeFile } from 'node:fs/promises'
|
|
34
41
|
import { createRequire } from 'node:module'
|
|
35
|
-
import { isAbsolute, dirname } from 'node:path'
|
|
42
|
+
import { isAbsolute, dirname, join, resolve } from 'node:path'
|
|
36
43
|
import { Readable, Writable } from 'node:stream'
|
|
37
44
|
import Schema from '@deepseek-ai/schemastery'
|
|
38
45
|
import { AgentSideConnection, ndJsonStream, PROTOCOL_VERSION, RequestError } from '@agentclientprotocol/sdk'
|
|
@@ -46,9 +53,58 @@ import { acpPromptToText, promptHasUnsupportedContent, sanitizeWireTitle, turnEn
|
|
|
46
53
|
* handshake can never drift from the released package version. */
|
|
47
54
|
const AGENT_VERSION = createRequire(import.meta.url)('../package.json').version
|
|
48
55
|
|
|
56
|
+
/** State-kept per-model reasoning-effort memory (`provider/model` → effort
|
|
57
|
+
* id). A model switch carries the session's current effort onto the new
|
|
58
|
+
* model; when the target does not offer it, the effort this bridge last
|
|
59
|
+
* successfully applied to that route is restored instead — and switching
|
|
60
|
+
* back restores the effort that model had before, so the editor's dropdown
|
|
61
|
+
* never falls back to an empty ("unknown") selection. Persisted as a small
|
|
62
|
+
* JSON file next to the profile (the shipped Zed launcher exports
|
|
63
|
+
* DSH_ACP_PROFILE_DIR); without that variable the memory simply lives for
|
|
64
|
+
* the process. */
|
|
65
|
+
const effortMemoryFile = process.env.DSH_ACP_PROFILE_DIR !== undefined && process.env.DSH_ACP_PROFILE_DIR.length > 0
|
|
66
|
+
? join(process.env.DSH_ACP_PROFILE_DIR, 'dsh-acp-enhanced-effort-memory.json')
|
|
67
|
+
: undefined
|
|
68
|
+
|
|
69
|
+
function loadEffortMemory() {
|
|
70
|
+
if (effortMemoryFile === undefined) return {}
|
|
71
|
+
try {
|
|
72
|
+
const parsed = JSON.parse(readFileSync(effortMemoryFile, 'utf8'))
|
|
73
|
+
if (parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed)) return parsed
|
|
74
|
+
} catch (error) {
|
|
75
|
+
if (error?.code !== 'ENOENT') {
|
|
76
|
+
console.warn(`acp-enhanced: effort memory unreadable (${effortMemoryFile}): ${String(error)}`)
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return {}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const effortMemory = loadEffortMemory()
|
|
83
|
+
|
|
84
|
+
function writeEffortMemory() {
|
|
85
|
+
if (effortMemoryFile === undefined) return
|
|
86
|
+
writeFile(effortMemoryFile, JSON.stringify(effortMemory, null, 2)).catch((error) => {
|
|
87
|
+
console.warn(`acp-enhanced: effort memory write failed (${effortMemoryFile}): ${String(error)}`)
|
|
88
|
+
})
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** The effort last successfully applied to a route, when one was recorded. */
|
|
92
|
+
function rememberedEffort(provider, model) {
|
|
93
|
+
const effort = effortMemory[`${provider}/${model}`]
|
|
94
|
+
return typeof effort === 'string' && effort.length > 0 ? effort : undefined
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Record the effort applied to a route, persisting when possible. */
|
|
98
|
+
function rememberEffort(provider, model, effort) {
|
|
99
|
+
const key = `${provider}/${model}`
|
|
100
|
+
if (effortMemory[key] === effort) return
|
|
101
|
+
effortMemory[key] = effort
|
|
102
|
+
writeEffortMemory()
|
|
103
|
+
}
|
|
104
|
+
|
|
49
105
|
export const name = 'acp-enhanced'
|
|
50
106
|
/** The bridge creates and owns agents; every other concern is carried by the composition. */
|
|
51
|
-
export const inject = ['agents', 'llm', 'approval', 'tools', 'commands']
|
|
107
|
+
export const inject = ['agents', 'llm', 'approval', 'tools', 'commands', 'systemPrompt']
|
|
52
108
|
|
|
53
109
|
export const Config = Schema.object({
|
|
54
110
|
/** Initial provider route for every created agent. */
|
|
@@ -77,12 +133,13 @@ function internalError(detail) {
|
|
|
77
133
|
/**
|
|
78
134
|
* Map a dsh tool name to the ACP ToolKind used for icons and card UX.
|
|
79
135
|
*
|
|
80
|
-
* Zed treats kind == 'execute' as a
|
|
81
|
-
* diff tool, and for both it HIDES
|
|
82
|
-
*
|
|
83
|
-
*
|
|
84
|
-
* (
|
|
85
|
-
*
|
|
136
|
+
* Kind and content must agree: Zed treats kind == 'execute' as a terminal tool
|
|
137
|
+
* and kind == 'edit' as a diff tool, and for both it HIDES the rawInput
|
|
138
|
+
* section. `zed_terminal` is genuinely terminal; write/edit tools map to
|
|
139
|
+
* 'edit' only because the bridge always pairs them with a `diff` content
|
|
140
|
+
* block (see toolCallContentFor) — the diff replaces the raw dump as the card
|
|
141
|
+
* body. Local executors like bash/run_code stay 'other' and carry the command
|
|
142
|
+
* as a markdown code block, keeping the raw sections available too.
|
|
86
143
|
*/
|
|
87
144
|
function toolKindFor(name) {
|
|
88
145
|
if (name === 'zed_terminal') return 'execute'
|
|
@@ -90,6 +147,7 @@ function toolKindFor(name) {
|
|
|
90
147
|
if (/search|find|grep/.test(name)) return 'search'
|
|
91
148
|
if (/fetch|http/.test(name)) return 'fetch'
|
|
92
149
|
if (/think/.test(name)) return 'think'
|
|
150
|
+
if (/write|edit|patch|apply/.test(name)) return 'edit'
|
|
93
151
|
return 'other'
|
|
94
152
|
}
|
|
95
153
|
|
|
@@ -120,6 +178,94 @@ function mdEscape(text) {
|
|
|
120
178
|
return String(text).replace(/([\\*_`[\]<>])/g, '\\$1')
|
|
121
179
|
}
|
|
122
180
|
|
|
181
|
+
/** Executor-class tool names (command runners) shared by title/content/kind. */
|
|
182
|
+
const EXECUTOR_NAME = /bash|pwsh|powershell|shell|exec|run_code|execute|terminal/
|
|
183
|
+
|
|
184
|
+
/** Fenced-code language hint per executor tool (best effort — highlight only). */
|
|
185
|
+
function executorLang(name) {
|
|
186
|
+
if (/pwsh|powershell/.test(name)) return 'powershell'
|
|
187
|
+
if (/run_code/.test(name)) return 'typescript'
|
|
188
|
+
return 'bash'
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** Shared file-path argument lookup (key order = display preference). */
|
|
192
|
+
function pathArg(obj) {
|
|
193
|
+
for (const key of ['path', 'file_path', 'filePath', 'file', 'target']) {
|
|
194
|
+
if (typeof obj[key] === 'string' && obj[key].trim() !== '') return obj[key].trim()
|
|
195
|
+
}
|
|
196
|
+
return undefined
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** Cap a long text body for a card, marking the cut so nothing looks lost. */
|
|
200
|
+
function boundBody(text, max) {
|
|
201
|
+
const body = String(text ?? '')
|
|
202
|
+
return body.length > max ? `${body.slice(0, max)}\n… (truncated)` : body
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** A markdown fenced code block, widening the fence when the text itself
|
|
206
|
+
* contains ``` runs so the body always renders literally. */
|
|
207
|
+
function codeFence(text, lang) {
|
|
208
|
+
const body = String(text ?? '')
|
|
209
|
+
const ticks = body.includes('```') ? '````' : '```'
|
|
210
|
+
return `${ticks}${lang}\n${boundBody(body, 12000)}\n${ticks}`
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* ACP content blocks giving the card a friendly body — the protocol's
|
|
215
|
+
* best-practice surface (clients render these INSTEAD of the raw JSON dump,
|
|
216
|
+
* which stays available as rawInput/rawOutput for transparency):
|
|
217
|
+
* - write/edit tools → a `diff` block rendered as a real diff card;
|
|
218
|
+
* - executors → the command as a syntax-highlighted code block.
|
|
219
|
+
*/
|
|
220
|
+
function toolCallContentFor(name, argumentsValue, kind) {
|
|
221
|
+
const obj = argumentsValue !== null && typeof argumentsValue === 'object' && !Array.isArray(argumentsValue)
|
|
222
|
+
? argumentsValue
|
|
223
|
+
: {}
|
|
224
|
+
if (kind === 'edit') {
|
|
225
|
+
const path = pathArg(obj)
|
|
226
|
+
if (path === undefined) return undefined
|
|
227
|
+
const newText = ['content', 'new_string', 'new_str', 'newText'].find((key) => typeof obj[key] === 'string')
|
|
228
|
+
if (newText === undefined) return undefined
|
|
229
|
+
const oldKey = ['old_string', 'old_str', 'oldText'].find((key) => typeof obj[key] === 'string')
|
|
230
|
+
return [{
|
|
231
|
+
type: 'diff',
|
|
232
|
+
path,
|
|
233
|
+
newText: boundBody(obj[newText], 8000),
|
|
234
|
+
...oldKey === undefined ? {} : { oldText: boundBody(obj[oldKey], 8000) },
|
|
235
|
+
}]
|
|
236
|
+
}
|
|
237
|
+
if (name !== 'zed_terminal' && EXECUTOR_NAME.test(name)) {
|
|
238
|
+
const command = typeof obj.command === 'string' ? obj.command
|
|
239
|
+
: typeof obj.cmd === 'string' ? obj.cmd
|
|
240
|
+
: typeof obj.script === 'string' ? obj.script
|
|
241
|
+
: Array.isArray(obj.args) && obj.args.every((part) => typeof part === 'string')
|
|
242
|
+
? obj.args.join(' ')
|
|
243
|
+
: undefined
|
|
244
|
+
if (typeof command === 'string' && command.trim() !== '') {
|
|
245
|
+
return [{ type: 'content', content: { type: 'text', text: codeFence(command.trim(), executorLang(name)) } }]
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
return undefined
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Files the call touches, as ACP `locations` — the "follow the agent"
|
|
253
|
+
* surface. Editors render them as clickable chips that open the file (and
|
|
254
|
+
* scroll to `line`), so a read or an edit is one click from its target.
|
|
255
|
+
*/
|
|
256
|
+
function toolCallLocationsFor(kind, argumentsValue) {
|
|
257
|
+
if (kind !== 'read' && kind !== 'edit' && kind !== 'delete' && kind !== 'move') return undefined
|
|
258
|
+
const obj = argumentsValue !== null && typeof argumentsValue === 'object' && !Array.isArray(argumentsValue)
|
|
259
|
+
? argumentsValue
|
|
260
|
+
: {}
|
|
261
|
+
const path = pathArg(obj)
|
|
262
|
+
if (path === undefined) return undefined
|
|
263
|
+
const line = [obj.line, obj.offset].find((value) => (
|
|
264
|
+
typeof value === 'number' && Number.isFinite(value) && value >= 1
|
|
265
|
+
))
|
|
266
|
+
return [{ path, ...line === undefined ? {} : { line } }]
|
|
267
|
+
}
|
|
268
|
+
|
|
123
269
|
/**
|
|
124
270
|
* One-line human-readable summary of a tool call for the ACP `title` field.
|
|
125
271
|
* Zed renders this as the collapsed tool-call header, so a bare tool name
|
|
@@ -146,11 +292,16 @@ function toolCallTitle(name, argumentsValue) {
|
|
|
146
292
|
)
|
|
147
293
|
|
|
148
294
|
// Execute-kind tools (plain-text label): show the command itself.
|
|
149
|
-
if (name === 'zed_terminal' ||
|
|
295
|
+
if (name === 'zed_terminal' || EXECUTOR_NAME.test(name)) {
|
|
150
296
|
// Only zed_terminal maps to kind 'execute' (Zed renders its label as plain
|
|
151
297
|
// text); local executors like bash/run_code are kind 'other' and render as
|
|
152
298
|
// markdown, so their command must be escaped to stay literal.
|
|
153
299
|
const literal = (text) => (name === 'zed_terminal' ? text : mdEscape(text))
|
|
300
|
+
// Codex-style card: the collapsed title is the model's own intent line
|
|
301
|
+
// (bash/pwsh mark `description` required — "Show working tree status"),
|
|
302
|
+
// and the exact command stays visible in rawInput when the card expands.
|
|
303
|
+
const description = str('description')
|
|
304
|
+
if (description) return clipOneLine(literal(description), 100)
|
|
154
305
|
const command = str('command') ?? str('cmd') ?? str('script')
|
|
155
306
|
if (command) return clipOneLine(literal(command), 100)
|
|
156
307
|
const argv = obj.args
|
|
@@ -161,7 +312,7 @@ function toolCallTitle(name, argumentsValue) {
|
|
|
161
312
|
|
|
162
313
|
// File-content tools: `Read <path>` (incl. line range when present).
|
|
163
314
|
if (/read|cat|show|view/.test(name)) {
|
|
164
|
-
const path =
|
|
315
|
+
const path = pathArg(obj)
|
|
165
316
|
if (path) {
|
|
166
317
|
const range = [num('line') ?? num('offset'), num('limit')].filter((value) => value !== undefined)
|
|
167
318
|
const suffix = range.length === 2 && Number.isFinite(range[0]) && Number.isFinite(range[1]) && range[1] > 0
|
|
@@ -175,7 +326,7 @@ function toolCallTitle(name, argumentsValue) {
|
|
|
175
326
|
|
|
176
327
|
// File-modifying tools: `Write <path>` / `Edit <path>`.
|
|
177
328
|
if (/write|edit|patch|apply/.test(name)) {
|
|
178
|
-
const path =
|
|
329
|
+
const path = pathArg(obj)
|
|
179
330
|
if (path) return clipOneLine(`Write ${mdEscape(path)}`, 120)
|
|
180
331
|
}
|
|
181
332
|
|
|
@@ -275,6 +426,24 @@ export function apply(ctx, config) {
|
|
|
275
426
|
/** Resolve the permission-presets service, tolerating a lazy mount. */
|
|
276
427
|
const permissionPresets = () => ctx.get('permissionPresets')
|
|
277
428
|
|
|
429
|
+
// Multi-root workspaces: describe the session's roots to the model. Zed
|
|
430
|
+
// passes the primary cwd plus every additional workspace root on the
|
|
431
|
+
// session lifecycle requests; the sandbox policy still resolves a single
|
|
432
|
+
// writable root (the primary cwd), so the text tells the model how writes
|
|
433
|
+
// behave under each policy. Reads are unrestricted by the file sandbox.
|
|
434
|
+
ctx.systemPrompt?.context({
|
|
435
|
+
name: 'acp:workspace-roots',
|
|
436
|
+
order: 115,
|
|
437
|
+
text: (context) => {
|
|
438
|
+
const session = context.agent?.session
|
|
439
|
+
if (session === undefined) return ''
|
|
440
|
+
return renderWorkspaceRoots(
|
|
441
|
+
session.header.cwd,
|
|
442
|
+
sessions.get(session.id)?.additionalDirectories ?? [],
|
|
443
|
+
)
|
|
444
|
+
},
|
|
445
|
+
})
|
|
446
|
+
|
|
278
447
|
/** Return the bridge-owned record for an agent, rejecting same-id impostors. */
|
|
279
448
|
const ownedRecord = (agent) => {
|
|
280
449
|
const record = sessions.get(agent.session.id)
|
|
@@ -309,6 +478,70 @@ export function apply(ctx, config) {
|
|
|
309
478
|
inflight.reject(internalError(`turn failed: ${reason.error.message}`))
|
|
310
479
|
}
|
|
311
480
|
|
|
481
|
+
/**
|
|
482
|
+
* Wire update for a harness `tool/call`: the one-line title header plus the
|
|
483
|
+
* protocol's friendly-body surfaces — `content` (diff for file edits, the
|
|
484
|
+
* command as a code block for executors), `locations` (clickable file chips),
|
|
485
|
+
* and `rawInput`/`rawOutput`-class transparency fields.
|
|
486
|
+
*/
|
|
487
|
+
function toolCallUpdateFor(record, event) {
|
|
488
|
+
const parsedArgs = parseToolArguments(event.data.arguments)
|
|
489
|
+
const kind = toolKindFor(event.data.name)
|
|
490
|
+
const content = toolCallContentFor(event.data.name, parsedArgs, kind)
|
|
491
|
+
const locations = toolCallLocationsFor(kind, parsedArgs)
|
|
492
|
+
// Result-side card bodies need the call's own args (the executor output
|
|
493
|
+
// fence); remember them per callId with a small ceiling against
|
|
494
|
+
// aborted-call leaks.
|
|
495
|
+
if (record.callArgs.size >= 64) record.callArgs.delete(record.callArgs.keys().next().value)
|
|
496
|
+
record.callArgs.set(event.data.callId, { name: event.data.name, parsedArgs })
|
|
497
|
+
return {
|
|
498
|
+
sessionUpdate: 'tool_call',
|
|
499
|
+
toolCallId: event.data.callId,
|
|
500
|
+
name: event.data.name,
|
|
501
|
+
title: toolCallTitle(event.data.name, parsedArgs),
|
|
502
|
+
kind,
|
|
503
|
+
status: 'in_progress',
|
|
504
|
+
...content === undefined ? {} : { content },
|
|
505
|
+
...locations === undefined ? {} : { locations },
|
|
506
|
+
rawInput: parsedArgs,
|
|
507
|
+
_meta: {
|
|
508
|
+
turn: event.data.turn,
|
|
509
|
+
step: event.data.step,
|
|
510
|
+
name: event.data.name,
|
|
511
|
+
argumentsPreview: event.data.arguments.slice(0, 200),
|
|
512
|
+
},
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
/** Wire update for a harness `tool/result`: terminal status plus, for
|
|
517
|
+
* executors, the output as a friendly code block under the command. */
|
|
518
|
+
function toolResultUpdateFor(record, event, callId, elapsed) {
|
|
519
|
+
const preview = resultPreview(event)
|
|
520
|
+
const call = record.callArgs.get(callId)
|
|
521
|
+
record.callArgs.delete(callId)
|
|
522
|
+
const isError = event.data.error !== undefined
|
|
523
|
+
let content
|
|
524
|
+
if (!isError && preview !== undefined && call !== undefined
|
|
525
|
+
&& call.name !== 'zed_terminal' && EXECUTOR_NAME.test(call.name)) {
|
|
526
|
+
content = [{ type: 'content', content: { type: 'text', text: codeFence(preview, executorLang(call.name)) } }]
|
|
527
|
+
}
|
|
528
|
+
return {
|
|
529
|
+
sessionUpdate: 'tool_call_update',
|
|
530
|
+
toolCallId: callId,
|
|
531
|
+
...call === undefined ? {} : { name: call.name },
|
|
532
|
+
status: isError ? 'failed' : 'completed',
|
|
533
|
+
...content === undefined ? {} : { content },
|
|
534
|
+
...preview === undefined ? {} : { rawOutput: preview },
|
|
535
|
+
_meta: {
|
|
536
|
+
turn: event.data.turn,
|
|
537
|
+
step: event.data.step,
|
|
538
|
+
elapsedMs: elapsed,
|
|
539
|
+
count: record.toolStats.count,
|
|
540
|
+
totalMs: record.toolStats.totalMs,
|
|
541
|
+
},
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
|
|
312
545
|
// ── session/event → ACP notifications ─────────────────────────────────────
|
|
313
546
|
|
|
314
547
|
ctx.on('session/event', (session, event) => {
|
|
@@ -369,25 +602,9 @@ export function apply(ctx, config) {
|
|
|
369
602
|
case 'tool/call': {
|
|
370
603
|
record.toolStats.lastCallAt = Date.now()
|
|
371
604
|
record.toolStats.lastName = event.data.name
|
|
372
|
-
const parsedArgs = parseToolArguments(event.data.arguments)
|
|
373
605
|
notify({
|
|
374
606
|
sessionId: session.header.id,
|
|
375
|
-
update:
|
|
376
|
-
sessionUpdate: 'tool_call',
|
|
377
|
-
toolCallId: event.data.callId,
|
|
378
|
-
// A one-line summary (not just the tool name) so Zed's collapsed
|
|
379
|
-
// header shows what the call is doing — which file is read, which
|
|
380
|
-
// command runs, what is searched — before expanding the card.
|
|
381
|
-
title: toolCallTitle(event.data.name, parsedArgs),
|
|
382
|
-
kind: toolKindFor(event.data.name),
|
|
383
|
-
rawInput: parsedArgs,
|
|
384
|
-
_meta: {
|
|
385
|
-
turn: event.data.turn,
|
|
386
|
-
step: event.data.step,
|
|
387
|
-
name: event.data.name,
|
|
388
|
-
argumentsPreview: event.data.arguments.slice(0, 200),
|
|
389
|
-
},
|
|
390
|
-
},
|
|
607
|
+
update: toolCallUpdateFor(record, event),
|
|
391
608
|
})
|
|
392
609
|
break
|
|
393
610
|
}
|
|
@@ -400,22 +617,9 @@ export function apply(ctx, config) {
|
|
|
400
617
|
record.toolStats.lastCallAt = undefined
|
|
401
618
|
// The tool call id lives on the ToolResultBlock, not on the event root.
|
|
402
619
|
const callId = event.data.message?.content?.[0]?.toolCallId ?? event.data.callId
|
|
403
|
-
const preview = resultPreview(event)
|
|
404
620
|
notify({
|
|
405
621
|
sessionId: session.header.id,
|
|
406
|
-
update:
|
|
407
|
-
sessionUpdate: 'tool_call_update',
|
|
408
|
-
toolCallId: callId,
|
|
409
|
-
status: event.data.error === undefined ? 'completed' : 'error',
|
|
410
|
-
...preview === undefined ? {} : { rawOutput: preview },
|
|
411
|
-
_meta: {
|
|
412
|
-
turn: event.data.turn,
|
|
413
|
-
step: event.data.step,
|
|
414
|
-
elapsedMs: elapsed,
|
|
415
|
-
count: record.toolStats.count,
|
|
416
|
-
totalMs: record.toolStats.totalMs,
|
|
417
|
-
},
|
|
418
|
-
},
|
|
622
|
+
update: toolResultUpdateFor(record, event, callId, elapsed),
|
|
419
623
|
})
|
|
420
624
|
break
|
|
421
625
|
}
|
|
@@ -704,7 +908,11 @@ export function apply(ctx, config) {
|
|
|
704
908
|
name: 'Reasoning effort',
|
|
705
909
|
description: 'Reasoning level applied to model requests for this session.',
|
|
706
910
|
category: 'thought_level',
|
|
707
|
-
|
|
911
|
+
// The chain mirrors applySelection: selected effort, the model's own
|
|
912
|
+
// default, then the first offered effort — a non-empty selection
|
|
913
|
+
// unless the model declares no reasoning at all. This backstop keeps
|
|
914
|
+
// the editor's dropdown from ever showing an "unknown" effort.
|
|
915
|
+
currentValue: selected.reasoningEffort ?? modelInfo?.reasoning?.defaultEffort ?? efforts[0]?.id ?? '',
|
|
708
916
|
options: efforts.map((effort) => ({
|
|
709
917
|
value: effort.id,
|
|
710
918
|
name: effort.name,
|
|
@@ -747,13 +955,49 @@ export function apply(ctx, config) {
|
|
|
747
955
|
|
|
748
956
|
/** Resolve and apply a new model/effort selection to the session. */
|
|
749
957
|
async function applySelection(record, next) {
|
|
750
|
-
|
|
958
|
+
// Per-model effort restoration. An explicit effort (Zed re-applies its
|
|
959
|
+
// saved default_config_options on every session, and a model switch
|
|
960
|
+
// carries the session's current effort onto the newly picked model) wins
|
|
961
|
+
// when the target offers it. Unsupported efforts are not just dropped:
|
|
962
|
+
// the effort last successfully applied to this route is restored, and a
|
|
963
|
+
// first-time route falls back to its own default — or, when it declares
|
|
964
|
+
// none, to the FIRST effort it offers — so the selection never carries an
|
|
965
|
+
// empty effort that renders as "unknown" in the editor's dropdown. A
|
|
966
|
+
// model without reasoning metadata resolves without an effort.
|
|
967
|
+
let info
|
|
968
|
+
try {
|
|
969
|
+
info = await llm.resolveModelInfo(next.provider, next.model)
|
|
970
|
+
} catch {
|
|
971
|
+
info = undefined
|
|
972
|
+
}
|
|
973
|
+
const efforts = info?.reasoning?.efforts ?? []
|
|
974
|
+
let candidate = next.reasoningEffort
|
|
975
|
+
if (candidate !== undefined && !efforts.some((effort) => effort.id === candidate)) {
|
|
976
|
+
logger.warn(`acp-enhanced: dropped reasoning effort "${candidate}" for ${next.provider}/${next.model} (unsupported)`)
|
|
977
|
+
candidate = undefined
|
|
978
|
+
}
|
|
979
|
+
if (candidate === undefined && efforts.length > 0) {
|
|
980
|
+
const remembered = rememberedEffort(next.provider, next.model)
|
|
981
|
+
candidate = remembered !== undefined && efforts.some((effort) => effort.id === remembered)
|
|
982
|
+
? remembered
|
|
983
|
+
: info?.reasoning?.defaultEffort ?? efforts[0].id
|
|
984
|
+
}
|
|
985
|
+
const resolved = await llm.resolveCallConfig(
|
|
986
|
+
candidate === undefined
|
|
987
|
+
? { provider: next.provider, model: next.model }
|
|
988
|
+
: { ...next, reasoningEffort: candidate },
|
|
989
|
+
)
|
|
751
990
|
const selected = {
|
|
752
991
|
provider: resolved.provider,
|
|
753
992
|
model: resolved.model,
|
|
754
993
|
...resolved.reasoningEffort === undefined ? {} : { reasoningEffort: resolved.reasoningEffort },
|
|
755
994
|
}
|
|
756
995
|
record.selection.current = selected
|
|
996
|
+
// Remember the effective effort for this route so a later switch back
|
|
997
|
+
// restores it (a model without reasoning metadata records nothing).
|
|
998
|
+
if (selected.reasoningEffort !== undefined) {
|
|
999
|
+
rememberEffort(selected.provider, selected.model, selected.reasoningEffort)
|
|
1000
|
+
}
|
|
757
1001
|
// Persist as the default only when the chosen route is one this bridge is
|
|
758
1002
|
// wired to serve. On a single-provider machine the default route is the
|
|
759
1003
|
// routable one, so switching within it always persists; a stray cross-provider
|
|
@@ -1278,6 +1522,8 @@ export function apply(ctx, config) {
|
|
|
1278
1522
|
return {
|
|
1279
1523
|
agent: handle.agent,
|
|
1280
1524
|
dispose: () => handle.dispose(),
|
|
1525
|
+
/** Additional workspace roots beyond the primary cwd (multi-root). */
|
|
1526
|
+
additionalDirectories: [],
|
|
1281
1527
|
inflight: undefined,
|
|
1282
1528
|
messageId: undefined,
|
|
1283
1529
|
stepStartedAt: undefined,
|
|
@@ -1286,6 +1532,8 @@ export function apply(ctx, config) {
|
|
|
1286
1532
|
title: undefined,
|
|
1287
1533
|
lastActivityAt: undefined,
|
|
1288
1534
|
toolStats: { count: 0, totalMs: 0, lastCallAt: undefined, lastName: undefined },
|
|
1535
|
+
/** callId → { name, parsedArgs } for result-time card bodies (bounded). */
|
|
1536
|
+
callArgs: new Map(),
|
|
1289
1537
|
buffer: {},
|
|
1290
1538
|
thoughtBuffer: {},
|
|
1291
1539
|
contextWindow: undefined,
|
|
@@ -1397,36 +1645,14 @@ export function apply(ctx, config) {
|
|
|
1397
1645
|
break
|
|
1398
1646
|
}
|
|
1399
1647
|
case 'tool/call': {
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
toolCallId: event.data.callId,
|
|
1404
|
-
title: toolCallTitle(event.data.name, parsedArgs),
|
|
1405
|
-
kind: toolKindFor(event.data.name),
|
|
1406
|
-
rawInput: parsedArgs,
|
|
1407
|
-
_meta: {
|
|
1408
|
-
turn: event.data.turn,
|
|
1409
|
-
step: event.data.step,
|
|
1410
|
-
name: event.data.name,
|
|
1411
|
-
argumentsPreview: String(event.data.arguments ?? '').slice(0, 200),
|
|
1412
|
-
},
|
|
1413
|
-
})
|
|
1648
|
+
// Same card shape as the live path — replayed threads render the
|
|
1649
|
+
// friendly body (diff / command block) and clickable locations too.
|
|
1650
|
+
await notifyNow(record, toolCallUpdateFor(record, event))
|
|
1414
1651
|
break
|
|
1415
1652
|
}
|
|
1416
1653
|
case 'tool/result': {
|
|
1417
|
-
const preview = resultPreview(event)
|
|
1418
1654
|
const callId = event.data.message?.content?.[0]?.toolCallId ?? event.data.callId
|
|
1419
|
-
await notifyNow(record,
|
|
1420
|
-
sessionUpdate: 'tool_call_update',
|
|
1421
|
-
toolCallId: callId,
|
|
1422
|
-
status: event.data.error === undefined ? 'completed' : 'error',
|
|
1423
|
-
...preview === undefined ? {} : { rawOutput: preview },
|
|
1424
|
-
_meta: {
|
|
1425
|
-
turn: event.data.turn,
|
|
1426
|
-
step: event.data.step,
|
|
1427
|
-
name: event.data.name,
|
|
1428
|
-
},
|
|
1429
|
-
})
|
|
1655
|
+
await notifyNow(record, toolResultUpdateFor(record, event, callId, 0))
|
|
1430
1656
|
break
|
|
1431
1657
|
}
|
|
1432
1658
|
}
|
|
@@ -1459,7 +1685,10 @@ export function apply(ctx, config) {
|
|
|
1459
1685
|
agentInfo: { name: 'deepseek-harness-acp-enhanced', version: AGENT_VERSION },
|
|
1460
1686
|
agentCapabilities: {
|
|
1461
1687
|
loadSession: true,
|
|
1462
|
-
|
|
1688
|
+
// additionalDirectories: multi-root workspaces (Zed passes every
|
|
1689
|
+
// workspace root on session/new / session/load instead of showing
|
|
1690
|
+
// the "doesn't currently support multi-root workspaces" callout).
|
|
1691
|
+
sessionCapabilities: { list: {}, delete: {}, additionalDirectories: {} },
|
|
1463
1692
|
promptCapabilities: { image: false, audio: false, embeddedContext: false },
|
|
1464
1693
|
// Stdio MCP servers always work; streamable HTTP maps onto
|
|
1465
1694
|
// dsh-mcp-client's second transport. Legacy SSE does not.
|
|
@@ -1475,7 +1704,7 @@ export function apply(ctx, config) {
|
|
|
1475
1704
|
|
|
1476
1705
|
async newSession(params) {
|
|
1477
1706
|
assertOpen()
|
|
1478
|
-
|
|
1707
|
+
const additionalDirectories = normalizeSessionParams(params)
|
|
1479
1708
|
const sessionId = SessionId(randomUUID())
|
|
1480
1709
|
const handle = await agents.create({
|
|
1481
1710
|
sessionId,
|
|
@@ -1490,6 +1719,7 @@ export function apply(ctx, config) {
|
|
|
1490
1719
|
throw internalError('connection closed during session/new')
|
|
1491
1720
|
}
|
|
1492
1721
|
const record = makeRecord(handle)
|
|
1722
|
+
record.additionalDirectories = additionalDirectories
|
|
1493
1723
|
sessions.set(sessionId, record)
|
|
1494
1724
|
await syncMcpServers(params.mcpServers, params.cwd)
|
|
1495
1725
|
publishCommandsAfterResponse(record)
|
|
@@ -1515,15 +1745,14 @@ export function apply(ctx, config) {
|
|
|
1515
1745
|
|
|
1516
1746
|
async loadSession(params) {
|
|
1517
1747
|
assertOpen()
|
|
1518
|
-
|
|
1519
|
-
if (params.additionalDirectories !== undefined && params.additionalDirectories.length > 0) {
|
|
1520
|
-
throw invalidParams('additionalDirectories is not supported')
|
|
1521
|
-
}
|
|
1748
|
+
const additionalDirectories = normalizeSessionParams(params)
|
|
1522
1749
|
await syncMcpServers(params.mcpServers, params.cwd)
|
|
1523
1750
|
const sessionId = SessionId(params.sessionId)
|
|
1524
1751
|
const live = sessions.get(sessionId)
|
|
1525
1752
|
if (live !== undefined) {
|
|
1526
1753
|
// Already live on this connection: return its state without replay.
|
|
1754
|
+
// The client's root list is authoritative for the loaded workspace.
|
|
1755
|
+
live.additionalDirectories = additionalDirectories
|
|
1527
1756
|
const permission = permissionPresets()
|
|
1528
1757
|
return {
|
|
1529
1758
|
...permission === undefined ? {} : {
|
|
@@ -1550,6 +1779,7 @@ export function apply(ctx, config) {
|
|
|
1550
1779
|
throw internalError('connection closed during session/load')
|
|
1551
1780
|
}
|
|
1552
1781
|
const record = makeRecord(handle)
|
|
1782
|
+
record.additionalDirectories = additionalDirectories
|
|
1553
1783
|
sessions.set(sessionId, record)
|
|
1554
1784
|
// Zed inserts the thread before the load RPC completes; replay the
|
|
1555
1785
|
// conversation history as notifications so the thread renders.
|
|
@@ -1760,9 +1990,11 @@ export function apply(ctx, config) {
|
|
|
1760
1990
|
const live = sessions.get(header.id)
|
|
1761
1991
|
const title = live?.title ?? await readStoredTitle(persistence, header.id)
|
|
1762
1992
|
const updatedAtMs = live?.lastActivityAt ?? header.createdAt
|
|
1993
|
+
const liveDirs = live?.additionalDirectories ?? []
|
|
1763
1994
|
out.push({
|
|
1764
1995
|
sessionId: header.id,
|
|
1765
1996
|
cwd: header.cwd ?? cwd ?? process.cwd(),
|
|
1997
|
+
...liveDirs.length === 0 ? {} : { additionalDirectories: liveDirs },
|
|
1766
1998
|
...title === undefined ? {} : { title },
|
|
1767
1999
|
updatedAt: new Date(updatedAtMs).toISOString(),
|
|
1768
2000
|
})
|
|
@@ -1865,10 +2097,46 @@ export function apply(ctx, config) {
|
|
|
1865
2097
|
ctx.effect(() => quiesce, 'acp-enhanced.connection')
|
|
1866
2098
|
}
|
|
1867
2099
|
|
|
1868
|
-
/**
|
|
1869
|
-
|
|
2100
|
+
/**
|
|
2101
|
+
* Validate `session/new`-style params and normalize the optional
|
|
2102
|
+
* `additionalDirectories` list. Entries must be absolute paths; they are
|
|
2103
|
+
* lexically resolved, deduplicated, and the primary cwd itself is dropped
|
|
2104
|
+
* (the schema treats the cwd as the first root — Zed never repeats it, but a
|
|
2105
|
+
* hand-rolled client may).
|
|
2106
|
+
*/
|
|
2107
|
+
function normalizeSessionParams(params) {
|
|
1870
2108
|
if (!isAbsolute(params.cwd)) throw invalidParams(`cwd must be an absolute path: ${params.cwd}`)
|
|
1871
|
-
|
|
1872
|
-
|
|
2109
|
+
const raw = params.additionalDirectories
|
|
2110
|
+
if (raw === undefined || raw === null) return []
|
|
2111
|
+
if (!Array.isArray(raw)) throw invalidParams('additionalDirectories must be an array of absolute paths')
|
|
2112
|
+
const primary = resolve(params.cwd)
|
|
2113
|
+
const seen = new Set()
|
|
2114
|
+
const dirs = []
|
|
2115
|
+
for (const entry of raw) {
|
|
2116
|
+
if (typeof entry !== 'string' || !isAbsolute(entry)) {
|
|
2117
|
+
throw invalidParams(`additionalDirectories entries must be absolute paths: ${String(entry)}`)
|
|
2118
|
+
}
|
|
2119
|
+
const dir = resolve(entry)
|
|
2120
|
+
if (dir === primary || seen.has(dir)) continue
|
|
2121
|
+
seen.add(dir)
|
|
2122
|
+
dirs.push(dir)
|
|
1873
2123
|
}
|
|
2124
|
+
return dirs
|
|
2125
|
+
}
|
|
2126
|
+
|
|
2127
|
+
/**
|
|
2128
|
+
* System-prompt context for a multi-root session (empty for single-root).
|
|
2129
|
+
* Reads are unrestricted by the file sandbox; writes follow the session
|
|
2130
|
+
* policy, whose single writable root is the primary cwd (dsh-sandbox-policy
|
|
2131
|
+
* resolves one `workspaceRoot` per session), so additional roots need
|
|
2132
|
+
* escalation under workspace-write and are unrestricted under
|
|
2133
|
+
* danger-full-access.
|
|
2134
|
+
*/
|
|
2135
|
+
function renderWorkspaceRoots(cwd, dirs) {
|
|
2136
|
+
if (dirs.length === 0 || cwd === undefined) return ''
|
|
2137
|
+
return [
|
|
2138
|
+
`Multi-root workspace: relative paths resolve against the primary root ${JSON.stringify(cwd)}. The session also spans these additional workspace roots:`,
|
|
2139
|
+
...dirs.map((dir) => `- ${dir}`),
|
|
2140
|
+
'File reads work in every root. File writes follow the sandbox policy: in workspace-write mode only the primary root (plus temp areas) is writable without approval, so a write under an additional root must obtain wider access first; in danger-full-access mode every root is writable.',
|
|
2141
|
+
].join('\n')
|
|
1874
2142
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-acp-enhanced",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.6",
|
|
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",
|
package/scripts/dsh-acp-zed.sh
CHANGED
|
@@ -61,4 +61,15 @@ if [ -z "${DEEPSEEK_API_KEY:-}" ]; then
|
|
|
61
61
|
fi
|
|
62
62
|
fi
|
|
63
63
|
|
|
64
|
+
# Derive the profile root from this launcher's own location:
|
|
65
|
+
# …/profiles/<name>/node_modules/<pkg>/scripts/dsh-acp-zed.sh
|
|
66
|
+
# — three levels above this script is the profile dir. pwd -L keeps the
|
|
67
|
+
# logical path (pnpm lays out node_modules as symlinks), so the climb lands
|
|
68
|
+
# on the profile root. The bridge persists small per-model state (the last
|
|
69
|
+
# reasoning effort per model) next to the profile's own files.
|
|
70
|
+
if [ -z "${DSH_ACP_PROFILE_DIR:-}" ]; then
|
|
71
|
+
ACP_LAUNCHER_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -L)"
|
|
72
|
+
export DSH_ACP_PROFILE_DIR="$(cd "${ACP_LAUNCHER_DIR}/../../.." && pwd -L)"
|
|
73
|
+
fi
|
|
74
|
+
|
|
64
75
|
exec "${DASH_BIN}" --profile acp-enhanced "$@"
|