dsh-vscode-mode 0.15.9 → 0.17.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.
@@ -19,6 +19,7 @@ import { useOpenReceipt } from '../useOpenReceipt.js'
19
19
  import { acceptsLoad, receiptErrorFor, receiptPath, receiptReadyFor, receiptTabPath } from '../receiptState.js'
20
20
  import { bindPanelCommand } from '../panelCommands.js'
21
21
  import { dbg, isDebug, rpc } from '../rpc.js'
22
+ import { refreshFeed, subscribeErrors, subscribeRecords } from '../state/recordsFeed.js'
22
23
  import { emitFileChanged, emitRefresh } from '../events.js'
23
24
  import { langOf, loadMonaco, snippetLanguageOf } from '../monaco/loader.js'
24
25
  import { bindEditActions, runEditAction } from '../monaco/editActions.js'
@@ -870,38 +871,43 @@ export function EditorView(props) {
870
871
  return true
871
872
  }
872
873
 
873
- const refreshRecords = (skipStale) => {
874
- if (!sessionId) return
874
+ /**
875
+ * 把共享订阅拿到的记录落进组件状态(内容一致时保持引用不变,避免整树重渲染)。
876
+ * @private @author ddj 2026年10月19号
877
+ * @param list 差异记录数组
878
+ */
879
+ const applyRecords = (list) => {
875
880
  // #region debug log
876
881
  const trace = isDebug()
877
- const start = trace ? performance.now() : 0
882
+ const compareStart = trace ? performance.now() : 0
878
883
  // #endregion
879
- rpc('edrv.list', { sessionId, ...(skipStale ? { skipStale: true } : {}) }).then((res) => {
884
+ const map = {}
885
+ for (const r of list) {
886
+ if (r.archived === true) continue
887
+ map[r.callId] = r
888
+ }
889
+ setRecords((prev) => {
890
+ const equal = sameRecords(prev, map)
880
891
  // #region debug log
881
- const received = trace ? performance.now() : 0
882
- // #endregion
883
- if (!res || !res.ok || !Array.isArray(res.records)) return
884
- const map = {}
885
- for (const r of res.records) {
886
- if (r.archived === true) continue
887
- map[r.callId] = r
892
+ if (trace) {
893
+ try {
894
+ const compareMs = performance.now() - compareStart
895
+ if (compareMs > 20) dbg(sessionId, `[DEBUG diffList] count=${list.length} compareMs=${compareMs.toFixed(1)} changed=${!equal}`)
896
+ } catch (e) { /* 诊断不可影响轮询 */ }
888
897
  }
889
- setRecords((prev) => {
890
- // #region debug log
891
- const compareStart = trace ? performance.now() : 0
892
- // #endregion
893
- const equal = sameRecords(prev, map)
894
- // #region debug log
895
- if (trace) {
896
- try {
897
- const compareMs = performance.now() - compareStart
898
- if (compareMs > 20 || received - start > 100) dbg(sessionId, `[DEBUG diffList] count=${res.records.length} skipStale=${skipStale === true} rpcMs=${(received - start).toFixed(1)} compareMs=${compareMs.toFixed(1)} changed=${!equal}`)
899
- } catch (e) { /* 诊断不可影响轮询 */ }
900
- }
901
- // #endregion
902
- return equal ? prev : map
903
- })
904
- }).catch((e) => setError('list异常:' + String(e)))
898
+ // #endregion
899
+ return equal ? prev : map
900
+ })
901
+ }
902
+
903
+ /**
904
+ * 主动刷新一次差异记录(走会话级共享通道,同会话多消费方共享一次 RPC)。
905
+ * @private @author ddj 2026年10月19号
906
+ * @param skipStale 是否跳过过期判定
907
+ */
908
+ const refreshRecords = (skipStale) => {
909
+ if (!sessionId) return
910
+ void refreshFeed(sessionId, skipStale === true)
905
911
  }
906
912
 
907
913
  /**
@@ -1185,14 +1191,13 @@ export function EditorView(props) {
1185
1191
  })
1186
1192
  }
1187
1193
 
1188
- // 挂载轮询 + 事件订阅
1194
+ // 挂载:接入会话级共享订阅(单定时器由 recordsFeed 持有,本组件不再自建 interval)
1189
1195
  React.useEffect(() => {
1190
- if (!sessionId) return
1196
+ if (!sessionId) return undefined
1197
+ const offRecords = subscribeRecords(sessionId, applyRecords)
1198
+ const offErrors = subscribeErrors(sessionId, (message) => setError('list异常:' + message))
1191
1199
  refreshRecords()
1192
- const t = setInterval(refreshRecords, 5000)
1193
- const onRefresh = () => refreshRecords()
1194
- window.addEventListener('edrv:refresh', onRefresh)
1195
- return () => { clearInterval(t); window.removeEventListener('edrv:refresh', onRefresh) }
1200
+ return () => { offRecords(); offErrors() }
1196
1201
  }, [sessionId])
1197
1202
 
1198
1203
  /**
@@ -1,12 +1,14 @@
1
1
  // @ts-nocheck
2
2
  /**
3
3
  * dsh-vscode-mode client — PerfSizeBadge:对话 header 会话体积指示器。
4
- * 5s 轮询当前会话持久化体积(host 仅 stat,近零成本),≥1MB 显示、超阈值变色,
4
+ * 定时器复用 state/recordsFeed.ts 的会话级心跳(单会话只保留 1 个定时器),
5
+ * 轮询当前会话持久化体积(host 仅 stat,近零成本),≥1MB 显示、超阈值变色,
5
6
  * 引导"一次任务一个短会话":会话过大时建议 /compact 或新开会话。
6
7
  * 作者 ddj 2026-09-02
7
8
  */
8
9
  import React from 'react'
9
10
  import { rpc } from '../rpc.js'
11
+ import { subscribeTick } from '../state/recordsFeed.js'
10
12
 
11
13
  /** 软阈值:≥ 此值琥珀色(默认 2MB)。 */
12
14
  const SOFT = 2 * 1024 * 1024
@@ -38,10 +40,11 @@ export function PerfSizeBadge(props) {
38
40
  }, [sessionId, sessions])
39
41
 
40
42
  React.useEffect(() => {
43
+ if (!sessionId) return undefined
41
44
  load()
42
- const t = setInterval(load, 5000)
43
- return () => { clearInterval(t) }
44
- }, [load])
45
+ // 共享心跳:recordsFeed 每周期回调一次,避免本组件再自建 setInterval。
46
+ return subscribeTick(sessionId, load)
47
+ }, [load, sessionId])
45
48
 
46
49
  if (!size || size < SHOW_MIN) return null
47
50
  const label = size >= 1024 * 1024 ? (size / (1024 * 1024)).toFixed(1) + 'MB' : Math.round(size / 1024) + 'KB'
@@ -0,0 +1,82 @@
1
+ // @ts-nocheck
2
+ /**
3
+ * dsh-vscode-mode client — 官方插件管理器详情页的只读状态段。
4
+ * 挂在 DSH 0.2.1 新增的 `plugins.detail.section`(list / root)扩展位上,
5
+ * 只在「本插件自己」的详情页渲染,其余插件页返回 null。
6
+ * 作者 ddj 2026年10月19号
7
+ */
8
+ import React from 'react'
9
+ import { rpc } from '../rpc.js'
10
+
11
+ /** 本插件标识(与 package.json name / bundle 行 id 一致)。 */
12
+ export const SELF_ID = 'dsh-vscode-mode'
13
+
14
+ /**
15
+ * 判断详情页 subject 是否指向本插件。
16
+ * 官方有两种形状:官方内置项 `{kind:'item', id}`、安装包 `{kind:'bundle', pkg:{name}}`。
17
+ * @author ddj 2026年10月19号
18
+ * @param subject 详情页主题
19
+ * @returns 是否为本插件
20
+ */
21
+ export function isSelfSubject(subject) {
22
+ if (!subject || typeof subject !== 'object') return false
23
+ if (subject.kind === 'item') return subject.id === SELF_ID
24
+ if (subject.kind === 'bundle') return subject.pkg?.name === SELF_ID
25
+ return false
26
+ }
27
+
28
+ /**
29
+ * 把适配项列表压成「正常 / 未生效」计数文案。
30
+ * @author ddj 2026年10月19号
31
+ * @param list 适配项数组
32
+ * @returns 计数文案
33
+ */
34
+ function countText(list) {
35
+ const items = Array.isArray(list) ? list : []
36
+ const ok = items.filter((item) => item?.active === true).length
37
+ return ok + ' / ' + items.length + ' 项生效'
38
+ }
39
+
40
+ /**
41
+ * 插件详情页的只读状态段。
42
+ * @author ddj 2026年10月19号
43
+ * @param props DSH 注入的 { subject }
44
+ * @returns 状态段元素或 null(非本插件页)
45
+ */
46
+ export function PluginDetailSection(props) {
47
+ const subject = props?.subject
48
+ const isSelf = isSelfSubject(subject)
49
+ const [report, setReport] = React.useState(null)
50
+
51
+ React.useEffect(() => {
52
+ if (!isSelf) return undefined
53
+ let alive = true
54
+ rpc('compat', {}).then((result) => {
55
+ if (!alive || !result || !result.ok) return
56
+ setReport(result.report ?? null)
57
+ }).catch(() => { /* 状态段属锦上添花,取不到就不显示 */ })
58
+ return () => { alive = false }
59
+ }, [isSelf])
60
+
61
+ if (!isSelf) return null
62
+
63
+ const row = (name, note) => React.createElement('div', { key: name, className: 'vsm-compat-item ok' },
64
+ React.createElement('span', { className: 'vsm-compat-name' }, name),
65
+ React.createElement('span', { className: 'vsm-compat-note' }, note),
66
+ )
67
+
68
+ const versionText = report?.dshVersion ? 'DSH ' + report.dshVersion : '未探测到 DSH 版本'
69
+ const pluginText = report?.pluginVersion ? '插件 ' + report.pluginVersion : '插件版本未上报'
70
+ const warnCount = Array.isArray(report?.warnings) ? report.warnings.length : 0
71
+
72
+ return React.createElement('section', { className: 'vsm-panel', 'data-edrv-plugin-status': '1' },
73
+ React.createElement('h3', { className: 'vsm-panel-title' }, 'VSCodeMode 运行状态'),
74
+ React.createElement('div', { className: 'vsm-panel-body' },
75
+ row('版本', versionText + ' · ' + pluginText),
76
+ row('版本适配', countText(report?.adapters)),
77
+ row('外部插件与服务', countText(report?.external)),
78
+ row('护栏', countText(report?.guards)),
79
+ row('兼容性警告', warnCount ? warnCount + ' 条(设置页「兼容性」可查看详情)' : '未检测到'),
80
+ ),
81
+ )
82
+ }
@@ -4,7 +4,8 @@
4
4
  * ~/.vscode-server/extensions、插件扩展目录),读各扩展 package.json 的
5
5
  * contributes.debuggers[] 声明,解析为 DapAdapterDecl:
6
6
  * - program 按平台取 windows/linux/osx 覆盖,相对扩展根解析;
7
- * - runtime='node' → 由 node 跑 js 入口;无 runtime → program 即可执行(args 为清单声明);
7
+ * - runtime='node' → 由 node 跑 js 入口;runtime='dotnet' → 由 dotnet 跑 dll 入口;
8
+ * 无 runtime → program 即可执行(args 为清单声明);
8
9
  * - 同 type 多扩展并存 → 按「清单版本降序(numeric) → 路径升序」取唯一(与 LSP 候选口径一致);
9
10
  * - 入口不存在 / runtime 不支持 → available=false + reason(如 DotRush 按需下载的 clrdbg,
10
11
  * 诚实标注不假成功,调用方据此在下拉置灰并提示原因)。
@@ -128,12 +129,13 @@ function declOf(extDir: string, entry: DebuggerEntry): DapAdapterDecl | null {
128
129
  const languages = stringsOf(entry.languages)
129
130
  const resolved = resolveProgram(extDir, entry)
130
131
  const runtime = typeof entry.runtime === 'string' && entry.runtime ? entry.runtime : undefined
131
- const runtimeBad = runtime !== undefined && runtime !== 'node'
132
+ // node = 扩展 js 入口;dotnet = dotnet 承载的 dll(如 DotRush monodbg),provider 层解析 dotnet 路径
133
+ const runtimeBad = runtime !== undefined && runtime !== 'node' && runtime !== 'dotnet'
132
134
  const available = resolved.available && !runtimeBad
133
135
  const reason = !resolved.available
134
136
  ? resolved.reason
135
137
  : runtimeBad
136
- ? '暂不支持运行时 "' + runtime + '"(仅支持 node 与原生可执行)'
138
+ ? '暂不支持运行时 "' + runtime + '"(仅支持 node、dotnet 与原生可执行)'
137
139
  : undefined
138
140
  return {
139
141
  type,
@@ -2,12 +2,17 @@
2
2
  * dsh-vscode-mode host — 调试适配器规格解析:按调试类型从扩展清单声明(discovery)
3
3
  * 解析 spawn 规格,通用不限定语言:
4
4
  * - runtime='node' → node 跑 js 入口(emmylua 形态,等价旧 process.execPath 硬编码);
5
+ * - runtime='dotnet' → dotnet 跑 dll 入口(DotRush monodbg 形态;按 dll runtimeconfig
6
+ * 要求的运行时大版本找满足的 dotnet,对齐 LSP 侧 dotrushLaunch 口径);
5
7
  * - 无 runtime → program 即原生可执行(clrdbg/monodbg 形态,args 为清单声明如 --interpreter=vscode);
6
8
  * - 其它 runtime 明确不支持(decl.available=false 带原因,不做假启动)。
7
9
  * 旧版仅探测 tangzx.emmylua 两型(attach/new),由清单驱动后自然覆盖并扩展到任意适配器。
8
10
  * 作者 ddj 2026年09月29号 / 2026年09月21号
9
11
  */
10
12
  import { debuggerDecls } from './discovery.js'
13
+ import { dirname, join } from 'node:path'
14
+ import { existsSync } from 'node:fs'
15
+ import { dotnetWithRuntime, findDotnet, runtimeMajorOf } from '../lsp/providers.js'
11
16
 
12
17
  /** 适配器启动规格(spawn 参数)。 */
13
18
  export interface DapAdapterSpec {
@@ -42,8 +47,29 @@ export function resolveAdapterSpec(type: string, force = false, home?: string):
42
47
  if (decl.runtime === 'node') {
43
48
  return { ...base, command: process.execPath, args: [decl.program, ...decl.args] }
44
49
  }
50
+ if (decl.runtime === 'dotnet') {
51
+ const dotnet = dotnetLaunch(decl.extensionPath, decl.program)
52
+ if (!dotnet) return null
53
+ return { ...base, command: dotnet, args: [decl.program, ...decl.args] }
54
+ }
45
55
  if (!decl.runtime) {
46
56
  return { ...base, command: decl.program, args: [...decl.args] }
47
57
  }
48
58
  return null
49
59
  }
60
+
61
+ /**
62
+ * 为 dotnet 型适配器找启动用 dotnet:优先扩展内置 SDK,其次满足 dll runtimeconfig
63
+ * 要求的运行时大版本,兜底任意 dotnet(对齐 LSP 侧 dotrushLaunch 口径)。
64
+ * @author ddj 2026年10月19号
65
+ * @param extensionPath 扩展根目录
66
+ * @param program 适配器 dll 绝对路径
67
+ * @returns dotnet 可执行路径;找不到满足条件的返回 null
68
+ */
69
+ function dotnetLaunch(extensionPath: string, program: string): string | null {
70
+ const sdkDotnet = join(extensionPath, 'extension', 'Sdk', 'dotnet' + (process.platform === 'win32' ? '.exe' : ''))
71
+ if (existsSync(sdkDotnet)) return sdkDotnet
72
+ const major = runtimeMajorOf(dirname(program))
73
+ if (major !== null) return dotnetWithRuntime(major) ?? findDotnet()
74
+ return findDotnet()
75
+ }
package/src/dshVersion.ts CHANGED
@@ -84,14 +84,16 @@ export function inDshRange(version: DshVersion | null | undefined, range: DshRan
84
84
 
85
85
  /**
86
86
  * 版本线标签:报告与文档展示版本归属;不可解析返回 '未知'。
87
- * 逐线自新到旧匹配,先命中先返回;仅已静态审计的 0.2.0-rc.1/rc.2 精确标记。
88
- * @author ddj 2026年09月02号 / 2026年09月18号 / 2026年09月22号 / 2026年09月29号 / 2026年10月08号
87
+ * 逐线自新到旧匹配,先命中先返回;仅已静态审计的 0.2.0-rc.1/rc.2、0.2.1-rc.2 精确标记。
88
+ * @author ddj 2026年09月02号 / 2026年09月18号 / 2026年09月22号 / 2026年09月29号 / 2026年10月08号 / 2026年10月19号
89
89
  * @param input 版本串(如 '0.1.6-alpha.2')
90
90
  * @returns 版本线标签
91
91
  */
92
92
  export function familyLabel(input: string): string {
93
93
  const version = parseDshVersion(input)
94
94
  if (!version) return '未知'
95
+ if (inDshRange(version, { from: '0.2.1-rc.2', to: '0.2.1-rc.2' })) return '0.2.1-rc.2(设置=profile Config;workingDirectory 投影跟随 cd;plugins.detail 扩展位;按能力探测)'
96
+ if (inDshRange(version, { from: '0.2.1-0', to: '0.2.1' })) return '0.2.1 线(rc.2 已逐版适配,其余按能力探测)'
95
97
  if (inDshRange(version, { from: '0.2.0-rc.2', to: '0.2.0-rc.2' })) return '0.2.0-rc.2(设置=profile Config;按能力探测)'
96
98
  if (inDshRange(version, { from: '0.2.0-rc.1', to: '0.2.0-rc.1' })) return '0.2.0-rc.1(设置=profile Config;按能力探测)'
97
99
  if (inDshRange(version, { from: '0.2.0-0' })) return '0.2.0 及更新(除 rc.1/rc.2 外未经逐版适配;按能力探测)'
@@ -20,7 +20,7 @@
20
20
  */
21
21
  import type { FileVersionItem } from './shared/rpc.js'
22
22
  import type { Ctx } from './store.js'
23
- import { cwdOf, sessionOf } from './registry.js'
23
+ import { cwdOfSession, sessionOf } from './registry.js'
24
24
  import { invalidateIndex } from './treeIndex.js'
25
25
  import { log } from './log.js'
26
26
 
@@ -150,7 +150,7 @@ export function createFileVersions(ctx: Ctx): FileVersions {
150
150
  .slice(0, MAX_PATHS)
151
151
  if (!fs || !paths.length) return { ok: true, items: [] }
152
152
  const session = sessionOf(ctx, args.sessionId)
153
- const cwd = cwdOf(session)
153
+ const cwd = cwdOfSession(ctx, session)
154
154
  const items: FileVersionItem[] = []
155
155
  for (const path of paths) items.push(await observe(fs, cwd, path))
156
156
  return { ok: true, items }
@@ -398,8 +398,8 @@ export function resolveLuaProvider(manual?: { command?: string; path?: string },
398
398
  }
399
399
 
400
400
  /**
401
- * 解析 C# provider(优先级:手动 > 扩展源 > 自动发现;Roslyn dll 需 dotnet;
402
- * DotRush 需满足其运行时的 dotnet;或 OmniSharp)。
401
+ * 解析 C# provider(优先级:手动 > 扩展源 > 自动发现;DotRush 优先——自带 sln 加载与
402
+ * 反编译跳转;Roslyn dll 需 dotnet,但无 C# Dev Kit 时不加载项目;或 OmniSharp)。
403
403
  * @author ddj 2026年08月27号
404
404
  * @param manual 手动配置
405
405
  * @param home DSH home(缺省真实;测试可注入临时目录)
@@ -416,9 +416,10 @@ export function resolveCSharpProvider(manual?: { command?: string; path?: string
416
416
  // SDK 环境注入:runtime-only 的 dotnet 会导致 MSBuild 求值/restore 退化
417
417
  const sdkRoot = findSdkRoot()
418
418
  const msbuildEnv = sdkRoot ? sdkEnvOf(sdkRoot) : undefined
419
- if (discovered.roslynDll && discovered.dotnet) {
420
- return { ...base, kind: 'discover', argv: [discovered.dotnet, discovered.roslynDll, '--stdio'], env: msbuildEnv, ready: true }
421
- }
419
+ // DotRush 优先:其服务器从 cwd 自动发现 sln 并自行加载(restore/compile/索引),
420
+ // 元数据符号可跳转反编译源码(实测 GameObject.Find → _decompiled_/UnityEngine.GameObject.cs)。
421
+ // Roslyn(ms-dotnettools.csharp)依赖 C# Dev Kit 的 serviceBroker 加载 sln;
422
+ // 无 Dev Kit 时即使发 solution/open 也不加载项目,references 不跨文件、元数据 definition 全空(实测 2026-10-19)。
422
423
  let dotrushReason: string | null = null
423
424
  let provisionMajor: number | undefined
424
425
  if (discovered.dotrushDll) {
@@ -430,6 +431,9 @@ export function resolveCSharpProvider(manual?: { command?: string; path?: string
430
431
  dotrushReason = launch.reason
431
432
  provisionMajor = major ?? undefined
432
433
  }
434
+ if (discovered.roslynDll && discovered.dotnet) {
435
+ return { ...base, kind: 'discover', argv: [discovered.dotnet, discovered.roslynDll, '--stdio'], env: msbuildEnv, ready: true }
436
+ }
433
437
  if (discovered.omnisharpExe) {
434
438
  return { ...base, kind: 'discover', argv: [discovered.omnisharpExe, '--stdio'], ready: true }
435
439
  }
package/src/lsp/rpc.ts CHANGED
@@ -7,7 +7,7 @@
7
7
  */
8
8
  import type { Ctx } from '../store.js'
9
9
  import { searchRoot } from '../search/ripgrep.js'
10
- import { sessionOf, cwdOf } from '../registry.js'
10
+ import { cwdOfSession, sessionOf } from '../registry.js'
11
11
  import { resolveProviderSpec, langOfPath, configFromPlugin, mergeConfig, sanitizeLang, LSP_LANGUAGES, type LspConfig, type LspLangConfig } from './config.js'
12
12
  import { clearProviderCache, candidatesFor } from './providers.js'
13
13
  import { capCompletions } from './completionCap.js'
@@ -154,7 +154,7 @@ export function createLspRpc(deps: LspRpcDeps): { handlers: Partial<RpcHandlerMa
154
154
  const rootOf = async (sessionId: string | undefined): Promise<{ root: string; sessionId: string } | { err: string }> => {
155
155
  const session = sessionOf(ctx, sessionId)
156
156
  if (!session) return { err: SESSION_MISSING_ERROR }
157
- const cwd = cwdOf(session)
157
+ const cwd = cwdOfSession(ctx, session)
158
158
  if (!cwd) return { err: SESSION_NO_WORKSPACE_ERROR }
159
159
  try {
160
160
  const root = await searchRoot(ctx, session)
package/src/lsp/server.ts CHANGED
@@ -8,7 +8,8 @@ import { spawnServer, type Transport } from './transport.js'
8
8
  import { createLspClient, type LspClient } from './client.js'
9
9
  import { pathToFileUri, toWorkspacePath, isAbsolutePath, fileUriToPath } from './uri.js'
10
10
  import { deriveDefinitionFromLocations, deriveCrossFileDeclaration, isMisresolvedSameFileUsage } from './derive.js'
11
- import { readFile } from 'node:fs/promises'
11
+ import { readFile, readdir } from 'node:fs/promises'
12
+ import { join } from 'node:path'
12
13
  import type { LspProviderSpec } from './providers.js'
13
14
  import { LSP_SEMANTIC_TOKEN_MODIFIERS, LSP_SEMANTIC_TOKEN_TYPES } from '../shared/lsp.js'
14
15
  import type {
@@ -61,6 +62,26 @@ interface OpenDoc {
61
62
  const RESTARTABLE = true
62
63
  const MAX_DOC_VERSION = 2147483647
63
64
 
65
+ /**
66
+ * 找工作区根目录下的第一个 .sln(字典序)。
67
+ *
68
+ * 背景:Roslyn LSP 启动后若收不到 solution/project 通知,会停留在「杂项文件」模式——
69
+ * 工作区内自有符号可解析,但 Unity 引擎程序集 / NuGet 包等元数据符号的
70
+ * definition/hover 一律返回空(实测 2026-10-16,GameObject.Find 全空)。
71
+ * 需在握手后发 Roslyn 专有 `solution/open` 通知加载项目上下文。
72
+ * @author ddj 2026年10月19号
73
+ * @param root 工作区根(绝对路径)
74
+ * @returns sln 绝对路径;根目录无 sln 返回 null
75
+ */
76
+ async function findSolutionFile(root: string): Promise<string | null> {
77
+ try {
78
+ const names = (await readdir(root)).filter((name) => name.toLowerCase().endsWith('.sln')).sort()
79
+ return names[0] ? join(root, names[0]) : null
80
+ } catch {
81
+ return null
82
+ }
83
+ }
84
+
64
85
  /**
65
86
  * 使用合法、递增的 Int32 文档版本;旧客户端的毫秒时间戳不能直发给 Roslyn。
66
87
  * @author ddj 2026年09月24号
@@ -320,6 +341,10 @@ export function createLspServer(spec: LspProviderSpec, root: string, languageId:
320
341
  // 对不认识的配置段按协议忽略,均安全
321
342
  if (languageId === 'csharp') {
322
343
  client.notify('workspace/didChangeConfiguration', { settings: { dotrush: { roslyn: {} } } })
344
+ // Roslyn 专有:加载解决方案后元数据符号(引擎/NuGet 程序集)才可跳转;
345
+ // 未知通知对其它 C# 服务器按协议安全忽略。无 sln 的纯脚本项目静默跳过。
346
+ const slnPath = await findSolutionFile(root)
347
+ if (slnPath) client.notify('solution/open', { solution: pathToFileUri(slnPath) })
323
348
  }
324
349
  capabilities = caps
325
350
  return true
package/src/mcpRuntime.ts CHANGED
@@ -6,6 +6,7 @@ import { matchWorkspace } from './mcpIsolation.js'
6
6
  import { listProjects, projectEntryId, reconcileProject } from './mcpProject.js'
7
7
  import type { MpcConfig, MpcServer } from './shared/mcp.js'
8
8
  import type { Ctx } from './store.js'
9
+ import { cwdOfSession } from './store.js'
9
10
 
10
11
  export type ProjectDef = { config: MpcConfig; enabled: boolean }
11
12
  type Instance = { fiber?: any; key: string; path: string; error?: string; stopping?: Promise<void> }
@@ -76,7 +77,7 @@ export class McpRuntime {
76
77
  */
77
78
  workspace(agent: any): string | undefined {
78
79
  const paths = (this.ctx.get('workspaceRegistry')?.list?.() ?? []).map((ws: { path: string }) => ws.path)
79
- return matchWorkspace(agent?.session?.header?.cwd, paths)
80
+ return matchWorkspace(cwdOfSession(this.ctx, agent?.session) ?? undefined, paths)
80
81
  }
81
82
 
82
83
  /**
package/src/registry.ts CHANGED
@@ -5,7 +5,7 @@
5
5
  */
6
6
  import type { DiffRecord } from './shared/types.js'
7
7
  import type { Ctx, Session } from './store.js'
8
- import { loadBucket } from './store.js'
8
+ import { cwdOfSession, loadBucket } from './store.js'
9
9
 
10
10
  /** cwd → 记录桶(内存缓存;随会话销毁清理)。 */
11
11
  export type Registry = Map<string, Map<string, DiffRecord>>
@@ -19,7 +19,20 @@ export function sessionOf(ctx: Ctx, sessionId?: string): Session | undefined {
19
19
  return live.length === 1 ? live[0] : undefined
20
20
  }
21
21
 
22
- /** 会话工作区 cwd。 */
22
+ /**
23
+ * 会话工作区 cwd(跟随会话内 cd;服务缺失时回落 header.cwd)。
24
+ * 定义在 store.ts 以避免 registry ↔ store 循环依赖,此处仅转出为会话助手入口。
25
+ * @author ddj 2026年10月19号
26
+ */
27
+ export { cwdOfSession }
28
+
29
+ /**
30
+ * 会话头部的原始 cwd(不跟随 cd)。仅用于会话销毁等无上下文场景:
31
+ * 此时投影可能已随会话卸载,读 header 才能保证拿到建会话时的目录。
32
+ * @author ddj 2026年10月19号
33
+ * @param session 目标会话
34
+ * @returns 绝对路径或 null
35
+ */
23
36
  export function cwdOf(session: Session | undefined): string | null {
24
37
  return session?.header?.cwd ?? null
25
38
  }
package/src/rpc.ts CHANGED
@@ -25,7 +25,7 @@ import { archiveEntryFor, markDecision, recordResolved, reconstructOriginal } fr
25
25
  import { clearRevertState, listArchives, pageArchives, readArchives, revertState, setRevertState, startReverts } from './archiveDb.js'
26
26
  import { log } from './log.js'
27
27
  import type { Registry } from './registry.js'
28
- import { bucketOf, cwdOf, sessionOf } from './registry.js'
28
+ import { bucketOf, cwdOfSession, sessionOf } from './registry.js'
29
29
  import type { SearchOrchestrator } from './search/orchestrator.js'
30
30
  import { newSearcher } from './search/orchestrator.js'
31
31
  import type { ContentSearcher } from './search/content.js'
@@ -100,7 +100,7 @@ async function requireSession(ctx: Ctx, sessionId: string | undefined): Promise<
100
100
  > {
101
101
  const session = sessionOf(ctx, sessionId)
102
102
  if (!session) return { err: SESSION_MISSING_ERROR }
103
- const cwd = cwdOf(session)
103
+ const cwd = cwdOfSession(ctx, session)
104
104
  if (!cwd) return { err: SESSION_NO_WORKSPACE_ERROR }
105
105
  return { session, cwd }
106
106
  }
package/src/rules.ts CHANGED
@@ -16,6 +16,7 @@ import { join, resolve } from 'node:path'
16
16
  import { dshHome } from './paths.js'
17
17
  import type { RuleInfo, RuleProject, RuleRefInput, RuleScope, RuleSaveInput, UserRuleOrigin } from './shared/rules.js'
18
18
  import type { Ctx } from './store.js'
19
+ import { cwdOfSession } from './store.js'
19
20
 
20
21
  // --region 常量与目录定位
21
22
  /** 规则文件名白名单:字母数字开头,仅字母数字点横下划线,.mdc 后缀(天然拒绝路径分隔符)。 */
@@ -550,13 +551,17 @@ export function readUserRulesSync(dirs = userRuleDirs()): LoadedRule[] {
550
551
  return mergeUserRules(current, legacy, (rule) => rule.info.file)
551
552
  }
552
553
 
553
- /** 从装配上下文解析会话工作区 cwd(agent.id → sessions → header.cwd;缺链返回 null)。 */
554
+ /**
555
+ * 从装配上下文解析会话工作区 cwd(agent.id → sessions → 当前目录;缺链返回 null)。
556
+ * @author ddj 2026年10月19号
557
+ * @param ctx DSH 上下文
558
+ * @param asm 装配上下文(含 agent.id)
559
+ * @returns 项目规则根的绝对路径或 null
560
+ */
554
561
  function cwdFromAssemble(ctx: Ctx, asm: { agent?: { id?: unknown } } | undefined): string | null {
555
562
  const id = asm?.agent?.id
556
563
  if (typeof id !== 'string' || !id) return null
557
- const session = ctx.get('sessions')?.get?.(id)
558
- const cwd = session?.header?.cwd
559
- return typeof cwd === 'string' && cwd ? cwd : null
564
+ return cwdOfSession(ctx, ctx.get('sessions')?.get?.(id))
560
565
  }
561
566
 
562
567
  /**
@@ -4,7 +4,7 @@
4
4
  * 作者 ddj 2026年08月24号
5
5
  */
6
6
  import type { Ctx, Session } from '../store.js'
7
- import { policyOf } from '../store.js'
7
+ import { cwdOfSession, policyOf } from '../store.js'
8
8
  import { pathText, pathMatch, prepareQuery } from './query.js'
9
9
  import type { PreparedQuery, WorkspaceSearchInput, WorkspaceSearchProvider, WorkspaceSearchResult } from './types.js'
10
10
 
@@ -61,7 +61,7 @@ export async function searchFallback(input: WorkspaceSearchInput): Promise<Works
61
61
  const fs = input.ctx.get('fs') as SearchFs | undefined
62
62
  if (!fs) throw new Error('缺少 fs')
63
63
  const policy = policyOf(input.ctx, input.session)
64
- const cwd = input.session?.header?.cwd
64
+ const cwd = cwdOfSession(input.ctx, input.session)
65
65
  const rootTarget = await fs.resolve(policy?.workspaceRoot ?? '.', cwd ? { cwd, signal: input.signal } : { signal: input.signal })
66
66
  const signal = input.signal ?? new AbortController().signal
67
67
  const files: string[] = []
@@ -7,7 +7,7 @@ import { existsSync } from 'node:fs'
7
7
  import { join } from 'node:path'
8
8
  import { createRequire } from 'node:module'
9
9
  import type { Ctx, Session } from '../store.js'
10
- import { policyOf } from '../store.js'
10
+ import { cwdOfSession, policyOf } from '../store.js'
11
11
  import { prepareQuery, pathText } from './query.js'
12
12
  import type { WorkspaceSearchInput, WorkspaceSearchProvider, WorkspaceSearchResult } from './types.js'
13
13
 
@@ -127,7 +127,7 @@ export async function searchRoot(ctx: Ctx, session: Session): Promise<string> {
127
127
  const fs = ctx.get('fs')
128
128
  if (!fs) throw new Error('缺少 fs')
129
129
  const policy = policyOf(ctx, session)
130
- const cwd = session?.header?.cwd
130
+ const cwd = cwdOfSession(ctx, session)
131
131
  const rootTarget = await fs.resolve(policy?.workspaceRoot ?? '.', cwd ? { cwd } : {})
132
132
  return fs.processPath(rootTarget)
133
133
  }
package/src/store.ts CHANGED
@@ -33,10 +33,35 @@ export function policyOf(ctx: Ctx, session: Session | undefined): any {
33
33
  return session ? svc.resolve({ session }) : svc.resolve()
34
34
  }
35
35
 
36
+ /**
37
+ * 读取会话当前工作目录:优先 workingDirectory 投影(DSH 0.2.1 起支持会话内 cd),
38
+ * 服务缺失或投影未就绪时回落到 header.cwd(会话创建时的原始目录)。
39
+ * 服务一律可选探测:不得写入模块 inject,否则旧版 DSH 会停等一个不存在的服务。
40
+ * @author ddj 2026年10月19号
41
+ * @param ctx DSH 上下文(用于可选探测 workingDirectory 服务)
42
+ * @param session 目标会话
43
+ * @returns 绝对路径;无法确定时 null
44
+ */
45
+ export function cwdOfSession(ctx: Ctx, session: Session | undefined): string | null {
46
+ const headerCwd = session?.header?.cwd
47
+ const fallback = typeof headerCwd === 'string' && headerCwd ? headerCwd : null
48
+ if (!session) return null
49
+
50
+ const service = ctx?.get?.('workingDirectory')
51
+ if (!service || typeof service.get !== 'function') return fallback
52
+ try {
53
+ const current = service.get(session)
54
+ return typeof current === 'string' && current ? current : fallback
55
+ } catch {
56
+ // 会话已销毁或投影缺失:回落原始目录,不让新能力变成新的故障点
57
+ return fallback
58
+ }
59
+ }
60
+
36
61
  /** 按会话 cwd 解析目标路径。 */
37
62
  export async function resolveTarget(ctx: Ctx, session: Session | undefined, path: string): Promise<string> {
38
63
  const fs = ctx.get('fs')
39
- const cwd = session?.header?.cwd
64
+ const cwd = cwdOfSession(ctx, session)
40
65
  return fs.resolve(path, cwd ? { cwd } : {})
41
66
  }
42
67
 
package/src/svn.ts CHANGED
@@ -10,7 +10,7 @@ import { readdir, stat } from 'node:fs/promises'
10
10
  import { basename, dirname, isAbsolute, join, relative, resolve } from 'node:path'
11
11
  import { tmpdir } from 'node:os'
12
12
  import type { Ctx, Session } from './store.js'
13
- import { cwdOf, sessionOf } from './registry.js'
13
+ import { cwdOfSession, sessionOf } from './registry.js'
14
14
  import type { SvnAction, SvnAiPlan, SvnChangeEntry, SvnConflictArtifact, SvnFeature, SvnIgnoreItem, SvnItemStatus, SvnLogEntry, SvnRemoteOutdatedEntry, SvnStatusPayload, SvnSumEntry } from './shared/svn.js'
15
15
  import { SESSION_MISSING_ERROR, SESSION_NO_WORKSPACE_ERROR } from './shared/rpc.js'
16
16
  import { AI_PLAN_DIFF_CAP, AI_PLAN_PATHS_CAP, BATCH_PATHS_CAP, FILE_SIZES_CAP, SVN_CLI_DEFAULT, TORTOISE_EXE, contentGuardOf, normalizeAiPlan, svnChangelistNameErrorOf, tortoiseLaunchArgv } from './shared/svn.js'
@@ -463,7 +463,7 @@ async function requireSvnSession(
463
463
  ): Promise<{ cwd: string } | { err: string }> {
464
464
  const session: Session | undefined = sessionOf(ctx, sessionId)
465
465
  if (!session) return { err: SESSION_MISSING_ERROR }
466
- const cwd = cwdOf(session)
466
+ const cwd = cwdOfSession(ctx, session)
467
467
  if (!cwd) return { err: SESSION_NO_WORKSPACE_ERROR }
468
468
  return { cwd }
469
469
  }