dsh-acp-enhanced 0.5.2 → 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 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, no typing
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
 
@@ -691,6 +709,17 @@ export function apply(ctx, config) {
691
709
  const record = sessions.get(session.header.id)
692
710
  if (record === undefined || record.agent.session !== session) return
693
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
+ }
694
723
  if (process.env.ACP_DEBUG) {
695
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}` : ''
696
725
  process.stderr.write(`[acp-debug] ${event.type} turn=${event.data?.turn} step=${event.data?.step}${extra}\n`)
@@ -1038,6 +1067,13 @@ export function apply(ctx, config) {
1038
1067
 
1039
1068
  /** Build the full config-option set for one session. */
1040
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
+ }
1041
1077
  const selected = record.selection.current
1042
1078
  const options = []
1043
1079
  const groups = await modelCatalog()
@@ -1451,21 +1487,48 @@ export function apply(ctx, config) {
1451
1487
  if (record === undefined) {
1452
1488
  throw new Error('ask_user_question is only usable inside a bridge-owned session')
1453
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()
1454
1497
  const properties = {}
1455
1498
  const required = []
1456
1499
  for (const question of request.questions) {
1457
1500
  required.push(question.id)
1458
- const labels = (question.options ?? []).map((option) => option.label)
1459
- if (question.multiSelect === true) {
1460
- properties[question.id] = {
1461
- type: 'array',
1462
- ...labels.length > 0 ? { items: { type: 'string', enum: labels } } : { items: { type: 'string' } },
1463
- description: question.question,
1464
- }
1465
- } else if (labels.length > 0) {
1466
- properties[question.id] = { type: 'string', enum: labels, description: question.question }
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 }
1467
1517
  } else {
1468
- 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
+ }
1469
1532
  }
1470
1533
  }
1471
1534
  const response = await conn.unstable_createElicitation({
@@ -1481,10 +1544,28 @@ export function apply(ctx, config) {
1481
1544
  return {
1482
1545
  answers: request.questions.map((question) => {
1483
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
+ }
1484
1554
  const selected = Array.isArray(value)
1485
1555
  ? value.filter((entry) => typeof entry === 'string')
1486
1556
  : typeof value === 'string' ? [value] : []
1487
- return { id: question.id, selected }
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
+ }
1488
1569
  }),
1489
1570
  }
1490
1571
  },
@@ -1647,6 +1728,16 @@ export function apply(ctx, config) {
1647
1728
  * cannot route session notifications for it until the response arrives; an
1648
1729
  * `available_commands_update` queued before that response is dropped and
1649
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.
1650
1741
  */
1651
1742
  function publishCommandsAfterResponse(record) {
1652
1743
  setImmediate(() => {
@@ -1955,7 +2046,7 @@ export function apply(ctx, config) {
1955
2046
  // additionalDirectories: multi-root workspaces (Zed passes every
1956
2047
  // workspace root on session/new / session/load instead of showing
1957
2048
  // the "doesn't currently support multi-root workspaces" callout).
1958
- sessionCapabilities: { list: {}, delete: {}, additionalDirectories: {} },
2049
+ sessionCapabilities: { list: {}, delete: {}, additionalDirectories: {}, close: {} },
1959
2050
  // Image support is a live capability: the harness advertises
1960
2051
  // `image: true` only when the composition mounted a working
1961
2052
  // attachment store (duck-typed, so dsh 0.1.1-rc.2+ with
@@ -2014,8 +2105,14 @@ export function apply(ctx, config) {
2014
2105
  record.additionalDirectories = additionalDirectories
2015
2106
  sessions.set(sessionId, record)
2016
2107
  await syncMcpServers(params.mcpServers, params.cwd)
2017
- publishCommandsAfterResponse(record)
2018
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)
2019
2116
  return {
2020
2117
  sessionId,
2021
2118
  ...permission === undefined ? {} : {
@@ -2031,7 +2128,7 @@ export function apply(ctx, config) {
2031
2128
  }),
2032
2129
  },
2033
2130
  },
2034
- configOptions: await buildConfigOptions(record),
2131
+ configOptions,
2035
2132
  }
2036
2133
  },
2037
2134
 
@@ -2042,9 +2139,15 @@ export function apply(ctx, config) {
2042
2139
  const sessionId = SessionId(params.sessionId)
2043
2140
  const live = sessions.get(sessionId)
2044
2141
  if (live !== undefined) {
2045
- // Already live on this connection: return its state without replay.
2046
- // The client's root list is authoritative for the loaded workspace.
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.
2047
2149
  live.additionalDirectories = additionalDirectories
2150
+ await replayHistory(live)
2048
2151
  const permission = permissionPresets()
2049
2152
  return {
2050
2153
  ...permission === undefined ? {} : {
@@ -2091,8 +2194,12 @@ export function apply(ctx, config) {
2091
2194
  // Zed inserts the thread before the load RPC completes; replay the
2092
2195
  // conversation history as notifications so the thread renders.
2093
2196
  await replayHistory(record)
2094
- publishCommandsAfterResponse(record)
2095
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)
2096
2203
  return {
2097
2204
  ...permission === undefined ? {} : {
2098
2205
  modes: {
@@ -2107,7 +2214,7 @@ export function apply(ctx, config) {
2107
2214
  }),
2108
2215
  },
2109
2216
  },
2110
- configOptions: await buildConfigOptions(record),
2217
+ configOptions,
2111
2218
  }
2112
2219
  },
2113
2220
 
@@ -2216,6 +2323,7 @@ export function apply(ctx, config) {
2216
2323
  }
2217
2324
  const message = createUserMessage({ content: blocks, source: { kind: 'user' } })
2218
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()
2219
2327
  const stopReason = await new Promise((resolve, reject) => {
2220
2328
  const inflight = {
2221
2329
  resolve,
@@ -2243,13 +2351,19 @@ export function apply(ctx, config) {
2243
2351
  }
2244
2352
  })
2245
2353
  })
2354
+ trace({ event: 'prompt/settled', stopReason, elapsedMs: Date.now() - promptAt })
2246
2355
  return { stopReason }
2247
2356
  },
2248
2357
 
2249
2358
  cancel(params) {
2250
2359
  const record = sessions.get(SessionId(params.sessionId))
2251
2360
  if (record === undefined) return Promise.resolve()
2252
- record.agent.cancel({ kind: 'user' })
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'))
2253
2367
  settlePrompt(record, 'cancelled')
2254
2368
  return Promise.resolve()
2255
2369
  },
@@ -2383,6 +2497,24 @@ export function apply(ctx, config) {
2383
2497
  return { sessions: out }
2384
2498
  },
2385
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
+
2386
2518
  async deleteSession(params) {
2387
2519
  assertOpen()
2388
2520
  const sessionId = SessionId(params.sessionId)
@@ -2432,7 +2564,7 @@ export function apply(ctx, config) {
2432
2564
  const records = [...sessions.values()]
2433
2565
  sessions.clear()
2434
2566
  for (const record of records) {
2435
- record.agent.cancel({ kind: 'user' })
2567
+ record.agent.cancel(new Error('cancelled because the session closed'))
2436
2568
  settlePrompt(record, 'cancelled')
2437
2569
  }
2438
2570
  for (const { fiber } of mcpMounts.values()) {
@@ -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|read_text|cat|show/.test(name)) return '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.5.2",
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",