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
@@ -48,6 +48,21 @@ function declarations(css: string): { prop: string; value: string; line: number
48
48
  }
49
49
 
50
50
  const STRIPPED = stripComments(CSS)
51
+
52
+ /**
53
+ * **全局 token 块**(文件里第一个 `body { … }`)。
54
+ *
55
+ * 为什么 scale 档位只看这里:`--cm-fs-*` / `--cm-r-*` 是全局 scale,定义在 body 上;
56
+ * 而文件里还存在**局部作用域**的同名重定义(如 §16 聊天卡「镜像宿主活值」块把
57
+ * `--cm-r-2/3` 钉成宿主的 6/8px)——那是宿主逐值镜像,不是我们的 scale 档位,
58
+ * 不参与本守卫的原生派生断言(否则会把镜像当成越界)。
59
+ */
60
+ const GLOBAL_TOKENS = (() => {
61
+ const at = STRIPPED.indexOf('body {')
62
+ if (at < 0) return ''
63
+ const end = STRIPPED.indexOf('}', at)
64
+ return end < 0 ? STRIPPED.slice(at) : STRIPPED.slice(at, end)
65
+ })()
51
66
  const DECLS = declarations(STRIPPED)
52
67
 
53
68
  function collect(prop: string, pick: (value: string) => string[]): Map<string, number[]> {
@@ -63,17 +78,40 @@ function collect(prop: string, pick: (value: string) => string[]): Map<string, n
63
78
  return seen
64
79
  }
65
80
 
66
- test('scale-01 字号只有 4 档(11 / 12 / 12.5 / 13px),且不得出现 var(--cm-fs-*) 之外的写法', () => {
81
+ /**
82
+ * 2026-10-17(对齐 DSH 原生):字号不再允许散落字面量,**一律走 `--cm-fs-*`**,
83
+ * 而每个 `--cm-fs-*` 都必须**取自 DSH 原生字体 token**(`--dsw-font-*` 的某一档)——
84
+ * 档位身份从此由原生 scale 定义,将来原生调档本插件跟着动。
85
+ *
86
+ * 例外必须登记(`FONT_TOKEN_EXCEPTIONS`,逐条写理由):**当前只有面板密集基准档**,
87
+ * 它是全站正文的锚点、改了等于全站字号地震(chat-entry-view.test.ts ③ 也钉着它)。
88
+ */
89
+ const FONT_TOKEN_EXCEPTIONS: Readonly<Record<string, string>> = {
90
+ '--cm-fs-base': '面板密集基准 12.5px:原生最小正文档是 13px,而插件画布 564×720 需要更密半档;改它 = 全站字号地震',
91
+ }
92
+
93
+ test('scale-01 字号只走 --cm-fs-*,且 token 必须取自原生字体 token', () => {
67
94
  const seen = collect('font-size', (v) => (/^[\d.]+px$/.test(v) ? [v] : v.startsWith('var(--cm-fs-') ? [] : [v]))
68
95
  const bad = [...seen.entries()].filter(([v]) => !ALLOWED_FONT_SIZE.has(v))
69
96
  assert.deepEqual(bad, [], 'font-size 越界值(新档位必须先改本文档与 DESIGN.md §4):' + JSON.stringify(bad))
70
- assert.ok(seen.size >= 3, '字号档位数量异常,疑似扫描失效')
97
+ const defs = [...GLOBAL_TOKENS.matchAll(/^\s*(--cm-fs-[a-z-]+)\s*:\s*([^;]+);/gm)]
98
+ assert.ok(defs.length >= 4, '字号档位数量异常,疑似扫描失效:' + JSON.stringify(defs.map((m) => m[1])))
99
+ const notNative = defs.filter((m) => !/^var\(--dsw-font-/.test((m[2] ?? '').trim()) && FONT_TOKEN_EXCEPTIONS[m[1] ?? ''] === undefined)
100
+ assert.deepEqual(notNative.map((m) => m[1]), [], '这些字号 token 没有取自 DSH 原生字体 token(--dsw-font-*),也不在例外表里')
101
+ // 反向自检:例外表只许登记真实存在的 token,防止表里堆死条目
102
+ for (const name of Object.keys(FONT_TOKEN_EXCEPTIONS)) {
103
+ assert.ok(defs.some((m) => m[1] === name), '例外表里的 ' + name + ' 在 §1 已经不存在(死条目应当删除)')
104
+ }
71
105
  })
72
106
 
73
- test('scale-02 圆角只有 4 档(4 / 6 / 8px + 药丸 999),另有 0 与 50% 两个结构性豁免', () => {
107
+ test('scale-02 圆角只走 --cm-r-*(+ 药丸/50%/0 豁免),且 token 必须派生自原生 radius scale', () => {
74
108
  const seen = collect('border-radius', (v) => [v])
75
109
  const bad = [...seen.entries()].filter(([v]) => !ALLOWED_RADIUS.has(v) && !v.startsWith('var(--cm-r-'))
76
110
  assert.deepEqual(bad, [], 'border-radius 越界值:' + JSON.stringify(bad))
111
+ const defs = [...GLOBAL_TOKENS.matchAll(/^\s*(--cm-r-[a-z0-9-]+)\s*:\s*([^;]+);/gm)]
112
+ assert.ok(defs.length >= 4, '圆角档位数量异常:' + JSON.stringify(defs.map((m) => m[1])))
113
+ const notNative = defs.filter((m) => m[1] !== '--cm-r-pill' && !/^var\(--dsw-radius-/.test((m[2] ?? '').trim()))
114
+ assert.deepEqual(notNative.map((m) => m[1]), [], '这些圆角 token 没有派生自原生 --dsw-radius-* scale')
77
115
  })
78
116
 
79
117
  test('scale-03 行高只有 2 档(1.25 标题 / 1.5 正文)', () => {
@@ -24,7 +24,8 @@ function stripComments(css: string): string {
24
24
  const GUARDED_DSW = [
25
25
  '--dsw-alias-state-business-primary',
26
26
  '--dsw-alias-state-success-primary',
27
- '--dsw-alias-state-info-primary',
27
+ // 2026-10-17(对齐原生):DSH 主题**没有** --dsw-alias-state-info-primary(旧条目指了个不存在的 token,
28
+ // 导致 --cm-info 的整条 var() 链无效、三处消费点静默退化)。--cm-info 现按同义别名接 --cm-accent。
28
29
  '--dsw-alias-state-warn-primary',
29
30
  '--dsw-alias-state-error-primary',
30
31
  '--dsw-alias-button-info-fill',
@@ -55,10 +56,15 @@ test('token-01 四态/主色 token 只能出现在 --cm-* 定义行里', () => {
55
56
  assert.deepEqual(violations, [], '这些行必须改为消费 --cm-*(或把新 token 加进 §1 TOKENS):\n' + violations.join('\n'))
56
57
  })
57
58
 
58
- test('token-02 每个受约束的 --dsw-* 语义 token 恰好定义一次(不得重复定义/漂移)', () => {
59
+ test('token-02 每个受约束的 --dsw-* 语义 token 仍被定义且只出现在定义行(不漂移、不失效)', () => {
59
60
  for (const tok of GUARDED_DSW) {
60
- const n = (STRIPPED.match(new RegExp(tok.replace(/[-]/g, '\\-'), 'g')) ?? []).length
61
- assert.equal(n, 1, tok + ' 应恰好出现 1 次(在 --cm-* 定义行),实际 ' + String(n))
61
+ // 判定口径(2026-10-17 对齐原生时放宽「恰好一次」):既要 ≥1 次
62
+ // —— token 被悄悄删掉会让整条 --cm-* 链解析成无效值(--cm-info 曾经就是这样),
63
+ // 也要每次都在定义行(裸用由 token-01 报错)。允许两个语义名指向同一个原生 token。
64
+ const lines = STRIPPED.split(String.fromCharCode(10)).filter((line) => line.includes(tok))
65
+ assert.ok(lines.length >= 1, tok + ' 在本文件里已不存在(删掉它时忘了同步本表?)')
66
+ const offDef = lines.filter((line) => !DEF_LINE.test(line))
67
+ assert.deepEqual(offDef, [], tok + ' 出现在非定义行:' + JSON.stringify(offDef))
62
68
  }
63
69
  })
64
70
 
@@ -181,6 +181,8 @@ export function ExportView({ api, t }: ExportViewProps) {
181
181
  unitsUnread: got === undefined,
182
182
  portability: c.portability,
183
183
  sensitive: c.sensitive === true,
184
+ // 分组标题(导出目录本已按 EXPORT_GROUPS 排好;选择器只在变化处插一行,不重排)
185
+ group: c.group,
184
186
  }
185
187
  })
186
188
  /**
@@ -47,8 +47,8 @@ import {
47
47
  type ChildOpenState,
48
48
  } from './chat/rollback-entry-view.ts'
49
49
  import { CHAT_NODE_PRIORITY, CHAT_NODE_SLOT, CHAT_NODE_USER_KEY } from './chat/chat-entry-view.ts'
50
- import { registerChatNodeEntry } from './chat/chat-node-registration.ts'
51
- import { setRollbackEntryEnabled } from './recovery/rollback-enabled.ts'
50
+ import { registerChatNodeEntryWhenEnabled } from './chat/chat-node-registration.ts'
51
+ import { isRollbackEntryEnabled, setRollbackEntryEnabled, subscribeRollbackEntryEnabled } from './recovery/rollback-enabled.ts'
52
52
  import { rollbackEntryVisible } from '../ui/rollback-view.ts'
53
53
  import { RollbackDiffTab, RollbackDiffTabTitle } from './chat/RollbackDiffTab.tsx'
54
54
  import {
@@ -158,6 +158,10 @@ export function apply(ctx: ClientContext): void {
158
158
  * 并留一条 warn 级日志便于排障。
159
159
  *
160
160
  * 判据读的是 storage 响应(`rollbackEntryVisible`:老宿主没有该字段 ⇒ 按「默认开」,与那一版语义一致)。
161
+ *
162
+ * 2026-10-17 起这条信号还决定**要不要接管用户消息气泡**(不再只是按钮可见性):信号为假时
163
+ * `registerChatNodeEntryWhenEnabled` 一次都不登记,那条 cell 由宿主 `UserMessageNodeView` 原生渲染
164
+ * —— 与「关掉功能后连查询都不发」同一条口径:关掉 = 整条入口链路不存在。
161
165
  */
162
166
  void recoveryApi.rollbackPointStorage?.().then(
163
167
  (status) => { setRollbackEntryEnabled(rollbackEntryVisible(status)) },
@@ -265,10 +269,14 @@ export function apply(ctx: ClientContext): void {
265
269
  * 宿主 UserMessageNodeView 是唯一合法做法,那条气泡由我们自复刻。依据(含 -1 为何是唯一合法优先级)
266
270
  * → `src/client/chat/chat-slots.ts` 头部注释。
267
271
  *
272
+ * **开关关着就完全不接管**(2026-10-17 用户要求):登记由 `registerChatNodeEntryWhenEnabled` 驱动 ——
273
+ * 关掉回合自动留点后让出 user cell,那条消息回到宿主原生气泡(旧实现是「登记常驻、只收起按钮」,
274
+ * 气泡仍由我们代画,用户看到的就是「关掉了还在接管」)。开关重新打开时登记当场补上,不需要重载插件。
275
+ *
268
276
  * 形态探测 + 失败软化在 `registerChatNodeEntry`:0.1.x 的 ui-chat 契约不同(非 keyed)或注册抛错,
269
277
  * 都只丢这一个入口,绝不打挂本插件其余槽位与宿主启动。
270
278
  */
271
- ctx.slots.inject(CHAT_NODE_SLOT, () => registerChatNodeEntry({
279
+ ctx.slots.inject(CHAT_NODE_SLOT, () => registerChatNodeEntryWhenEnabled({
272
280
  // 探测面两条都是只读:spec(形态)+ entriesOfSlot(user cell 当前赢家;老宿主可能没有该方法)。
273
281
  // 必须写成**成员调用**(obj.method?.()):把方法抽出来单独调会丢 receiver —— 宿主实现里那句
274
282
  // this._core.entriesOfSlot(...) 会直接抛(同类真机事故:uiWorkspace.openSession 丢 receiver,见 AGENTS.md)。
@@ -283,6 +291,10 @@ export function apply(ctx: ClientContext): void {
283
291
  locale: CHAT_NS,
284
292
  inject: chatEntryInjected,
285
293
  }, ChatEntryNodeView),
294
+ // 信号 = `rollbackEnabled` 的进程内投影(挂载时读一次;设置页改开关时再写一次)。
295
+ // 订阅先建后读:登记只发生在真正需要它的那一刻,关掉即撤销(见 chat-node-registration.ts)。
296
+ isEnabled: isRollbackEntryEnabled,
297
+ subscribe: subscribeRollbackEntryEnabled,
286
298
  }))
287
299
 
288
300
  /**
@@ -12,20 +12,20 @@ export const zh = {
12
12
  'section.settings': '设置',
13
13
  'section.ui': 'UI 偏好',
14
14
  'section.providers': 'Provider 与模型',
15
- 'section.plugins': '插件',
15
+ 'section.plugins': '插件清单',
16
16
  'section.mcp': 'MCP 服务器',
17
17
  'section.prompts': '提示词',
18
18
  'section.skills': '技能',
19
19
  'section.agentPresets': 'Agent 预设',
20
20
  'section.agentInstructions': '全局指令',
21
21
  'section.workspaces': '工作区',
22
- 'section.pluginFiles': '其他插件零散文件',
22
+ 'section.pluginFiles': '插件散落配置',
23
23
  'section.credentialsStatus': '凭据状态',
24
24
  'section.secrets': '密钥',
25
25
  'section.sessions': '历史会话',
26
- 'section.self': '本插件自身配置',
27
- 'section.pluginData': '其他插件数据',
28
- 'section.pluginAssets': '其他插件大文件',
26
+ 'section.self': '配置管理器设置',
27
+ 'section.pluginData': '插件数据目录',
28
+ 'section.pluginAssets': '插件大文件',
29
29
  // 视图切换
30
30
  'view.about': '关于',
31
31
  // 关于页「一键复制环境信息」片段(提 issue 用):zh 保留双语标题(维护者可读),en 纯英文
@@ -180,6 +180,28 @@ export const zh = {
180
180
  'picker.sensitive': '敏感',
181
181
  'picker.showAll': '显示全部(共 {count} 条)',
182
182
  'picker.summaryImport': '将导入 {sections} 个分区 · {units} 个条目',
183
+ // —— 「插件与扩展」组内嵌的插件树(插件 → 清单条目 / 数据目录 / 大文件)——
184
+ 'picker.pluginRoot': '插件',
185
+ 'picker.pluginLeafPlugin': '插件清单条目',
186
+ 'picker.pluginLeafConfig': '数据目录(配置)',
187
+ 'picker.pluginLeafAssets': '大文件(壁纸 / 数据库)',
188
+ 'picker.pluginAreaUnread': '未读取',
189
+ 'picker.pluginAreaAbsent': '无内容',
190
+ 'picker.pluginViewEmpty': '插件清单还没读到,先读取「插件」分区。',
191
+ 'picker.bucketPlugins': '插件分区的其他条目(补丁行 / pnpm-workspace.yaml)',
192
+ 'picker.bucketLooseFiles': '未归属到插件的配置(~/.dsh 根)',
193
+ 'picker.bucketHint': '这些条目没有可证明的插件归属,不猜;展开可逐条勾选。',
194
+ 'picker.groupHint.extensions': '这一组里的插件分区合并成一棵按插件分组的树:默认只显示一个「插件」区块,展开它才列出各个插件,插件名下面是它的清单条目、数据目录与大文件。只显示能证明的归属(内置来源表 + 插件自述 dsh.dataDirs)——「插件散落配置」没有包名信息,因此留在「未归属」里;带「设备相关」的分区默认不勾选,跨设备导入时可能不适用。',
195
+ // 导出目录的分组标题(与 ui/types.ts 的 EXPORT_GROUPS 一一对应,顺序由它决定)
196
+ 'group.general': '通用',
197
+ 'group.ai': 'AI',
198
+ 'group.extensions': '插件与扩展',
199
+ 'group.mcp': 'MCP / 工具',
200
+ 'group.customization': '定制',
201
+ 'group.automation': '自动化',
202
+ 'group.workspace': '工作区',
203
+ 'group.ui': '界面',
204
+ 'group.optional': '可选数据',
183
205
  'import.nothingSelected': '当前没有勾选任何要导入的内容,请把需要导入的分区或条目勾上。',
184
206
  'import.excludedByUser': '本次有 {count} 项被你取消勾选:未导入,也未进快照。',
185
207
  'import.consult.hint': '只读分析:先看结论与依据,确认后再选择要导入的内容。',
@@ -953,20 +975,20 @@ export const en: Record<keyof typeof zh, string> = {
953
975
  'section.settings': 'Settings',
954
976
  'section.ui': 'UI Preferences',
955
977
  'section.providers': 'Providers & Models',
956
- 'section.plugins': 'Plugins',
978
+ 'section.plugins': 'Plugin list',
957
979
  'section.mcp': 'MCP Servers',
958
980
  'section.prompts': 'Prompts',
959
981
  'section.skills': 'Skills',
960
982
  'section.agentPresets': 'Agent Presets',
961
983
  'section.agentInstructions': 'Agent Instructions',
962
984
  'section.workspaces': 'Workspaces',
963
- 'section.pluginFiles': "Other Plugins' Loose Files",
985
+ 'section.pluginFiles': 'Plugin config files',
964
986
  'section.credentialsStatus': 'Credentials Status',
965
987
  'section.secrets': 'Secrets',
966
988
  'section.sessions': 'Sessions',
967
- 'section.self': "This Plugin's Own Config",
968
- 'section.pluginData': "Other Plugins' Data",
969
- 'section.pluginAssets': "Other Plugins' Large Files",
989
+ 'section.self': 'Config Manager settings',
990
+ 'section.pluginData': 'Plugin data dirs',
991
+ 'section.pluginAssets': 'Plugin large files',
970
992
  'view.about': 'About',
971
993
  'about.snippet.environment': '### Environment',
972
994
  'about.releaseNotes.rateLimited': 'GitHub API rate limit exceeded — please try again later, or view the release notes directly on GitHub.',
@@ -1111,6 +1133,28 @@ export const en: Record<keyof typeof zh, string> = {
1111
1133
  'picker.sensitive': 'Sensitive',
1112
1134
  'picker.showAll': 'Show all ({count})',
1113
1135
  'picker.summaryImport': 'Will import {sections} section(s) · {units} item(s)',
1136
+ // —— Plugin tree embedded in the Extensions group (plugin → list entry / data dirs / large files) ——
1137
+ 'picker.pluginRoot': 'Plugins',
1138
+ 'picker.pluginLeafPlugin': 'Plugin list entry',
1139
+ 'picker.pluginLeafConfig': 'Data dir (config)',
1140
+ 'picker.pluginLeafAssets': 'Large files (wallpapers / databases)',
1141
+ 'picker.pluginAreaUnread': 'Not read yet',
1142
+ 'picker.pluginAreaAbsent': 'No content',
1143
+ 'picker.pluginViewEmpty': 'The plugin list has not been read yet — read the "Plugins" section first.',
1144
+ 'picker.bucketPlugins': 'Other entries of the plugins section (patch lines / pnpm-workspace.yaml)',
1145
+ 'picker.bucketLooseFiles': 'Config not attributable to a plugin (~/.dsh root)',
1146
+ 'picker.bucketHint': 'These entries have no provable plugin ownership — we never guess; expand to tick them one by one.',
1147
+ 'picker.groupHint.extensions': 'The plugin sections in this group are merged into one tree grouped by plugin: by default you only see a single \u201cPlugins\u201d block \u2014 expand it to list the plugins, each with its list entry, data dirs and large files. Only provable ownership is shown (built-in source table + plugins that declare dsh.dataDirs) \u2014 \u2018Plugin config files\u2019 carries no package name and therefore stays under "unowned"; device-specific sections are unchecked by default and may not apply on another machine.',
1148
+ // Export catalog group titles (mirror of ui/types.ts EXPORT_GROUPS; it decides the order)
1149
+ 'group.general': 'General',
1150
+ 'group.ai': 'AI',
1151
+ 'group.extensions': 'Extensions',
1152
+ 'group.mcp': 'MCP / Tools',
1153
+ 'group.customization': 'Customization',
1154
+ 'group.automation': 'Automation',
1155
+ 'group.workspace': 'Workspace',
1156
+ 'group.ui': 'UI',
1157
+ 'group.optional': 'Optional Data',
1114
1158
  'import.nothingSelected': 'Nothing is selected to import — tick the sections or items you want to import.',
1115
1159
  'import.excludedByUser': '{count} item(s) were deselected: neither imported nor snapshotted.',
1116
1160
  'import.consult.hint': 'Read-only analysis: review the verdict and its evidence first, then choose what to import.',
@@ -34,8 +34,12 @@ import {
34
34
  rollbackPointGroups,
35
35
  rollbackPreviewGuidance,
36
36
  rollbackPreviewScope,
37
+ rollbackDefaultSelection,
38
+ rollbackOnlyPaths,
37
39
  rollbackPreviewVerdict,
38
40
  rollbackPreviewWarnings,
41
+ rollbackSelectionBlocksSubmit,
42
+ rollbackSelectionRows,
39
43
  rollbackRewindCounts,
40
44
  rollbackRewindRows,
41
45
  rollbackRowFileCount,
@@ -142,6 +146,11 @@ export function RollbackCard({ recoveryApi, t }: RollbackCardProps) {
142
146
  * (= 最新那条对话,见下方 activeGroup 的推导)。
143
147
  */
144
148
  const [selectedSession, setSelectedSession] = useState<string | null>(null)
149
+ /**
150
+ * 路径级勾选(2026-10-17 定案 A):这次回退要恢复哪些文件。
151
+ * 初值在**每次打开预览**时按引擎的归属标记重置(别的对话改过的默认不勾,见 rollbackSelectionRows)。
152
+ */
153
+ const [selectedPaths, setSelectedPaths] = useState<string[]>([])
145
154
  /** 「刷新」在途:**保留旧内容**,只把刷新按钮置为在途(见 load 的 keep 语义) */
146
155
  const [refreshing, setRefreshing] = useState(false)
147
156
  /** 开关(issue #88)在途:置灰防重复点击,但不改开关的显示值(值只在宿主确认后变) */
@@ -214,6 +223,8 @@ export function RollbackCard({ recoveryApi, t }: RollbackCardProps) {
214
223
  if (!mounted.current || previewSeq.current !== seq) return
215
224
  setPreviewLoading(false)
216
225
  setPreview(res)
226
+ // 默认勾选 = 非 foreign 的行(= B 的自动隔离口径),用户在清单里可以逐条改回去
227
+ setSelectedPaths(rollbackDefaultSelection(rollbackSelectionRows(res)))
217
228
  // 默认 mode = 按可用段推导(判定在 ui 层,这里只取结果)
218
229
  setMode(rollbackPreviewVerdict(res).mode)
219
230
  if (!res.ok) setActionError(t(dyn(rollbackCodeKey(res.code))))
@@ -260,6 +271,8 @@ export function RollbackCard({ recoveryApi, t }: RollbackCardProps) {
260
271
  mode,
261
272
  segments: rollbackModeSegments(mode),
262
273
  allowPartial,
274
+ // 全选(或清单为空)⇒ onlyPaths 为 null ⇒ 不传字段,走既有全量口径
275
+ ...(onlyPaths === null ? {} : { onlyPaths }),
263
276
  }).then(
264
277
  (res) => {
265
278
  if (!mounted.current) return
@@ -369,6 +382,11 @@ export function RollbackCard({ recoveryApi, t }: RollbackCardProps) {
369
382
  const previewGuidance = rollbackPreviewGuidance(preview)
370
383
  /** 会被**改动** / 会被**删除**的文件:与聊天里的回退预览同一对读法(引擎 preview.workspace 的同一份数据)。 */
371
384
  const previewFiles = readRollbackPreviewFiles(preview?.workspace)
385
+ /** 可勾选行 / 勾选结果(判定全在 src/ui/rollback-view.ts) */
386
+ const selectionRows = rollbackSelectionRows(preview)
387
+ const selectionBlocked = rollbackSelectionBlocksSubmit(selectionRows, selectedPaths)
388
+ const selectionForeign = selectionRows.filter((row) => row.foreign).map((row) => row.path)
389
+ const onlyPaths = rollbackOnlyPaths(selectionRows, selectedPaths)
372
390
  const previewRemovals = readRollbackPreviewRemovals(preview?.workspace)
373
391
  /** 三个数字的口径与「文件行」同源(rollbackPreviewScope:行数只累加两侧都可信的那部分)。 */
374
392
  const previewScope = rollbackPreviewScope(previewFiles, previewRemovals)
@@ -714,7 +732,22 @@ export function RollbackCard({ recoveryApi, t }: RollbackCardProps) {
714
732
  </span>
715
733
  </div>
716
734
  <div className={css.planScroll}>
717
- <RollbackFileChanges files={previewFiles} removals={previewRemovals} rt={t} view={fileView} />
735
+ <RollbackFileChanges
736
+ files={previewFiles}
737
+ removals={previewRemovals}
738
+ rt={t}
739
+ view={fileView}
740
+ selection={{
741
+ selected: selectedPaths,
742
+ foreign: selectionForeign,
743
+ foreignLabel: t('rollback.preview.select.foreign'),
744
+ toggleLabel: (path: string) => t('rollback.preview.select.toggle', { path }),
745
+ disabled: busy,
746
+ onToggle: (path: string) => {
747
+ setSelectedPaths((prev) => (prev.includes(path) ? prev.filter((item) => item !== path) : [...prev, path]))
748
+ },
749
+ }}
750
+ />
718
751
  </div>
719
752
  {/* 「查看差异」的就地展示:放在清单**外面**(列表自己有内滚,放里面会滚出视野)。
720
753
  弹窗内只有这一条通道(设置页没有 DSH 右侧栏),渲染与聊天侧降级通道同一个面板。 */}
@@ -752,7 +785,7 @@ export function RollbackCard({ recoveryApi, t }: RollbackCardProps) {
752
785
  )}
753
786
 
754
787
  <div className={css.actionRow}>
755
- <Button variant="danger" disabled={busy || !canRewind} loading={busy} onClick={() => { setConfirmOpen(true) }}>{t('rollback.rewind')}</Button>
788
+ <Button variant="danger" disabled={busy || !canRewind || selectionBlocked} loading={busy} onClick={() => { setConfirmOpen(true) }}>{t('rollback.rewind')}</Button>
756
789
  <span className={css.statusSpacer} />
757
790
  <Button disabled={busy} onClick={closePreview}>{t('common.cancel')}</Button>
758
791
  </div>
@@ -779,6 +812,27 @@ export function RollbackCard({ recoveryApi, t }: RollbackCardProps) {
779
812
  <div className={css.rollbackPointMeta}>
780
813
  <span>{t('rollback.rewind.result.counts', { restored: segment.restored, removed: segment.removedCount, failed: segment.failed, leftovers: segment.leftoversCount })}</span>
781
814
  {segment.childSessionId !== null && <span>{t('rollback.rewind.result.child', { id: segment.childSessionId })}</span>}
815
+ {/* 归属隔离与路径选择的**如实**回传:跳过了就要说,不能只报「恢复 N 项」 */}
816
+ {segment.foreignSkipped > 0 && (
817
+ <span>{t('rollback.result.foreignSkipped', { n: segment.foreignSkipped })}</span>
818
+ )}
819
+ {segment.selectionSkipped > 0 && (
820
+ <span>{t('rollback.result.selectionSkipped', { n: segment.selectionSkipped })}</span>
821
+ )}
822
+ {/* 三方合并的四态(2026-10-17):合并成功 / 冲突 / 无法安全撤销 / 并发变化 —— 一条都不能省 */}
823
+ {segment.mergedCount > 0 && (
824
+ <span>{t('rollback.result.merged', { n: segment.mergedCount })}</span>
825
+ )}
826
+ {segment.conflictCount > 0 && (
827
+ <span>{t('rollback.result.conflict', { n: segment.conflictCount })}</span>
828
+ )}
829
+ {segment.unavailableCount > 0 && (
830
+ <span>{t('rollback.result.unavailable', { n: segment.unavailableCount })}</span>
831
+ )}
832
+ {segment.concurrentCount > 0 && (
833
+ <span>{t('rollback.result.concurrent', { n: segment.concurrentCount })}</span>
834
+ )}
835
+ {segment.conflictPaths.map((line) => <span key={'conflict:' + line}>{line}</span>)}
782
836
  {segment.detail.map((line) => <span key={line}>{line}</span>)}
783
837
  {segment.guidance.map((row) => (
784
838
  <span key={row.key + (row.detail ?? '')}>{t(dyn(row.key))}{row.detail !== null ? ' — ' + row.detail : ''}</span>
@@ -60,9 +60,14 @@ export function RollbackDiffPane({ state, rt, title, fill }: RollbackDiffPanePro
60
60
  const key = rollbackFileDiffReasonKey(diff.reason)
61
61
  return shell(<div className={css.hint}>{key === null ? rt('rollback.diff.reason.unknown') : rt(rk(key))}</div>)
62
62
  }
63
+ // 「不会改动」与「一致」是两件事:前者说明**这次回退有意不动它**(冲突 / 无法安全合并),
64
+ // 后者只是「两侧碰巧一样」。有原因时先说原因,别让用户以为「碰巧一样」。
65
+ if (diff.mergeSkipped === true) return shell(<div className={css.hint}>{rt(rk('rollback.diff.skipped'))}</div>)
63
66
  if (diff.identical) return shell(<div className={css.hint}>{rt('rollback.diff.identical')}</div>)
64
67
  return (
65
68
  <div className={fill === true ? css.diffPane + ' ' + css.diffPaneFill : css.diffPane}>
69
+ {/* 「回退后」这一侧到底是什么,必须说清(引擎的 merged 标记证明它):合并结果不是记录里的原文 */}
70
+ {diff.merged === true && <div className={css.hint}>{rt(rk('rollback.diff.merged'))}</div>}
66
71
  {diff.degraded && <div className={css.hint}>{rt('rollback.diff.degraded')}</div>}
67
72
  <DiffTable
68
73
  hunks={diff.hunks}
@@ -50,6 +50,25 @@ export interface RollbackFileChangesProps {
50
50
  view?: RollbackFileViewEntry
51
51
  /** 文件类型图标的外部来源(装了 dsh-better-sidebar 时由装配点注入)。 */
52
52
  renderFileIcon?: RollbackFileIconRenderer
53
+ /**
54
+ * 路径级勾选(2026-10-17 定案 A):**给了**才在每行前面画复选框。
55
+ * 没给的表面(聊天预览)渲染与旧版**逐字一致** —— 这是刻意的向后兼容,不是遗漏。
56
+ */
57
+ selection?: RollbackFileSelection
58
+ }
59
+
60
+ /** 逐文件勾选的渲染参数(判定全在 src/ui/rollback-view.ts,这里只画) */
61
+ export interface RollbackFileSelection {
62
+ /** 已勾选的路径 */
63
+ selected: readonly string[]
64
+ /** 其它对话在留点之后写过的路径(行上标一句;默认不勾) */
65
+ foreign: readonly string[]
66
+ /** 「其它对话改过」那一句(调用点传已翻译文本) */
67
+ foreignLabel: string
68
+ /** 勾选框的无障碍名 / 悬停提示(调用点把路径插好的整句) */
69
+ toggleLabel: (path: string) => string
70
+ disabled: boolean
71
+ onToggle: (path: string) => void
53
72
  }
54
73
 
55
74
  /**
@@ -60,15 +79,20 @@ export interface RollbackFileChangesProps {
60
79
  * (取值域在 src/ui/rollback-view.ts,契约里已有),**绝不**从 `added === 0` 反推删除 ——
61
80
  * 一个文件被清空成 0 行同样是 `+0 −N`,那是另一件事。
62
81
  */
63
- function FileStat({ status, added, removed, unknown, deleted, rt }: {
82
+ function FileStat({ status, added, removed, unknown, deleted, skipped, rt }: {
64
83
  status: string
65
84
  added: number | null
66
85
  removed: number | null
67
86
  unknown: string
68
87
  /** 「删除」这一句(调用点传已翻译文本,本组件不碰字典键)。 */
69
88
  deleted: string
89
+ /** true = 这次回退**不会改动**它(冲突 / 无法安全合并)——与「0 行变化」是两件事 */
90
+ skipped?: boolean
70
91
  rt: TranslateNS<'config-manager-recovery'>
71
92
  }) {
93
+ // 「不会改动」必须说在前面:行级统计对它是 null,落到下面的「行数未统计」会把用户引向
94
+ // 「它还是会被写、只是没算出行数」的错误理解。
95
+ if (skipped === true) return <span className={css.hint}>{rt(rk('rollback.preview.file.skipped'))}</span>
72
96
  if (status === 'delete') return <span className={css.rollbackDeleted}>{deleted}</span>
73
97
  if (added === null || removed === null) return <span className={css.hint}>{unknown}</span>
74
98
  return (
@@ -80,7 +104,10 @@ function FileStat({ status, added, removed, unknown, deleted, rt }: {
80
104
  }
81
105
 
82
106
  /** 文件清单(改动行 + 删除行)。两处调用共用这一份渲染。 */
83
- export function RollbackFileChanges({ files, removals, rt, view, renderFileIcon }: RollbackFileChangesProps) {
107
+ export function RollbackFileChanges({ files, removals, rt, view, renderFileIcon, selection }: RollbackFileChangesProps) {
108
+ // 勾选与归属都用集合查(清单可达几百条,逐条 includes 是 O(n²))
109
+ const selectedSet = selection === undefined ? null : new Set(selection.selected)
110
+ const foreignSet = selection === undefined ? null : new Set(selection.foreign)
84
111
  return (
85
112
  <div className={css.rollbackFileList} role="list">
86
113
  {files.map((file, index) => {
@@ -99,6 +126,18 @@ export function RollbackFileChanges({ files, removals, rt, view, renderFileIcon
99
126
  role="listitem"
100
127
  data-kind={previewFileStatusKnown(file.status) ? file.status : undefined}
101
128
  >
129
+ {/* 路径级勾选(A):只有调用点给了 selection 才画。用原生 input 而不是 Checkbox 原语 ——
130
+ 原语是「复选框**行**」(label 会渲染成可见文本),而这里的可访问名就是文件名本身。 */}
131
+ {selection !== undefined && selectedSet !== null && (
132
+ <input
133
+ type="checkbox"
134
+ checked={selectedSet.has(file.path)}
135
+ disabled={selection.disabled}
136
+ title={selection.toggleLabel(file.path)}
137
+ aria-label={selection.toggleLabel(file.path)}
138
+ onChange={() => { selection.onToggle(file.path) }}
139
+ />
140
+ )}
102
141
  {/* 文件类型图标(DSH 文件树同一颗)。装饰性:行内文件名就是它的可访问名。 */}
103
142
  <span className={css.rollbackFileIcon} aria-hidden="true">
104
143
  <RollbackFileIcon path={file.path} size={14} render={renderFileIcon} />
@@ -121,6 +160,10 @@ export function RollbackFileChanges({ files, removals, rt, view, renderFileIcon
121
160
  {label}
122
161
  </button>
123
162
  )}
163
+ {/* 归属提示(B):这一行在留点之后是**别的对话**写的 —— 默认没勾它,勾上就会覆盖对方的改动。 */}
164
+ {selection !== undefined && foreignSet !== null && foreignSet.has(file.path) && (
165
+ <span className={css.hint} data-foreign="true">{selection.foreignLabel}</span>
166
+ )}
124
167
  {/* ±行数紧跟文件名(统计列右对齐、列宽固定):三种取值的右缘对齐成一条竖线。 */}
125
168
  <span className={css.rollbackFileStat}>
126
169
  <FileStat
@@ -129,6 +172,7 @@ export function RollbackFileChanges({ files, removals, rt, view, renderFileIcon
129
172
  removed={file.removed}
130
173
  unknown={rt('rollback.preview.file.unknown')}
131
174
  deleted={rt(rk('rollback.preview.file.deleted'))}
175
+ skipped={file.skipped === true}
132
176
  rt={rt}
133
177
  />
134
178
  </span>
@@ -443,6 +443,14 @@ export const zh = {
443
443
  * 文件 —— 整文件删除走**另一条**清单(`rollback.preview.removalsTitle` / `createdSkipped`),
444
444
  * 删除清单由引擎与执行阶段**共用同一个函数**算出(预览说会删的 = 实际删的)。 */
445
445
  'rollback.preview.files.title': '将被回退的文件:',
446
+ 'rollback.preview.select.foreign': '其它对话改过',
447
+ 'rollback.preview.select.toggle': '「{path}」:勾上则这次回退恢复它',
448
+ 'rollback.result.foreignSkipped': '{n} 个文件在留点后被其它对话改过,这次没有回退它们',
449
+ 'rollback.result.merged': '{n} 个文件已合并:撤销本次对话的改动,保留其它对话的改动',
450
+ 'rollback.result.conflict': '{n} 个文件两侧改到同一处,已保持原样 —— 需要你手工处理',
451
+ 'rollback.result.unavailable': '{n} 个文件无法安全撤销(缺少中间版本 / 二进制 / 差异过大),已保持原样',
452
+ 'rollback.result.concurrent': '{n} 个文件在回退过程中又被改过,已保持原样',
453
+ 'rollback.result.selectionSkipped': '{n} 个文件按你的勾选跳过了',
446
454
  'rollback.preview.files.empty': '工作区与该回退点一致,无需恢复',
447
455
  /* —— T15:**预览态**(点「回退」之前那一屏)的四态文案 ——
448
456
  * 判据只来自 preview 字段(available / overwrite / different / missing / reasonCode /
@@ -458,6 +466,9 @@ export const zh = {
458
466
  'rollback.preview.file.removed': '−{n}',
459
467
  /** 整文件删除(用户 2026-10-12 第 6 条):写「删除」而不是 `+0 −N` —— 那是两件事。 */
460
468
  'rollback.preview.file.deleted': '删除',
469
+ 'rollback.preview.file.skipped': '不会改动(与其它对话的改动冲突,或无法安全合并)',
470
+ 'rollback.diff.merged': '下面是合并后的结果:撤销本对话的改动、保留其它对话的改动',
471
+ 'rollback.diff.skipped': '这次回退不会改动它(与其它对话的改动冲突,或无法安全合并)',
461
472
  'rollback.preview.file.unknown': '行数未统计',
462
473
  'rollback.preview.file.unknownHint': '这些文件的行数没有统计出来(文件过大、不是文本或内容副本读不出来)—— 回退仍会照常覆盖它们。',
463
474
  'rollback.diff.viewHint': '查看差异:{path}',
@@ -1015,6 +1026,14 @@ export const en: Record<keyof typeof zh, string> = {
1015
1026
  'rollback.preview.notice.skipped': '{n} item(s) were skipped at capture time (not recorded; they are in the proof list, so they are outside the deletion test)',
1016
1027
  'rollback.preview.notice.truncated': 'Capture was truncated by a limit (per-file / byte budget / file-count budget); whether anything is deleted also depends on whether the proof list (path index) is complete',
1017
1028
  'rollback.preview.files.title': 'Files that will be rewound:',
1029
+ 'rollback.preview.select.foreign': 'Changed by another conversation',
1030
+ 'rollback.preview.select.toggle': '"{path}": check to restore it in this rewind',
1031
+ 'rollback.result.foreignSkipped': '{n} file(s) were changed by another conversation after this point; this rewind left them untouched',
1032
+ 'rollback.result.merged': '{n} file(s) merged: the change from this conversation was reverted and the change from the other conversation was kept',
1033
+ 'rollback.result.conflict': '{n} file(s) were changed on both sides in the same place and were left as they are — they need your decision',
1034
+ 'rollback.result.unavailable': '{n} file(s) could not be reverted safely (no common ancestor / binary / diff too large) and were left as they are',
1035
+ 'rollback.result.concurrent': '{n} file(s) changed again while this rewind was running and were left as they are',
1036
+ 'rollback.result.selectionSkipped': '{n} file(s) skipped by your selection',
1018
1037
  'rollback.preview.files.empty': 'The workspace already matches this rollback point, nothing to restore',
1019
1038
  'rollback.preview.files.unavailable': 'The workspace state of this rollback point has no usable record (reason: {reason}), nothing to restore',
1020
1039
  'rollback.preview.files.unavailableSession': 'The workspace state of this rollback point has no usable record (reason: {reason}); the session will still be rewound to before this point',
@@ -1023,6 +1042,9 @@ export const en: Record<keyof typeof zh, string> = {
1023
1042
  'rollback.preview.file.added': '+{n}',
1024
1043
  'rollback.preview.file.removed': '\u2212{n}',
1025
1044
  'rollback.preview.file.deleted': 'Delete',
1045
+ 'rollback.preview.file.skipped': 'Left unchanged (conflicts with another conversation, or cannot be merged safely)',
1046
+ 'rollback.diff.merged': 'Below is the merged result: the change from this conversation is reverted and the change from the other conversation is kept',
1047
+ 'rollback.diff.skipped': 'This rewind will not change it (it conflicts with another conversation, or cannot be merged safely)',
1026
1048
  'rollback.preview.file.unknown': 'line counts not measured',
1027
1049
  'rollback.preview.file.unknownHint': 'Line counts could not be measured for these files (too large, not text, or the content copy is unreadable) \u2014 the rewind will still overwrite them.',
1028
1050
  'rollback.diff.viewHint': 'View diff: {path}',
@@ -132,7 +132,7 @@ test('侧栏标签页吃满宿主高度:整块高度跟侧栏走(源级,20
132
132
  // ② 撑满档下**每个出口**都要套同一个整块容器:只让「就绪且有差异」撑满时,
133
133
  // 加载 / 一致 / 看不了 / 读失败会缩成一行,一屏高度来回跳(同样是「高度不跟侧栏走」)。
134
134
  const pane = readFileSync(PANE, 'utf8')
135
- assert.equal([...pane.matchAll(/return shell\(/g)].length, 5, '加载 / 读失败 / 结构化拒绝 / 看不了 / 一致 五个出口都要经同一个撑满容器')
135
+ assert.equal([...pane.matchAll(/return shell\(/g)].length, 6, '加载 / 读失败 / 结构化拒绝 / 看不了 / 不会改动 / 一致 六个出口都要经同一个撑满容器')
136
136
  assert.match(pane, /fill === true \? <div className=\{css\.diffPane \+ ' ' \+ css\.diffPaneFill\}>/, '撑满容器必须是 .diffPane + .diffPaneFill 那一对类名')
137
137
  })
138
138
 
@@ -1806,9 +1806,11 @@ function applyTombstoneFilter(
1806
1806
  /** PlanItem → 墓碑键({kind,id})映射;非「将写入」的条目(Skip 等)与配置类项返回 null 不过滤 */
1807
1807
  function tombstoneKeyForItem(item: PlanItem): { kind: TombstoneKind; id: string } | null {
1808
1808
  // 插件本体(plugin:<pkg>):Install / Update / Conflict 均视为「将写入该插件」→ 命中墓碑剔除;
1809
+ // issue #95 的 Create = 「把 host 自带的层声明进 dsh.profile.bundles」,同样是写入该插件,
1810
+ // 必须一起被墓碑剔除(用户删过的插件不得被一份旧备份重新声明回来)。
1809
1811
  // patch: 行与 plugins:pnpm-workspace 是配置类项,不按插件墓碑过滤(分区级墓碑可覆盖)。
1810
1812
  if (item.adapter === 'plugins' && item.id.startsWith('plugin:')) {
1811
- if (item.kind !== 'Install' && item.kind !== 'Update' && item.kind !== 'Conflict') return null;
1813
+ if (item.kind !== 'Install' && item.kind !== 'Update' && item.kind !== 'Conflict' && item.kind !== 'Create') return null;
1812
1814
  return { kind: 'plugin', id: item.id.slice('plugin:'.length) };
1813
1815
  }
1814
1816
  // 文件类分区条目(id = <分区>:<相对路径>):skills → skill 墓碑;其余文件类(agentPresets/
@@ -164,6 +164,16 @@ for (const id of SECTION_IDS) {
164
164
  */
165
165
  export const PLUGIN_PATCH_REF_PREFIX = 'patchFile:'
166
166
 
167
+ /**
168
+ * plugins 分区中「profile 的 package.json」这一文件目标的 ref(issue #95)。
169
+ *
170
+ * 声明 host 自带的 bundle 层(`dsh.profile.bundles`)就是写这个文件,因此它必须与
171
+ * `pnpm-workspace.yaml` 同级:进导入前快照 → 回滚能整文件还原,否则一次导入留下的声明
172
+ * 撤不掉(照抄 \`patchFile:\` 的做法,用一个符号 ref 而不是真实文件名——ref 空间里还有
173
+ * 任意 patch 行 id,靠形状判断会撞车)。
174
+ */
175
+ export const PLUGIN_PROFILE_MANIFEST_REF = 'profileManifest'
176
+
167
177
  /** profile 目录绝对路径(plugins 分区内文件类目标的基准) */
168
178
  function profileDirOf(ctx: HostContext): string {
169
179
  const profile = ctx.profile !== undefined && ctx.profile !== '' ? ctx.profile : 'web';
@@ -216,6 +226,10 @@ export function resolveFileTarget(ctx: HostContext, adapter: SectionId, ref: str
216
226
  if (adapter === 'plugins' && ref.startsWith(PLUGIN_PATCH_REF_PREFIX)) {
217
227
  return path.join(profileDirOf(ctx), ref.slice(PLUGIN_PATCH_REF_PREFIX.length));
218
228
  }
229
+ // issue #95:声明 host 自带 bundle 层 = 写 profile 的 package.json(快照/回滚必须覆盖它)
230
+ if (adapter === 'plugins' && ref === PLUGIN_PROFILE_MANIFEST_REF) {
231
+ return path.join(profileDirOf(ctx), 'package.json');
232
+ }
219
233
  const base = FILE_BASES[adapter] ?? '';
220
234
  return path.join(ctx.homeDir, base, ref);
221
235
  }
@@ -236,6 +250,7 @@ export function resolveFileTargetRel(adapter: SectionId, ref: string, profile?:
236
250
  if (adapter === 'plugins') {
237
251
  const profileDir = `profiles/${profile !== undefined && profile !== '' ? profile : 'web'}`;
238
252
  if (ref === 'pnpm-workspace.yaml') return normalizePath(`${profileDir}/pnpm-workspace.yaml`);
253
+ if (ref === PLUGIN_PROFILE_MANIFEST_REF) return normalizePath(`${profileDir}/package.json`);
239
254
  if (ref.startsWith(PLUGIN_PATCH_REF_PREFIX)) {
240
255
  return normalizePath(`${profileDir}/${ref.slice(PLUGIN_PATCH_REF_PREFIX.length)}`);
241
256
  }
@@ -316,7 +331,7 @@ async function engineSnapshotEntry(ctx: HostContext, target: SnapshotTarget): Pr
316
331
  case 'plugins':
317
332
  case 'prompts': {
318
333
  // plugins 分区的 pnpm-workspace.yaml / patch 文件 → 整文件快照(file 类,回滚可整文件还原)
319
- if (target.adapter === 'plugins' && (target.ref === 'pnpm-workspace.yaml' || target.ref.startsWith(PLUGIN_PATCH_REF_PREFIX))) {
334
+ if (target.adapter === 'plugins' && (target.ref === 'pnpm-workspace.yaml' || target.ref === PLUGIN_PROFILE_MANIFEST_REF || target.ref.startsWith(PLUGIN_PATCH_REF_PREFIX))) {
320
335
  const abs = resolveFileTarget(ctx, target.adapter, target.ref);
321
336
  if (!(await ctx.fs.exists(abs))) {
322
337
  return { kind: 'file', adapter: target.adapter, ref: target.ref, before: null, existed: false };