dsh-config-manager 0.1.76 → 0.1.77

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/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.js +92 -12
  6. package/lib/client.d.ts +104 -9
  7. package/lib/client.js +1796 -714
  8. package/lib/core/messages.d.ts +1 -0
  9. package/lib/core/messages.js +2 -0
  10. package/lib/core/rollback-attribution.d.ts +81 -0
  11. package/lib/core/rollback-attribution.js +236 -0
  12. package/lib/core/rollback-merge.d.ts +22 -0
  13. package/lib/core/rollback-merge.js +203 -0
  14. package/lib/core/rollback-point.d.ts +114 -0
  15. package/lib/core/rollback-point.js +534 -17
  16. package/lib/core/types.d.ts +18 -0
  17. package/lib/index.d.ts +26 -3
  18. package/lib/index.js +129 -16
  19. package/lib/routes/recovery.js +11 -0
  20. package/lib/schema/section-registry.js +5 -5
  21. package/lib/ui/badge-tone.d.ts +16 -0
  22. package/lib/ui/badge-tone.js +20 -0
  23. package/lib/ui/i18n.d.ts +4 -4
  24. package/lib/ui/i18n.js +8 -8
  25. package/lib/ui/rollback-view.d.ts +95 -0
  26. package/lib/ui/rollback-view.js +55 -0
  27. package/lib/ui/selection-model.d.ts +140 -1
  28. package/lib/ui/selection-model.js +241 -9
  29. package/package.json +1 -1
  30. package/src/adapters/plugin-data-declared.ts +60 -0
  31. package/src/adapters/plugin-data.ts +7 -33
  32. package/src/adapters/plugins.ts +98 -13
  33. package/src/adapters/units.test.ts +26 -0
  34. package/src/client/chat/ChatEntryView.tsx +147 -46
  35. package/src/client/chat/chat-entry-view.test.ts +277 -1
  36. package/src/client/chat/chat-entry-view.ts +98 -4
  37. package/src/client/chat/chat-node-registration.ts +71 -0
  38. package/src/client/chat/chat-slots.ts +30 -12
  39. package/src/client/common/ContentPicker.tsx +581 -175
  40. package/src/client/common/host-primitives.ts +64 -1
  41. package/src/client/common/ui.tsx +17 -3
  42. package/src/client/config-manager.module.css +630 -397
  43. package/src/client/css-scale-guard.test.ts +41 -3
  44. package/src/client/css-token-guard.test.ts +10 -4
  45. package/src/client/export/ExportView.tsx +2 -0
  46. package/src/client/index.ts +15 -3
  47. package/src/client/locales.ts +54 -10
  48. package/src/client/recovery/RollbackCard.tsx +56 -2
  49. package/src/client/recovery/RollbackDiffPane.tsx +5 -0
  50. package/src/client/recovery/RollbackFileChanges.tsx +46 -2
  51. package/src/client/recovery/recovery-locales.ts +22 -0
  52. package/src/client/recovery/rollback-file-changes.test.ts +1 -1
  53. package/src/core/messages.ts +2 -0
  54. package/src/core/rollback-attribution.test.ts +120 -0
  55. package/src/core/rollback-attribution.ts +267 -0
  56. package/src/core/rollback-merge.test.ts +133 -0
  57. package/src/core/rollback-merge.ts +213 -0
  58. package/src/core/rollback-point.test.ts +412 -0
  59. package/src/core/rollback-point.ts +560 -20
  60. package/src/core/types.ts +19 -0
  61. package/src/index.facade.test.ts +60 -0
  62. package/src/index.ts +133 -19
  63. package/src/routes/recovery.ts +11 -0
  64. package/src/schema/section-registry.ts +5 -5
  65. package/src/ui/badge-tone.test.ts +23 -0
  66. package/src/ui/badge-tone.ts +27 -0
  67. package/src/ui/i18n.ts +8 -8
  68. package/src/ui/rollback-view.test.ts +27 -0
  69. package/src/ui/rollback-view.ts +139 -0
  70. package/src/ui/selection-model.test.ts +240 -2
  71. package/src/ui/selection-model.ts +349 -1
@@ -145,6 +145,24 @@ export interface ExportUnit {
145
145
  * 只下发**事实**,不下发文案 —— 界面按当前语言显示(与 DSH 同口径),绝不用 uuid 冒充标题。
146
146
  */
147
147
  blank?: boolean;
148
+ /**
149
+ * 该插件**声明的第三方数据区域**(只有 \`plugin:<包名>\` 单元可能有值;导出选择器「按插件看」用)。
150
+ *
151
+ * 用途:选择器要在**不读任何数据目录**的前提下画出「插件 → 数据目录 / 大文件」的树骨架 ——
152
+ * 区域清单来自来源注册表(内置来源表 + 插件自述 dsh.dataDirs),是静态知识,零额外 I/O;
153
+ * 体积与「实际有没有内容」等该分区的清单到货后再填。
154
+ *
155
+ * 语义:**纯展示**,不参与勾选/导出契约(契约只认 id)。这里是「注册表说它应该在哪儿」,
156
+ * **不是**「磁盘上确实有」—— 界面必须把两者分开渲染(unread / 无内容 / 已读到)。
157
+ */
158
+ relatedAreas?: readonly PluginAreaRef[];
159
+ }
160
+ /** 插件数据区域引用(pluginData / pluginAssets;只读展示用,不参与勾选契约)。 */
161
+ export interface PluginAreaRef {
162
+ section: 'pluginData' | 'pluginAssets';
163
+ /** home 相对 POSIX 路径 —— 就是该分区单元的 id 后缀(\`pluginData:.mnemon/runtime\` 的末段) */
164
+ relPath: string;
165
+ role: 'config' | 'assets';
148
166
  }
149
167
  export interface ValidationResult {
150
168
  valid: boolean;
package/lib/index.d.ts CHANGED
@@ -78,7 +78,7 @@ export declare const name = "config-manager";
78
78
  /** Services required before the engine can mount (present in every profile). */
79
79
  export declare const inject: string[];
80
80
  /** Plugin version, kept in sync with package.json ("version"). */
81
- export declare const PLUGIN_VERSION = "0.1.76";
81
+ export declare const PLUGIN_VERSION = "0.1.77";
82
82
  /**
83
83
  * Star 引导弹窗指向的 GitHub 仓库(用户引导点 Star 的目标)。
84
84
  * 与 package.json 的 repository 字段保持一致;界面不可改(硬编码,参照
@@ -340,6 +340,11 @@ export declare class DshSessionStoreFacade implements SessionStoreFacade {
340
340
  private readonly ctx;
341
341
  /** DSH 缺省会话根(配置改过时只影响快路径命中率,慢路径仍正确) */
342
342
  private readonly root;
343
+ /**
344
+ * 插件数据根(`<DSH_HOME>/dsh-config-manager`)—— **只**用于归位失败的诊断落盘。
345
+ * 权威状态仍在 DSH(工作区注册表 / 会话存储),这里绝不旁路写任何 DSH 数据。
346
+ */
347
+ private readonly dataDir;
343
348
  /**
344
349
  * 父子关系的短 TTL 缓存(见 `PARENT_RELATIONS_CACHE_TTL_MS`;同 key 并发合并成一次列举)。
345
350
  * F-1 后**只缓存成功结果**:列举失败/形状识别失败会让 `parentRelations()` 抛错,
@@ -472,8 +477,26 @@ export declare class DshSessionStoreFacade implements SessionStoreFacade {
472
477
  * 宁少不猜(DSH 官方用 `sessionQuery.traceSession`;这里只依赖 `sessions.get(id).header`)。
473
478
  */
474
479
  private ancestorSessionIds;
475
- /** 子会话 header 的 cwd(只读;读不到返回 undefined,绝不猜路径)。 */
476
- private liveSessionCwd;
480
+ /**
481
+ * 会话 cwd 的**多源**读取(活会话 → 宿主会话存储列举)。
482
+ *
483
+ * 为什么不能只读活会话(真机 2026-10-17 根因之一):旧实现只看 `ctx.get('sessions').get(id)`,
484
+ * 拿不到就整体放弃归位 ⇒ fork 出来的子会话停在侧栏「未分组」。而 DSH 自己的读法有**磁盘回落**
485
+ * (`dsh-workspace` 的 `readSessionHeader`:活会话 → `sessionPersistence.list()` 的 header 缓存),
486
+ * 本方法与该回落同源,绝不猜路径、绝不拿父会话目录以外的任何东西凑数。
487
+ *
488
+ * 失败时返回**具体细节**(哪个来源、为什么),由调用方拼进诊断;绝不静默成 undefined。
489
+ */
490
+ private sessionCwdOf;
491
+ /**
492
+ * 归位失败的**诊断落盘**(一行 JSON,追加写)。
493
+ *
494
+ * 为什么需要:桌面版 DSH 的宿主 stdout 不落盘 ⇒ 用户界面上只有「未挂回原工作区」这句,
495
+ * 具体理由(registry 拿不到 / attach 被拒 / cwd 读不到 / 复核不过)谁都看不到,
496
+ * 「为什么会进未分组」于是长期不可排查。这里只记 id、理由码与路径来源,**不含任何凭据**。
497
+ * 落盘失败绝不升级成回退失败(诊断是旁路,不是回退的一部分)。
498
+ */
499
+ private recordAttachDiagnostic;
477
500
  /**
478
501
  * 活会话 header(`ctx.get('sessions').get(id).header`)。
479
502
  *
package/lib/index.js CHANGED
@@ -38,6 +38,7 @@ import { randomBytes } from 'node:crypto';
38
38
  import { execFile } from 'node:child_process';
39
39
  import { createWriteStream, existsSync, mkdirSync, readFileSync } from 'node:fs';
40
40
  import fs from 'node:fs/promises';
41
+ import path from 'node:path';
41
42
  import { basename, dirname, isAbsolute, join, relative, resolve } from 'node:path';
42
43
  import { homedir } from 'node:os';
43
44
  import { promisify } from 'node:util';
@@ -64,7 +65,8 @@ import { RunRegistry } from './core/run-registry.js';
64
65
  import { registerModelTools } from './core/model-tools.js';
65
66
  import { msgOf, zhMsg } from './core/messages.js';
66
67
  import { engineMsg, getEngineLocale, setEngineLanguageSource, setEngineLocale } from './core/engine-locale.js';
67
- import { RollbackAutoTracker, RollbackPointEngine, rollbackSessionBindingOf, rollbackTrackedEventOf } from './core/rollback-point.js';
68
+ import { RollbackAutoTracker, RollbackPointEngine, ROLLBACK_POINTS_DIR_NAME, rollbackSessionBindingOf, rollbackTrackedEventOf } from './core/rollback-point.js';
69
+ import { ROLLBACK_ATTRIBUTION_FILE_NAME, RollbackWriteAttribution } from './core/rollback-attribution.js';
68
70
  import { isRollbackEnabled, onRollbackEnabledChange, setRollbackEnabled } from './core/rollback-gate.js';
69
71
  // t3:宿主斜杠命令 /rollback(与 HTTP 路由同一道写入门;命令服务惰取,未组合时安静跳过)。
70
72
  import { agentSessionFactsOf, registerRollbackCommand } from './core/command-rollback.js';
@@ -136,7 +138,7 @@ export const name = 'config-manager';
136
138
  /** Services required before the engine can mount (present in every profile). */
137
139
  export const inject = ['settings', 'credentials'];
138
140
  /** Plugin version, kept in sync with package.json ("version"). */
139
- export const PLUGIN_VERSION = '0.1.76';
141
+ export const PLUGIN_VERSION = '0.1.77';
140
142
  /** Plugin own package name — excluded from its own exported plugins list. */
141
143
  const PLUGIN_NAME = 'dsh-config-manager';
142
144
  /**
@@ -646,6 +648,7 @@ export function normalizeListedSession(element) {
646
648
  const SESSION_STORE_PURPOSE_KEY = {
647
649
  reindexSessionHeader: 'host.sessionStorePurposeReindex',
648
650
  parentRelations: 'host.sessionStorePurposeParentRelations',
651
+ rewindWorkspaceAttach: 'host.sessionStorePurposeRewindAttach',
649
652
  };
650
653
  /**
651
654
  * 父子关系缓存的 TTL(毫秒)。
@@ -668,6 +671,18 @@ const PARENT_RELATIONS_CACHE_TTL_MS = 5_000;
668
671
  const ANCESTOR_LOOKUP_MAX_DEPTH = 8;
669
672
  /** 归位失败时给调用方的 warning 串(稳定前缀 + 具体理由码)。 */
670
673
  export const WORKSPACE_ATTACH_FAILED_PREFIX = 'workspace-attach-failed';
674
+ /**
675
+ * 归位失败后的重试次数与间隔(毫秒)。
676
+ *
677
+ * 为什么需要(真机 2026-10-17/10-12):fork 返回时子会话刚由 `agents.create` 建好,
678
+ * DSH 侧的会话索引 / 持久化列举可能还没就绪 —— 与「目录根本不存在」这类终结性失败不同,
679
+ * 这是**时序性**失败。归位本身幂等(`attachSession` 自查成员名单、`mutate` 空操作即不改写),
680
+ * 因此重试零副作用;不重试则子会话永久停在侧栏「未分组」。
681
+ */
682
+ const WORKSPACE_ATTACH_ATTEMPTS = 2;
683
+ const WORKSPACE_ATTACH_RETRY_DELAY_MS = 200;
684
+ /** 归位诊断文件的相对路径(相对插件数据根;只在**失败**时追加一行 JSON)。 */
685
+ const WORKSPACE_ATTACH_DIAGNOSTIC_REL = 'logs/rewind-workspace-attach.jsonl';
671
686
  /**
672
687
  * DSH 官方 fork 在「子会话已创建、但 `workspace.attachSession` 失败」时抛的错误码。
673
688
  *
@@ -735,6 +750,11 @@ export class DshSessionStoreFacade {
735
750
  ctx;
736
751
  /** DSH 缺省会话根(配置改过时只影响快路径命中率,慢路径仍正确) */
737
752
  root;
753
+ /**
754
+ * 插件数据根(`<DSH_HOME>/dsh-config-manager`)—— **只**用于归位失败的诊断落盘。
755
+ * 权威状态仍在 DSH(工作区注册表 / 会话存储),这里绝不旁路写任何 DSH 数据。
756
+ */
757
+ dataDir;
738
758
  /**
739
759
  * 父子关系的短 TTL 缓存(见 `PARENT_RELATIONS_CACHE_TTL_MS`;同 key 并发合并成一次列举)。
740
760
  * F-1 后**只缓存成功结果**:列举失败/形状识别失败会让 `parentRelations()` 抛错,
@@ -760,6 +780,7 @@ export class DshSessionStoreFacade {
760
780
  constructor(ctx, homeDir, msg = zhMsg, log, realpath) {
761
781
  this.ctx = ctx;
762
782
  this.root = join(homeDir, 'sessions');
783
+ this.dataDir = join(homeDir, 'dsh-config-manager');
763
784
  this.log = log ?? createLogger({ level: 'warn' });
764
785
  this.msgFunc = msg;
765
786
  this.realpath = realpath ?? (async (value) => await fs.realpath(value));
@@ -819,14 +840,23 @@ export class DshSessionStoreFacade {
819
840
  childId = recovered;
820
841
  }
821
842
  // 工作区归位:官方已归位 → 只复核;官方没归位(或 attach 抛错被上面救回)→ 本兜底写;写后一律复核。
822
- const outcome = await this.attachChildToParentWorkspace(sessionId, childId);
843
+ //
844
+ // 失败要重试(真机 2026-10-17):fork 返回时子会话刚建好,DSH 侧的索引/持久化列举可能还没就绪,
845
+ // 属**时序性**失败;三档判定与 attach 本身幂等,重试零副作用。不重试的代价是子会话永久停在
846
+ // 侧栏「未分组」(用户 2026-10-11 起的反馈)。
847
+ let outcome = await this.attachChildToParentWorkspace(sessionId, childId);
848
+ for (let attempt = 1; attempt < WORKSPACE_ATTACH_ATTEMPTS && !outcome.ok; attempt += 1) {
849
+ await new Promise((resolve) => { setTimeout(resolve, WORKSPACE_ATTACH_RETRY_DELAY_MS); });
850
+ outcome = await this.attachChildToParentWorkspace(sessionId, childId);
851
+ }
823
852
  if (outcome.ok) {
824
853
  this.log.info('rewind: child session filed into workspace: ' + childId + ' (' + outcome.reason + (outcome.workspaceId !== undefined ? ' ' + outcome.workspaceId : '') + ')');
825
854
  return { childSessionId: childId };
826
855
  }
827
856
  // 归位失败**带具体理由**(绝不静默):日志留痕 + warning 串交给调用方如实上屏。
828
- this.log.warn('rewind: child session not filed into any workspace: ' + childId + ' reason=' + outcome.reason
829
- + (outcome.detail !== undefined ? ' detail=' + outcome.detail : ''));
857
+ //
858
+ // 桌面版 DSH 拿不到宿主 stdout ⇒ 只有理由码还不够排查「为什么进未分组」,故再落一行诊断。
859
+ await this.recordAttachDiagnostic({ parentId: sessionId, childId, outcome });
830
860
  const warning = workspaceAttachWarningOf(outcome);
831
861
  return warning === undefined ? { childSessionId: childId } : { childSessionId: childId, warning };
832
862
  }
@@ -896,12 +926,29 @@ export class DshSessionStoreFacade {
896
926
  attachFailure ??= attempt;
897
927
  }
898
928
  // ③ 同目录:cwd 与某工作区 path 指向同一目录(目录同一性由 DSH 判定,见方法注释)
899
- const cwd = this.liveSessionCwd(childId);
900
- if (cwd === undefined) {
901
- cwdFailure = { ok: false, reason: 'cwd-unavailable' };
929
+ //
930
+ // cwd 取**多源候选**(真机 2026-10-17):旧实现只看子会话的活 header,拿不到就直接
931
+ // `cwd-unavailable` ⇒ 三档全落空 ⇒ 子会话停在「未分组」。两条候选都来自 DSH 自己写下的 header,
932
+ // 不是猜测:① 子会话(活 / 落盘 header);② 父会话 —— DSH 的 fork 逐字继承父 cwd
933
+ // (`SessionCommandController.fork`:`meta.cwd = source.header.cwd`,见 dsh-api-session-controller)。
934
+ const candidates = [];
935
+ const missing = [];
936
+ const childLookup = await this.sessionCwdOf(childId);
937
+ if (childLookup.ok)
938
+ candidates.push({ cwd: childLookup.cwd, source: 'child:' + childLookup.source });
939
+ else
940
+ missing.push('child(' + childLookup.detail + ')');
941
+ const parentLookup = await this.sessionCwdOf(parentId);
942
+ if (parentLookup.ok) {
943
+ // 与已入列候选同目录时不重复(同目录再 attach 一次只是白跑一次 list())
944
+ if (!candidates.some((c) => foldWorkspacePath(c.cwd) === foldWorkspacePath(parentLookup.cwd))) {
945
+ candidates.push({ cwd: parentLookup.cwd, source: 'parent:' + parentLookup.source });
946
+ }
902
947
  }
903
- else {
904
- const resolution = await this.resolveWorkspaceByCwd(registry, records, cwd);
948
+ else
949
+ missing.push('parent(' + parentLookup.detail + ')');
950
+ for (const candidate of candidates) {
951
+ const resolution = await this.resolveWorkspaceByCwd(registry, records, candidate.cwd);
905
952
  if (resolution.records !== undefined && resolution.records.length > 0) {
906
953
  for (const record of resolution.records) {
907
954
  const attempt = await this.attachChildTo(record, childId);
@@ -911,9 +958,15 @@ export class DshSessionStoreFacade {
911
958
  }
912
959
  }
913
960
  else {
914
- cwdFailure = resolution.failure;
961
+ // 目录不存在 / 不属于任何工作区:按候选来源标注,便于诊断区分「读到的是谁」
962
+ const failure = resolution.failure;
963
+ if (failure !== undefined) {
964
+ cwdFailure ??= { ok: false, reason: failure.reason, detail: candidate.source + ':' + (failure.detail ?? '') };
965
+ }
915
966
  }
916
967
  }
968
+ if (candidates.length === 0)
969
+ cwdFailure = { ok: false, reason: 'cwd-unavailable', detail: missing.join(';') };
917
970
  return attachFailure ?? cwdFailure ?? { ok: false, reason: 'no-workspace-for-cwd' };
918
971
  }
919
972
  catch (error) {
@@ -1090,10 +1143,57 @@ export class DshSessionStoreFacade {
1090
1143
  }
1091
1144
  return out;
1092
1145
  }
1093
- /** 子会话 header 的 cwd(只读;读不到返回 undefined,绝不猜路径)。 */
1094
- liveSessionCwd(sessionId) {
1095
- const cwd = this.liveSessionHeader(sessionId)?.['cwd'];
1096
- return typeof cwd === 'string' && cwd !== '' ? cwd : undefined;
1146
+ /**
1147
+ * 会话 cwd 的**多源**读取(活会话 → 宿主会话存储列举)。
1148
+ *
1149
+ * 为什么不能只读活会话(真机 2026-10-17 根因之一):旧实现只看 `ctx.get('sessions').get(id)`,
1150
+ * 拿不到就整体放弃归位 ⇒ fork 出来的子会话停在侧栏「未分组」。而 DSH 自己的读法有**磁盘回落**
1151
+ * (`dsh-workspace` 的 `readSessionHeader`:活会话 → `sessionPersistence.list()` 的 header 缓存),
1152
+ * 本方法与该回落同源,绝不猜路径、绝不拿父会话目录以外的任何东西凑数。
1153
+ *
1154
+ * 失败时返回**具体细节**(哪个来源、为什么),由调用方拼进诊断;绝不静默成 undefined。
1155
+ */
1156
+ async sessionCwdOf(sessionId) {
1157
+ const live = this.liveSessionHeader(sessionId)?.['cwd'];
1158
+ if (typeof live === 'string' && live !== '')
1159
+ return { ok: true, cwd: live, source: 'live' };
1160
+ const store = readService(this.ctx, 'sessionPersistence');
1161
+ if (store === undefined)
1162
+ return { ok: false, detail: 'no-live-cwd;session-persistence-unavailable' };
1163
+ const listed = await this.listSessionElements(store, 'rewindWorkspaceAttach');
1164
+ if (!listed.ok)
1165
+ return { ok: false, detail: 'no-live-cwd;' + listed.reason };
1166
+ const view = listed.items.find((item) => item.id === sessionId);
1167
+ if (view === undefined)
1168
+ return { ok: false, detail: 'no-live-cwd;not-in-stored-sessions' };
1169
+ const raw = isRecord(view.raw) ? view.raw['cwd'] : undefined;
1170
+ if (typeof raw === 'string' && raw !== '')
1171
+ return { ok: true, cwd: raw, source: 'stored' };
1172
+ return { ok: false, detail: 'no-live-cwd;stored-header-has-no-cwd' };
1173
+ }
1174
+ /**
1175
+ * 归位失败的**诊断落盘**(一行 JSON,追加写)。
1176
+ *
1177
+ * 为什么需要:桌面版 DSH 的宿主 stdout 不落盘 ⇒ 用户界面上只有「未挂回原工作区」这句,
1178
+ * 具体理由(registry 拿不到 / attach 被拒 / cwd 读不到 / 复核不过)谁都看不到,
1179
+ * 「为什么会进未分组」于是长期不可排查。这里只记 id、理由码与路径来源,**不含任何凭据**。
1180
+ * 落盘失败绝不升级成回退失败(诊断是旁路,不是回退的一部分)。
1181
+ */
1182
+ async recordAttachDiagnostic(entry) {
1183
+ try {
1184
+ const target = join(this.dataDir, WORKSPACE_ATTACH_DIAGNOSTIC_REL);
1185
+ await fs.mkdir(dirname(target), { recursive: true });
1186
+ await fs.appendFile(target, JSON.stringify({
1187
+ at: new Date().toISOString(),
1188
+ parentId: entry.parentId,
1189
+ childId: entry.childId,
1190
+ reason: entry.outcome.reason,
1191
+ ...(entry.outcome.detail !== undefined ? { detail: entry.outcome.detail } : {}),
1192
+ }) + '\n');
1193
+ }
1194
+ catch {
1195
+ // 诊断写不进去(只读盘 / 权限 / 配额):静默 —— 回退本身的结论不受影响。
1196
+ }
1097
1197
  }
1098
1198
  /**
1099
1199
  * 活会话 header(`ctx.get('sessions').get(id).header`)。
@@ -4285,10 +4385,19 @@ export function apply(ctx, config) {
4285
4385
  // 不为了这一处调用去改公共接口;端口上确实有 forkSession(DshSessionStoreFacade 实现)才走
4286
4386
  // 真 fork,否则如实结构化 `session-fork-unavailable`(绝不静默降级)。
4287
4387
  const forkPort = host.sessions;
4388
+ // 写入归属台账(2026-10-17 定案 B):只记「哪个会话的哪次**写类**工具调用写了哪个路径」。
4389
+ // 惰性读盘 + best-effort 落盘:读不到 / 写不进只让归属退化成「未判定」——
4390
+ // 既不影响留点与回退,也绝不会把「不知道」当成「别的会话写的」(见 rollback-attribution.ts)。
4391
+ const rollbackAttribution = new RollbackWriteAttribution({
4392
+ file: path.join(dataDir, ROLLBACK_POINTS_DIR_NAME, ROLLBACK_ATTRIBUTION_FILE_NAME),
4393
+ warn: (line) => host.log.warn(line),
4394
+ });
4395
+ void rollbackAttribution.load();
4288
4396
  const rollbackEngine = new RollbackPointEngine({
4289
4397
  dataDir,
4290
4398
  homeDir,
4291
4399
  log: (line) => host.log.warn(line),
4400
+ attribution: rollbackAttribution,
4292
4401
  fork: async ({ sessionId, boundarySeq }) => {
4293
4402
  const forkSession = forkPort.forkSession;
4294
4403
  if (typeof forkSession !== 'function') {
@@ -4307,7 +4416,11 @@ export function apply(ctx, config) {
4307
4416
  return { ok: false, code: 'fork-failed', detail: result.error };
4308
4417
  },
4309
4418
  });
4310
- const rollbackTracker = new RollbackAutoTracker({ engine: rollbackEngine, log: (line) => host.log.warn(line) });
4419
+ const rollbackTracker = new RollbackAutoTracker({
4420
+ engine: rollbackEngine,
4421
+ log: (line) => host.log.warn(line),
4422
+ attribution: rollbackAttribution,
4423
+ });
4311
4424
  ctx.effect(() => {
4312
4425
  let chain = Promise.resolve();
4313
4426
  // 「会话没有 cwd」这类**形状合法但信息不全**的情况只记一次日志(避免每回合刷屏);
@@ -766,6 +766,17 @@ export function recoveryRoutes(env) {
766
766
  // A(用户 2026-10-11 定案):「回到某点之前」要连文件一起退回去 —— 默认把**回退点之后新建**的路径删掉;
767
767
  // 调用方显式传 false 才保留老口径(只报告 leftovers,不删任何东西)。
768
768
  rewindInput.removeCreated = body['removeCreated'] !== false;
769
+ // 路径级选择性恢复(2026-10-17 定案 A):缺省 = 全量口径(一字不变)。
770
+ // 给了数组就只恢复这些路径;**空数组**视为无效输入(400)—— 「一个都没勾」应当由
771
+ // 界面拦住,而不是让引擎把整次回退悄悄变成 noop。
772
+ if (body['onlyPaths'] !== undefined) {
773
+ const raw = body['onlyPaths'];
774
+ if (!Array.isArray(raw) || raw.length === 0 || raw.some((item) => typeof item !== 'string' || item === '')) {
775
+ writeJson(res, 400, { ok: false, code: 'invalid-input', error: 'onlyPaths must be a non-empty array of non-empty strings' });
776
+ return;
777
+ }
778
+ rewindInput.onlyPaths = raw;
779
+ }
769
780
  if (body['preRewindGuard'] === 'off' || body['preRewindGuard'] === 'require' || body['preRewindGuard'] === 'warn') {
770
781
  rewindInput.guardPolicy = body['preRewindGuard'];
771
782
  }
@@ -55,7 +55,7 @@ export const SECTION_REGISTRY = {
55
55
  },
56
56
  plugins: {
57
57
  id: 'plugins',
58
- displayName: 'Plugins',
58
+ displayName: 'Plugin list',
59
59
  group: 'extensions',
60
60
  payload: {
61
61
  kind: 'array', zipPath: 'plugins/plugins.json', key: 'plugins',
@@ -152,7 +152,7 @@ export const SECTION_REGISTRY = {
152
152
  },
153
153
  pluginFiles: {
154
154
  id: 'pluginFiles',
155
- displayName: "Other Plugins' Loose Files",
155
+ displayName: 'Plugin config files',
156
156
  group: 'extensions',
157
157
  payload: { kind: 'files', filePrefix: 'plugin-files/' },
158
158
  dataVersion: SECTION_DATA_VERSION,
@@ -205,7 +205,7 @@ export const SECTION_REGISTRY = {
205
205
  },
206
206
  self: {
207
207
  id: 'self',
208
- displayName: "This Plugin's Own Config",
208
+ displayName: 'Config Manager settings',
209
209
  group: 'extensions',
210
210
  payload: { kind: 'files', filePrefix: 'self/' },
211
211
  dataVersion: SECTION_DATA_VERSION,
@@ -218,7 +218,7 @@ export const SECTION_REGISTRY = {
218
218
  },
219
219
  pluginData: {
220
220
  id: 'pluginData',
221
- displayName: "Other Plugins' Data",
221
+ displayName: 'Plugin data dirs',
222
222
  group: 'extensions',
223
223
  payload: { kind: 'files', filePrefix: 'plugin-data/' },
224
224
  dataVersion: SECTION_DATA_VERSION,
@@ -234,7 +234,7 @@ export const SECTION_REGISTRY = {
234
234
  },
235
235
  pluginAssets: {
236
236
  id: 'pluginAssets',
237
- displayName: "Other Plugins' Large Files",
237
+ displayName: 'Plugin large files',
238
238
  group: 'extensions',
239
239
  payload: { kind: 'files', filePrefix: 'plugin-assets/' },
240
240
  dataVersion: SECTION_DATA_VERSION,
@@ -0,0 +1,16 @@
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
+ export type BadgeKind = 'info' | 'ok' | 'warn' | 'error';
11
+ /** DSH 原生 Tag 的调色板选项(与其 .d.ts 逐字同形)。 */
12
+ export type DshTagTone = 'outline' | 'solid' | 'neutral' | 'quiet' | 'success' | 'info' | 'warning' | 'danger';
13
+ /** 四态 → 原生 tone(ok=success / warn=warning / error=danger / info=info)。 */
14
+ export declare const BADGE_TONE: Readonly<Record<BadgeKind, DshTagTone>>;
15
+ /** 取某个四态对应的原生 tone;未知输入按 info 处理(调用方给的是字面量联合,此为防御)。 */
16
+ export declare function badgeTone(kind: BadgeKind): DshTagTone;
@@ -0,0 +1,20 @@
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
+ /** 四态 → 原生 tone(ok=success / warn=warning / error=danger / info=info)。 */
10
+ export const BADGE_TONE = {
11
+ ok: 'success',
12
+ info: 'info',
13
+ warn: 'warning',
14
+ error: 'danger',
15
+ };
16
+ /** 取某个四态对应的原生 tone;未知输入按 info 处理(调用方给的是字面量联合,此为防御)。 */
17
+ export function badgeTone(kind) {
18
+ return BADGE_TONE[kind] ?? 'info';
19
+ }
20
+ //# sourceMappingURL=badge-tone.js.map
package/lib/ui/i18n.d.ts CHANGED
@@ -517,10 +517,10 @@ export declare const uiZh: {
517
517
  readonly 'export.category.settings': 'DSH 全局设置(namespace 分区,redacted)';
518
518
  readonly 'export.category.providers': 'LLM Provider / Model / 默认模型 / BaseURL';
519
519
  readonly 'export.category.plugins': '已安装插件清单与启用状态(不含二进制)';
520
- readonly 'export.category.pluginFiles': '其他插件放在 ~/.dsh 根下的零散配置文件(白名单 + plugin-config/ 目录,整文件复制)';
521
- readonly 'export.category.self': '本插件自身配置(同步/自动同步/分区选择/UI 偏好/市场;sync-*.json 等,不含凭据值)';
522
- readonly 'export.category.pluginData': '其他插件在系统主目录下的数据(如 ~/.dsh-wallpaper-engine、~/.mnemon 的配置与状态;可再生成缓存绝不收录)';
523
- readonly 'export.category.pluginAssets': '其他插件的大体积文件(上传的壁纸、本地数据库等;默认不勾选)';
520
+ readonly 'export.category.pluginFiles': '插件放在 ~/.dsh 根下的配置文件(白名单 + plugin-config/ 目录,整文件复制)';
521
+ readonly 'export.category.self': '配置管理器自身的设置(同步/自动同步/分区选择/UI 偏好/市场;sync-*.json 等,不含凭据值)';
522
+ readonly 'export.category.pluginData': '插件在系统主目录下的数据(如 ~/.dsh-wallpaper-engine、~/.mnemon 的配置与状态;可再生成缓存绝不收录)';
523
+ readonly 'export.category.pluginAssets': '插件的大体积文件(上传的壁纸、本地数据库等;默认不勾选)';
524
524
  readonly 'export.category.mcp': 'MCP 服务器组合配置(需重启生效)';
525
525
  readonly 'export.category.prompts': 'System Prompt / Plan Mode 提示';
526
526
  readonly 'export.category.skills': '用户技能文件(~/.dsh/skills)';
package/lib/ui/i18n.js CHANGED
@@ -547,10 +547,10 @@ export const uiZh = {
547
547
  'export.category.settings': 'DSH 全局设置(namespace 分区,redacted)',
548
548
  'export.category.providers': 'LLM Provider / Model / 默认模型 / BaseURL',
549
549
  'export.category.plugins': '已安装插件清单与启用状态(不含二进制)',
550
- 'export.category.pluginFiles': '其他插件放在 ~/.dsh 根下的零散配置文件(白名单 + plugin-config/ 目录,整文件复制)',
551
- 'export.category.self': '本插件自身配置(同步/自动同步/分区选择/UI 偏好/市场;sync-*.json 等,不含凭据值)',
552
- 'export.category.pluginData': '其他插件在系统主目录下的数据(如 ~/.dsh-wallpaper-engine、~/.mnemon 的配置与状态;可再生成缓存绝不收录)',
553
- 'export.category.pluginAssets': '其他插件的大体积文件(上传的壁纸、本地数据库等;默认不勾选)',
550
+ 'export.category.pluginFiles': '插件放在 ~/.dsh 根下的配置文件(白名单 + plugin-config/ 目录,整文件复制)',
551
+ 'export.category.self': '配置管理器自身的设置(同步/自动同步/分区选择/UI 偏好/市场;sync-*.json 等,不含凭据值)',
552
+ 'export.category.pluginData': '插件在系统主目录下的数据(如 ~/.dsh-wallpaper-engine、~/.mnemon 的配置与状态;可再生成缓存绝不收录)',
553
+ 'export.category.pluginAssets': '插件的大体积文件(上传的壁纸、本地数据库等;默认不勾选)',
554
554
  'export.category.mcp': 'MCP 服务器组合配置(需重启生效)',
555
555
  'export.category.prompts': 'System Prompt / Plan Mode 提示',
556
556
  'export.category.skills': '用户技能文件(~/.dsh/skills)',
@@ -1093,10 +1093,10 @@ export const uiEn = {
1093
1093
  'export.category.settings': 'Global DSH settings (namespace sections, redacted)',
1094
1094
  'export.category.providers': 'LLM providers / models / default model / base URL',
1095
1095
  'export.category.plugins': 'Installed plugin list and enablement (binaries excluded)',
1096
- 'export.category.pluginFiles': "Other plugins' loose config files at the ~/.dsh root (allowlist + plugin-config/, copied whole)",
1097
- 'export.category.self': "This plugin's own config (sync / autosync / section selection / UI prefs / market; sync-*.json etc., no credential values)",
1098
- '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)",
1099
- 'export.category.pluginAssets': "Other plugins' large files (uploaded wallpapers, local databases, ...; not selected by default)",
1096
+ 'export.category.pluginFiles': "Plugin config files at the ~/.dsh root (allowlist + plugin-config/, copied whole)",
1097
+ 'export.category.self': "Config Manager's own settings (sync / autosync / section selection / UI prefs / market; sync-*.json etc., no credential values)",
1098
+ '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)",
1099
+ 'export.category.pluginAssets': "Plugins' large files (uploaded wallpapers, local databases, ...; not selected by default)",
1100
1100
  'export.category.mcp': 'MCP server composition (restart required)',
1101
1101
  'export.category.prompts': 'System prompt / Plan Mode prompts',
1102
1102
  'export.category.skills': 'Your skill files (~/.dsh/skills)',
@@ -243,6 +243,23 @@ export interface RollbackPreviewFile {
243
243
  removed: number | null;
244
244
  /** true = 本次没有可信行数(added/removed 均为 null)。**宁可说「不知道」,也绝不谎报行数。** */
245
245
  truncated: boolean;
246
+ /**
247
+ * true = 该文件在留点**之后**是**别的会话**写的(写入归属台账证明;见 core/rollback-attribution.ts)。
248
+ * 界面据此默认不勾选它(用户仍可勾上)。缺字段 / false = **没有归属证据**,
249
+ * 而不是「本会话写的」——「未判定」与「确知是本会话」必须分开。
250
+ */
251
+ foreign?: boolean;
252
+ /** 那些「别的会话」的会话 id(仅当 foreign 为 true) */
253
+ foreignSessionId?: string;
254
+ /** 那次写入的时间(毫秒;仅当 foreign 为 true) */
255
+ foreignAt?: number;
256
+ /**
257
+ * true = 这次回退**不会改动它**(与其它对话的改动冲突 / 无法安全合并)。行级统计为 null ——
258
+ * 「不改动」不是「0 行变化」,界面必须分开说。
259
+ */
260
+ skipped?: boolean;
261
+ /** 不改动的机器原因(界面映射成字典键,绝不裸渲染) */
262
+ mergeReason?: string;
246
263
  }
247
264
  export interface RollbackWorkspacePreview {
248
265
  /** 将被写回(覆盖)的条目数 */
@@ -364,6 +381,12 @@ export interface RollbackFileDiffResult {
364
381
  identical: boolean;
365
382
  degraded: boolean;
366
383
  reason?: RollbackFileDiffReason;
384
+ /** true = 「回退后」一侧显示的是三方合并的结果(不是记录里的原文) */
385
+ merged?: boolean;
386
+ /** true = 这次回退不会改动它(冲突 / 无法安全合并)——此时两侧相同 */
387
+ mergeSkipped?: boolean;
388
+ /** 不改动的机器原因(界面映射成字典键) */
389
+ mergeReason?: string;
367
390
  }
368
391
  export interface RollbackSegmentResult {
369
392
  state: string;
@@ -389,6 +412,22 @@ export interface RollbackSegmentResult {
389
412
  createdRefusedReason?: string | null;
390
413
  /** 工作区段:实际写回的字节数(宿主回传才有) */
391
414
  writtenBytes?: number;
415
+ /** 因归属隔离(B)跳过的记录条目数:该文件在留点后被**别的会话**写过(宿主回传才有) */
416
+ foreignSkipped?: number;
417
+ /** 因路径选择(A)跳过的记录条目数(宿主回传才有) */
418
+ selectionSkipped?: number;
419
+ /** 三方合并成功、写回「撤销本会话 + 保留对方改动」的条目数(宿主回传才有) */
420
+ mergedCount?: number;
421
+ mergedPaths?: string[];
422
+ /** 冲突:两侧改到同一处 ⇒ 未写(宿主回传才有) */
423
+ conflictCount?: number;
424
+ conflictPaths?: string[];
425
+ /** 无法安全撤销 ⇒ 未写(宿主回传才有) */
426
+ unavailableCount?: number;
427
+ unavailablePaths?: string[];
428
+ /** 写入前发现又被改过(并发)⇒ 未写(宿主回传才有) */
429
+ concurrentCount?: number;
430
+ concurrentPaths?: string[];
392
431
  /** 兼容形态:宿主只回传计数时也算得出来 */
393
432
  createdAfter?: RollbackCount;
394
433
  createdAfterTruncated?: boolean;
@@ -427,6 +466,13 @@ export interface RollbackRewindOptions {
427
466
  segments?: readonly RollbackState[];
428
467
  allowPartial?: boolean;
429
468
  preRewindGuard?: RollbackGuardPolicy;
469
+ /**
470
+ * 路径级选择性恢复(2026-10-17 定案 A):只恢复这些路径。
471
+ *
472
+ * 缺省 = 全量口径(一字不变)。**空数组是无效输入**(宿主 400)—— 「一个都没勾」由界面拦住,
473
+ * 绝不让它变成「整次回退静默 noop」。
474
+ */
475
+ onlyPaths?: readonly string[];
430
476
  }
431
477
  /** 端口:由 src/client/recovery/recovery-api.ts 实现(Partial 消费,缺失时界面如实说能力缺失)。 */
432
478
  export interface RollbackPointPort {
@@ -660,6 +706,39 @@ export declare function rollbackPreviewScope(files: readonly {
660
706
  added: number | null;
661
707
  removed: number | null;
662
708
  }[], removals: readonly unknown[]): RollbackPreviewScope;
709
+ /**
710
+ * 预览里「能被单独勾选」的一行 —— `workspace.files` 的逐条映射(**只含会被改动的文件**)。
711
+ *
712
+ * 为什么要有这一层(2026-10-17 定案 A+B):A 要求用户能**逐文件**决定这次动哪些;
713
+ * B(写入归属)给出「哪几个文件在留点之后是别的会话写的」,用来决定**默认**勾不勾。
714
+ * 两件事都收敛在这里、只在这一处(框架无关、node 可测),组件只负责渲染与点击。
715
+ */
716
+ export interface RollbackSelectionRow {
717
+ path: string;
718
+ /** 目录前缀 + 基名(与文件行同口径;展示用) */
719
+ parts: RollbackPathParts;
720
+ /** true = 该文件在留点**之后**是别的会话写的(引擎 foreign 标记;缺字段 = 没有归属证据) */
721
+ foreign: boolean;
722
+ /** 那些会话的 id(仅 foreign 时给得出) */
723
+ foreignSessionId?: string;
724
+ }
725
+ /** 从预览推出可勾选行(只读;绝不改任何判定) */
726
+ export declare function rollbackSelectionRows(preview: RollbackPreviewResult | null): RollbackSelectionRow[];
727
+ /**
728
+ * 默认勾选 = **非 foreign** 的行(B 的自动隔离口径)。用户可在弹窗里逐个改回去。
729
+ *
730
+ * 为什么要在这里定而不在组件里:同一条默认值要在面板与聊天两个入口一致,
731
+ * 且「默认排除别的会话的改动」是产品决定,必须可测。
732
+ */
733
+ export declare function rollbackDefaultSelection(rows: readonly RollbackSelectionRow[]): string[];
734
+ /**
735
+ * 勾选集 → `onlyPaths`:
736
+ * · **全选**(含空清单)⇒ `null`(不传字段 = 走既有全量口径,老宿主也照样工作);
737
+ * · 真的少勾 ⇒ 把选中的清单原样传给宿主(空数组由调用方拦:宿主侧它是 400)。
738
+ */
739
+ export declare function rollbackOnlyPaths(rows: readonly RollbackSelectionRow[], selected: readonly string[]): string[] | null;
740
+ /** 该不该拦住提交:清单非空、却一个都没勾(宿主会把空数组当 400) */
741
+ export declare function rollbackSelectionBlocksSubmit(rows: readonly RollbackSelectionRow[], selected: readonly string[]): boolean;
663
742
  /**
664
743
  * 差异无法逐行展示时的字典键(本层**只回键**,文案在字典里)。
665
744
  * 未知 / 缺失原因回落 null —— 调用方按「未知原因」出话,绝不回落成「没有差异」(那是另一件事)。
@@ -780,6 +859,22 @@ export interface RollbackRewindRowView {
780
859
  /** 逐条事实:**渲染前已逐条过 redact** */
781
860
  detail: string[];
782
861
  guidance: RollbackGuidanceRowView[];
862
+ /**
863
+ * 归属隔离(B)跳过的文件数:这些文件在留点之后是**别的会话**写的,回退没动它们。
864
+ * 0 = 没有(或宿主没回传)—— 界面只在 > 0 时说这句话(绝不编造 0 的成就)。
865
+ */
866
+ foreignSkipped: number;
867
+ /** 路径选择(A)跳过的文件数(用户自己没勾) */
868
+ selectionSkipped: number;
869
+ /** 三方合并成功、写回「撤销本会话 + 保留对方改动」的文件数 */
870
+ mergedCount: number;
871
+ /** 冲突(两侧改到同一处)未写的文件数 + 有界清单 */
872
+ conflictCount: number;
873
+ conflictPaths: string[];
874
+ /** 无法安全撤销(无中间版本 / 二进制 / 降级 / 对方删了它)未写的文件数 */
875
+ unavailableCount: number;
876
+ /** 写入前发现又被改过(并发)未写的文件数 */
877
+ concurrentCount: number;
783
878
  }
784
879
  export declare function rollbackRewindRows(result: RollbackRewindResult | null): RollbackRewindRowView[];
785
880
  /** 回退总体结论 → 字典键 + 徽章语义(未知回落 unknown,绝不裸渲染 outcome)。 */