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
package/src/devForm.ts CHANGED
@@ -1,133 +1,293 @@
1
- /**
2
- * dsh-vscode-mode host — 开发形态管理(安装形态切换)。
3
- * 开发形态 = profile 中以 link: 依赖 + junction 指向工作区的安装(用于插件开发);
4
- * 关闭 = 改回版本依赖 + 删 junction + pnpm install 装配正式版。
5
- * 切换后需重启 DSH 生效(运行中的代码仍是旧形态)。
6
- * 与 compat.ts 存在模块环(devForm 用 pluginVersionOf、compat 用 readDevForm),
7
- * 双方仅在函数体内交叉使用,模块求值期无依赖,ESM 安全。
8
- * 作者 ddj 2026-08-24
9
- */
10
- import { existsSync, readFileSync, readdirSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'
11
- import { dirname, join } from 'node:path'
12
- import { homedir } from 'node:os'
1
+ /** 当前运行 profile 的开发形态切换;失败恢复 manifest、lockfile 与原安装入口。 */
2
+ import { existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, statSync, symlinkSync, unlinkSync, writeFileSync } from 'node:fs'
3
+ import { isAbsolute, join, relative, resolve } from 'node:path'
13
4
  import { PLUGIN_NAME, pluginVersionOf } from './compat.js'
5
+ import { dshHome } from './paths.js'
6
+ import { childEnv } from './childEnv.js'
14
7
  import type { DevFormInfo } from './shared/compat.js'
15
8
  import type { Ctx } from './store.js'
16
9
 
17
- /** 扫描 $DSH_HOME/profiles 找到依赖本插件的 profile 目录(无 env 依赖,多 profile 安全)。 */
18
- export function findProfileDir(): string | undefined {
19
- const home = process.env.DSH_HOME ?? join(homedir(), '.dsh')
10
+ /** 官方 dsh profile-boot 提供的当前运行事实;不从 inventory 猜测。 */
11
+ interface ProfileContext {
12
+ dir: string
13
+ packageManager?: { command: string; args?: string[]; env?: NodeJS.ProcessEnv }
14
+ }
15
+ type SwitchResult = { ok: boolean; error?: string; restart: boolean }
16
+ interface ProfileBackup {
17
+ dir: string
18
+ link: string
19
+ savedLink: string
20
+ hadLink: boolean
21
+ files: { path: string; text: Buffer | undefined }[]
22
+ }
23
+ const switching = new Set<string>()
24
+
25
+ // --region 运行上下文
26
+ /**
27
+ * 获取官方 profileContext 服务;服务存在但无有效目录时拒绝猜测。
28
+ * @private
29
+ * @author ddj 2026年09月28号
30
+ * @param ctx 可选运行上下文
31
+ * @returns 官方服务对象或缺失
32
+ * @throws 服务对象目录无效
33
+ */
34
+ function profileOf(ctx?: Ctx): ProfileContext | undefined {
35
+ const profile = ctx?.get?.('profileContext')
36
+ if (profile === undefined || profile === null) return undefined
37
+ if (typeof profile.dir !== 'string' || !isAbsolute(profile.dir)) throw new Error('当前 profileContext.dir 无效')
38
+ return profile as ProfileContext
39
+ }
40
+
41
+ /**
42
+ * 检查 manifest 是否明确依赖本插件;坏 manifest 不作候选。
43
+ * @private
44
+ * @author ddj 2026年09月28号
45
+ * @param dir profile 目录
46
+ * @returns 是否包含插件依赖
47
+ */
48
+ function ownsPlugin(dir: string): boolean {
20
49
  try {
21
- for (const name of readdirSync(join(home, 'profiles'))) {
22
- const pkgFile = join(home, 'profiles', name, 'package.json')
23
- if (!existsSync(pkgFile)) continue
24
- try {
25
- const pkg = JSON.parse(readFileSync(pkgFile, 'utf8')) as { dependencies?: Record<string, unknown> } | undefined
26
- if (pkg?.dependencies?.[PLUGIN_NAME]) return dirname(pkgFile)
27
- } catch {
28
- /* 坏 manifest 跳过该 profile */
29
- }
30
- }
31
- } catch {
32
- /* profiles 目录不存在时放弃 */
33
- }
34
- return undefined
50
+ const pkg = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8').replace(/^\uFEFF/, ''))
51
+ return typeof pkg?.dependencies?.[PLUGIN_NAME] === 'string'
52
+ } catch { return false }
35
53
  }
36
54
 
37
55
  /**
38
- * 读取当前开发形态(manifest link: 判定)。
39
- * @author ddj 2026年08月24号
40
- * @returns 开发形态状态
56
+ * 优先使用真实运行 profile;旧宿主仅允许唯一候选回退,多候选拒绝。
57
+ * @public
58
+ * @author ddj 2026年09月28号
59
+ * @param ctx DSH host 上下文(可选,旧宿主兼容)
60
+ * @returns 当前 profile 目录;无有效或唯一候选时 undefined
41
61
  */
42
- export function readDevForm(): DevFormInfo {
43
- const profileDir = findProfileDir()
44
- if (!profileDir) return { enabled: false }
62
+ export function findProfileDir(ctx?: Ctx): string | undefined {
45
63
  try {
46
- const text = readFileSync(join(profileDir, 'package.json'), 'utf8').replace(/^\uFEFF/, '')
47
- const pkg = JSON.parse(text) as { dependencies?: Record<string, unknown> } | undefined
64
+ const profile = profileOf(ctx)
65
+ if (profile) return ownsPlugin(profile.dir) ? profile.dir : undefined
66
+ const homePath = ctx?.get?.('dshHomePath')
67
+ const root = join(typeof homePath === 'function' ? homePath() : dshHome(), 'profiles')
68
+ const candidates = readdirSync(root).map((name) => join(root, name)).filter(ownsPlugin)
69
+ return candidates.length === 1 ? candidates[0] : undefined
70
+ } catch { return undefined }
71
+ }
72
+
73
+ /**
74
+ * 读取选中 profile 的 link 依赖状态。
75
+ * @private
76
+ * @author ddj 2026年09月28号
77
+ * @param dir 已选定目录
78
+ * @returns 开发形态;无法读取时未开启
79
+ */
80
+ function readProfileForm(dir: string): DevFormInfo {
81
+ try {
82
+ const pkg = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8').replace(/^\uFEFF/, ''))
48
83
  const spec = pkg?.dependencies?.[PLUGIN_NAME]
49
- if (typeof spec === 'string' && spec.startsWith('link:')) {
50
- return { enabled: true, path: spec.slice('link:'.length) }
51
- }
52
- } catch {
53
- /* manifest 不可读视为非开发形态 */
54
- }
84
+ if (typeof spec === 'string' && spec.startsWith('link:')) return { enabled: true, path: spec.slice(5) }
85
+ } catch { /* 只读探测失败 */ }
55
86
  return { enabled: false }
56
87
  }
57
88
 
58
89
  /**
59
- * 规划切换后的 manifest 文本(纯函数,可单测;非法 JSON 抛错)。
60
- * @author ddj 2026年08月24号
90
+ * 读取当前运行 profile 的开发形态。
91
+ * @public
92
+ * @author ddj 2026年09月28号
93
+ * @param ctx DSH host 上下文
94
+ * @returns 开发形态状态
95
+ */
96
+ export function readDevForm(ctx?: Ctx): DevFormInfo {
97
+ const dir = findProfileDir(ctx)
98
+ return dir ? readProfileForm(dir) : { enabled: false }
99
+ }
100
+ // --endregion
101
+
102
+ /**
103
+ * 规划切换后的 manifest,保留其它字段。
104
+ * @public
105
+ * @author ddj 2026年09月28号
61
106
  * @param text 原 manifest 文本
62
- * @param enabled 目标形态(true=link:,false=版本)
63
- * @param path 工作区路径(开启时必填)
64
- * @param version 版本号(关闭时使用,写入 ^<version>)
65
- * @returns 新 manifest 文本(无 BOM,2 空格缩进)
107
+ * @param enabled true 为开发链接
108
+ * @param path 开发目录
109
+ * @param version 正式版本号
110
+ * @returns 新 manifest 文本
111
+ * @throws JSON 或 dependencies 无效
66
112
  */
67
113
  export function planManifest(text: string, enabled: boolean, path: string, version: string): string {
68
114
  const data = JSON.parse(text.replace(/^\uFEFF/, '')) as { dependencies?: Record<string, unknown> }
69
- if (!data.dependencies || typeof data.dependencies !== 'object') {
115
+ if (!data.dependencies || typeof data.dependencies !== 'object' || Array.isArray(data.dependencies)) {
70
116
  throw new Error('profile package.json 缺少 dependencies 字段')
71
117
  }
72
118
  data.dependencies[PLUGIN_NAME] = enabled ? 'link:' + path.replace(/\\/g, '/') : '^' + version
73
119
  return JSON.stringify(data, null, 2) + '\n'
74
120
  }
75
121
 
76
- /** 建立或移除 node_modules 下的开发 junction(目标不存在时开启失败)。 */
77
- function applyJunction(profileDir: string, enabled: boolean, target: string): void {
78
- const linkDir = join(profileDir, 'node_modules', PLUGIN_NAME)
79
- if (existsSync(linkDir)) rmSync(linkDir, { recursive: true, force: true })
80
- if (!enabled) return
81
- if (!existsSync(target)) throw new Error('工作区路径不存在:' + target)
82
- symlinkSync(target, linkDir, 'junction')
122
+ // --region 切换事务
123
+ /**
124
+ * 检查开发目录属于本插件且不位于会被替换的 node_modules 内。
125
+ * @private
126
+ * @author ddj 2026年09月28号
127
+ * @param dir profile 目录
128
+ * @param target 用户目标路径
129
+ * @returns 真实绝对目录
130
+ * @throws 目录、包身份或位置不安全
131
+ */
132
+ function checkTarget(dir: string, target: string): string {
133
+ if (!target || !isAbsolute(target)) throw new Error('开启开发形态需要提供工作区绝对路径')
134
+ const full = realpathSync(target)
135
+ if (!statSync(full).isDirectory()) throw new Error('工作区路径不是目录:' + target)
136
+ const pkg = JSON.parse(readFileSync(join(full, 'package.json'), 'utf8').replace(/^\uFEFF/, ''))
137
+ if (pkg?.name !== PLUGIN_NAME) throw new Error('工作区 package.json.name 必须是 ' + PLUGIN_NAME)
138
+ const modules = join(dir, 'node_modules')
139
+ const moduleRoot = existsSync(modules) ? realpathSync(modules) : resolve(realpathSync(dir), 'node_modules')
140
+ const inside = relative(moduleRoot, full)
141
+ if (!inside || (!inside.startsWith('..' + (process.platform === 'win32' ? '\\' : '/')) && !isAbsolute(inside))) {
142
+ throw new Error('开发源码不能位于当前 profile 的 node_modules 内')
143
+ }
144
+ return full
145
+ }
146
+
147
+ /**
148
+ * 删除安装入口;链接仅 unlink,绝不递归其源码目标。
149
+ * @private
150
+ * @author ddj 2026年09月28号
151
+ * @param path 安装入口或备份入口
152
+ * @throws 删除失败
153
+ */
154
+ function removeEntry(path: string): void {
155
+ const info = lstatSync(path, { throwIfNoEntry: false })
156
+ if (!info) return
157
+ if (info.isSymbolicLink()) unlinkSync(path)
158
+ else rmSync(path, { recursive: true, force: true })
159
+ }
160
+
161
+ /**
162
+ * 在首次写入前保存 manifest/lockfile 原字节,并移动原安装入口保留链接身份。
163
+ * @private
164
+ * @author ddj 2026年09月28号
165
+ * @param dir profile 目录
166
+ * @returns 可回滚的备份
167
+ * @throws 备份失败;尚未修改 manifest
168
+ */
169
+ function backupProfile(dir: string): ProfileBackup {
170
+ const files = ['package.json', 'pnpm-lock.yaml'].map((name) => {
171
+ const path = join(dir, name)
172
+ return { path, text: existsSync(path) ? readFileSync(path) : undefined }
173
+ })
174
+ const backupDir = mkdtempSync(join(dir, '.vscode-mode-backup-'))
175
+ const link = join(dir, 'node_modules', PLUGIN_NAME)
176
+ const savedLink = join(backupDir, 'module')
177
+ const hadLink = !!lstatSync(link, { throwIfNoEntry: false })
178
+ try {
179
+ for (const file of files) {
180
+ if (file.text !== undefined) writeFileSync(join(backupDir, relative(dir, file.path)), file.text)
181
+ }
182
+ if (hadLink) renameSync(link, savedLink)
183
+ return { dir: backupDir, link, savedLink, hadLink, files }
184
+ } catch (error) {
185
+ rmSync(backupDir, { recursive: true, force: true })
186
+ throw error
187
+ }
83
188
  }
84
189
 
85
190
  /**
86
- * 切换开发形态:改 manifest → 建/删 junction → pnpm install(subprocess)。
87
- * @author ddj 2026年08月24号
88
- * @param ctx DSH host 上下文(subprocess 服务)
191
+ * 恢复原安装入口和配置文件;恢复失败时保留备份供恢复。
192
+ * @private
193
+ * @author ddj 2026年09月28号
194
+ * @param saved 原状态备份
195
+ * @throws 回滚失败,错误包含备份位置
196
+ */
197
+ function restoreProfile(saved: ProfileBackup): void {
198
+ const failures: string[] = []
199
+ try {
200
+ removeEntry(saved.link)
201
+ if (saved.hadLink) renameSync(saved.savedLink, saved.link)
202
+ } catch (error) { failures.push(String(error)) }
203
+ for (const file of saved.files) {
204
+ try {
205
+ if (file.text === undefined) rmSync(file.path, { force: true })
206
+ else writeFileSync(file.path, file.text)
207
+ } catch (error) { failures.push(String(error)) }
208
+ }
209
+ if (failures.length) throw new Error('回滚失败,备份保留于 ' + saved.dir + ':' + failures.join('; '))
210
+ rmSync(saved.dir, { recursive: true, force: true })
211
+ }
212
+
213
+ /**
214
+ * 执行已预检的切换;安装失败时恢复原入口及 manifest/lockfile。
215
+ * @private
216
+ * @author ddj 2026年09月28号
217
+ * @param ctx host 上下文
218
+ * @param dir 已确定的 profile
89
219
  * @param enabled 目标形态
90
- * @param path 工作区绝对路径(开启时必填)
91
- * @returns 切换结果(restart=true 表示需重启 DSH 生效)
220
+ * @param target 已预检目标
221
+ * @returns 事务结果;成功需重启
92
222
  */
93
- export async function setDevForm(ctx: Ctx, enabled: boolean, path?: string): Promise<{ ok: boolean; error?: string; restart: boolean }> {
94
- const profileDir = findProfileDir()
95
- if (!profileDir) return { ok: false, error: '未找到依赖本插件的 profile(检查 DSH_HOME/profiles)', restart: false }
96
- const current = readDevForm()
97
- const target = (path ?? current.path ?? '').replace(/\\/g, '/')
98
- if (enabled && !target) return { ok: false, error: '开启开发形态需要提供工作区路径(path 参数)', restart: false }
99
- if (enabled === current.enabled && (!enabled || target === current.path)) {
100
- return { ok: true, restart: false }
223
+ async function switchProfile(ctx: Ctx, dir: string, enabled: boolean, target: string): Promise<SwitchResult> {
224
+ const version = pluginVersionOf()
225
+ if (!version) throw new Error('读取插件版本失败,无法写入版本依赖')
226
+ const next = planManifest(readFileSync(join(dir, 'package.json'), 'utf8'), enabled, target, version)
227
+ const saved = backupProfile(dir)
228
+ try {
229
+ writeFileSync(join(dir, 'package.json'), next, 'utf8')
230
+ mkdirSync(join(dir, 'node_modules'), { recursive: true })
231
+ if (enabled) symlinkSync(target, saved.link, 'junction')
232
+ await runPnpmInstall(ctx, dir)
233
+ } catch (error) {
234
+ restoreProfile(saved)
235
+ throw error
101
236
  }
102
237
  try {
103
- const pkgFile = join(profileDir, 'package.json')
104
- const version = pluginVersionOf()
105
- if (!version) return { ok: false, error: '读取插件版本失败,无法写入版本依赖', restart: false }
106
- const next = planManifest(readFileSync(pkgFile, 'utf8').replace(/^\uFEFF/, ''), enabled, target, version)
107
- writeFileSync(pkgFile, next, 'utf8')
108
- applyJunction(profileDir, enabled, target)
109
- const installError = await runPnpmInstall(ctx, profileDir)
110
- return { ok: !installError, error: installError, restart: true }
238
+ removeEntry(saved.savedLink)
239
+ rmSync(saved.dir, { recursive: true, force: true })
111
240
  } catch (error) {
112
- return { ok: false, error: String(error), restart: false }
241
+ return { ok: true, error: '切换成功,旧安装备份清理失败:' + saved.dir + ':' + String(error), restart: true }
113
242
  }
243
+ return { ok: true, restart: true }
114
244
  }
115
245
 
116
- /** 在 profile 目录执行 pnpm install(subprocess 契约:spawn({argv,cwd,stdio,graceMs}))。 */
117
- async function runPnpmInstall(ctx: Ctx, profileDir: string): Promise<string | undefined> {
118
- const sub = ctx.get('subprocess')
119
- if (!sub || typeof sub.spawn !== 'function') return 'manifest/junction 已更新,但缺少 subprocess:请手动在 profile 目录运行 pnpm install'
120
- const handle = sub.spawn({
121
- argv: ['pnpm', 'install'],
246
+ /**
247
+ * 预检运行 profile、目标与安装服务,再串行执行可回滚切换。
248
+ * @public
249
+ * @author ddj 2026年09月28号
250
+ * @param ctx DSH host 上下文
251
+ * @param enabled 目标形态
252
+ * @param path 开发工作区绝对路径
253
+ * @returns 结果与是否需重启;失败不提交切换
254
+ */
255
+ export async function setDevForm(ctx: Ctx, enabled: boolean, path?: string): Promise<SwitchResult> {
256
+ const dir = findProfileDir(ctx)
257
+ if (!dir) return { ok: false, error: '无法唯一定位当前 profile;请检查 profileContext 或 DSH_HOME/profiles', restart: false }
258
+ const key = process.platform === 'win32' ? resolve(dir).toLowerCase() : resolve(dir)
259
+ if (switching.has(key)) return { ok: false, error: '该 profile 正在切换开发形态', restart: false }
260
+ switching.add(key)
261
+ try {
262
+ const current = readProfileForm(dir)
263
+ const raw = path ?? current.path ?? ''
264
+ const target = enabled ? checkTarget(dir, raw) : ''
265
+ if (enabled === current.enabled && (!enabled || resolve(dir, current.path ?? '') === target)) return { ok: true, restart: false }
266
+ if (typeof ctx?.get?.('subprocess')?.spawn !== 'function') throw new Error('缺少 subprocess 服务,未修改 profile')
267
+ return await switchProfile(ctx, dir, enabled, target)
268
+ } catch (error) {
269
+ return { ok: false, error: String(error), restart: false }
270
+ } finally { switching.delete(key) }
271
+ }
272
+
273
+ /**
274
+ * 使用官方 profileContext.packageManager 运行 Desktop 内置 pnpm;旧宿主使用 PATH。
275
+ * @private
276
+ * @author ddj 2026年09月28号
277
+ * @param ctx host 上下文
278
+ * @param profileDir profile 工作目录
279
+ * @throws 启动失败或非零退出;只需退出码,stdio 使用 inherit
280
+ */
281
+ async function runPnpmInstall(ctx: Ctx, profileDir: string): Promise<void> {
282
+ const runner = profileOf(ctx)?.packageManager
283
+ const handle = ctx.get('subprocess').spawn({
284
+ argv: [runner?.command ?? 'pnpm', ...runner?.args ?? [], 'install'],
122
285
  cwd: profileDir,
123
- stdio: { stdout: { maxBytes: 1 << 16 }, stderr: { maxBytes: 1 << 16 }, stdin: 'ignore' },
286
+ env: childEnv(runner?.env),
287
+ stdio: { stdout: 'inherit', stderr: 'inherit', stdin: 'ignore' },
124
288
  graceMs: 120000,
125
289
  })
126
290
  const outcome = await handle.done
127
- const code = outcome?.exitCode ?? outcome?.code
128
- if (code !== 0) {
129
- const stderr = handle.collected?.stderr?.readFrom(0).text ?? ''
130
- return 'pnpm install 失败(exit ' + code + '):' + stderr.slice(0, 500)
131
- }
132
- return undefined
291
+ if (outcome?.exitCode !== 0) throw new Error('pnpm install 失败(exit ' + String(outcome?.exitCode) + ')')
133
292
  }
293
+ // --endregion
@@ -1,70 +1,111 @@
1
- /**
2
- * dsh-vscode-mode host — 深链移交(避免重复打开 DSH 页面)。
3
- * launcher 投递待打开请求(edrv.external.handoff)→ 已打开页面 3s 轮询领取(edrv.external.pending)
4
- * 并就地执行打开规则;15s 内无页面轮询过 = 无活跃页面 → launcher 回退打开新页。
5
- * token 防多实例串扰;TTL 兜底防陈旧悬挂。模块级状态(host 单进程单例)。
6
- * 作者 ddj 2026-09-08
7
- */
8
1
  import { randomUUID } from 'node:crypto'
2
+ import { OPEN_TTL_MS } from './shared/externalOpen.js'
9
3
 
10
- /** 一次外部打开请求(launcher 投递)。 */
11
- export interface ExternalOpenRequest {
12
- paths: string[]
13
- line?: number
14
- column?: number
4
+ export interface ExternalOpenRequest { paths: string[]; line?: number; column?: number }
5
+ export interface OpenClaim extends ExternalOpenRequest { token: string; leaseId: string }
6
+ export interface OpenReceipt { token: string; clientId: string; leaseId: string; success: boolean; error?: string }
7
+ interface Pending extends ExternalOpenRequest {
8
+ token: string; at: number; clientId?: string; leaseId?: string; leaseAt?: number
9
+ success?: boolean; error?: string
15
10
  }
16
-
17
- /** 待打开 TTL:超时未领取即丢弃(launcher 回退已自行处理)。 */
18
- const PENDING_TTL_MS = 60_000
19
- /** 活跃页面判定窗口:窗口内有过 pending 轮询 = 有页面在。 */
20
- const PRESENCE_WINDOW_MS = 15_000
21
-
22
- let pending: (ExternalOpenRequest & { token: string; at: number }) | null = null
11
+ const pending = new Map<string, Pending>()
12
+ const LEASE_MS = 30_000
23
13
  let lastPollAt = 0
24
14
 
25
- /**
26
- * 投递一次外部打开请求;返回活跃页面数(0 = launcher 应回退开新页)与移交 token。
27
- * @author ddj 2026年09月08号
28
- * @param req 打开请求
29
- * @returns 活跃页面数与 token
15
+ /** @public @author ddj 2026年09月28号
16
+ * Queue a bounded request without replacing earlier opens.
17
+ * @param req Paths and navigation coordinates.
18
+ * @param token Optional inbox request ID.
19
+ * @param createdAt Original producer timestamp; queue ingestion never extends it.
20
+ * @returns Presence and stable request token.
30
21
  */
31
- export function handoffOpen(req: ExternalOpenRequest): { clients: number; token: string } {
32
- const token = randomUUID()
33
- pending = { ...req, token, at: Date.now() }
34
- return { token, clients: clientsAlive() ? 1 : 0 }
22
+ export function handoffOpen(req: ExternalOpenRequest, token: string = randomUUID(), createdAt = Date.now()): { clients: number; token: string } {
23
+ prunePending()
24
+ const clients = Date.now() - lastPollAt <= 15_000 ? 1 : 0
25
+ if (!Number.isSafeInteger(createdAt) || createdAt + OPEN_TTL_MS <= Date.now()) return { token, clients }
26
+ if (!pending.has(token)) {
27
+ if (pending.size >= 128) throw new Error('外部打开队列已满')
28
+ pending.set(token, { ...req, paths: [...req.paths], token, at: createdAt })
29
+ }
30
+ return { token, clients }
35
31
  }
36
32
 
37
- /**
38
- * 页面轮询:刷新活跃态并领取待打开请求(取走即清,多页面仅一方执行)。
39
- * @author ddj 2026年09月08号
40
- * @returns 待打开请求或 null
33
+ /** @public @author ddj 2026年09月28号
34
+ * Claim one request; repeated heartbeats renew this client's active lease.
35
+ * @param args Authenticated page identity.
36
+ * @returns A leased request or null.
41
37
  */
42
- export function pollPending(): ExternalOpenRequest | null {
38
+ export function pollPending(args: { clientId: string; busy?: boolean }): OpenClaim | null {
43
39
  lastPollAt = Date.now()
44
- if (!pending) return null
45
- if (Date.now() - pending.at > PENDING_TTL_MS) {
46
- pending = null
40
+ prunePending()
41
+ if (!args?.clientId || args.clientId.length > 128) return null
42
+ const active = [...pending.values()].find((row) => row.clientId === args.clientId && row.success === undefined)
43
+ if (active && Date.now() - (active.leaseAt ?? 0) < LEASE_MS) {
44
+ active.leaseAt = Date.now()
47
45
  return null
48
46
  }
49
- const taken: ExternalOpenRequest = { paths: pending.paths, line: pending.line, column: pending.column }
50
- pending = null
51
- return taken
47
+ if (args.busy) return null
48
+ for (const row of pending.values()) {
49
+ if (row.success !== undefined || (row.leaseAt && Date.now() - row.leaseAt < LEASE_MS)) continue
50
+ row.clientId = args.clientId
51
+ row.leaseId = randomUUID()
52
+ row.leaseAt = Date.now()
53
+ return { paths: row.paths, line: row.line, column: row.column, token: row.token, leaseId: row.leaseId }
54
+ }
55
+ return null
52
56
  }
53
57
 
54
- /**
55
- * 查询移交是否已被页面领取(take=true 时未领取则取走清除,供 launcher 回退开新页)。
56
- * @author ddj 2026年09月08号
57
- * @param token 移交 token
58
- * @param take 未领取时是否取走清除
59
- * @returns delivered=true = 已有页面领取(或已被更新的请求取代)
58
+ /** @public @author ddj 2026年09月28号
59
+ * Accept only the current lease's actual editor completion.
60
+ * @param receipt Claimed request identity and result.
61
+ * @returns Whether the receipt was accepted.
60
62
  */
61
- export function pendingState(token: string, take: boolean): { delivered: boolean } {
62
- const delivered = pending === null || pending.token !== token
63
- if (take && !delivered) pending = null
64
- return { delivered }
63
+ export function ackPending(receipt: OpenReceipt): boolean {
64
+ prunePending()
65
+ const row = pending.get(receipt.token)
66
+ if (!row || Date.now() >= row.at + OPEN_TTL_MS || row.clientId !== receipt.clientId || row.leaseId !== receipt.leaseId) return false
67
+ if (!row.leaseAt || Date.now() - row.leaseAt >= LEASE_MS) return false
68
+ if (row.success !== undefined) return row.success === receipt.success
69
+ row.success = receipt.success === true
70
+ row.error = receipt.error?.slice(0, 1000)
71
+ return true
65
72
  }
66
73
 
67
- /** 活跃页面判定。 */
68
- function clientsAlive(): boolean {
69
- return Date.now() - lastPollAt <= PRESENCE_WINDOW_MS
74
+ /** @public @author ddj 2026年09月28号
75
+ * Inspect completion; absence and mere delivery never imply success.
76
+ * @param token Request identity.
77
+ * @param take Legacy cancellation flag, only cancels unclaimed requests.
78
+ * @returns Terminal completion state.
79
+ */
80
+ export function pendingState(token: string, take = false): { delivered: boolean; completed: boolean; error?: string } {
81
+ prunePending()
82
+ const row = pending.get(token)
83
+ if (take && row && !row.leaseId) cancelPending(token, '打开已取消')
84
+ return { delivered: row?.success === true, completed: row?.success !== undefined, error: row?.error }
70
85
  }
86
+
87
+ /** @public @author ddj 2026年09月28号
88
+ * Cancel queued or claimed work while retaining its identity against replay.
89
+ * @param token Original request ID.
90
+ * @param error Terminal diagnostic.
91
+ */
92
+ export function cancelPending(token: string, error = '打开超时,请重试'): void {
93
+ const row = pending.get(token)
94
+ if (!row) return
95
+ if (row.success === undefined) { row.success = false; row.error = error }
96
+ row.clientId = undefined
97
+ row.leaseId = undefined
98
+ row.leaseAt = undefined
99
+ }
100
+
101
+ /** @private @author ddj 2026年09月28号 Expire at the original deadline and retain bounded replay tombstones. */
102
+ function prunePending(): void {
103
+ const now = Date.now()
104
+ for (const [key, row] of pending) {
105
+ if (now >= row.at + OPEN_TTL_MS && row.success === undefined) cancelPending(key)
106
+ if (now >= row.at + OPEN_TTL_MS * 2) pending.delete(key)
107
+ }
108
+ }
109
+
110
+ /** @public @author ddj 2026年09月28号 Release profile-scoped state during host disposal. */
111
+ export function clearPending(): void { pending.clear(); lastPollAt = 0 }
@@ -8,8 +8,7 @@
8
8
  * 全程 try/catch,不产生未捕获 rejection。
9
9
  * 作者 ddj 2026年08月24号 / 2026年08月26号 / 2026年09月02号 / 2026年09月22号 / 2026年09月23号
10
10
  */
11
- import { createRequire } from 'node:module'
12
- import { pathToFileURL } from 'node:url'
11
+ import { loadHostModule as hostImport } from './hostImport.js'
13
12
  import type { Ctx } from './store.js'
14
13
  import { KEYBINDING_DEFAULTS } from './shared/keybindings.js'
15
14
  import { EDITOR_LIMIT_DEFAULT } from './shared/editorLimit.js'
@@ -56,28 +55,6 @@ export type SettingsDepsLoader = () => Promise<SettingsDeps | null>
56
55
 
57
56
  let depsPromise: Promise<SettingsDeps | null> | undefined
58
57
 
59
- /**
60
- * 宿主锚点动态导入:先经 process.argv[1](DSH 启动脚本所在树)解析并加载目标包,
61
- * 避开本插件自身 node_modules 的 dev 依赖副本(dev-link 下 import() 相对插件位置
62
- * 解析,会命中 rc 线旧包 dsh-settings@0.1.0-rc.8);argv[1] 缺失或解析失败回退
63
- * 普通 specifier import。
64
- * @author ddj 2026年09月15号
65
- * @param specifier 包名
66
- * @returns 加载的模块命名空间
67
- */
68
- async function hostImport(specifier: string): Promise<unknown> {
69
- const entry = process.argv[1]
70
- if (entry) {
71
- try {
72
- const resolved = createRequire(entry).resolve(specifier)
73
- return await import(pathToFileURL(resolved).href)
74
- } catch {
75
- /* 锚点不可用:回退普通 import */
76
- }
77
- }
78
- return import(specifier)
79
- }
80
-
81
58
  /**
82
59
  * schema 库候选名(新名在前)。
83
60
  * DSH 官方自 0.1.5 起把 vendored 包改名为 @deepseek-ai/schemastery 并全树改用新名,
@@ -0,0 +1,19 @@
1
+ /** Host-anchored optional package imports. @author ddj 2026年09月28号 */
2
+ import { createRequire } from 'node:module'
3
+ import { pathToFileURL } from 'node:url'
4
+
5
+ /**
6
+ * Resolve against the running DSH entry before the plugin's development dependencies.
7
+ * Import failures after successful resolution are preserved, not hidden by an older copy.
8
+ * @author ddj 2026年09月28号
9
+ * @param specifier Host package name.
10
+ * @returns The evaluated host module namespace.
11
+ */
12
+ export async function loadHostModule(specifier: string): Promise<unknown> {
13
+ const anchor = process.argv[1]
14
+ let resolved: string | undefined
15
+ if (anchor) {
16
+ try { resolved = createRequire(anchor).resolve(specifier) } catch { /* Source-only tests may have no Host tree. */ }
17
+ }
18
+ return import(resolved ? pathToFileURL(resolved).href : specifier)
19
+ }