dsh-config-manager 0.1.73 → 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 (96) 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 +112 -13
  7. package/lib/client.js +2102 -888
  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-ledger.d.ts +29 -3
  13. package/lib/core/rollback-ledger.js +125 -7
  14. package/lib/core/rollback-merge.d.ts +22 -0
  15. package/lib/core/rollback-merge.js +203 -0
  16. package/lib/core/rollback-point.d.ts +335 -3
  17. package/lib/core/rollback-point.js +1323 -169
  18. package/lib/core/types.d.ts +18 -0
  19. package/lib/index.d.ts +26 -3
  20. package/lib/index.js +129 -16
  21. package/lib/routes/recovery.js +11 -0
  22. package/lib/schema/section-registry.js +5 -5
  23. package/lib/sync/messages.d.ts +1 -0
  24. package/lib/sync/messages.js +3 -0
  25. package/lib/sync/sync-engine.js +56 -7
  26. package/lib/ui/badge-tone.d.ts +16 -0
  27. package/lib/ui/badge-tone.js +20 -0
  28. package/lib/ui/i18n.d.ts +5 -5
  29. package/lib/ui/i18n.js +10 -10
  30. package/lib/ui/rollback-view.d.ts +95 -0
  31. package/lib/ui/rollback-view.js +55 -0
  32. package/lib/ui/selection-model.d.ts +140 -1
  33. package/lib/ui/selection-model.js +241 -9
  34. package/lib/utils/bundle-scan.d.ts +5 -3
  35. package/lib/utils/bundle-scan.js +5 -3
  36. package/lib/utils/line-diff.d.ts +7 -1
  37. package/lib/utils/line-diff.js +8 -2
  38. package/package.json +1 -1
  39. package/src/adapters/plugin-data-declared.ts +60 -0
  40. package/src/adapters/plugin-data.ts +7 -33
  41. package/src/adapters/plugins.ts +98 -13
  42. package/src/adapters/units.test.ts +26 -0
  43. package/src/client/chat/ChatEntryView.tsx +179 -49
  44. package/src/client/chat/RollbackFileIcon.tsx +7 -45
  45. package/src/client/chat/chat-entry-view.test.ts +440 -1
  46. package/src/client/chat/chat-entry-view.ts +222 -11
  47. package/src/client/chat/chat-node-registration.ts +111 -6
  48. package/src/client/chat/chat-slots.ts +30 -12
  49. package/src/client/common/ContentPicker.tsx +581 -175
  50. package/src/client/common/DiffTable.tsx +12 -23
  51. package/src/client/common/host-primitives.ts +141 -0
  52. package/src/client/common/ui.tsx +17 -3
  53. package/src/client/config-manager.module.css +658 -404
  54. package/src/client/css-scale-guard.test.ts +41 -3
  55. package/src/client/css-token-guard.test.ts +10 -4
  56. package/src/client/environment/maintenance/DiskUsageCard.tsx +20 -3
  57. package/src/client/export/ExportView.tsx +2 -0
  58. package/src/client/index.ts +22 -4
  59. package/src/client/library/RestorePlanView.tsx +0 -1
  60. package/src/client/locales.ts +54 -12
  61. package/src/client/recovery/RollbackCard.tsx +56 -2
  62. package/src/client/recovery/RollbackDiffPane.tsx +18 -6
  63. package/src/client/recovery/RollbackFileChanges.tsx +46 -2
  64. package/src/client/recovery/recovery-locales.ts +24 -4
  65. package/src/client/recovery/rollback-file-changes.test.ts +37 -1
  66. package/src/client/sync/PushPreviewCard.tsx +57 -0
  67. package/src/client/sync/SyncPanel.tsx +2 -37
  68. package/src/client/sync/SyncSettingsView.test.ts +3 -0
  69. package/src/client/sync/sync-push-preview.test.ts +14 -0
  70. package/src/client/sync/sync-view.ts +9 -1
  71. package/src/core/messages.ts +2 -0
  72. package/src/core/rollback-attribution.test.ts +120 -0
  73. package/src/core/rollback-attribution.ts +267 -0
  74. package/src/core/rollback-ledger.ts +123 -7
  75. package/src/core/rollback-merge.test.ts +133 -0
  76. package/src/core/rollback-merge.ts +213 -0
  77. package/src/core/rollback-point.test.ts +1004 -17
  78. package/src/core/rollback-point.ts +1421 -175
  79. package/src/core/types.ts +19 -0
  80. package/src/index.facade.test.ts +60 -0
  81. package/src/index.ts +133 -19
  82. package/src/routes/recovery.ts +11 -0
  83. package/src/schema/section-registry.ts +5 -5
  84. package/src/sync/messages.ts +3 -0
  85. package/src/sync/sync-engine.ts +55 -7
  86. package/src/sync/sync-preview-push.test.ts +158 -0
  87. package/src/ui/badge-tone.test.ts +23 -0
  88. package/src/ui/badge-tone.ts +27 -0
  89. package/src/ui/i18n.ts +10 -10
  90. package/src/ui/rollback-view.test.ts +27 -0
  91. package/src/ui/rollback-view.ts +139 -0
  92. package/src/ui/selection-model.test.ts +240 -2
  93. package/src/ui/selection-model.ts +349 -1
  94. package/src/utils/bundle-scan.ts +5 -3
  95. package/src/utils/line-diff.test.ts +12 -0
  96. package/src/utils/line-diff.ts +9 -3
@@ -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.73";
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.73';
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,
@@ -57,6 +57,7 @@ export declare const zh: {
57
57
  readonly 'sync.backupHistoryDone': '{trigger}完成:{zip}';
58
58
  readonly 'sync.backupHistorySkipped': '{trigger}跳过:{reason}';
59
59
  readonly 'sync.backupHistoryFailed': '{trigger}失败';
60
+ readonly 'sync.pushPreviewUnpackedLocalSources': '本地源插件(link:/file:)需打包才能精确比对变更,分区 {sections} 本次按「有变化」展示';
60
61
  };
61
62
  export declare const en: Record<keyof typeof zh, string>;
62
63
  /** 本域活翻译器(与 core 的 engineMsg 同一实现;导入本模块即完成目录注册)。 */
@@ -83,6 +83,8 @@ export const zh = {
83
83
  'sync.backupHistoryDone': '{trigger}完成:{zip}',
84
84
  'sync.backupHistorySkipped': '{trigger}跳过:{reason}',
85
85
  'sync.backupHistoryFailed': '{trigger}失败',
86
+ // ---------- 推送前只读预览(issue #91) ----------
87
+ 'sync.pushPreviewUnpackedLocalSources': '本地源插件(link:/file:)需打包才能精确比对变更,分区 {sections} 本次按「有变化」展示',
86
88
  };
87
89
  export const en = {
88
90
  'sync.ancestorSnapshotIdRequired': 'loadAncestor: snapshotId must not be empty',
@@ -142,6 +144,7 @@ export const en = {
142
144
  'sync.backupHistoryDone': '{trigger} finished: {zip}',
143
145
  'sync.backupHistorySkipped': '{trigger} skipped: {reason}',
144
146
  'sync.backupHistoryFailed': '{trigger} failed',
147
+ 'sync.pushPreviewUnpackedLocalSources': 'Local-source plugins (link:/file:) must be packed to compare changes exactly; section(s) {sections} are shown as changed',
145
148
  };
146
149
  registerMessageCatalog({ id: 'sync-domain', zh, en });
147
150
  /** 本域活翻译器(与 core 的 engineMsg 同一实现;导入本模块即完成目录注册)。 */
@@ -22,6 +22,8 @@ import path from 'node:path';
22
22
  import { createAdapters } from '../adapters/index.js';
23
23
  import { defaultSecretScanner } from '../core/exporter.js';
24
24
  import { Importer } from '../core/importer.js';
25
+ // issue #91:只读预览与基线 hash 的口径判定(本地源插件在预览里不会被 npm pack)
26
+ import { isLocalPluginSpec } from '../core/local-plugin-pack.js';
25
27
  import { isFileSection, SECTION_FILE_PREFIXES, SECTION_JSON_PATHS } from '../schema/config.js';
26
28
  import { buildManifest, CHECKSUMS_FILE, MANIFEST_FILE } from '../schema/manifest.js';
27
29
  import { CURRENT_SCHEMA_VERSION } from '../schema/versions.js';
@@ -83,6 +85,24 @@ const FORBIDDEN_SECTIONS = ['credentialsStatus', 'secrets'];
83
85
  * 保留导出是为了让既有调用方/测试仍能引用「缺省保留份数」这一事实,不再作为唯一判据。
84
86
  */
85
87
  export const MAX_REMOTE_SNAPSHOTS = DEFAULT_RETENTION_POLICY.keepLast;
88
+ /**
89
+ * issue #91:走只读 `preview()` 拿到的产物,能否直接与该分区**基线 hash**(push 时按 export() 产物记录)比对。
90
+ *
91
+ * 为什么需要这个判定:`plugins` 适配器的 `preview()` 走 `collect(…, pack=false)` —— 刻意**不做**
92
+ * 本地源(`link:` / `file:`)插件的 `npm pack`。于是当 profile 里存在本地源插件时,预览产物既没有
93
+ * `localTarballs`、插件 spec 也保持原样(export() 会改写成 `file:<tarball>`),与基线**不同口径**:
94
+ * 此时既不能断言「无变化」(本地插件源码改了体积/内容,预览看不见),也不该为了比对去打包
95
+ * (那正是本 issue 的缺陷本身)。
96
+ *
97
+ * 返回 true = 该分区本趟**不可比** ⇒ 调用方只能保守报「有变化」并如实说明原因。
98
+ * 其余分区的 preview() 产物与 export() 逐字节相同(文件类分区不走 preview(),见 previewPush)。
99
+ */
100
+ function previewCaliberDiffersFromExport(sectionId, data) {
101
+ if (sectionId !== 'plugins')
102
+ return false;
103
+ const plugins = data.plugins;
104
+ return Array.isArray(plugins) && plugins.some((p) => isLocalPluginSpec(p?.spec));
105
+ }
86
106
  export class SyncEngine {
87
107
  ctx;
88
108
  transport;
@@ -516,14 +536,29 @@ export class SyncEngine {
516
536
  const targets = this.pushTargets(opts.sections, warnings);
517
537
  const plainSections = {};
518
538
  const counts = {};
539
+ /** issue #91:本趟只读预览省掉了本地源打包 ⇒ 与基线不可比的分区(不可断言「无变化」) */
540
+ const unpackedSections = new Set();
519
541
  for (const adapter of targets) {
542
+ const exportOptions = {
543
+ includeSecrets,
544
+ // 与 push 同口径:显式勾选清单 > 「最新 N 个」上限
545
+ ...this.sessionExportOptions(adapter.id, opts.sessions),
546
+ };
520
547
  let section;
521
548
  try {
522
- section = await adapter.export(this.ctx, {
523
- includeSecrets,
524
- // 与 push 同口径:显式勾选清单 > 「最新 N 个」上限
525
- ...this.sessionExportOptions(adapter.id, opts.sessions),
526
- });
549
+ // issue #91:**非文件类**分区优先走 adapter.preview()。只读预览必须零进程副作用,而
550
+ // plugins 的 export() 会对每个 `link:`/`file:` 插件真的跑一次 `npm pack`
551
+ // (真机 2 GB 本地源插件 = 297 s;这段时间全局写锁被该请求占满 ⇒ 界面上所有写操作静默 423)。
552
+ // 为什么文件类分区**不能**走 preview():其预览产物是「无内容」形态(条目 data 为空、
553
+ // contentHash 为空串),既不能与基线 hash 比对,也不能进 assertNoForbiddenSections
554
+ // —— 那会让预览谎报「每个文件分区都变了」。
555
+ const summary = !isFileSection(adapter.id) && adapter.preview !== undefined
556
+ ? await adapter.preview(this.ctx, exportOptions)
557
+ : undefined;
558
+ section = summary?.section ?? await adapter.export(this.ctx, exportOptions);
559
+ if (summary !== undefined && previewCaliberDiffersFromExport(adapter.id, section.data)) {
560
+ unpackedSections.add(adapter.id);
561
+ }
527
562
  }
528
563
  catch (err) {
529
564
  warnings.push(this.msg('sync.sectionFailed', { adapter: adapter.id, reason: err instanceof Error ? err.message : String(err) }));
@@ -544,15 +579,29 @@ export class SyncEngine {
544
579
  if (!encrypted) {
545
580
  try {
546
581
  const state = await loadSyncState(this.stateDir, this.fsx, this.msg);
547
- baselineHashes = state.sections;
582
+ // 基线条目是 SyncSectionState({hash, updatedAt}),**不是**裸 hash 字符串。
583
+ // 这里曾把整个对象 as Record<string,string> 直接比对 ⇒ 对象 !== 字符串 恒真
584
+ // ⇒ 预览对**每一个**分区都报「有变化」,「N 个有变化」这句永远是「全都有变化」。
585
+ for (const [sid, rec] of Object.entries(state.sections)) {
586
+ if (rec !== undefined && typeof rec.hash === 'string')
587
+ baselineHashes[sid] = rec.hash;
588
+ }
548
589
  }
549
590
  catch {
550
591
  baselineHashes = {};
551
592
  }
552
593
  }
594
+ if (unpackedSections.size > 0) {
595
+ // 如实说明:本次预览没有为本地源插件执行 npm pack,故这些分区的基线比对不可得
596
+ warnings.push(this.msg('sync.pushPreviewUnpackedLocalSources', { sections: [...unpackedSections].join(', ') }));
597
+ }
553
598
  const sections = Object.keys(plainSections).map((sid) => {
554
599
  const id = sid;
555
- const changed = encrypted || baselineHashes[id] === undefined || baselineHashes[id] !== hashSection(plainSections[id]);
600
+ const changed = encrypted
601
+ // issue #91:只读预览省掉了本地源打包 ⇒ 产物与基线不同口径,**绝不**据此断言「无变化」
602
+ || unpackedSections.has(id)
603
+ || baselineHashes[id] === undefined
604
+ || baselineHashes[id] !== hashSection(plainSections[id]);
556
605
  return { section: id, count: counts[id] ?? 0, changed };
557
606
  });
558
607
  let remoteSnapshotCount = 0;
@@ -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
@@ -92,7 +92,7 @@ export declare const uiZh: {
92
92
  readonly 'error.downloadFailed': '下载失败:HTTP {status}';
93
93
  readonly 'error.exportTimeout': '导出超时({minutes} 分钟):导出过程未完成,请重试;若反复超时请检查 DSH 状态';
94
94
  readonly 'error.syncTimeout': '同步请求超时({minutes} 分钟):请检查网络与仓库可达性后重试';
95
- readonly 'error.recoveryTimeout': '恢复请求超时({minutes} 分钟):恢复过程未完成,请重试;若反复超时请检查 DSH 状态';
95
+ readonly 'error.recoveryTimeout': '恢复请求超时({minutes} 分钟):过程可能仍在后台进行,请先刷新预览确认;确实未完成再重试';
96
96
  readonly 'error.settingsConflict.title': '配置已被并发修改';
97
97
  readonly 'error.settingsConflict.action': '目标 DSH 的该配置在导入期间被其他进程修改,为避免覆盖请重新导出或手动核对后再试。';
98
98
  readonly 'error.notConfirmed.title': '导入未确认';
@@ -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
@@ -99,7 +99,7 @@ export const uiZh = {
99
99
  'error.downloadFailed': '下载失败:HTTP {status}',
100
100
  'error.exportTimeout': '导出超时({minutes} 分钟):导出过程未完成,请重试;若反复超时请检查 DSH 状态',
101
101
  'error.syncTimeout': '同步请求超时({minutes} 分钟):请检查网络与仓库可达性后重试',
102
- 'error.recoveryTimeout': '恢复请求超时({minutes} 分钟):恢复过程未完成,请重试;若反复超时请检查 DSH 状态',
102
+ 'error.recoveryTimeout': '恢复请求超时({minutes} 分钟):过程可能仍在后台进行,请先刷新预览确认;确实未完成再重试',
103
103
  'error.settingsConflict.title': '配置已被并发修改',
104
104
  'error.settingsConflict.action': '目标 DSH 的该配置在导入期间被其他进程修改,为避免覆盖请重新导出或手动核对后再试。',
105
105
  'error.notConfirmed.title': '导入未确认',
@@ -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)',
@@ -662,7 +662,7 @@ export const uiEn = {
662
662
  'error.downloadFailed': 'Download failed: HTTP {status}',
663
663
  'error.exportTimeout': 'Export timed out ({minutes} min): the export did not complete, please retry; if it keeps timing out, check the DSH state',
664
664
  'error.syncTimeout': 'Sync request timed out ({minutes} min): check network and repository reachability, then retry',
665
- 'error.recoveryTimeout': 'Recovery request timed out ({minutes} min): the recovery did not complete, please retry; if it keeps timing out, check the DSH state',
665
+ 'error.recoveryTimeout': 'Recovery request timed out ({minutes} min): it may still be running in the background — refresh the preview to confirm, then retry if it did not finish',
666
666
  'error.settingsConflict.title': 'Configuration changed concurrently',
667
667
  'error.settingsConflict.action': 'This configuration on the target DSH was modified by another process during import. To avoid overwriting it, re-export or review it manually, then retry.',
668
668
  'error.notConfirmed.title': 'Import not confirmed',
@@ -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)',