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 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
 
@@ -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
- const isBlankSession = (session) => !session.events.some((event) => event.type === 'turn/start')
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
- /** The preset a persisted session runs, or undefined when unrecorded. */
480
- async function persistedPresetOf(sessionId) {
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 resolveSessionPreset({ header: meta, events })
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: 'Model-facing tool/prompt composition for this session (switch only while blank).',
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: runningPresetOf(record.agent.session) ?? '',
1100
- options: mountable.map((preset) => ({
1101
- value: preset.id,
1102
- name: preset.name ?? preset.id,
1103
- ...preset.description === undefined ? {} : { description: preset.description },
1104
- })),
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 labels = (question.options ?? []).map((option) => option.label)
1425
- if (question.multiSelect === true) {
1426
- properties[question.id] = {
1427
- type: 'array',
1428
- ...labels.length > 0 ? { items: { type: 'string', enum: labels } } : { items: { type: 'string' } },
1429
- description: question.question,
1430
- }
1431
- } else if (labels.length > 0) {
1432
- 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 }
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
- 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
+ }
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: await buildConfigOptions(record),
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: return its state without replay.
2008
- // 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.
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 persistedPresetOf(sessionId)
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: await buildConfigOptions(record),
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
- 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'))
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({ kind: 'user' })
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()) {
@@ -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.1",
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",