dsh-acp-enhanced 0.5.1 → 0.6.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 +47 -1
- package/README.md +70 -1
- package/lib/index.js +205 -34
- package/lib/terminal-codec.js +6 -1
- package/package.json +1 -1
package/README-zh.md
CHANGED
|
@@ -51,7 +51,9 @@ ACP 线上。
|
|
|
51
51
|
状态机为进行中 → 完成/失败
|
|
52
52
|
- **Zed 文件与终端**:`zed_read_text_file` / `zed_write_text_file` / `zed_terminal` 把
|
|
53
53
|
文件编辑放进 Zed 的"编辑文件"区(diff + 接受/拒绝)、命令跑在 Zed 真实终端
|
|
54
|
-
- **原生表单提问**:`ask_user_question` → `elicitation/create`
|
|
54
|
+
- **原生表单提问**:`ask_user_question` → `elicitation/create` 表单,选项即点即答;
|
|
55
|
+
选项带描述展示,每个带选项的问题附一个"自定义答案"输入框——选项都不合适时可自由输入,
|
|
56
|
+
单选时自定义答案覆盖所选、多选时与所选并存(与 dsh 原生提问卡片语义一致)
|
|
55
57
|
- **Plan 面板**:plan mode 开关 → Zed 底部"规划中"状态条
|
|
56
58
|
|
|
57
59
|
### 会话
|
|
@@ -212,6 +214,50 @@ dsh plugin --profile acp-enhanced add dsh-web-search-openrouter
|
|
|
212
214
|
> LLM provider id(即上面 `DSH_ACP_PROVIDER` 填的那个)。web 插件按 id 精确匹配,
|
|
213
215
|
> 填错时配置期不会报错,直到首次搜索才抛 `WEB_PROVIDER_CONFIGURED_MISSING`。
|
|
214
216
|
|
|
217
|
+
### 管理 profile 的插件
|
|
218
|
+
|
|
219
|
+
dsh-acp-enhanced 跑在**独立的 profile** 里——`acp-enhanced`(由上面的安装命令创建于
|
|
220
|
+
`~/.dsh/profiles/acp-enhanced/`),与 `dsh web` 背后的 `web` profile 完全隔离,
|
|
221
|
+
在这里增删改插件不会影响 web 侧的任何配置。
|
|
222
|
+
|
|
223
|
+
profile 的插件树由三层组合而成,后层修补前层:
|
|
224
|
+
|
|
225
|
+
1. **bundle 层**:profile `package.json` 的 `dsh.profile.bundles`——模板自带的
|
|
226
|
+
`@deepseek-ai/dsh-base` 在前,随后是每个声明了 `dsh.bundle` 的已安装包(如
|
|
227
|
+
`dsh-acp-enhanced`),按数组顺序排列。
|
|
228
|
+
2. **用户层**:`~/.dsh/profiles/acp-enhanced/cordis.patch.yml`——按 id 定位的行配置
|
|
229
|
+
覆写、`disabled: true` 行禁用,以及 `insert` 挂载(无 `dsh.bundle` 的包——如上面
|
|
230
|
+
的 `dsh-web-search-openrouter`——就靠它装配)。
|
|
231
|
+
3. **临时覆盖**:`dsh --profile acp-enhanced --patch extra.yml`。
|
|
232
|
+
|
|
233
|
+
调整插件集:
|
|
234
|
+
|
|
235
|
+
```sh
|
|
236
|
+
dsh plugin --profile acp-enhanced add <package> # 安装;声明 dsh.bundle 的包自动加入层栈
|
|
237
|
+
dsh plugin --profile acp-enhanced remove <package> # 卸载;自动退出层栈
|
|
238
|
+
dsh plugin --profile acp-enhanced update [package] # 更新一个/全部并 reconcile
|
|
239
|
+
dsh --profile acp-enhanced --dump-config # 查看组合后的完整树(标注每行来自哪一层)
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
`dsh plugin` 本质是在 profile 目录里转发 pnpm,并在每次运行后按安装状态 reconcile
|
|
243
|
+
`dsh.profile.bundles`。两个值得知道的推论:
|
|
244
|
+
|
|
245
|
+
- **靠从 `bundles` 里删条目来禁用 bundle 是禁不住的**——包仍是已安装依赖,下一次
|
|
246
|
+
`dsh plugin` 运行会原样加回来。想不禁载地禁用某一行,请在用户层按**行 id**(不是
|
|
247
|
+
包名,id 可在 `--dump-config` 输出里查)定位:
|
|
248
|
+
|
|
249
|
+
```yaml
|
|
250
|
+
- id: mnemon
|
|
251
|
+
disabled: true
|
|
252
|
+
```
|
|
253
|
+
|
|
254
|
+
- **无 `dsh.bundle` 的包自身不会装配**——它只作为普通依赖安装(带一次性警告),需要
|
|
255
|
+
像上面的 `web-search-openrouter` 行那样在用户层 `insert` 挂载;要改已有行的配置,
|
|
256
|
+
用 `- id: <行>` + `config:` 覆写——patch 条目是整行替换、不做合并。
|
|
257
|
+
|
|
258
|
+
改动在**下一个**进程生效:Zed 为每个 agent 线程拉起一个全新的
|
|
259
|
+
`dsh --profile acp-enhanced`,编辑 profile 后新开 agent 线程(或重启 Zed)即可。
|
|
260
|
+
|
|
215
261
|
## 故障排查
|
|
216
262
|
|
|
217
263
|
| 症状 | 处理 |
|
package/README.md
CHANGED
|
@@ -59,7 +59,10 @@ over the ACP wire.
|
|
|
59
59
|
put file edits into Zed's "edited files" area (diff + accept/reject) and commands into a
|
|
60
60
|
real Zed terminal
|
|
61
61
|
- **Native form questions**: `ask_user_question` → `elicitation/create` form, click an
|
|
62
|
-
option
|
|
62
|
+
option — or type a custom answer when none of them fit: options render with their
|
|
63
|
+
descriptions, each option-backed question gets a free-text "Custom answer" field, and a
|
|
64
|
+
custom answer replaces the single selection / accompanies a multi-select (same semantics
|
|
65
|
+
as dsh's native question card)
|
|
63
66
|
- **Plan panel**: plan mode toggle → "planning" status bar in Zed
|
|
64
67
|
|
|
65
68
|
### Sessions
|
|
@@ -151,6 +154,21 @@ Zed spawns agents with a minimal PATH, so use the shipped launcher
|
|
|
151
154
|
> store it in `~/.dsh/.credentials.yaml` (`DEEPSEEK_API_KEY`) and the dsh credentials
|
|
152
155
|
> service resolves it; the launcher also falls back to a running `dsh web` process's key.
|
|
153
156
|
|
|
157
|
+
Debugging a stalled turn (is it the model request or the tool?):
|
|
158
|
+
|
|
159
|
+
```jsonc
|
|
160
|
+
"env": {
|
|
161
|
+
// ...existing vars...
|
|
162
|
+
"ACP_LOG": "/Users/you/.dsh/dsh-acp-enhanced.trace.jsonl" // append-only JSONL event trace
|
|
163
|
+
}
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
Each line is one session event with wall-clock `time` (ms epoch); a turn that appears to
|
|
167
|
+
hang is attributable afterwards: a **model request stall** shows a long gap between
|
|
168
|
+
`step/start` and the first `assistant/chunk`, while a **tool-execution stall** shows a
|
|
169
|
+
long gap between `tool/call` and `tool/result` (the result line carries `elapsedMs`).
|
|
170
|
+
`prompt/settled` lines cover the full user-message round trip (stopReason + elapsed).
|
|
171
|
+
|
|
154
172
|
Optional: pin the panel's default config options (all still changeable in the panel):
|
|
155
173
|
|
|
156
174
|
```jsonc
|
|
@@ -233,6 +251,57 @@ dsh plugin --profile acp-enhanced add dsh-web-search-openrouter
|
|
|
233
251
|
> exactly, so a wrong value produces no error at config time and only fails at the first
|
|
234
252
|
> search with `WEB_PROVIDER_CONFIGURED_MISSING`.
|
|
235
253
|
|
|
254
|
+
### Managing the profile's plugins
|
|
255
|
+
|
|
256
|
+
dsh-acp-enhanced runs in its **own profile** — `acp-enhanced`, created at
|
|
257
|
+
`~/.dsh/profiles/acp-enhanced/` by the install command above — fully separate from the
|
|
258
|
+
`web` profile behind `dsh web`, so plugin changes here never affect your web setup.
|
|
259
|
+
|
|
260
|
+
The profile composes its plugin tree from three sources, each layer patching the ones
|
|
261
|
+
before it:
|
|
262
|
+
|
|
263
|
+
1. **Bundle layers** — `dsh.profile.bundles` in the profile's `package.json`: the
|
|
264
|
+
template's `@deepseek-ai/dsh-base` first, then every installed package that declares
|
|
265
|
+
`dsh.bundle` (like `dsh-acp-enhanced`), in array order.
|
|
266
|
+
2. **Your user layer** — `~/.dsh/profiles/acp-enhanced/cordis.patch.yml`: id-targeted
|
|
267
|
+
row config overrides, `disabled: true` row disables, and `insert` lists (how a
|
|
268
|
+
package without `dsh.bundle` — like `dsh-web-search-openrouter` above — gets
|
|
269
|
+
mounted).
|
|
270
|
+
3. **Per-run overlays** — `dsh --profile acp-enhanced --patch extra.yml`.
|
|
271
|
+
|
|
272
|
+
Adjust the set with:
|
|
273
|
+
|
|
274
|
+
```sh
|
|
275
|
+
dsh plugin --profile acp-enhanced add <package> # install; a dsh.bundle package auto-joins the layer stack
|
|
276
|
+
dsh plugin --profile acp-enhanced remove <package> # uninstall; auto-leaves the stack
|
|
277
|
+
dsh plugin --profile acp-enhanced update [package] # update one/all, then reconcile
|
|
278
|
+
dsh --profile acp-enhanced --dump-config # inspect the composed tree (per-layer provenance)
|
|
279
|
+
```
|
|
280
|
+
|
|
281
|
+
`dsh plugin` is a thin pnpm forwarder (run inside the profile directory) that
|
|
282
|
+
reconciles `dsh.profile.bundles` against the installed state after every run. Two
|
|
283
|
+
consequences worth knowing:
|
|
284
|
+
|
|
285
|
+
- **Disabling a bundle by deleting it from `bundles` does not stick** — the package is
|
|
286
|
+
still an installed dependency, and the next `dsh plugin` run appends it right back.
|
|
287
|
+
To disable a single row without uninstalling, target it in the user layer by its
|
|
288
|
+
**row id** (not the package name — find ids in the `--dump-config` output):
|
|
289
|
+
|
|
290
|
+
```yaml
|
|
291
|
+
- id: mnemon
|
|
292
|
+
disabled: true
|
|
293
|
+
```
|
|
294
|
+
|
|
295
|
+
- **A package without `dsh.bundle` loads nothing by itself** — it installs as a plain
|
|
296
|
+
dependency (with a one-time warning) and needs your own `insert` entry in the user
|
|
297
|
+
layer, like the `web-search-openrouter` row above. To change an existing row's
|
|
298
|
+
config, override it with `- id: <row>` + `config:` — patch entries replace the
|
|
299
|
+
whole row config, they do not merge.
|
|
300
|
+
|
|
301
|
+
Changes take effect in the **next** process: Zed spawns a fresh
|
|
302
|
+
`dsh --profile acp-enhanced` for every agent thread, so open a new agent thread (or
|
|
303
|
+
restart Zed) after editing the profile.
|
|
304
|
+
|
|
236
305
|
## Troubleshooting
|
|
237
306
|
|
|
238
307
|
| Symptom | Fix |
|
package/lib/index.js
CHANGED
|
@@ -126,6 +126,13 @@ export const Config = Schema.object({
|
|
|
126
126
|
* default. The `DSH_ACP_PRESET` environment variable overrides this value
|
|
127
127
|
* when set. */
|
|
128
128
|
preset: Schema.string().default(undefined),
|
|
129
|
+
/** Optional JSONL trace of session events with wall-clock timestamps and
|
|
130
|
+
* tool call durations. After a stalled turn, the log discriminates a hung
|
|
131
|
+
* model request (long gap between `step/start` and the first
|
|
132
|
+
* `assistant/chunk`) from a hung tool call (long `tool/call` →
|
|
133
|
+
* `tool/result` gap). The `ACP_LOG` environment variable overrides this
|
|
134
|
+
* value; unset disables tracing. */
|
|
135
|
+
logFile: Schema.string().default(undefined),
|
|
129
136
|
})
|
|
130
137
|
|
|
131
138
|
/** Preserve invalid-parameter detail in the SDK wire error message. */
|
|
@@ -399,6 +406,17 @@ export function apply(ctx, config) {
|
|
|
399
406
|
const commands = ctx.commands
|
|
400
407
|
const skills = ctx.skills
|
|
401
408
|
const logger = ctx.logger
|
|
409
|
+
/** Optional JSONL trace target: config option, overridable by `ACP_LOG`
|
|
410
|
+
* (mirrors `ACP_DEBUG`, which remains stderr-only). Every event carries a
|
|
411
|
+
* wall-clock `time` so a stalled turn can be attributed to the model
|
|
412
|
+
* request vs the tool execution afterwards. */
|
|
413
|
+
const tracePath = process.env.ACP_LOG || config.logFile
|
|
414
|
+
const trace = (entry) => {
|
|
415
|
+
if (tracePath === undefined) return
|
|
416
|
+
writeFile(tracePath, `${JSON.stringify({ time: Date.now(), ...entry })}\n`, { flag: 'a' }).catch((error) => {
|
|
417
|
+
logger.warn(`acp-enhanced: trace write failed: ${String(error)}`)
|
|
418
|
+
})
|
|
419
|
+
}
|
|
402
420
|
/** The user-questions service (mounted by dsh-base); absent in minimal deployments. */
|
|
403
421
|
const userQuestions = ctx.get('userQuestions')
|
|
404
422
|
|
|
@@ -446,8 +464,12 @@ export function apply(ctx, config) {
|
|
|
446
464
|
/** Whether one live session has produced anything yet. A preset swap is only
|
|
447
465
|
* legal while it is blank (dsh-agent-presets product rule): swapping tools
|
|
448
466
|
* mid-conversation would strand logged tool calls the new composition cannot
|
|
449
|
-
* make.
|
|
450
|
-
|
|
467
|
+
* make. Checks both the live turn marker and persisted user messages, so the
|
|
468
|
+
* same rule holds for resumed sessions whose on-disk logs may not carry
|
|
469
|
+
* `turn/start`. */
|
|
470
|
+
const isBlankSession = (session) => !session.events.some((event) => (
|
|
471
|
+
event.type === 'turn/start' || event.type === 'user/message'
|
|
472
|
+
))
|
|
451
473
|
|
|
452
474
|
/**
|
|
453
475
|
* Resolve the preset a new/resumed session will run under, and the setup hook
|
|
@@ -476,13 +498,21 @@ export function apply(ctx, config) {
|
|
|
476
498
|
}
|
|
477
499
|
}
|
|
478
500
|
|
|
479
|
-
/**
|
|
480
|
-
|
|
501
|
+
/** Persisted facts for one stored session: the preset it runs under and
|
|
502
|
+
* whether its log shows any real content. Both read from the on-disk log
|
|
503
|
+
* because a just-resumed session's in-memory event log fills asynchronously
|
|
504
|
+
* — the loadSession response would otherwise judge blank-ness before the
|
|
505
|
+
* events arrive. Returns undefined when the artifact is missing/corrupt
|
|
506
|
+
* (resume itself reports the failure; this lookup must not mask it). */
|
|
507
|
+
async function persistedFactsOf(sessionId) {
|
|
481
508
|
const persistence = ctx.get('sessionPersistence')
|
|
482
509
|
if (persistence === undefined) return undefined
|
|
483
510
|
try {
|
|
484
511
|
const { meta, events } = await persistence.load(sessionId)
|
|
485
|
-
return
|
|
512
|
+
return {
|
|
513
|
+
preset: resolveSessionPreset({ header: meta, events }),
|
|
514
|
+
blank: isBlankSession({ header: meta, events }),
|
|
515
|
+
}
|
|
486
516
|
} catch {
|
|
487
517
|
// A missing/corrupt artifact leaves resume itself to report the failure;
|
|
488
518
|
// the preset lookup must not mask it with a second, misleading error.
|
|
@@ -679,6 +709,17 @@ export function apply(ctx, config) {
|
|
|
679
709
|
const record = sessions.get(session.header.id)
|
|
680
710
|
if (record === undefined || record.agent.session !== session) return
|
|
681
711
|
try {
|
|
712
|
+
if (tracePath !== undefined) {
|
|
713
|
+
const extra = event.type === 'tool/call'
|
|
714
|
+
? { name: event.data.name }
|
|
715
|
+
: event.type === 'tool/result'
|
|
716
|
+
? { name: event.data.name ?? event.data.message?.content?.[0]?.name,
|
|
717
|
+
elapsedMs: record.toolStats.lastCallAt === undefined ? undefined : Date.now() - record.toolStats.lastCallAt }
|
|
718
|
+
: event.type === 'turn/end'
|
|
719
|
+
? { elapsedMs: record.turnStartedAt === undefined ? undefined : Date.now() - record.turnStartedAt }
|
|
720
|
+
: {}
|
|
721
|
+
trace({ event: event.type, turn: event.data?.turn, step: event.data?.step, ...extra })
|
|
722
|
+
}
|
|
682
723
|
if (process.env.ACP_DEBUG) {
|
|
683
724
|
const extra = event.type === 'turn/end' ? ` reason=${JSON.stringify(event.data.reason)}` : event.type === 'assistant/chunk' ? ` chunkType=${event.data.chunk.type}` : event.type === 'agent/inbox/spliced' ? ` hasPending=${record.agent.inbox?.hasPending}` : event.type === 'tool/call' ? ` name=${event.data.name}` : ''
|
|
684
725
|
process.stderr.write(`[acp-debug] ${event.type} turn=${event.data?.turn} step=${event.data?.step}${extra}\n`)
|
|
@@ -710,6 +751,16 @@ export function apply(ctx, config) {
|
|
|
710
751
|
case 'turn/start': {
|
|
711
752
|
record.turnCount += 1
|
|
712
753
|
record.turnStartedAt = Date.now()
|
|
754
|
+
// The preset-selection window closes the moment the session produces
|
|
755
|
+
// its first turn: re-advertise the config so the editor's
|
|
756
|
+
// agent_preset dropdown collapses to the running preset (ACP has no
|
|
757
|
+
// disabled state; without this the client keeps showing the full
|
|
758
|
+
// session/new list and offers switches the server must reject).
|
|
759
|
+
if (record.turnCount === 1) {
|
|
760
|
+
broadcastConfig(record).catch((error) => {
|
|
761
|
+
logger.warn(`acp-enhanced: config rebroadcast after first turn failed: ${String(error)}`)
|
|
762
|
+
})
|
|
763
|
+
}
|
|
713
764
|
break
|
|
714
765
|
}
|
|
715
766
|
case 'turn/end': {
|
|
@@ -1016,6 +1067,13 @@ export function apply(ctx, config) {
|
|
|
1016
1067
|
|
|
1017
1068
|
/** Build the full config-option set for one session. */
|
|
1018
1069
|
async function buildConfigOptions(record) {
|
|
1070
|
+
// Test hook (unset in production): artificial latency proving the
|
|
1071
|
+
// response-vs-broadcast ordering holds even when config-option assembly
|
|
1072
|
+
// spans real event-loop turns (the cold-runner condition that raced the
|
|
1073
|
+
// command broadcast past the session/new response on CI).
|
|
1074
|
+
if (process.env.DSH_TEST_SLOW_CATALOG_MS !== undefined) {
|
|
1075
|
+
await new Promise((resolve) => setTimeout(resolve, Number(process.env.DSH_TEST_SLOW_CATALOG_MS) || 0))
|
|
1076
|
+
}
|
|
1019
1077
|
const selected = record.selection.current
|
|
1020
1078
|
const options = []
|
|
1021
1079
|
const groups = await modelCatalog()
|
|
@@ -1090,18 +1148,30 @@ export function apply(ctx, config) {
|
|
|
1090
1148
|
// Broken presets must not be offered: mounting one always fails.
|
|
1091
1149
|
const mountable = (await presets.list()).filter((preset) => preset.broken === undefined)
|
|
1092
1150
|
if (mountable.length > 0) {
|
|
1151
|
+
const current = runningPresetOf(record.agent.session) ?? ''
|
|
1152
|
+
// A just-resumed session's in-memory events fill asynchronously; trust
|
|
1153
|
+
// the on-disk log snapshot taken at load time when one exists.
|
|
1154
|
+
const blank = record.blankFromLog ?? isBlankSession(record.agent.session)
|
|
1093
1155
|
options.push({
|
|
1094
1156
|
id: 'agent_preset',
|
|
1095
1157
|
type: 'select',
|
|
1096
1158
|
name: 'Agent preset',
|
|
1097
|
-
description:
|
|
1159
|
+
description: blank
|
|
1160
|
+
? 'Model-facing tool/prompt composition for this session (switch only while blank).'
|
|
1161
|
+
: 'Model-facing tool/prompt composition for this session (locked: switching requires a blank session).',
|
|
1098
1162
|
category: 'model_config',
|
|
1099
|
-
currentValue:
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1163
|
+
currentValue: current,
|
|
1164
|
+
// ACP has no per-option disabled state, so a non-blank session
|
|
1165
|
+
// advertises only the running preset — the editor shows the current
|
|
1166
|
+
// mode without offering a switch the server would reject (the
|
|
1167
|
+
// setSessionConfigOption guard stays as the authoritative check).
|
|
1168
|
+
options: blank
|
|
1169
|
+
? mountable.map((preset) => ({
|
|
1170
|
+
value: preset.id,
|
|
1171
|
+
name: preset.name ?? preset.id,
|
|
1172
|
+
...preset.description === undefined ? {} : { description: preset.description },
|
|
1173
|
+
}))
|
|
1174
|
+
: [{ value: current, name: mountable.find((preset) => preset.id === current)?.name ?? (current || 'host') }],
|
|
1105
1175
|
})
|
|
1106
1176
|
}
|
|
1107
1177
|
}
|
|
@@ -1417,21 +1487,48 @@ export function apply(ctx, config) {
|
|
|
1417
1487
|
if (record === undefined) {
|
|
1418
1488
|
throw new Error('ask_user_question is only usable inside a bridge-owned session')
|
|
1419
1489
|
}
|
|
1490
|
+
// Editor forms cannot mix a fixed option list with free text in one
|
|
1491
|
+
// field, so every option-backed question gains a companion
|
|
1492
|
+
// `<id>__custom` text field: the form equivalent of dsh's native
|
|
1493
|
+
// "type your own answer" row, for when none of the offered options
|
|
1494
|
+
// fit. Option-free questions are already free-text fields.
|
|
1495
|
+
const usedKeys = new Set(request.questions.map((question) => question.id))
|
|
1496
|
+
const customKeys = new Map()
|
|
1420
1497
|
const properties = {}
|
|
1421
1498
|
const required = []
|
|
1422
1499
|
for (const question of request.questions) {
|
|
1423
1500
|
required.push(question.id)
|
|
1424
|
-
const
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1501
|
+
const options = question.options ?? []
|
|
1502
|
+
// Titled options (const/title/description) instead of a bare enum
|
|
1503
|
+
// so Zed renders each option's description under its label, like
|
|
1504
|
+
// the native question card. A question without options becomes a
|
|
1505
|
+
// plain text field: an optionless multi-select would otherwise
|
|
1506
|
+
// render as an empty, unanswerable checkbox list.
|
|
1507
|
+
const titled = options.map((option) => ({
|
|
1508
|
+
const: option.label,
|
|
1509
|
+
title: option.label,
|
|
1510
|
+
...option.description === undefined ? {} : { description: option.description },
|
|
1511
|
+
}))
|
|
1512
|
+
const heading = question.header === undefined ? {} : { title: question.header }
|
|
1513
|
+
if (options.length === 0) {
|
|
1514
|
+
properties[question.id] = { type: 'string', description: question.question, ...heading }
|
|
1515
|
+
} else if (question.multiSelect === true) {
|
|
1516
|
+
properties[question.id] = { type: 'array', items: { anyOf: titled }, description: question.question, ...heading }
|
|
1433
1517
|
} else {
|
|
1434
|
-
properties[question.id] = { type: 'string', description: question.question }
|
|
1518
|
+
properties[question.id] = { type: 'string', oneOf: titled, description: question.question, ...heading }
|
|
1519
|
+
}
|
|
1520
|
+
if (options.length > 0) {
|
|
1521
|
+
let customKey = `${question.id}__custom`
|
|
1522
|
+
while (usedKeys.has(customKey)) customKey = `${customKey}_`
|
|
1523
|
+
usedKeys.add(customKey)
|
|
1524
|
+
customKeys.set(question.id, customKey)
|
|
1525
|
+
properties[customKey] = {
|
|
1526
|
+
type: 'string',
|
|
1527
|
+
title: 'Custom answer',
|
|
1528
|
+
description: question.multiSelect === true
|
|
1529
|
+
? 'None of the options fit? Type your own answer; it is returned alongside your selections.'
|
|
1530
|
+
: 'None of the options fit? Type your own answer; it replaces the selection.',
|
|
1531
|
+
}
|
|
1435
1532
|
}
|
|
1436
1533
|
}
|
|
1437
1534
|
const response = await conn.unstable_createElicitation({
|
|
@@ -1447,10 +1544,28 @@ export function apply(ctx, config) {
|
|
|
1447
1544
|
return {
|
|
1448
1545
|
answers: request.questions.map((question) => {
|
|
1449
1546
|
const value = content[question.id]
|
|
1547
|
+
const customKey = customKeys.get(question.id)
|
|
1548
|
+
if (customKey === undefined) {
|
|
1549
|
+
// Option-free question: the typed text IS the answer, reported
|
|
1550
|
+
// as the custom ("other") answer the way the native UI does.
|
|
1551
|
+
const text = typeof value === 'string' ? value.trim() : ''
|
|
1552
|
+
return { id: question.id, selected: [], ...text === '' ? {} : { custom: text } }
|
|
1553
|
+
}
|
|
1450
1554
|
const selected = Array.isArray(value)
|
|
1451
1555
|
? value.filter((entry) => typeof entry === 'string')
|
|
1452
1556
|
: typeof value === 'string' ? [value] : []
|
|
1453
|
-
|
|
1557
|
+
const customRaw = content[customKey]
|
|
1558
|
+
const custom = typeof customRaw === 'string' && customRaw.trim() !== '' ? customRaw.trim() : undefined
|
|
1559
|
+
if (custom === undefined) {
|
|
1560
|
+
return { id: question.id, selected }
|
|
1561
|
+
}
|
|
1562
|
+
// A custom answer replaces a single selection and accompanies a
|
|
1563
|
+
// multi-select, matching the native question card.
|
|
1564
|
+
return {
|
|
1565
|
+
id: question.id,
|
|
1566
|
+
selected: question.multiSelect === true ? selected : [],
|
|
1567
|
+
custom,
|
|
1568
|
+
}
|
|
1454
1569
|
}),
|
|
1455
1570
|
}
|
|
1456
1571
|
},
|
|
@@ -1613,6 +1728,16 @@ export function apply(ctx, config) {
|
|
|
1613
1728
|
* cannot route session notifications for it until the response arrives; an
|
|
1614
1729
|
* `available_commands_update` queued before that response is dropped and
|
|
1615
1730
|
* the slash menu stays empty ("Available commands: none").
|
|
1731
|
+
*
|
|
1732
|
+
* Ordering contract: arm this only as the handler's LAST statement, after
|
|
1733
|
+
* every await. The SDK serializes all outgoing messages through one FIFO
|
|
1734
|
+
* write queue and enqueues the handler's response synchronously in the
|
|
1735
|
+
* microtask continuation of the handler's promise, which strictly
|
|
1736
|
+
* precedes this setImmediate (check phase). Arming before a remaining
|
|
1737
|
+
* macrotask await (e.g. the config-option catalog build) lets the
|
|
1738
|
+
* immediate fire first, the broadcast overtakes the response, and
|
|
1739
|
+
* clients drop it — observed on CI as the broadcast landing before the
|
|
1740
|
+
* session/new response.
|
|
1616
1741
|
*/
|
|
1617
1742
|
function publishCommandsAfterResponse(record) {
|
|
1618
1743
|
setImmediate(() => {
|
|
@@ -1761,6 +1886,10 @@ export function apply(ctx, config) {
|
|
|
1761
1886
|
buffer: {},
|
|
1762
1887
|
thoughtBuffer: {},
|
|
1763
1888
|
contextWindow: undefined,
|
|
1889
|
+
/** Blank-ness captured from the on-disk log at load time (resumed
|
|
1890
|
+
* sessions' in-memory events fill asynchronously). Undefined for
|
|
1891
|
+
* fresh/live sessions, which judge blank-ness live. */
|
|
1892
|
+
blankFromLog: undefined,
|
|
1764
1893
|
lastUsage: undefined,
|
|
1765
1894
|
selection,
|
|
1766
1895
|
}
|
|
@@ -1917,7 +2046,7 @@ export function apply(ctx, config) {
|
|
|
1917
2046
|
// additionalDirectories: multi-root workspaces (Zed passes every
|
|
1918
2047
|
// workspace root on session/new / session/load instead of showing
|
|
1919
2048
|
// the "doesn't currently support multi-root workspaces" callout).
|
|
1920
|
-
sessionCapabilities: { list: {}, delete: {}, additionalDirectories: {} },
|
|
2049
|
+
sessionCapabilities: { list: {}, delete: {}, additionalDirectories: {}, close: {} },
|
|
1921
2050
|
// Image support is a live capability: the harness advertises
|
|
1922
2051
|
// `image: true` only when the composition mounted a working
|
|
1923
2052
|
// attachment store (duck-typed, so dsh 0.1.1-rc.2+ with
|
|
@@ -1976,8 +2105,14 @@ export function apply(ctx, config) {
|
|
|
1976
2105
|
record.additionalDirectories = additionalDirectories
|
|
1977
2106
|
sessions.set(sessionId, record)
|
|
1978
2107
|
await syncMcpServers(params.mcpServers, params.cwd)
|
|
1979
|
-
publishCommandsAfterResponse(record)
|
|
1980
2108
|
const permission = permissionPresets()
|
|
2109
|
+
const configOptions = await buildConfigOptions(record)
|
|
2110
|
+
// Arm strictly after the handler's last await (see
|
|
2111
|
+
// publishCommandsAfterResponse): the SDK enqueues the response into
|
|
2112
|
+
// its FIFO write queue in the microtask continuation of this
|
|
2113
|
+
// handler's promise, which always precedes the immediate — so the
|
|
2114
|
+
// broadcast can never overtake the response.
|
|
2115
|
+
publishCommandsAfterResponse(record)
|
|
1981
2116
|
return {
|
|
1982
2117
|
sessionId,
|
|
1983
2118
|
...permission === undefined ? {} : {
|
|
@@ -1993,7 +2128,7 @@ export function apply(ctx, config) {
|
|
|
1993
2128
|
}),
|
|
1994
2129
|
},
|
|
1995
2130
|
},
|
|
1996
|
-
configOptions
|
|
2131
|
+
configOptions,
|
|
1997
2132
|
}
|
|
1998
2133
|
},
|
|
1999
2134
|
|
|
@@ -2004,9 +2139,15 @@ export function apply(ctx, config) {
|
|
|
2004
2139
|
const sessionId = SessionId(params.sessionId)
|
|
2005
2140
|
const live = sessions.get(sessionId)
|
|
2006
2141
|
if (live !== undefined) {
|
|
2007
|
-
// Already live on this connection
|
|
2008
|
-
//
|
|
2142
|
+
// Already live on this connection. The client is re-loading the
|
|
2143
|
+
// session because it dropped its local thread (that is the only
|
|
2144
|
+
// reason a load arrives for a session the client already knows), so
|
|
2145
|
+
// replay the history — without it the client renders a blank thread
|
|
2146
|
+
// and Zed misreads the empty thread as a draft, permanently losing
|
|
2147
|
+
// the session linkage in its sidebar. The client's root list is
|
|
2148
|
+
// authoritative for the loaded workspace.
|
|
2009
2149
|
live.additionalDirectories = additionalDirectories
|
|
2150
|
+
await replayHistory(live)
|
|
2010
2151
|
const permission = permissionPresets()
|
|
2011
2152
|
return {
|
|
2012
2153
|
...permission === undefined ? {} : {
|
|
@@ -2021,10 +2162,10 @@ export function apply(ctx, config) {
|
|
|
2021
2162
|
configOptions: await buildConfigOptions(live),
|
|
2022
2163
|
}
|
|
2023
2164
|
}
|
|
2024
|
-
const runningPreset = await
|
|
2165
|
+
const runningPreset = await persistedFactsOf(sessionId)
|
|
2025
2166
|
let composition
|
|
2026
2167
|
try {
|
|
2027
|
-
composition = await composePreset(runningPreset)
|
|
2168
|
+
composition = await composePreset(runningPreset?.preset)
|
|
2028
2169
|
} catch (error) {
|
|
2029
2170
|
// Same mapping as session/new: a roster that cannot supply the
|
|
2030
2171
|
// session's logged preset is reported as a client mistake.
|
|
@@ -2048,12 +2189,17 @@ export function apply(ctx, config) {
|
|
|
2048
2189
|
}
|
|
2049
2190
|
const record = makeRecord(handle)
|
|
2050
2191
|
record.additionalDirectories = additionalDirectories
|
|
2192
|
+
record.blankFromLog = runningPreset?.blank
|
|
2051
2193
|
sessions.set(sessionId, record)
|
|
2052
2194
|
// Zed inserts the thread before the load RPC completes; replay the
|
|
2053
2195
|
// conversation history as notifications so the thread renders.
|
|
2054
2196
|
await replayHistory(record)
|
|
2055
|
-
publishCommandsAfterResponse(record)
|
|
2056
2197
|
const permission = permissionPresets()
|
|
2198
|
+
const configOptions = await buildConfigOptions(record)
|
|
2199
|
+
// Arm strictly after the handler's last await (see
|
|
2200
|
+
// publishCommandsAfterResponse): the broadcast must land after the
|
|
2201
|
+
// load response, never before it.
|
|
2202
|
+
publishCommandsAfterResponse(record)
|
|
2057
2203
|
return {
|
|
2058
2204
|
...permission === undefined ? {} : {
|
|
2059
2205
|
modes: {
|
|
@@ -2068,7 +2214,7 @@ export function apply(ctx, config) {
|
|
|
2068
2214
|
}),
|
|
2069
2215
|
},
|
|
2070
2216
|
},
|
|
2071
|
-
configOptions
|
|
2217
|
+
configOptions,
|
|
2072
2218
|
}
|
|
2073
2219
|
},
|
|
2074
2220
|
|
|
@@ -2177,6 +2323,7 @@ export function apply(ctx, config) {
|
|
|
2177
2323
|
}
|
|
2178
2324
|
const message = createUserMessage({ content: blocks, source: { kind: 'user' } })
|
|
2179
2325
|
if (process.env.ACP_DEBUG) process.stderr.write(`[acp-debug] followup queued, agent phase=${record.agent.phase?.kind} inboxPending=${record.agent.inbox?.hasPending}\n`)
|
|
2326
|
+
const promptAt = Date.now()
|
|
2180
2327
|
const stopReason = await new Promise((resolve, reject) => {
|
|
2181
2328
|
const inflight = {
|
|
2182
2329
|
resolve,
|
|
@@ -2204,13 +2351,19 @@ export function apply(ctx, config) {
|
|
|
2204
2351
|
}
|
|
2205
2352
|
})
|
|
2206
2353
|
})
|
|
2354
|
+
trace({ event: 'prompt/settled', stopReason, elapsedMs: Date.now() - promptAt })
|
|
2207
2355
|
return { stopReason }
|
|
2208
2356
|
},
|
|
2209
2357
|
|
|
2210
2358
|
cancel(params) {
|
|
2211
2359
|
const record = sessions.get(SessionId(params.sessionId))
|
|
2212
2360
|
if (record === undefined) return Promise.resolve()
|
|
2213
|
-
|
|
2361
|
+
// An Error reason, not a bare object: the harness surfaces an aborted
|
|
2362
|
+
// tool's signal reason verbatim into the tool result the model reads,
|
|
2363
|
+
// so a plain object would render as the useless "Error: [object
|
|
2364
|
+
// Object]" and push the model off the tool that was merely
|
|
2365
|
+
// interrupted by the user's stop.
|
|
2366
|
+
record.agent.cancel(new Error('cancelled by user'))
|
|
2214
2367
|
settlePrompt(record, 'cancelled')
|
|
2215
2368
|
return Promise.resolve()
|
|
2216
2369
|
},
|
|
@@ -2344,6 +2497,24 @@ export function apply(ctx, config) {
|
|
|
2344
2497
|
return { sessions: out }
|
|
2345
2498
|
},
|
|
2346
2499
|
|
|
2500
|
+
async closeSession(params) {
|
|
2501
|
+
assertOpen()
|
|
2502
|
+
const sessionId = SessionId(params.sessionId)
|
|
2503
|
+
const live = sessions.get(sessionId)
|
|
2504
|
+
if (live === undefined) return {}
|
|
2505
|
+
sessions.delete(sessionId)
|
|
2506
|
+
// The client released its last handle on this session (its thread was
|
|
2507
|
+
// dropped). Dispose the in-memory record so a later session/load fully
|
|
2508
|
+
// resumes from the persisted session; keeping it "live" would serve an
|
|
2509
|
+
// empty thread with no history replay on the next load.
|
|
2510
|
+
try {
|
|
2511
|
+
await live.dispose()
|
|
2512
|
+
} catch (error) {
|
|
2513
|
+
logger.warn(`acp-enhanced: failed to dispose closed session ${sessionId}: ${String(error)}`)
|
|
2514
|
+
}
|
|
2515
|
+
return {}
|
|
2516
|
+
},
|
|
2517
|
+
|
|
2347
2518
|
async deleteSession(params) {
|
|
2348
2519
|
assertOpen()
|
|
2349
2520
|
const sessionId = SessionId(params.sessionId)
|
|
@@ -2393,7 +2564,7 @@ export function apply(ctx, config) {
|
|
|
2393
2564
|
const records = [...sessions.values()]
|
|
2394
2565
|
sessions.clear()
|
|
2395
2566
|
for (const record of records) {
|
|
2396
|
-
record.agent.cancel(
|
|
2567
|
+
record.agent.cancel(new Error('cancelled because the session closed'))
|
|
2397
2568
|
settlePrompt(record, 'cancelled')
|
|
2398
2569
|
}
|
|
2399
2570
|
for (const { fiber } of mcpMounts.values()) {
|
package/lib/terminal-codec.js
CHANGED
|
@@ -23,10 +23,15 @@ import { isAbsolute, resolve } from 'node:path'
|
|
|
23
23
|
* diff replaces the raw dump as the card body. Remaining local executors like
|
|
24
24
|
* run_code stay 'other' and carry the command as a markdown code block,
|
|
25
25
|
* keeping the raw sections available too.
|
|
26
|
+
*
|
|
27
|
+
* Read tools map to 'read' so the bridge can pair the kind with ACP
|
|
28
|
+
* `locations` (the clickable file chips via `toolCallLocationsFor`): the bare
|
|
29
|
+
* dsh tool name is exactly `read`, so it must match by itself — `read_text`
|
|
30
|
+
* and `fs_*read` only cover the long-form names.
|
|
26
31
|
*/
|
|
27
32
|
export function toolKindFor(name) {
|
|
28
33
|
if (name === 'bash' || name === 'pwsh' || name === 'zed_terminal') return 'execute'
|
|
29
|
-
if (/^fs_.*read
|
|
34
|
+
if (/^fs_.*read|^read$|read_text|cat|show/.test(name)) return 'read'
|
|
30
35
|
if (/search|find|grep/.test(name)) return 'search'
|
|
31
36
|
if (/fetch|http/.test(name)) return 'fetch'
|
|
32
37
|
if (/think/.test(name)) return 'think'
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-acp-enhanced",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.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",
|