dsh-config-manager 0.1.76 → 0.1.78

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 (84) hide show
  1. package/lib/adapters/plugin-data-declared.d.ts +12 -0
  2. package/lib/adapters/plugin-data-declared.js +57 -0
  3. package/lib/adapters/plugin-data.d.ts +4 -6
  4. package/lib/adapters/plugin-data.js +8 -36
  5. package/lib/adapters/plugins.d.ts +16 -0
  6. package/lib/adapters/plugins.js +223 -16
  7. package/lib/client.d.ts +104 -9
  8. package/lib/client.js +1800 -718
  9. package/lib/core/analyzer.js +3 -1
  10. package/lib/core/backup.d.ts +9 -0
  11. package/lib/core/backup.js +16 -1
  12. package/lib/core/messages.d.ts +11 -0
  13. package/lib/core/messages.js +22 -0
  14. package/lib/core/plugin-cli.d.ts +26 -0
  15. package/lib/core/plugin-cli.js +62 -2
  16. package/lib/core/rollback-attribution.d.ts +81 -0
  17. package/lib/core/rollback-attribution.js +236 -0
  18. package/lib/core/rollback-merge.d.ts +22 -0
  19. package/lib/core/rollback-merge.js +203 -0
  20. package/lib/core/rollback-point.d.ts +114 -0
  21. package/lib/core/rollback-point.js +534 -17
  22. package/lib/core/types.d.ts +28 -0
  23. package/lib/index.d.ts +26 -3
  24. package/lib/index.js +129 -16
  25. package/lib/routes/recovery.js +11 -0
  26. package/lib/schema/section-registry.js +5 -5
  27. package/lib/schema/types.d.ts +14 -0
  28. package/lib/ui/badge-tone.d.ts +16 -0
  29. package/lib/ui/badge-tone.js +20 -0
  30. package/lib/ui/i18n.d.ts +4 -4
  31. package/lib/ui/i18n.js +8 -8
  32. package/lib/ui/rollback-view.d.ts +95 -0
  33. package/lib/ui/rollback-view.js +55 -0
  34. package/lib/ui/selection-model.d.ts +140 -1
  35. package/lib/ui/selection-model.js +241 -9
  36. package/package.json +1 -1
  37. package/src/adapters/plugin-data-declared.ts +60 -0
  38. package/src/adapters/plugin-data.ts +7 -33
  39. package/src/adapters/plugins.test.ts +162 -0
  40. package/src/adapters/plugins.ts +236 -17
  41. package/src/adapters/units.test.ts +26 -0
  42. package/src/client/chat/ChatEntryView.tsx +147 -46
  43. package/src/client/chat/chat-entry-view.test.ts +277 -1
  44. package/src/client/chat/chat-entry-view.ts +98 -4
  45. package/src/client/chat/chat-node-registration.ts +71 -0
  46. package/src/client/chat/chat-slots.ts +30 -12
  47. package/src/client/common/ContentPicker.tsx +581 -175
  48. package/src/client/common/host-primitives.ts +64 -1
  49. package/src/client/common/ui.tsx +17 -3
  50. package/src/client/config-manager.module.css +630 -397
  51. package/src/client/css-scale-guard.test.ts +41 -3
  52. package/src/client/css-token-guard.test.ts +10 -4
  53. package/src/client/export/ExportView.tsx +2 -0
  54. package/src/client/index.ts +15 -3
  55. package/src/client/locales.ts +54 -10
  56. package/src/client/recovery/RollbackCard.tsx +56 -2
  57. package/src/client/recovery/RollbackDiffPane.tsx +5 -0
  58. package/src/client/recovery/RollbackFileChanges.tsx +46 -2
  59. package/src/client/recovery/recovery-locales.ts +22 -0
  60. package/src/client/recovery/rollback-file-changes.test.ts +1 -1
  61. package/src/core/analyzer.ts +3 -1
  62. package/src/core/backup.ts +16 -1
  63. package/src/core/messages.ts +22 -0
  64. package/src/core/plugin-cli.fs.test.ts +65 -3
  65. package/src/core/plugin-cli.ts +55 -2
  66. package/src/core/rollback-attribution.test.ts +120 -0
  67. package/src/core/rollback-attribution.ts +267 -0
  68. package/src/core/rollback-merge.test.ts +133 -0
  69. package/src/core/rollback-merge.ts +213 -0
  70. package/src/core/rollback-point.test.ts +412 -0
  71. package/src/core/rollback-point.ts +560 -20
  72. package/src/core/types.ts +29 -0
  73. package/src/index.facade.test.ts +60 -0
  74. package/src/index.ts +133 -19
  75. package/src/routes/recovery.ts +11 -0
  76. package/src/schema/section-registry.ts +5 -5
  77. package/src/schema/types.ts +14 -0
  78. package/src/ui/badge-tone.test.ts +23 -0
  79. package/src/ui/badge-tone.ts +27 -0
  80. package/src/ui/i18n.ts +8 -8
  81. package/src/ui/rollback-view.test.ts +27 -0
  82. package/src/ui/rollback-view.ts +139 -0
  83. package/src/ui/selection-model.test.ts +240 -2
  84. package/src/ui/selection-model.ts +349 -1
package/src/core/types.ts CHANGED
@@ -147,6 +147,25 @@ export interface ExportUnit {
147
147
  * 只下发**事实**,不下发文案 —— 界面按当前语言显示(与 DSH 同口径),绝不用 uuid 冒充标题。
148
148
  */
149
149
  blank?: boolean;
150
+ /**
151
+ * 该插件**声明的第三方数据区域**(只有 \`plugin:<包名>\` 单元可能有值;导出选择器「按插件看」用)。
152
+ *
153
+ * 用途:选择器要在**不读任何数据目录**的前提下画出「插件 → 数据目录 / 大文件」的树骨架 ——
154
+ * 区域清单来自来源注册表(内置来源表 + 插件自述 dsh.dataDirs),是静态知识,零额外 I/O;
155
+ * 体积与「实际有没有内容」等该分区的清单到货后再填。
156
+ *
157
+ * 语义:**纯展示**,不参与勾选/导出契约(契约只认 id)。这里是「注册表说它应该在哪儿」,
158
+ * **不是**「磁盘上确实有」—— 界面必须把两者分开渲染(unread / 无内容 / 已读到)。
159
+ */
160
+ relatedAreas?: readonly PluginAreaRef[];
161
+ }
162
+
163
+ /** 插件数据区域引用(pluginData / pluginAssets;只读展示用,不参与勾选契约)。 */
164
+ export interface PluginAreaRef {
165
+ section: 'pluginData' | 'pluginAssets';
166
+ /** home 相对 POSIX 路径 —— 就是该分区单元的 id 后缀(\`pluginData:.mnemon/runtime\` 的末段) */
167
+ relPath: string;
168
+ role: 'config' | 'assets';
150
169
  }
151
170
 
152
171
  export interface ValidationResult {
@@ -215,6 +234,16 @@ export interface PluginInfo {
215
234
  inBundles?: string[];
216
235
  /** 声明依赖 spec(profile package.json dependencies 原样);文件视图来自 readInstalled */
217
236
  spec?: string;
237
+ /**
238
+ * 只在 `dsh.profile.bundles` 里声明、`dependencies` 里没有该包,且本 profile 的
239
+ * `node_modules` 里也没有它的落盘副本(issue #95)。
240
+ *
241
+ * DSH 对这类层从**自己的安装位置**解析(`boot-safety.ts` 的「@deepseek-ai/* 由 DSH 从它自己的
242
+ * 安装位置解析」与 `RESCUE_KEEP_BUNDLE_PREFIXES` 同一口径),因此导入端**不得**把它当
243
+ * registry 依赖去 `dsh plugin add`:那会把 npm dist-tag `latest` 的副本装进目标 profile,
244
+ * 与 host 的版本线脱节(issue #95 的真机故障)。只能把名字声明进目标机的 bundles。
245
+ */
246
+ bundlesOnly?: boolean;
218
247
  }
219
248
 
220
249
  export interface PluginsFacade {
@@ -427,6 +427,66 @@ test('回退 fork:原会话自己就不在任何工作区时,按**子会话
427
427
  )
428
428
  assert.deepEqual(membersOf(project), ['someone-else', 'child-cwd'], '只对同目录的那一条调 attachSession')
429
429
  assert.deepEqual(membersOf(otherDir), [], '不同目录的工作区一个字节都不许写')
430
+
431
+ test('回退 fork(2026-10-17 真机):子会话还没进活会话服务 ⇒ 按**落盘 header** 的 cwd 归位(与 DSH 自己的 readSessionHeader 同源回落)', async () => {
432
+ const project = workspaceFixture({ id: 'ws-a', path: 'D:/proj/a', sessionIds: [] })
433
+ const facade = new DshSessionStoreFacade(storeCtx({
434
+ sessionController: { fork: async () => ({ sessionId: 'child-stored' }) },
435
+ // 活会话服务里**没有**这个子会话(fork 刚返回、索引还没就绪)——修复前这里直接 cwd-unavailable
436
+ sessions: { get: () => undefined },
437
+ sessionPersistence: { list: async () => [{ header: { id: 'child-stored', cwd: 'D:/proj/a' } }] },
438
+ workspaceRegistry: { list: () => [project] },
439
+ }), 'C:/home/.dsh', zhMsg, silentLogger(), async (p) => canonicalPath(p))
440
+
441
+ assert.deepEqual(
442
+ await facade.forkSession('parent-nowhere', 5),
443
+ { childSessionId: 'child-stored' },
444
+ '落盘 header 读得到 cwd ⇒ 必须归位(修复前报 workspace-attach-failed:cwd-unavailable ⇒ 侧栏「未分组」)',
445
+ )
446
+ assert.deepEqual(membersOf(project), ['child-stored'])
447
+ })
448
+
449
+ test('回退 fork(2026-10-17 真机):子会话 header 读不出 cwd ⇒ 用**父会话 cwd** 归位(DSH 的 fork 逐字继承父 header.cwd)', async () => {
450
+ const project = workspaceFixture({ id: 'ws-a', path: 'D:/proj/a', sessionIds: [] })
451
+ const facade = new DshSessionStoreFacade(storeCtx({
452
+ sessionController: { fork: async () => ({ sessionId: 'child-nocwd' }) },
453
+ sessions: {
454
+ get: (id: string) => {
455
+ if (id === 'child-nocwd') return { header: { id } }
456
+ if (id === 'parent-1') return { header: { id, cwd: 'D:/proj/a' } }
457
+ return undefined
458
+ },
459
+ },
460
+ workspaceRegistry: { list: () => [project] },
461
+ }), 'C:/home/.dsh', zhMsg, silentLogger(), async (p) => canonicalPath(p))
462
+
463
+ assert.deepEqual(await facade.forkSession('parent-1', 5), { childSessionId: 'child-nocwd' })
464
+ assert.deepEqual(membersOf(project), ['child-nocwd'], '父 cwd 与工作区同目录 ⇒ 必须归位(同源事实,不是猜测)')
465
+ })
466
+
467
+ test('回退 fork:两条 cwd 来源都拿不到 ⇒ 如实失败,并把**具体理由**落一行可查的归位诊断(桌面版拿不到宿主 stdout)', async () => {
468
+ const home = mkdtempSync(join(tmpdir(), 'dcm-attach-diag-'))
469
+ try {
470
+ const project = workspaceFixture({ id: 'ws-a', path: 'D:/proj/a', sessionIds: [] })
471
+ const facade = new DshSessionStoreFacade(storeCtx({
472
+ sessionController: { fork: async () => ({ sessionId: 'child-lost' }) },
473
+ sessions: { get: () => undefined },
474
+ workspaceRegistry: { list: () => [project] },
475
+ }), home, zhMsg, silentLogger(), async (p) => canonicalPath(p))
476
+
477
+ assert.deepEqual(await facade.forkSession('parent-nowhere', 5), {
478
+ childSessionId: 'child-lost',
479
+ warning: 'workspace-attach-failed:cwd-unavailable',
480
+ })
481
+ const log = readFileSync(join(home, 'dsh-config-manager', 'logs', 'rewind-workspace-attach.jsonl'), 'utf8')
482
+ assert.match(log, /"childId":"child-lost"/)
483
+ assert.match(log, /"reason":"cwd-unavailable"/)
484
+ assert.match(log, /child\(/, 'detail 必须带子会话那一侧的读取细节,光有理由码不够排查')
485
+ } finally {
486
+ rmSync(home, { recursive: true, force: true })
487
+ }
488
+ })
489
+
430
490
  })
431
491
 
432
492
  test('t10 ③ 档(修复前必红):cwd 与工作区 path 仅大小写/分隔符/短名不同 —— 由 DSH 的 resolveByPath 判同一目录后仍归位', async () => {
package/src/index.ts CHANGED
@@ -39,6 +39,7 @@ import { randomBytes } from 'node:crypto'
39
39
  import { execFile } from 'node:child_process'
40
40
  import { createWriteStream, existsSync, mkdirSync, readFileSync } from 'node:fs'
41
41
  import fs from 'node:fs/promises'
42
+ import path from 'node:path'
42
43
  import type { IncomingMessage, ServerResponse } from 'node:http'
43
44
  import { basename, dirname, isAbsolute, join, relative, resolve } from 'node:path'
44
45
  import { homedir } from 'node:os'
@@ -78,7 +79,8 @@ import { RunRegistry, type RunState } from './core/run-registry.ts'
78
79
  import { registerModelTools } from './core/model-tools.ts'
79
80
  import { msgOf, zhMsg } from './core/messages.ts'
80
81
  import { engineMsg, getEngineLocale, setEngineLanguageSource, setEngineLocale } from './core/engine-locale.ts'
81
- import { RollbackAutoTracker, RollbackPointEngine, rollbackSessionBindingOf, rollbackTrackedEventOf } from './core/rollback-point.ts'
82
+ import { RollbackAutoTracker, RollbackPointEngine, ROLLBACK_POINTS_DIR_NAME, rollbackSessionBindingOf, rollbackTrackedEventOf } from './core/rollback-point.ts'
83
+ import { ROLLBACK_ATTRIBUTION_FILE_NAME, RollbackWriteAttribution } from './core/rollback-attribution.ts'
82
84
  import { isRollbackEnabled, onRollbackEnabledChange, setRollbackEnabled } from './core/rollback-gate.ts'
83
85
  import type { RollbackForkCarrier, RollbackRewindResult } from './core/rollback-point.ts'
84
86
  // t3:宿主斜杠命令 /rollback(与 HTTP 路由同一道写入门;命令服务惰取,未组合时安静跳过)。
@@ -179,7 +181,7 @@ export const name = 'config-manager'
179
181
  export const inject = ['settings', 'credentials']
180
182
 
181
183
  /** Plugin version, kept in sync with package.json ("version"). */
182
- export const PLUGIN_VERSION = '0.1.76'
184
+ export const PLUGIN_VERSION = '0.1.78'
183
185
 
184
186
  /** Plugin own package name — excluded from its own exported plugins list. */
185
187
  const PLUGIN_NAME = 'dsh-config-manager'
@@ -807,12 +809,13 @@ type ListedSessions =
807
809
  | { ok: false; reason: 'unavailable' | 'shape-unknown'; detail: string }
808
810
 
809
811
  /** 列举会话存储的用途(内部枚举;渲染时必须经字典键映射 —— **绝不把裸枚举塞进文案**)。 */
810
- type SessionStorePurpose = 'reindexSessionHeader' | 'parentRelations'
812
+ type SessionStorePurpose = 'reindexSessionHeader' | 'parentRelations' | 'rewindWorkspaceAttach'
811
813
 
812
814
  /** 用途 → 字典键(本仓纪律:文案只做「原因 → 字典键」映射,禁止渲染裸枚举)。 */
813
815
  const SESSION_STORE_PURPOSE_KEY: Record<SessionStorePurpose, string> = {
814
816
  reindexSessionHeader: 'host.sessionStorePurposeReindex',
815
817
  parentRelations: 'host.sessionStorePurposeParentRelations',
818
+ rewindWorkspaceAttach: 'host.sessionStorePurposeRewindAttach',
816
819
  }
817
820
 
818
821
  /**
@@ -897,6 +900,16 @@ export interface WorkspaceAttachFailure {
897
900
  /** 一次 `attachSession` 尝试(只表达「写调用是否成功」,最终判据在 {@link DshSessionStoreFacade} 的复核里)。 */
898
901
  type AttachAttempt = { ok: true } | { ok: false; reason: 'attach-session-missing' | 'attach-rejected'; detail?: string }
899
902
 
903
+ /**
904
+ * 会话 cwd 的读取结果(**多源**:活会话 → 宿主会话存储列举)。
905
+ *
906
+ * `source` 只用于诊断与日志(live = `ctx.get('sessions').get(id).header`;stored = `sessionPersistence.list()`),
907
+ * 调用方**不得**据此改变归位判据 —— 两个来源读的都是 DSH 自己写下的同一个 header.cwd。
908
+ */
909
+ type SessionCwdLookup =
910
+ | { ok: true; cwd: string; source: 'live' | 'stored' }
911
+ | { ok: false; detail: string }
912
+
900
913
  /** 一次工作区归位的结果(成功/失败都带**具体**理由)。 */
901
914
  export type WorkspaceAttachOutcome =
902
915
  | { ok: true; reason: WorkspaceAttachSuccessReason; workspaceId?: string }
@@ -905,6 +918,20 @@ export type WorkspaceAttachOutcome =
905
918
  /** 归位失败时给调用方的 warning 串(稳定前缀 + 具体理由码)。 */
906
919
  export const WORKSPACE_ATTACH_FAILED_PREFIX = 'workspace-attach-failed'
907
920
 
921
+ /**
922
+ * 归位失败后的重试次数与间隔(毫秒)。
923
+ *
924
+ * 为什么需要(真机 2026-10-17/10-12):fork 返回时子会话刚由 `agents.create` 建好,
925
+ * DSH 侧的会话索引 / 持久化列举可能还没就绪 —— 与「目录根本不存在」这类终结性失败不同,
926
+ * 这是**时序性**失败。归位本身幂等(`attachSession` 自查成员名单、`mutate` 空操作即不改写),
927
+ * 因此重试零副作用;不重试则子会话永久停在侧栏「未分组」。
928
+ */
929
+ const WORKSPACE_ATTACH_ATTEMPTS = 2
930
+ const WORKSPACE_ATTACH_RETRY_DELAY_MS = 200
931
+
932
+ /** 归位诊断文件的相对路径(相对插件数据根;只在**失败**时追加一行 JSON)。 */
933
+ const WORKSPACE_ATTACH_DIAGNOSTIC_REL = 'logs/rewind-workspace-attach.jsonl'
934
+
908
935
  /**
909
936
  * DSH 官方 fork 在「子会话已创建、但 `workspace.attachSession` 失败」时抛的错误码。
910
937
  *
@@ -975,6 +1002,11 @@ export class DshSessionStoreFacade implements SessionStoreFacade {
975
1002
  private readonly ctx: Context
976
1003
  /** DSH 缺省会话根(配置改过时只影响快路径命中率,慢路径仍正确) */
977
1004
  private readonly root: string
1005
+ /**
1006
+ * 插件数据根(`<DSH_HOME>/dsh-config-manager`)—— **只**用于归位失败的诊断落盘。
1007
+ * 权威状态仍在 DSH(工作区注册表 / 会话存储),这里绝不旁路写任何 DSH 数据。
1008
+ */
1009
+ private readonly dataDir: string
978
1010
  /**
979
1011
  * 父子关系的短 TTL 缓存(见 `PARENT_RELATIONS_CACHE_TTL_MS`;同 key 并发合并成一次列举)。
980
1012
  * F-1 后**只缓存成功结果**:列举失败/形状识别失败会让 `parentRelations()` 抛错,
@@ -1001,6 +1033,7 @@ export class DshSessionStoreFacade implements SessionStoreFacade {
1001
1033
  constructor(ctx: Context, homeDir: string, msg: MsgFunc = zhMsg, log?: Logger, realpath?: (path: string) => Promise<string>) {
1002
1034
  this.ctx = ctx
1003
1035
  this.root = join(homeDir, 'sessions')
1036
+ this.dataDir = join(homeDir, 'dsh-config-manager')
1004
1037
  this.log = log ?? createLogger({ level: 'warn' })
1005
1038
  this.msgFunc = msg
1006
1039
  this.realpath = realpath ?? (async (value: string): Promise<string> => await fs.realpath(value))
@@ -1059,7 +1092,15 @@ export class DshSessionStoreFacade implements SessionStoreFacade {
1059
1092
  childId = recovered
1060
1093
  }
1061
1094
  // 工作区归位:官方已归位 → 只复核;官方没归位(或 attach 抛错被上面救回)→ 本兜底写;写后一律复核。
1062
- const outcome = await this.attachChildToParentWorkspace(sessionId, childId)
1095
+ //
1096
+ // 失败要重试(真机 2026-10-17):fork 返回时子会话刚建好,DSH 侧的索引/持久化列举可能还没就绪,
1097
+ // 属**时序性**失败;三档判定与 attach 本身幂等,重试零副作用。不重试的代价是子会话永久停在
1098
+ // 侧栏「未分组」(用户 2026-10-11 起的反馈)。
1099
+ let outcome = await this.attachChildToParentWorkspace(sessionId, childId)
1100
+ for (let attempt = 1; attempt < WORKSPACE_ATTACH_ATTEMPTS && !outcome.ok; attempt += 1) {
1101
+ await new Promise<void>((resolve) => { setTimeout(resolve, WORKSPACE_ATTACH_RETRY_DELAY_MS) })
1102
+ outcome = await this.attachChildToParentWorkspace(sessionId, childId)
1103
+ }
1063
1104
  if (outcome.ok) {
1064
1105
  this.log.info(
1065
1106
  'rewind: child session filed into workspace: ' + childId + ' (' + outcome.reason + (outcome.workspaceId !== undefined ? ' ' + outcome.workspaceId : '') + ')',
@@ -1067,10 +1108,9 @@ export class DshSessionStoreFacade implements SessionStoreFacade {
1067
1108
  return { childSessionId: childId }
1068
1109
  }
1069
1110
  // 归位失败**带具体理由**(绝不静默):日志留痕 + warning 串交给调用方如实上屏。
1070
- this.log.warn(
1071
- 'rewind: child session not filed into any workspace: ' + childId + ' reason=' + outcome.reason
1072
- + (outcome.detail !== undefined ? ' detail=' + outcome.detail : ''),
1073
- )
1111
+ //
1112
+ // 桌面版 DSH 拿不到宿主 stdout ⇒ 只有理由码还不够排查「为什么进未分组」,故再落一行诊断。
1113
+ await this.recordAttachDiagnostic({ parentId: sessionId, childId, outcome })
1074
1114
  const warning = workspaceAttachWarningOf(outcome)
1075
1115
  return warning === undefined ? { childSessionId: childId } : { childSessionId: childId, warning }
1076
1116
  }
@@ -1139,11 +1179,25 @@ export class DshSessionStoreFacade implements SessionStoreFacade {
1139
1179
  attachFailure ??= attempt
1140
1180
  }
1141
1181
  // ③ 同目录:cwd 与某工作区 path 指向同一目录(目录同一性由 DSH 判定,见方法注释)
1142
- const cwd = this.liveSessionCwd(childId)
1143
- if (cwd === undefined) {
1144
- cwdFailure = { ok: false, reason: 'cwd-unavailable' }
1145
- } else {
1146
- const resolution = await this.resolveWorkspaceByCwd(registry, records, cwd)
1182
+ //
1183
+ // cwd 取**多源候选**(真机 2026-10-17):旧实现只看子会话的活 header,拿不到就直接
1184
+ // `cwd-unavailable` ⇒ 三档全落空 ⇒ 子会话停在「未分组」。两条候选都来自 DSH 自己写下的 header,
1185
+ // 不是猜测:① 子会话(活 / 落盘 header);② 父会话 —— DSH 的 fork 逐字继承父 cwd
1186
+ // (`SessionCommandController.fork`:`meta.cwd = source.header.cwd`,见 dsh-api-session-controller)。
1187
+ const candidates: { cwd: string; source: string }[] = []
1188
+ const missing: string[] = []
1189
+ const childLookup = await this.sessionCwdOf(childId)
1190
+ if (childLookup.ok) candidates.push({ cwd: childLookup.cwd, source: 'child:' + childLookup.source })
1191
+ else missing.push('child(' + childLookup.detail + ')')
1192
+ const parentLookup = await this.sessionCwdOf(parentId)
1193
+ if (parentLookup.ok) {
1194
+ // 与已入列候选同目录时不重复(同目录再 attach 一次只是白跑一次 list())
1195
+ if (!candidates.some((c) => foldWorkspacePath(c.cwd) === foldWorkspacePath(parentLookup.cwd))) {
1196
+ candidates.push({ cwd: parentLookup.cwd, source: 'parent:' + parentLookup.source })
1197
+ }
1198
+ } else missing.push('parent(' + parentLookup.detail + ')')
1199
+ for (const candidate of candidates) {
1200
+ const resolution = await this.resolveWorkspaceByCwd(registry, records, candidate.cwd)
1147
1201
  if (resolution.records !== undefined && resolution.records.length > 0) {
1148
1202
  for (const record of resolution.records) {
1149
1203
  const attempt = await this.attachChildTo(record, childId)
@@ -1151,9 +1205,14 @@ export class DshSessionStoreFacade implements SessionStoreFacade {
1151
1205
  attachFailure ??= attempt
1152
1206
  }
1153
1207
  } else {
1154
- cwdFailure = resolution.failure
1208
+ // 目录不存在 / 不属于任何工作区:按候选来源标注,便于诊断区分「读到的是谁」
1209
+ const failure = resolution.failure
1210
+ if (failure !== undefined) {
1211
+ cwdFailure ??= { ok: false, reason: failure.reason, detail: candidate.source + ':' + (failure.detail ?? '') }
1212
+ }
1155
1213
  }
1156
1214
  }
1215
+ if (candidates.length === 0) cwdFailure = { ok: false, reason: 'cwd-unavailable', detail: missing.join(';') }
1157
1216
  return attachFailure ?? cwdFailure ?? { ok: false, reason: 'no-workspace-for-cwd' }
1158
1217
  } catch (error) {
1159
1218
  const detail = errorMessage(error)
@@ -1331,10 +1390,52 @@ export class DshSessionStoreFacade implements SessionStoreFacade {
1331
1390
  return out
1332
1391
  }
1333
1392
 
1334
- /** 子会话 header 的 cwd(只读;读不到返回 undefined,绝不猜路径)。 */
1335
- private liveSessionCwd(sessionId: string): string | undefined {
1336
- const cwd = this.liveSessionHeader(sessionId)?.['cwd']
1337
- return typeof cwd === 'string' && cwd !== '' ? cwd : undefined
1393
+ /**
1394
+ * 会话 cwd 的**多源**读取(活会话 → 宿主会话存储列举)。
1395
+ *
1396
+ * 为什么不能只读活会话(真机 2026-10-17 根因之一):旧实现只看 `ctx.get('sessions').get(id)`,
1397
+ * 拿不到就整体放弃归位 ⇒ fork 出来的子会话停在侧栏「未分组」。而 DSH 自己的读法有**磁盘回落**
1398
+ * (`dsh-workspace` 的 `readSessionHeader`:活会话 → `sessionPersistence.list()` 的 header 缓存),
1399
+ * 本方法与该回落同源,绝不猜路径、绝不拿父会话目录以外的任何东西凑数。
1400
+ *
1401
+ * 失败时返回**具体细节**(哪个来源、为什么),由调用方拼进诊断;绝不静默成 undefined。
1402
+ */
1403
+ private async sessionCwdOf(sessionId: string): Promise<SessionCwdLookup> {
1404
+ const live = this.liveSessionHeader(sessionId)?.['cwd']
1405
+ if (typeof live === 'string' && live !== '') return { ok: true, cwd: live, source: 'live' }
1406
+ const store = readService<SessionListService>(this.ctx, 'sessionPersistence')
1407
+ if (store === undefined) return { ok: false, detail: 'no-live-cwd;session-persistence-unavailable' }
1408
+ const listed = await this.listSessionElements(store, 'rewindWorkspaceAttach')
1409
+ if (!listed.ok) return { ok: false, detail: 'no-live-cwd;' + listed.reason }
1410
+ const view = listed.items.find((item) => item.id === sessionId)
1411
+ if (view === undefined) return { ok: false, detail: 'no-live-cwd;not-in-stored-sessions' }
1412
+ const raw = isRecord(view.raw) ? view.raw['cwd'] : undefined
1413
+ if (typeof raw === 'string' && raw !== '') return { ok: true, cwd: raw, source: 'stored' }
1414
+ return { ok: false, detail: 'no-live-cwd;stored-header-has-no-cwd' }
1415
+ }
1416
+
1417
+ /**
1418
+ * 归位失败的**诊断落盘**(一行 JSON,追加写)。
1419
+ *
1420
+ * 为什么需要:桌面版 DSH 的宿主 stdout 不落盘 ⇒ 用户界面上只有「未挂回原工作区」这句,
1421
+ * 具体理由(registry 拿不到 / attach 被拒 / cwd 读不到 / 复核不过)谁都看不到,
1422
+ * 「为什么会进未分组」于是长期不可排查。这里只记 id、理由码与路径来源,**不含任何凭据**。
1423
+ * 落盘失败绝不升级成回退失败(诊断是旁路,不是回退的一部分)。
1424
+ */
1425
+ private async recordAttachDiagnostic(entry: { parentId: string; childId: string; outcome: WorkspaceAttachFailure }): Promise<void> {
1426
+ try {
1427
+ const target = join(this.dataDir, WORKSPACE_ATTACH_DIAGNOSTIC_REL)
1428
+ await fs.mkdir(dirname(target), { recursive: true })
1429
+ await fs.appendFile(target, JSON.stringify({
1430
+ at: new Date().toISOString(),
1431
+ parentId: entry.parentId,
1432
+ childId: entry.childId,
1433
+ reason: entry.outcome.reason,
1434
+ ...(entry.outcome.detail !== undefined ? { detail: entry.outcome.detail } : {}),
1435
+ }) + '\n')
1436
+ } catch {
1437
+ // 诊断写不进去(只读盘 / 权限 / 配额):静默 —— 回退本身的结论不受影响。
1438
+ }
1338
1439
  }
1339
1440
 
1340
1441
  /**
@@ -4644,10 +4745,19 @@ export function apply(ctx: Context, config?: Config): void {
4644
4745
  // 不为了这一处调用去改公共接口;端口上确实有 forkSession(DshSessionStoreFacade 实现)才走
4645
4746
  // 真 fork,否则如实结构化 `session-fork-unavailable`(绝不静默降级)。
4646
4747
  const forkPort = host.sessions as unknown as RollbackForkCarrier
4748
+ // 写入归属台账(2026-10-17 定案 B):只记「哪个会话的哪次**写类**工具调用写了哪个路径」。
4749
+ // 惰性读盘 + best-effort 落盘:读不到 / 写不进只让归属退化成「未判定」——
4750
+ // 既不影响留点与回退,也绝不会把「不知道」当成「别的会话写的」(见 rollback-attribution.ts)。
4751
+ const rollbackAttribution = new RollbackWriteAttribution({
4752
+ file: path.join(dataDir, ROLLBACK_POINTS_DIR_NAME, ROLLBACK_ATTRIBUTION_FILE_NAME),
4753
+ warn: (line) => host.log.warn(line),
4754
+ })
4755
+ void rollbackAttribution.load()
4647
4756
  const rollbackEngine = new RollbackPointEngine({
4648
4757
  dataDir,
4649
4758
  homeDir,
4650
4759
  log: (line) => host.log.warn(line),
4760
+ attribution: rollbackAttribution,
4651
4761
  fork: async ({ sessionId, boundarySeq }) => {
4652
4762
  const forkSession = forkPort.forkSession
4653
4763
  if (typeof forkSession !== 'function') {
@@ -4666,7 +4776,11 @@ export function apply(ctx: Context, config?: Config): void {
4666
4776
  return { ok: false as const, code: 'fork-failed' as const, detail: result.error }
4667
4777
  },
4668
4778
  })
4669
- const rollbackTracker = new RollbackAutoTracker({ engine: rollbackEngine, log: (line) => host.log.warn(line) })
4779
+ const rollbackTracker = new RollbackAutoTracker({
4780
+ engine: rollbackEngine,
4781
+ log: (line) => host.log.warn(line),
4782
+ attribution: rollbackAttribution,
4783
+ })
4670
4784
  ctx.effect(() => {
4671
4785
  let chain: Promise<unknown> = Promise.resolve()
4672
4786
  // 「会话没有 cwd」这类**形状合法但信息不全**的情况只记一次日志(避免每回合刷屏);
@@ -733,6 +733,17 @@ export function recoveryRoutes(env: RoutesEnv): WebRoute[] {
733
733
  // A(用户 2026-10-11 定案):「回到某点之前」要连文件一起退回去 —— 默认把**回退点之后新建**的路径删掉;
734
734
  // 调用方显式传 false 才保留老口径(只报告 leftovers,不删任何东西)。
735
735
  rewindInput.removeCreated = body['removeCreated'] !== false
736
+ // 路径级选择性恢复(2026-10-17 定案 A):缺省 = 全量口径(一字不变)。
737
+ // 给了数组就只恢复这些路径;**空数组**视为无效输入(400)—— 「一个都没勾」应当由
738
+ // 界面拦住,而不是让引擎把整次回退悄悄变成 noop。
739
+ if (body['onlyPaths'] !== undefined) {
740
+ const raw = body['onlyPaths']
741
+ if (!Array.isArray(raw) || raw.length === 0 || raw.some((item) => typeof item !== 'string' || item === '')) {
742
+ writeJson(res, 400, { ok: false, code: 'invalid-input', error: 'onlyPaths must be a non-empty array of non-empty strings' })
743
+ return
744
+ }
745
+ rewindInput.onlyPaths = raw as string[]
746
+ }
736
747
  if (body['preRewindGuard'] === 'off' || body['preRewindGuard'] === 'require' || body['preRewindGuard'] === 'warn') {
737
748
  rewindInput.guardPolicy = body['preRewindGuard']
738
749
  }
@@ -227,7 +227,7 @@ export const SECTION_REGISTRY: Record<SectionId, SectionMeta> = {
227
227
  },
228
228
  plugins: {
229
229
  id: 'plugins',
230
- displayName: 'Plugins',
230
+ displayName: 'Plugin list',
231
231
  group: 'extensions',
232
232
  payload: {
233
233
  kind: 'array', zipPath: 'plugins/plugins.json', key: 'plugins',
@@ -324,7 +324,7 @@ export const SECTION_REGISTRY: Record<SectionId, SectionMeta> = {
324
324
  },
325
325
  pluginFiles: {
326
326
  id: 'pluginFiles',
327
- displayName: "Other Plugins' Loose Files",
327
+ displayName: 'Plugin config files',
328
328
  group: 'extensions',
329
329
  payload: { kind: 'files', filePrefix: 'plugin-files/' },
330
330
  dataVersion: SECTION_DATA_VERSION,
@@ -377,7 +377,7 @@ export const SECTION_REGISTRY: Record<SectionId, SectionMeta> = {
377
377
  },
378
378
  self: {
379
379
  id: 'self',
380
- displayName: "This Plugin's Own Config",
380
+ displayName: 'Config Manager settings',
381
381
  group: 'extensions',
382
382
  payload: { kind: 'files', filePrefix: 'self/' },
383
383
  dataVersion: SECTION_DATA_VERSION,
@@ -390,7 +390,7 @@ export const SECTION_REGISTRY: Record<SectionId, SectionMeta> = {
390
390
  },
391
391
  pluginData: {
392
392
  id: 'pluginData',
393
- displayName: "Other Plugins' Data",
393
+ displayName: 'Plugin data dirs',
394
394
  group: 'extensions',
395
395
  payload: { kind: 'files', filePrefix: 'plugin-data/' },
396
396
  dataVersion: SECTION_DATA_VERSION,
@@ -406,7 +406,7 @@ export const SECTION_REGISTRY: Record<SectionId, SectionMeta> = {
406
406
  },
407
407
  pluginAssets: {
408
408
  id: 'pluginAssets',
409
- displayName: "Other Plugins' Large Files",
409
+ displayName: 'Plugin large files',
410
410
  group: 'extensions',
411
411
  payload: { kind: 'files', filePrefix: 'plugin-assets/' },
412
412
  dataVersion: SECTION_DATA_VERSION,
@@ -113,6 +113,20 @@ export interface PluginEntry {
113
113
  * 否则按裸包名走 npm 最新版(官方机制)。
114
114
  */
115
115
  spec?: string;
116
+ /**
117
+ * 该层只在 profile 的 `dsh.profile.bundles` 里声明、`dependencies` 里没有它,且该 profile 的
118
+ * `node_modules` 里也**没有**它的落盘副本(issue #95,导出侧写入)。
119
+ *
120
+ * 语义:这不是一个「装在本 profile 里的包」,而是由 DSH 从**自己的安装位置**解析的层
121
+ * (与 `boot-safety.ts` / `RESCUE_KEEP_BUNDLE_PREFIXES` 同口径)。导入端对这类条目
122
+ * **只把名字声明进目标机的 `dsh.profile.bundles`,绝不走 npm 安装通道**——按裸包名安装会让
123
+ * 目标机拿到 `dist-tags.latest` 的副本(2026-10 真机:host 0.2.1-rc.2 + 包 0.2.1-alpha.2
124
+ * → session-controller 一直 pending → 新建会话 service-unavailable)。
125
+ *
126
+ * 缺省(旧备份没有该字段)= 由 `spec` / `version` 形状推断,见
127
+ * `src/core/plugin-cli.ts` 的 `isBundlesOnlyEntry`。
128
+ */
129
+ bundlesOnly?: boolean;
116
130
  isBundle: boolean;
117
131
  inBundles: string[];
118
132
  enabled: boolean;
@@ -0,0 +1,23 @@
1
+ /**
2
+ * badge-tone 单测:四态 → 原生 Tag tone 的映射必须稳定(React 壳只装配,语义在这一层可测)。
3
+ */
4
+ import test from 'node:test'
5
+ import assert from 'node:assert/strict'
6
+ import { BADGE_TONE, badgeTone, type BadgeKind } from './badge-tone.ts'
7
+
8
+ test('badge-tone:四态各自映射到原生 Tag 的 tone', () => {
9
+ assert.equal(badgeTone('ok'), 'success')
10
+ assert.equal(badgeTone('warn'), 'warning')
11
+ assert.equal(badgeTone('error'), 'danger')
12
+ assert.equal(badgeTone('info'), 'info')
13
+ })
14
+
15
+ test('badge-tone:映射表覆盖全部四态且 tone 都是原生调色板里的值', () => {
16
+ const kinds: BadgeKind[] = ['info', 'ok', 'warn', 'error']
17
+ const palette = new Set(['outline', 'solid', 'neutral', 'quiet', 'success', 'info', 'warning', 'danger'])
18
+ for (const k of kinds) {
19
+ assert.ok(BADGE_TONE[k] !== undefined, k + ' 缺少映射')
20
+ assert.ok(palette.has(BADGE_TONE[k]), BADGE_TONE[k] + ' 不在原生 Tag 调色板内')
21
+ }
22
+ assert.equal(Object.keys(BADGE_TONE).length, kinds.length)
23
+ })
@@ -0,0 +1,27 @@
1
+ /**
2
+ * 徽章四态 → DSH 原生 Tag tone 的映射(**唯一一份**)。
3
+ *
4
+ * 为什么单独成模块:徽章现在优先渲染 DSH 自带的 `Tag`(@deepseek-ai/dsh-client-ui-primitives,
5
+ * 与宿主同一颗组件),它只有 tone 语义、没有 kind 语义;映射关系是纯函数,放这里可被 node 单测覆盖,
6
+ * 也让 React 壳保持「只装配」。老宿主拿不到原生包时回落本仓库的 `.badge[data-kind]` 样式,
7
+ * 两条路径共用同一套 kind → tone 语义,不允许各自漂移。
8
+ */
9
+
10
+ /** 插件侧的四态徽章语义。 */
11
+ export type BadgeKind = 'info' | 'ok' | 'warn' | 'error'
12
+
13
+ /** DSH 原生 Tag 的调色板选项(与其 .d.ts 逐字同形)。 */
14
+ export type DshTagTone = 'outline' | 'solid' | 'neutral' | 'quiet' | 'success' | 'info' | 'warning' | 'danger'
15
+
16
+ /** 四态 → 原生 tone(ok=success / warn=warning / error=danger / info=info)。 */
17
+ export const BADGE_TONE: Readonly<Record<BadgeKind, DshTagTone>> = {
18
+ ok: 'success',
19
+ info: 'info',
20
+ warn: 'warning',
21
+ error: 'danger',
22
+ }
23
+
24
+ /** 取某个四态对应的原生 tone;未知输入按 info 处理(调用方给的是字面量联合,此为防御)。 */
25
+ export function badgeTone(kind: BadgeKind): DshTagTone {
26
+ return BADGE_TONE[kind] ?? 'info'
27
+ }
package/src/ui/i18n.ts CHANGED
@@ -548,10 +548,10 @@ export const uiZh = {
548
548
  'export.category.settings': 'DSH 全局设置(namespace 分区,redacted)',
549
549
  'export.category.providers': 'LLM Provider / Model / 默认模型 / BaseURL',
550
550
  'export.category.plugins': '已安装插件清单与启用状态(不含二进制)',
551
- 'export.category.pluginFiles': '其他插件放在 ~/.dsh 根下的零散配置文件(白名单 + plugin-config/ 目录,整文件复制)',
552
- 'export.category.self': '本插件自身配置(同步/自动同步/分区选择/UI 偏好/市场;sync-*.json 等,不含凭据值)',
553
- 'export.category.pluginData': '其他插件在系统主目录下的数据(如 ~/.dsh-wallpaper-engine、~/.mnemon 的配置与状态;可再生成缓存绝不收录)',
554
- 'export.category.pluginAssets': '其他插件的大体积文件(上传的壁纸、本地数据库等;默认不勾选)',
551
+ 'export.category.pluginFiles': '插件放在 ~/.dsh 根下的配置文件(白名单 + plugin-config/ 目录,整文件复制)',
552
+ 'export.category.self': '配置管理器自身的设置(同步/自动同步/分区选择/UI 偏好/市场;sync-*.json 等,不含凭据值)',
553
+ 'export.category.pluginData': '插件在系统主目录下的数据(如 ~/.dsh-wallpaper-engine、~/.mnemon 的配置与状态;可再生成缓存绝不收录)',
554
+ 'export.category.pluginAssets': '插件的大体积文件(上传的壁纸、本地数据库等;默认不勾选)',
555
555
  'export.category.mcp': 'MCP 服务器组合配置(需重启生效)',
556
556
  'export.category.prompts': 'System Prompt / Plan Mode 提示',
557
557
  'export.category.skills': '用户技能文件(~/.dsh/skills)',
@@ -1097,10 +1097,10 @@ export const uiEn: Record<UiTextKey, string> = {
1097
1097
  'export.category.settings': 'Global DSH settings (namespace sections, redacted)',
1098
1098
  'export.category.providers': 'LLM providers / models / default model / base URL',
1099
1099
  'export.category.plugins': 'Installed plugin list and enablement (binaries excluded)',
1100
- 'export.category.pluginFiles': "Other plugins' loose config files at the ~/.dsh root (allowlist + plugin-config/, copied whole)",
1101
- 'export.category.self': "This plugin's own config (sync / autosync / section selection / UI prefs / market; sync-*.json etc., no credential values)",
1102
- 'export.category.pluginData': "Other plugins' data under the system home directory (e.g. the config/state of ~/.dsh-wallpaper-engine and ~/.mnemon; regenerable caches are never included)",
1103
- 'export.category.pluginAssets': "Other plugins' large files (uploaded wallpapers, local databases, ...; not selected by default)",
1100
+ 'export.category.pluginFiles': "Plugin config files at the ~/.dsh root (allowlist + plugin-config/, copied whole)",
1101
+ 'export.category.self': "Config Manager's own settings (sync / autosync / section selection / UI prefs / market; sync-*.json etc., no credential values)",
1102
+ 'export.category.pluginData': "Plugin data under the system home directory (e.g. the config/state of ~/.dsh-wallpaper-engine and ~/.mnemon; regenerable caches are never included)",
1103
+ 'export.category.pluginAssets': "Plugins' large files (uploaded wallpapers, local databases, ...; not selected by default)",
1104
1104
  'export.category.mcp': 'MCP server composition (restart required)',
1105
1105
  'export.category.prompts': 'System prompt / Plan Mode prompts',
1106
1106
  'export.category.skills': 'Your skill files (~/.dsh/skills)',
@@ -834,3 +834,30 @@ test('issue #88:rollbackEntryVisible —— false 收起 / true 显示 / 缺
834
834
  })
835
835
 
836
836
 
837
+
838
+ /* ------------------------------------------------------------------ A+B:逐文件选择的界面模型 */
839
+
840
+ test('A/B 选择模型:默认排除别的对话改过的文件;全选时不传 onlyPaths', async () => {
841
+ const { rollbackSelectionRows, rollbackDefaultSelection, rollbackOnlyPaths, rollbackSelectionBlocksSubmit } = await import('./rollback-view.ts')
842
+ const preview = {
843
+ ok: true, id: 'rp-1', storage: { available: true },
844
+ workspace: {
845
+ files: [
846
+ { path: 'C:/ws/a.txt', status: 'overwrite', added: 1, removed: 1, truncated: false },
847
+ { path: 'C:/ws/b.txt', status: 'overwrite', added: null, removed: null, truncated: true, foreign: true, foreignSessionId: 'session-x' },
848
+ ],
849
+ },
850
+ } as unknown as import('./rollback-view.ts').RollbackPreviewResult
851
+ const rows = rollbackSelectionRows(preview)
852
+ assert.equal(rows.length, 2)
853
+ assert.equal(rows[1]?.foreign, true)
854
+ assert.equal(rows[1]?.foreignSessionId, 'session-x')
855
+ const fallback = rollbackDefaultSelection(rows)
856
+ assert.deepEqual(fallback, ['C:/ws/a.txt'], '默认只勾非 foreign 的文件')
857
+ assert.deepEqual(rollbackOnlyPaths(rows, fallback), ['C:/ws/a.txt'])
858
+ assert.equal(rollbackOnlyPaths(rows, rows.map((row) => row.path)), null, '全选 ⇒ null(不传字段,走既有全量口径)')
859
+ assert.equal(rollbackSelectionBlocksSubmit(rows, []), true, '一个都没勾必须拦住(宿主侧空数组是 400)')
860
+ assert.equal(rollbackSelectionBlocksSubmit(rows, fallback), false)
861
+ assert.deepEqual(rollbackSelectionRows(null), [])
862
+ assert.deepEqual(rollbackSelectionRows({ workspace: { files: 'nope' } } as unknown as import('./rollback-view.ts').RollbackPreviewResult), [])
863
+ })