dsh-vscode-mode 0.12.0 → 0.13.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.
Files changed (71) hide show
  1. package/README.md +129 -103
  2. package/assets/shell/dsh-open.cs +65 -149
  3. package/assets/shell/dsh-open.mjs +241 -0
  4. package/assets/shell/dsh-open.ps1 +28 -47
  5. package/assets/shell/dsh-open.sh +25 -66
  6. package/lib/client.js +12465 -11526
  7. package/lib/client.js.map +1 -1
  8. package/lib/index.js +3889 -2336
  9. package/lib/index.js.map +1 -1
  10. package/package.json +9 -8
  11. package/skills/dsh-vscodemode-mcp/SKILL.md +94 -132
  12. package/src/childEnv.ts +19 -0
  13. package/src/client/addToConversation.ts +4 -2
  14. package/src/client/ai/inlineProvider.ts +1 -8
  15. package/src/client/commandBridge.ts +6 -2
  16. package/src/client/externalOpen.ts +40 -19
  17. package/src/client/index.ts +10 -4
  18. package/src/client/keybindings.ts +18 -1
  19. package/src/client/monaco/loader.ts +3 -1
  20. package/src/client/openFlow.ts +36 -28
  21. package/src/client/openReceipt.ts +64 -0
  22. package/src/client/panelCommands.ts +101 -0
  23. package/src/client/pdf/pdfLoader.ts +4 -2
  24. package/src/client/receiptState.ts +120 -0
  25. package/src/client/rpc.ts +30 -4
  26. package/src/client/shortcutRecorder.ts +161 -0
  27. package/src/client/shortcutsOfficial.ts +13 -4
  28. package/src/client/sidebarBridge.ts +7 -0
  29. package/src/client/ui/DiffBadge.ts +2 -1
  30. package/src/client/ui/EditorView.ts +237 -55
  31. package/src/client/ui/McpSettings.ts +6 -3
  32. package/src/client/ui/OfficialSideTab.ts +5 -2
  33. package/src/client/ui/QuickOpen.ts +10 -9
  34. package/src/client/ui/ShortcutSettings.ts +34 -54
  35. package/src/client/ui/commandCatalog.ts +5 -4
  36. package/src/client/useOpenReceipt.ts +128 -0
  37. package/src/compat.ts +5 -3
  38. package/src/dap/manager.ts +46 -13
  39. package/src/devForm.ts +252 -92
  40. package/src/externalHandoff.ts +93 -52
  41. package/src/fileOpenSettings.ts +1 -24
  42. package/src/hostImport.ts +19 -0
  43. package/src/httpGuard.ts +101 -0
  44. package/src/index.ts +106 -97
  45. package/src/integrate.ts +56 -50
  46. package/src/lsp/transport.ts +84 -127
  47. package/src/mcp.ts +58 -9
  48. package/src/mcpIsolation.ts +211 -102
  49. package/src/mcpProject.ts +220 -133
  50. package/src/mcpRuntime.ts +343 -0
  51. package/src/openBridge.ts +69 -0
  52. package/src/openInbox.ts +207 -0
  53. package/src/paths.ts +8 -5
  54. package/src/processTree.ts +63 -0
  55. package/src/reveal.ts +5 -3
  56. package/src/routes.ts +156 -154
  57. package/src/rpc.ts +14 -13
  58. package/src/shared/appUrl.ts +19 -0
  59. package/src/shared/externalOpen.ts +71 -0
  60. package/src/shared/integration.ts +2 -0
  61. package/src/shared/keybindings.ts +8 -5
  62. package/src/shared/mcp.ts +4 -1
  63. package/src/shared/rpc.ts +7 -4
  64. package/src/shared/svn.ts +6 -8
  65. package/src/svnPlanTool.ts +51 -0
  66. package/src/unityBridge.ts +26 -3
  67. package/unity/com.dsh.editor/Editor/DshCodeEditor.cs +99 -203
  68. package/unity/com.dsh.editor/Editor/DshOpenWorker.cs +166 -0
  69. package/unity/com.dsh.editor/Editor/DshOpenWorker.cs.meta +2 -0
  70. package/unity/com.dsh.editor/README.md +16 -25
  71. package/unity/com.dsh.editor/package.json +2 -2
@@ -11,22 +11,12 @@
11
11
  import React from 'react'
12
12
  import { COMMANDS } from '../keybindings.js'
13
13
  import { log } from '../log.js'
14
+ import { startRecording } from '../shortcutRecorder.js'
14
15
  import { resetPluginKeys } from '../shortcutsOfficial.js'
15
16
  import type { ShortcutsServiceLike, ShortcutCatalogRow } from '../shortcutsOfficial.js'
16
17
 
17
18
  const settingsLog = log.child('shortcut-settings')
18
19
 
19
- const MODIFIER_ONLY = new Set(['Control', 'Shift', 'Alt', 'Meta', 'AltGraph'])
20
-
21
- /** 录制键 → 官方绑定(物理 code + 修饰符;Ctrl/Cmd 同为 primary,与官方互认语义一致)。 */
22
- function bindingFromEvent(e) {
23
- const modifiers = []
24
- if (e.ctrlKey || e.metaKey) modifiers.push('primary')
25
- if (e.altKey) modifiers.push('alt')
26
- if (e.shiftKey) modifiers.push('shift')
27
- return { code: e.code, modifiers }
28
- }
29
-
30
20
  /** 目录行展示键位(keys 已按平台格式化;binding null = 未绑定)。 */
31
21
  function keysText(row) {
32
22
  if (!row || row.binding === null) return '未绑定'
@@ -114,7 +104,7 @@ function OfficialRow({ row }) {
114
104
  row.modified && React.createElement('small', { className: 'vsm-kb-warn' }, '已自定义'),
115
105
  row.issue && React.createElement('small', { className: 'vsm-kb-warn' }, '键位不受当前环境支持'),
116
106
  row.conflicts.length > 0 && React.createElement('small', { className: 'vsm-kb-warn' }, '与 ' + row.conflicts.join('、') + ' 冲突')),
117
- React.createElement('kbd', { className: 'vsm-kb-key' + (row.conflicts.length ? ' warn' : '') }, keysText(row)),
107
+ React.createElement('kbd', { 'aria-label': row?.aria, className: 'vsm-kb-key' + (row.conflicts.length ? ' warn' : '') }, keysText(row)),
118
108
  )
119
109
  }
120
110
 
@@ -122,11 +112,13 @@ function OfficialRow({ row }) {
122
112
  function OfficialShortcutsPane({ service }) {
123
113
  const [error, setError] = React.useState('')
124
114
  const rows = service.catalog.getSnapshot()
115
+ const shortcut = rows.find((row) => row.id === 'shortcuts.open')
116
+ const hint = shortcut?.keys?.length ? '(' + shortcut.keys.join(' ') + ')' : ''
125
117
  const config = service.config.getSnapshot()
126
118
  const openOfficial = () => {
127
119
  setError('')
128
120
  if (!service.registry || typeof service.registry.invoke !== 'function') {
129
- setError('官方弹窗入口不可用;可按 Ctrl+/ 或到 设置 → 通用 → 快捷键 打开')
121
+ setError('官方弹窗入口不可用;可到 设置 → 通用 → 快捷键 打开')
130
122
  return
131
123
  }
132
124
  try {
@@ -137,15 +129,15 @@ function OfficialShortcutsPane({ service }) {
137
129
  })
138
130
  } catch (e) {
139
131
  settingsLog.warn('打开官方快捷键弹窗失败:' + String(e))
140
- setError('打开官方弹窗失败:' + String(e) + ';可按 Ctrl+/ 或到 设置 → 通用 → 快捷键 打开')
132
+ setError('打开官方弹窗失败:' + String(e) + ';可到 设置 → 通用 → 快捷键 打开')
141
133
  }
142
134
  }
143
135
  return React.createElement(React.Fragment, null,
144
136
  React.createElement('section', { className: 'vsm-panel' },
145
137
  React.createElement('h3', { className: 'vsm-panel-title' }, '官方快捷键配置'),
146
138
  React.createElement('div', { className: 'vsm-panel-body' },
147
- React.createElement('p', null, '官方快捷键弹窗(Ctrl+/ 或 设置 → 通用 → 快捷键)提供全部命令的查看、录键与恢复默认;下方为当前目录只读总览(含插件注册的命令)。'),
148
- React.createElement('button', { className: 'vsm-primary vsm-small', onClick: openOfficial }, '打开官方快捷键设置(Ctrl+/)'),
139
+ React.createElement('p', null, '官方快捷键弹窗提供全部命令的查看、录键与恢复默认;也可从 设置 → 通用 → 快捷键 打开。下方为当前目录只读总览(含插件注册的命令)。'),
140
+ React.createElement('button', { className: 'vsm-primary vsm-small', onClick: openOfficial, 'aria-keyshortcuts': shortcut?.aria }, '打开官方快捷键设置' + hint),
149
141
  error && React.createElement('div', { className: 'vsm-mcp-error vsm-mcp-banner' }, error),
150
142
  config?.status === 'unreadable' && React.createElement('div', { className: 'vsm-mcp-error vsm-mcp-banner' },
151
143
  '快捷键配置读取失败(内容损坏或由更新版本写入),当前使用默认键位;请备份后清理配置,或升级 DSH。')),
@@ -161,6 +153,7 @@ function OfficialShortcutsPane({ service }) {
161
153
 
162
154
  /** 子页二:插件命令键位(行内录键 + 行级清除 + 恢复默认;写入走官方 edit API)。 */
163
155
  function PluginShortcutEditor({ service }) {
156
+ const [nativeReady, setNativeReady] = React.useState(false)
164
157
  const [recording, setRecording] = React.useState(null) // 正在录制的命令 id
165
158
  const [busy, setBusy] = React.useState(false)
166
159
  const [message, setMessage] = React.useState('')
@@ -225,46 +218,33 @@ function PluginShortcutEditor({ service }) {
225
218
  }
226
219
  }
227
220
 
228
- // 录制态:窗口捕获下一次组合键(Desktop 原生桥已经 recording(true) 暂停拦截)。
229
- // capture 阶段 preventDefault → 官方 DOM 派发因 defaultPrevented 放行,不会误触发命令。
230
- React.useEffect(() => {
221
+ /**
222
+ * Own capture and native suspension for the selected command's entire lifetime.
223
+ * @author ddj 2026年09月28号
224
+ * @returns Cleanup on cancel, save, failure, service change and unmount.
225
+ */
226
+ const recordEffect = () => {
231
227
  if (!recording) return undefined
232
- const onKey = (e) => {
233
- e.preventDefault()
234
- e.stopPropagation()
235
- if (e.key === 'Escape') {
236
- setRecording(null)
237
- return
238
- }
239
- if (e.key === 'Backspace' || e.key === 'Delete') {
240
- const id = recording
241
- setRecording(null)
242
- void saveBinding(id, null)
243
- return
244
- }
245
- if (MODIFIER_ONLY.has(e.key)) return
246
- const id = recording
247
- setRecording(null)
248
- void saveBinding(id, bindingFromEvent(e))
249
- }
250
- window.addEventListener('keydown', onKey, true)
251
- return () => window.removeEventListener('keydown', onKey, true)
252
- }, [recording])
253
-
254
- // 录制期间通知 Desktop 原生桥暂停拦截(Web 适配器为空实现)
255
- React.useEffect(() => {
256
- if (!service || typeof service.recording !== 'function') return undefined
257
- void service.recording(Boolean(recording)).catch(() => {})
258
- return () => {
259
- if (recording) void service.recording(false).catch(() => {})
260
- }
261
- }, [recording, service])
228
+ let mounted = true
229
+ setNativeReady(false)
230
+ /** @author ddj 2026年09月28号 Mark the native bridge ready to collect physical keys. */
231
+ const ready = () => { if (mounted) setNativeReady(true) }
232
+ /** @author ddj 2026年09月28号 Cancel capture without modifying preferences. */
233
+ const cancel = () => { if (mounted) setRecording(null) }
234
+ /** @author ddj 2026年09月28号 @param binding Accepted physical binding or reset. */
235
+ const save = (binding) => { if (mounted) { setRecording(null); void saveBinding(recording, binding) } }
236
+ /** @author ddj 2026年09月28号 @param reason Recorder failure to show while mounted. */
237
+ const fail = (reason) => { if (mounted) { setRecording(null); setError('录制失败:' + String(reason)) } }
238
+ const dispose = startRecording(service, window, { ready, cancel, save, error: fail })
239
+ return () => { mounted = false; dispose() }
240
+ }
241
+ React.useEffect(recordEffect, [recording, service])
262
242
 
263
243
  const rowsView = rows.map(({ id, label, row }) => {
264
244
  const recordingRow = recording === id
265
245
  const display = recordingRow
266
- ? React.createElement('span', { className: 'vsm-kb-rec' }, '按下组合键…(Esc 取消 / Backspace 清除)')
267
- : React.createElement('kbd', { className: 'vsm-kb-key' + (row && row.conflicts.length ? ' warn' : '') }, keysText(row))
246
+ ? React.createElement('span', { className: 'vsm-kb-rec' }, nativeReady ? '按下组合键…(Esc 取消 / Backspace 恢复默认)' : '正在准备录制…')
247
+ : React.createElement('kbd', { 'aria-label': row?.aria, className: 'vsm-kb-key' + (row && row.conflicts.length ? ' warn' : '') }, keysText(row))
268
248
  return React.createElement('div', { key: id, className: 'vsm-kb-row' + (recordingRow ? ' rec' : '') },
269
249
  React.createElement('div', { className: 'vsm-kb-label' },
270
250
  React.createElement('span', null, label),
@@ -276,8 +256,8 @@ function PluginShortcutEditor({ service }) {
276
256
  recordingRow
277
257
  ? React.createElement('button', { onClick: () => setRecording(null) }, '取消')
278
258
  : React.createElement(React.Fragment, null,
279
- React.createElement('button', { disabled: busy || config?.status !== 'ready', onClick: () => { setRecording(id); setMessage('') } }, '编辑'),
280
- React.createElement('button', { disabled: busy || config?.status !== 'ready', title: '清除该命令的键位(恢复默认)', onClick: () => void saveBinding(id, null) }, '清除'))),
259
+ React.createElement('button', { disabled: busy || Boolean(recording) || config?.status !== 'ready', onClick: () => { setRecording(id); setMessage('') } }, '编辑'),
260
+ React.createElement('button', { disabled: busy || Boolean(recording) || config?.status !== 'ready', title: '清除该命令的键位(恢复默认)', onClick: () => void saveBinding(id, null) }, '清除'))),
281
261
  )
282
262
  })
283
263
 
@@ -289,6 +269,6 @@ function PluginShortcutEditor({ service }) {
289
269
  React.createElement('span', { className: 'vsm-kb-dirty' }, recording ? '录制中…' : ''),
290
270
  message && React.createElement('span', { className: 'vsm-kb-message' }, message),
291
271
  error && React.createElement('span', { className: 'vsm-kb-warn' }, error),
292
- React.createElement('button', { className: 'vsm-small', disabled: busy || config?.status !== 'ready', onClick: () => void resetAll() }, '恢复默认')),
272
+ React.createElement('button', { className: 'vsm-small', disabled: busy || Boolean(recording) || config?.status !== 'ready', onClick: () => void resetAll() }, '恢复默认')),
293
273
  )
294
274
  }
@@ -6,6 +6,7 @@
6
6
  * 与既有 `edrv:` 刷新/主题事件分属不同命名空间,互不干扰)。
7
7
  * 作者 ddj 2026年09月10号
8
8
  */
9
+ import { panelAvailable, runPanelCommand } from '../panelCommands.js'
9
10
  import { hasEditorModel, hasOpenTabs } from '../editorModelState.js'
10
11
  import { activeEditorPath } from '../activePathStore.js'
11
12
  import { isMarkdownPath } from '../markdownPreview.js'
@@ -201,8 +202,8 @@ export const EDITOR_COMMANDS: readonly CommandDef[] = [
201
202
  },
202
203
  {
203
204
  id: 'edrv.quickOpen', label: '快速打开文件', category: '文件', order: 20,
204
- available: alwaysAvailable,
205
- run: () => emit('quickOpen'),
205
+ available: () => panelAvailable('quickOpen'),
206
+ run: () => { runPanelCommand('quickOpen') },
206
207
  },
207
208
  {
208
209
  id: 'edrv.toggleSidebar', label: '切换侧边栏', category: '视图', order: 10,
@@ -211,8 +212,8 @@ export const EDITOR_COMMANDS: readonly CommandDef[] = [
211
212
  },
212
213
  {
213
214
  id: 'edrv.searchInFiles', label: '在工作区中搜索', category: '视图', order: 20,
214
- available: alwaysAvailable,
215
- run: () => emit('searchInFiles'),
215
+ available: () => panelAvailable('searchInFiles'),
216
+ run: () => { runPanelCommand('searchInFiles') },
216
217
  },
217
218
  {
218
219
  id: 'edrv.toggleMarkdownPreview', label: '切换 Markdown 预览', category: '视图', order: 25,
@@ -0,0 +1,128 @@
1
+ /** Correlated editor readiness for external file opens. @author ddj 2026年09月28号 */
2
+ import React from 'react'
3
+ import { finishOpen, hasOpen, peekOpen } from './openReceipt.js'
4
+ import type { EditorRequest } from './openReceipt.js'
5
+
6
+ export interface ReceiptHost {
7
+ sessionId: string
8
+ open(request: EditorRequest): void | Promise<void>
9
+ ready(request: EditorRequest): boolean
10
+ error(request: EditorRequest): string | null
11
+ }
12
+
13
+ /** One mounted editor owns at most one claimed external request. */
14
+ export class OpenMonitor {
15
+ private pending: EditorRequest | undefined
16
+ private frame: number | undefined
17
+ private stopped = false
18
+ private opening = false
19
+ /** @private @author ddj 2026年09月28号 Claim a request arriving after mount. */
20
+ private listener = (): void => { this.claim() }
21
+
22
+ /** @author ddj 2026年09月28号 @param read Read current component state without a stale closure. */
23
+ constructor(private readonly read: () => ReceiptHost) {}
24
+
25
+ /** @public @author ddj 2026年09月28号 Attach the listener and claim a pre-mount request. */
26
+ start(): void {
27
+ window.addEventListener('edrv:open-request', this.listener)
28
+ this.claim()
29
+ }
30
+
31
+ /** @public @author ddj 2026年09月28号 Fail claimed work and cancel the frame on unmount. */
32
+ stop(): void {
33
+ this.stopped = true
34
+ window.removeEventListener('edrv:open-request', this.listener)
35
+ if (this.frame !== undefined) cancelAnimationFrame(this.frame)
36
+ this.frame = undefined
37
+ if (this.pending) finishOpen(this.pending.requestId, false, '编辑器已关闭')
38
+ this.pending = undefined
39
+ }
40
+
41
+ /** @private @author ddj 2026年09月28号 Schedule at most one readiness/queue check. */
42
+ private arm(): void {
43
+ if (!this.stopped && this.frame === undefined) this.frame = requestAnimationFrame(this.check)
44
+ }
45
+
46
+ /**
47
+ * Settle only the request this instance still owns; queued work runs next frame.
48
+ * @private @author ddj 2026年09月28号
49
+ * @param request Claimed request, captured before asynchronous work.
50
+ * @param success Whether actual readiness was confirmed.
51
+ * @param error Failure description.
52
+ */
53
+ private finish(request: EditorRequest, success: boolean, error?: string): void {
54
+ if (this.stopped || this.pending !== request) return
55
+ this.pending = undefined
56
+ this.opening = false
57
+ if (hasOpen(request.requestId)) finishOpen(request.requestId, success, error)
58
+ this.arm()
59
+ }
60
+
61
+ /**
62
+ * Observe startup without letting a late completion affect a new claim.
63
+ * @private @author ddj 2026年09月28号
64
+ * @param request Startup's request identity.
65
+ * @param operation Optional asynchronous editor startup.
66
+ */
67
+ private watchOpen(request: EditorRequest, operation: void | Promise<void>): void {
68
+ if (!operation) return
69
+ this.opening = true
70
+ /** @author ddj 2026年09月28号 Release readiness only for this live claim. */
71
+ const ready = (): void => {
72
+ if (!this.stopped && this.pending === request) this.opening = false
73
+ }
74
+ /** @author ddj 2026年09月28号 @param error Correlated startup rejection. */
75
+ const fail = (error: unknown): void => this.finish(request, false, String(error))
76
+ void Promise.resolve(operation).then(ready, fail)
77
+ }
78
+
79
+ /** @private @author ddj 2026年09月28号 Claim this session; exceptions fail the exact request. */
80
+ private claim(): void {
81
+ if (this.stopped || (this.pending && hasOpen(this.pending.requestId))) return
82
+ this.pending = undefined
83
+ this.opening = false
84
+ try {
85
+ const host = this.read()
86
+ const request = peekOpen(host.sessionId)
87
+ if (!request) return
88
+ this.pending = request
89
+ this.watchOpen(request, host.open(request))
90
+ this.arm()
91
+ } catch (error) {
92
+ if (this.pending) this.finish(this.pending, false, String(error))
93
+ }
94
+ }
95
+
96
+ /** @private @author ddj 2026年09月28号 Check readiness; expired claims and exceptions cannot ACK later work. */
97
+ private check = (): void => {
98
+ this.frame = undefined
99
+ if (this.stopped) return
100
+ const request = this.pending
101
+ if (!request || !hasOpen(request.requestId)) { this.pending = undefined; this.claim(); return }
102
+ try {
103
+ const host = this.read()
104
+ if (request.sessionId !== host.sessionId) { this.finish(request, false, '会话已切换'); return }
105
+ if (this.opening) { this.arm(); return }
106
+ const error = host.error(request)
107
+ if (error || host.ready(request)) { this.finish(request, !error, error ?? undefined); return }
108
+ this.arm()
109
+ } catch (error) { this.finish(request, false, String(error)) }
110
+ }
111
+ }
112
+
113
+ /**
114
+ * Bind a component while reading its latest model state.
115
+ * @author ddj 2026年09月28号
116
+ * @param host Mounted editor actions and readiness checks.
117
+ */
118
+ export function useOpenReceipt(host: ReceiptHost): void {
119
+ const latest = React.useRef(host)
120
+ latest.current = host
121
+ /** @author ddj 2026年09月28号 @returns Listener and animation-frame cleanup. */
122
+ function mount(): () => void {
123
+ const monitor = new OpenMonitor(() => latest.current)
124
+ monitor.start()
125
+ return () => monitor.stop()
126
+ }
127
+ React.useEffect(mount, [host.sessionId])
128
+ }
package/src/compat.ts CHANGED
@@ -132,8 +132,10 @@ export function detectGuards(ctx: Ctx): CompatAdapter[] {
132
132
  ]
133
133
  }
134
134
 
135
- /** 已实测覆盖的最高 DSH 版本(适配矩阵上界,超过则提示,见 buildReport)。 */
136
- const TESTED_DSH_MAX = '0.1.7-alpha.2'
135
+ /** 已实测覆盖的最高 DSH 版本(适配矩阵上界,超过则提示,见 buildReport)。
136
+ * 0.1.7-rc.2 已在官方已签名打包 Desktop 上实机装配(认证路由、同源 RPC、项目 MCP
137
+ * 与私有打开队列均生效);更高版本仍按能力探测运行并提示。 */
138
+ const TESTED_DSH_MAX = '0.1.7-rc.2'
137
139
 
138
140
  /** Config volatile 标记起效的版本线(此前设置走 section 安装,与 volatile 无关)。 */
139
141
  const CONFIG_VOLATILE_MIN = '0.1.7-alpha.1'
@@ -210,7 +212,7 @@ export async function buildReport(
210
212
  guards,
211
213
  adapters,
212
214
  warnings,
213
- devForm: readDevForm(),
215
+ devForm: readDevForm(ctx),
214
216
  dshVersion,
215
217
  }
216
218
  }
@@ -18,6 +18,8 @@
18
18
  * 作者 ddj 2026年09月29号 / 2026年09月21号
19
19
  */
20
20
  import { spawn, type ChildProcess } from 'node:child_process'
21
+ import { childEnv } from '../childEnv.js'
22
+ import { stopTree } from '../processTree.js'
21
23
  import { join } from 'node:path'
22
24
  import { createFrameParser, encodeMessage } from '../lsp/jsonrpc.js'
23
25
  import { sourceBaseOf, sourceKeyOf, sourcePathOf, normalizeSourcePath } from './sourcePath.js'
@@ -288,9 +290,18 @@ export class DapSession {
288
290
 
289
291
  // ---------------------------------------------------------------- 内部实现
290
292
 
291
- /** spawn 适配器进程并接好帧解析。 */
293
+ /**
294
+ * 启动独立适配器进程组并接入协议,清理隐式宿主凭据。
295
+ * @private
296
+ * @author ddj 2026年09月28号
297
+ * @param spec 适配器规格
298
+ * @throws spawn 同步失败
299
+ */
292
300
  private spawnAdapter(spec: DapAdapterSpec): void {
293
- const child = spawn(spec.command, spec.args, { stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true })
301
+ const child = spawn(spec.command, spec.args, {
302
+ stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true,
303
+ detached: process.platform !== 'win32', env: this.adapterEnv(),
304
+ })
294
305
  this.child = child
295
306
  const parser = createFrameParser()
296
307
  child.stdout?.on('data', (chunk: Buffer) => {
@@ -300,11 +311,16 @@ export class DapSession {
300
311
  log.debug('[dap-adapter] ' + chunk.toString('utf8').trim())
301
312
  this.stderrTail = (this.stderrTail + chunk.toString('utf8')).slice(-STDERR_TAIL_CAP)
302
313
  })
303
- child.on('error', (error) => this.fail('适配器进程错误:' + String(error)))
314
+ child.on('error', (error) => {
315
+ if (this.child === child) this.fail('适配器进程错误:' + String(error))
316
+ })
317
+ child.stdin?.on('error', (error) => {
318
+ if (this.child === child) this.fail('适配器 stdin 错误:' + String(error))
319
+ })
304
320
  child.on('exit', (code) => {
305
- if (this.child === child && this.phase !== 'idle' && this.phase !== 'terminated') {
306
- this.fail('适配器退出(code=' + code + ')' + this.stderrSuffix())
307
- }
321
+ if (this.child !== child) return
322
+ if (this.phase === 'idle' || this.phase === 'terminated') this.killChild()
323
+ else this.fail('适配器退出(code=' + code + ')' + this.stderrSuffix())
308
324
  })
309
325
  }
310
326
 
@@ -529,18 +545,35 @@ export class DapSession {
529
545
  this.setPhase('terminated')
530
546
  }
531
547
 
532
- /** 杀适配器进程树(Windows taskkill /T /F,同 LSP transport 口径)。 */
548
+ /**
549
+ * 清理父环境后合并 launch 配置显式 env,null 表示移除该项。
550
+ * @private
551
+ * @author ddj 2026年09月28号
552
+ * @returns 适配器的环境副本
553
+ */
554
+ private adapterEnv(): NodeJS.ProcessEnv {
555
+ const explicit: NodeJS.ProcessEnv = {}
556
+ const raw = this.config?.raw?.env
557
+ if (raw && typeof raw === 'object' && !Array.isArray(raw)) {
558
+ for (const [key, value] of Object.entries(raw)) {
559
+ if (typeof value === 'string' || value === null) explicit[key] = value ?? undefined
560
+ }
561
+ }
562
+ return childEnv(explicit)
563
+ }
564
+
565
+ /**
566
+ * 解除会话拥有权并终止适配器进程树;不先杀根进程。
567
+ * @private
568
+ * @author ddj 2026年09月28号
569
+ * @description 同时清理所有待处理请求与计时器。
570
+ */
533
571
  private killChild(): void {
534
572
  const child = this.child
535
573
  this.child = null
536
574
  for (const entry of this.pending.values()) { clearTimeout(entry.timer); entry.resolve(null) }
537
575
  this.pending.clear()
538
- if (!child) return
539
- try { child.kill() } catch { /* 忽略 */ }
540
- if (process.platform === 'win32' && child.pid) {
541
- try { spawn('taskkill', ['/PID', String(child.pid), '/T', '/F'], { windowsHide: true, stdio: 'ignore' }) } catch { /* 忽略 */ }
542
- }
543
- setTimeout(() => { try { child.kill('SIGKILL') } catch { /* 忽略 */ } }, 1500).unref?.()
576
+ if (child) stopTree(child, (line) => log.warn('[dap] ' + line))
544
577
  }
545
578
 
546
579
  /** 相位切换(running→running 等幂等跳过)。 */